Skip to content

Commit 072f1a6

Browse files
committed
fix: harden deployment list sync and SQLite timestamp storage
- Filter list/count and sync by deployment_type in SQL so type-scoped sync can prune provider-unknown rows without a Python type skip. - Keep list sync alive on provider round failures; backfill remaining page slots from local rows after sync (may be stale; tune batch size / max rounds to reduce padding). - Bind attachment cleanup/recount to provider-confirmed IDs only (no needless snapshot copy before backfill). - Soften DELETE rowcount handling with UnknownDeleteCount when the driver does not report an int. - Document that deployment_list_sync_batch_size should stay within the provider's ID-filtered list limit. - Stop writing deployment-family timestamps with server_default=func.now(); use Column default/onupdate=utc_now (Field stays default=None) so SQLite stores microsecond DateTime strings matching later query parameters. - Add SQLite migration a8f3c2d1e4b5: rewrite existing second-level timestamp strings through DateTime binds, and drop CURRENT_TIMESTAMP server defaults on all dialects. - Cover type filter, keyset after rewrite, ORM microsecond writes, sync backfill/failure paths, and the server_default drop in unit tests; extend integration coverage for the sync path.
1 parent 1ee78f1 commit 072f1a6

6 files changed

Lines changed: 663 additions & 89 deletions

File tree

src/backend/base/langflow/api/v1/mappers/deployments/helpers.py

Lines changed: 83 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -892,7 +892,9 @@ async def list_deployments_synced(
892892
893893
Each round fetches a candidate window so one provider lookup reconciles
894894
stale rows beyond the page. Refill rounds seek after the last processed row
895-
to avoid corruption to the offset under concurrent deletes.
895+
to avoid offset drift under concurrent deletes. After provider sync finishes,
896+
any remaining page slots are backfilled from local rows without another
897+
provider check (they may be stale).
896898
897899
``allowed_ids`` is the DB-layer authorization prefilter, threaded into both
898900
the page query and the total count so a registered authorization plugin can
@@ -915,46 +917,57 @@ async def list_deployments_synced(
915917
for _ in range(max_sync_rounds):
916918
if len(accepted) >= size:
917919
break
918-
batch = await list_deployments_page(
919-
db,
920-
user_id=user_id,
921-
deployment_provider_account_id=provider_id,
922-
offset=initial_offset if not cursor_created_at else None,
923-
limit=sync_batch_size,
924-
flow_version_ids=flow_version_ids,
925-
project_id=project_id,
926-
allowed_ids=allowed_ids,
927-
cursor_created_at=cursor_created_at,
928-
cursor_exclude_id=cursor_exclude_id,
929-
)
930-
if not batch:
931-
break
920+
try:
921+
batch = await list_deployments_page(
922+
db,
923+
user_id=user_id,
924+
deployment_provider_account_id=provider_id,
925+
offset=initial_offset if cursor_created_at is None else None,
926+
limit=sync_batch_size,
927+
flow_version_ids=flow_version_ids,
928+
project_id=project_id,
929+
deployment_type=deployment_type,
930+
allowed_ids=allowed_ids,
931+
cursor_created_at=cursor_created_at,
932+
cursor_exclude_id=cursor_exclude_id,
933+
)
934+
if not batch:
935+
break
932936

933-
known, provider_view = await fetch_provider_resource_keys(
934-
deployment_adapter=deployment_adapter,
935-
user_id=user_id,
936-
provider_id=provider_id,
937-
db=db,
938-
resource_keys=[row.resource_key for row, _, _ in batch],
939-
deployment_type=deployment_type,
940-
)
941-
provider_bindings.extend(deployment_mapper.extract_snapshot_bindings(provider_view))
942-
provider_data_by_resource_key.update(deployment_mapper.extract_list_item_provider_data(provider_view))
943-
provider_metadata_by_resource_key.update(deployment_mapper.extract_metadata_for_list(provider_view))
944-
945-
last_row = batch[-1][0]
946-
cursor_created_at = last_row.created_at
947-
cursor_exclude_id = last_row.id
948-
for row, attached_count, matched_flow_versions in batch:
949-
if row.resource_key not in known:
950-
# Provider `known` is type-filtered; skip other local types instead of deleting as stale.
951-
if deployment_type is not None and row.deployment_type != deployment_type:
937+
known, provider_view = await fetch_provider_resource_keys(
938+
deployment_adapter=deployment_adapter,
939+
user_id=user_id,
940+
provider_id=provider_id,
941+
db=db,
942+
resource_keys=[row.resource_key for row, _, _ in batch],
943+
deployment_type=deployment_type,
944+
)
945+
provider_bindings.extend(deployment_mapper.extract_snapshot_bindings(provider_view))
946+
provider_data_by_resource_key.update(deployment_mapper.extract_list_item_provider_data(provider_view))
947+
provider_metadata_by_resource_key.update(deployment_mapper.extract_metadata_for_list(provider_view))
948+
949+
last_row = batch[-1][0]
950+
cursor_created_at = last_row.created_at
951+
cursor_exclude_id = last_row.id
952+
for row, attached_count, matched_flow_versions in batch:
953+
if row.resource_key not in known:
954+
# Page rows are already type-filtered in SQL when deployment_type
955+
# is set, so provider-unknown rows are safe to prune as stale.
956+
stale_deployment_owner_pairs.append(DeploymentOwnerPair(owner_id=row.user_id, deployment_id=row.id))
952957
continue
953-
stale_deployment_owner_pairs.append(DeploymentOwnerPair(owner_id=row.user_id, deployment_id=row.id))
954-
continue
955-
if len(accepted) < size:
956-
accepted.append((row, attached_count, matched_flow_versions))
957-
accepted_deployment_ids.append(row.id)
958+
if len(accepted) < size:
959+
accepted.append((row, attached_count, matched_flow_versions))
960+
accepted_deployment_ids.append(row.id)
961+
except Exception: # noqa: BLE001
962+
# Keep the list path alive: return whatever earlier rounds accepted
963+
# and still prune confirmed stales once below.
964+
logger.warning(
965+
"Deployment list sync round failed for provider %s; continuing with %d accepted row(s)",
966+
provider_id,
967+
len(accepted),
968+
exc_info=True,
969+
)
970+
break
958971

959972
if stale_deployment_owner_pairs:
960973
logger.warning(
@@ -964,7 +977,7 @@ async def list_deployments_synced(
964977
)
965978
await delete_deployments_by_owner_and_ids(db, deployment_owner_pairs=stale_deployment_owner_pairs)
966979

967-
# Phase 2: metadata and binding-level sync.
980+
# Phase 2: metadata and binding-level sync (provider-confirmed rows only).
968981
if accepted:
969982
metadata_updates: list[DeploymentMetadataUpdate] = []
970983
for row, _attached_count, _matched in accepted:
@@ -982,7 +995,8 @@ async def list_deployments_synced(
982995

983996
# Remove stale local attachments based on provider bindings, then recount.
984997
# Best-effort - provider or DB failures should not block the list response.
985-
if accepted:
998+
# Only provider-confirmed IDs: backfill rows below are not binding-synced.
999+
if accepted_deployment_ids:
9861000
try:
9871001
async with db.begin_nested():
9881002
await delete_unbound_attachments(
@@ -1005,12 +1019,40 @@ async def list_deployments_synced(
10051019
exc_info=True,
10061020
)
10071021

1022+
# Pad the response page from local rows after all provider sync is done.
1023+
# These rows may be stale and are not metadata/binding-synced above.
1024+
remaining = size - len(accepted)
1025+
if remaining > 0:
1026+
try:
1027+
backfill_batch = await list_deployments_page(
1028+
db,
1029+
user_id=user_id,
1030+
deployment_provider_account_id=provider_id,
1031+
offset=initial_offset if cursor_created_at is None else None,
1032+
limit=remaining,
1033+
flow_version_ids=flow_version_ids,
1034+
project_id=project_id,
1035+
deployment_type=deployment_type,
1036+
allowed_ids=allowed_ids,
1037+
cursor_created_at=cursor_created_at,
1038+
cursor_exclude_id=cursor_exclude_id,
1039+
)
1040+
accepted.extend(backfill_batch)
1041+
except Exception: # noqa: BLE001
1042+
logger.warning(
1043+
"Deployment list backfill failed for provider %s; returning %d accepted row(s)",
1044+
provider_id,
1045+
len(accepted),
1046+
exc_info=True,
1047+
)
1048+
10081049
total = await count_deployments_by_provider(
10091050
db,
10101051
user_id=user_id,
10111052
deployment_provider_account_id=provider_id,
10121053
flow_version_ids=flow_version_ids,
10131054
project_id=project_id,
1055+
deployment_type=deployment_type,
10141056
allowed_ids=allowed_ids,
10151057
)
10161058
return accepted, total, provider_data_by_resource_key

src/backend/base/langflow/services/database/models/deployment/crud.py

Lines changed: 57 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar
66

77
from lfx.log.logger import logger
8-
from sqlalchemy import Select, column, or_, tuple_, values
8+
from sqlalchemy import Select, and_, column, or_, tuple_, values
99
from sqlalchemy.exc import IntegrityError
1010
from sqlmodel import col, delete, func, select, update
1111

@@ -36,6 +36,21 @@ class DeploymentOwnerPair(NamedTuple):
3636
deployment_id: UUID
3737

3838

39+
class UnknownDeleteCount:
40+
"""Sentinel returned when DELETE rowcount is not a usable integer."""
41+
42+
__slots__ = ()
43+
44+
def __repr__(self) -> str:
45+
return "UnknownDeleteCount()"
46+
47+
def __bool__(self) -> bool:
48+
return False
49+
50+
51+
UNKNOWN_DELETE_COUNT = UnknownDeleteCount()
52+
53+
3954
@dataclass(frozen=True, slots=True)
4055
class DeploymentMetadataUpdate:
4156
langflow_db_row: Deployment
@@ -365,6 +380,7 @@ async def list_deployments_page(
365380
offset: int | None = None,
366381
flow_version_ids: list[UUID] | None = None,
367382
project_id: UUID | None = None,
383+
deployment_type: DeploymentType | None = None,
368384
allowed_ids: list[UUID] | None = None,
369385
cursor_created_at: datetime | None = None,
370386
cursor_exclude_id: UUID | None = None,
@@ -381,9 +397,13 @@ async def list_deployments_page(
381397
authorization plugin reports the caller may read) — see
382398
``langflow.services.authorization.restrict_to_owned_or_visible``.
383399
400+
``deployment_type`` optionally restricts the page to one local deployment
401+
type. When set, callers that also type-filter the provider can treat
402+
provider-unknown rows as stale without a Python type skip.
403+
384404
Use ``offset`` for the first page. Use ``cursor_created_at`` and
385405
``cursor_exclude_id`` together for follow-up keyset reads; cursor reads must
386-
not also pass ``offset``.
406+
not also pass ``offset``. Exactly one of those modes is required.
387407
"""
388408
if offset is not None and offset < 0:
389409
msg = "offset must be greater than or equal to 0"
@@ -394,9 +414,12 @@ async def list_deployments_page(
394414
if (cursor_created_at is None) != (cursor_exclude_id is None):
395415
msg = "cursor_created_at and cursor_exclude_id must be provided together"
396416
raise ValueError(msg)
397-
if cursor_created_at and offset is not None:
417+
if cursor_created_at is not None and offset is not None:
398418
msg = "offset cannot be used with cursor_created_at and cursor_exclude_id"
399419
raise ValueError(msg)
420+
if offset is None and cursor_created_at is None:
421+
msg = "either offset or cursor_created_at/cursor_exclude_id is required"
422+
raise ValueError(msg)
400423
attachment_counts_subquery = (
401424
select(
402425
col(FlowVersionDeploymentAttachment.deployment_id).label("deployment_id"),
@@ -423,13 +446,17 @@ async def list_deployments_page(
423446
stmt = _scope_to_owner_or_allowed(stmt, user_id=user_id, allowed_ids=allowed_ids)
424447
if project_id is not None:
425448
stmt = stmt.where(Deployment.project_id == project_id)
426-
if cursor_created_at:
449+
if deployment_type is not None:
450+
stmt = stmt.where(Deployment.deployment_type == deployment_type)
451+
if cursor_created_at is not None:
452+
# DESC keyset: seek strictly after (created_at, id) cursor.
427453
stmt = stmt.where(
428454
or_(
429-
# older deployments
430455
col(Deployment.created_at) < cursor_created_at,
431-
# Same age but different deployments
432-
(col(Deployment.created_at) == cursor_created_at) & (col(Deployment.id) < cursor_exclude_id),
456+
and_(
457+
col(Deployment.created_at) == cursor_created_at,
458+
col(Deployment.id) < cursor_exclude_id,
459+
),
433460
)
434461
)
435462
if flow_version_ids:
@@ -571,20 +598,24 @@ async def count_deployments_by_provider(
571598
deployment_provider_account_id: UUID,
572599
flow_version_ids: list[UUID] | None = None,
573600
project_id: UUID | None = None,
601+
deployment_type: DeploymentType | None = None,
574602
allowed_ids: list[UUID] | None = None,
575603
) -> int:
576604
"""Count deployments for a provider account.
577605
578606
``allowed_ids`` mirrors ``list_deployments_page``: ``None`` counts owner rows
579607
only (OSS default); a list counts the owner ⊕ ``allowed_ids`` union so the
580608
pagination total reflects the same authorization prefilter as the page.
609+
``deployment_type``, when set, counts only rows of that local type.
581610
"""
582611
stmt = select(func.count(Deployment.id)).where(
583612
Deployment.deployment_provider_account_id == deployment_provider_account_id,
584613
)
585614
stmt = _scope_to_owner_or_allowed(stmt, user_id=user_id, allowed_ids=allowed_ids)
586615
if project_id is not None:
587616
stmt = stmt.where(Deployment.project_id == project_id)
617+
if deployment_type is not None:
618+
stmt = stmt.where(Deployment.deployment_type == deployment_type)
588619
if flow_version_ids:
589620
matched_deployments_subquery = (
590621
select(FlowVersionDeploymentAttachment.deployment_id)
@@ -713,8 +744,13 @@ async def delete_deployments_by_owner_and_ids(
713744
db: AsyncSession,
714745
*,
715746
deployment_owner_pairs: list[DeploymentOwnerPair],
716-
) -> int:
717-
"""Delete owner-scoped deployment rows; return the number of deployment rows deleted."""
747+
) -> int | UnknownDeleteCount:
748+
"""Delete owner-scoped deployment rows.
749+
750+
Returns:
751+
The deleted row count when the driver reports an ``int`` rowcount;
752+
otherwise ``UNKNOWN_DELETE_COUNT``.
753+
"""
718754
if not deployment_owner_pairs:
719755
return 0
720756

@@ -735,14 +771,15 @@ async def delete_deployments_by_owner_and_ids(
735771
).in_(deployment_owner_pairs),
736772
)
737773
)
738-
if result.rowcount is None:
739-
msg = (
740-
"DELETE rowcount was None for deployments=%s -- "
741-
"database driver may not support rowcount for DELETE statements"
742-
)
743-
await logger.aerror(
744-
msg,
745-
deployment_owner_pairs,
746-
)
747-
raise RuntimeError(msg % deployment_owner_pairs)
748-
return result.rowcount
774+
# Prefer isinstance over ``is not None``: some drivers may return non-int
775+
# sentinels, and inventing a count from those would be misleading.
776+
if isinstance(result.rowcount, int):
777+
return result.rowcount
778+
779+
await logger.aerror(
780+
"DELETE rowcount was not an int for deployments=%s (got %r) -- "
781+
"database driver may not support rowcount for DELETE statements",
782+
deployment_owner_pairs,
783+
result.rowcount,
784+
)
785+
return UNKNOWN_DELETE_COUNT

0 commit comments

Comments
 (0)