Skip to content

Commit 6eb45cb

Browse files
committed
refactor(api): review round 2 for the DB-backed scaled backend
Index the claim poll: composite (status, type, created_timestamp) on the job table, declared on the model and added in a guarded EXPAND migration — the claim SELECT and watchdog scan run per worker poll on a table with no retention, so an unindexed scan grows without bound. Operational hardening from the production-lens review: log once at facade wiring when scaled mode is active (a fleet with zero workers otherwise queues silently), state honestly that inline request globals are dropped on every scaled run, document the single-host limit of scaled + SQLite, end the event tail on SUSPENDED (a paused run produces no more frames until resume), and describe the grace-pass window and the deliberately client-bounded QUEUED tail in the events docstring.
1 parent 3958b65 commit 6eb45cb

5 files changed

Lines changed: 75 additions & 11 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""add_job_claim_scan_index
2+
3+
Revision ID: 4b8f2c6d9e1a
4+
Revises: d19e7b3c5a42
5+
Create Date: 2026-07-23 12:00:00.000000
6+
7+
Phase: EXPAND
8+
"""
9+
10+
from collections.abc import Sequence
11+
12+
import sqlalchemy as sa
13+
from alembic import op
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = "4b8f2c6d9e1a" # pragma: allowlist secret
17+
down_revision: str | Sequence[str] | None = "d19e7b3c5a42" # pragma: allowlist secret
18+
branch_labels: str | Sequence[str] | None = None
19+
depends_on: str | Sequence[str] | None = None
20+
21+
_INDEX = "ix_job_claim_scan"
22+
23+
24+
def upgrade() -> None:
25+
"""Composite index for the scaled backend's claim poll and watchdog scan.
26+
27+
``claim_next_queued_lease`` filters ``status`` + ``type`` and sorts by
28+
``created_timestamp`` on every worker poll; the watchdog scans
29+
``status`` + ``type`` on its interval. The job table has no retention, so
30+
without this index the hottest query is an ever-growing scan+sort.
31+
"""
32+
conn = op.get_bind()
33+
existing = {ix["name"] for ix in sa.inspect(conn).get_indexes("job")}
34+
if _INDEX not in existing:
35+
op.create_index(_INDEX, "job", ["status", "type", "created_timestamp"], unique=False)
36+
37+
38+
def downgrade() -> None:
39+
conn = op.get_bind()
40+
existing = {ix["name"] for ix in sa.inspect(conn).get_indexes("job")}
41+
if _INDEX in existing:
42+
op.drop_index(_INDEX, table_name="job")

src/backend/base/langflow/services/background_execution/db_backend.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@
3737

3838
# Durable statuses that mean the run is over (the event tail drains and ends).
3939
_TERMINAL_STATUSES = frozenset({JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED, JobStatus.TIMED_OUT})
40+
# Statuses that end the event tail: terminal runs, plus SUSPENDED — a paused
41+
# run will produce no more durable frames until it is resumed, so a tail
42+
# waiting on it would poll forever (mirrors the default backend's is_done).
43+
_TAIL_END_STATUSES = _TERMINAL_STATUSES | {JobStatus.SUSPENDED}
4044

4145

4246
def _coerce_uuid(job_id: Any) -> Any:
@@ -180,9 +184,17 @@ async def events(self, job_id: str, last_event_id: int = 0) -> AsyncIterator[Any
180184
flips FAILED, then appends ``run_failed``), so a tail that returned on
181185
the first terminal observation could end without the terminal
182186
milestone. On observing a terminal status the tail therefore drains,
183-
waits one more poll interval, drains again, and ends — the append
184-
happens within milliseconds of the flip, so the bounded grace pass
185-
closes the window without risking an unterminated stream.
187+
waits one more poll interval, drains again, and ends. The gap between
188+
flip and append is a handful of sequential DB round trips (widest on
189+
the CANCELLED path), comfortably inside the default 0.5s interval —
190+
an operator tuning ``background_poll_interval_s`` very low narrows the
191+
grace window with it, degrading to a status-only stream end (never a
192+
hang).
193+
194+
A job that no worker ever claims stays QUEUED and the tail keeps
195+
polling: deliberately client-bounded (the job may start any moment;
196+
two PK-indexed queries per interval), matching the semantics of
197+
waiting on a queued run.
186198
"""
187199
# ponytail: poll-based tail (<= poll_interval_s added latency per
188200
# milestone); switch to pg LISTEN/NOTIFY wakes if that ever matters.
@@ -202,7 +214,7 @@ async def _drain() -> AsyncIterator[Any]:
202214
yield event
203215

204216
job = await self._job_service.get_job_by_job_id(durable_id)
205-
if job is None or job.status in _TERMINAL_STATUSES:
217+
if job is None or job.status in _TAIL_END_STATUSES:
206218
# Grace pass: drain, give the terminal-event append one poll
207219
# interval to land, drain once more, then end the stream.
208220
async for event in _drain():

src/backend/base/langflow/services/background_execution/service.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -232,10 +232,12 @@ async def submit(self, *, flow_id: UUID, request: dict[str, Any], user: UserRead
232232
# carry inline secrets (API keys), and storing them plaintext in the
233233
# durable ``job`` table (JSONB on Postgres) widens the blast radius of any
234234
# DB read (backup, ops access, a SQL-injection elsewhere) beyond the
235-
# live-only handling globals get on the sync path. Tradeoff: a background
236-
# re-enqueue after a restart drops inline globals — reference STORED global
237-
# variables by name for background runs rather than passing secrets inline.
238-
# The live in-memory run below still uses the full ``request``.
235+
# live-only handling globals get on the sync path. Tradeoff: inline
236+
# globals are dropped on EVERY scaled-mode run (the worker always
237+
# hydrates from this redacted row) and on a default-mode re-enqueue
238+
# after a restart — reference STORED global variables by name for
239+
# background runs rather than passing secrets inline. Only the
240+
# default-mode live in-process run below uses the full ``request``.
239241
await job_service.update_job_metadata(job_id, {"request": self._redact_request(request)})
240242
# After create_job so an idempotent retry returns the existing job instead of
241243
# cancelling it; the new job is QUEUED, so the suspended-only query skips it.
@@ -488,7 +490,7 @@ async def sweep_orphans_on_startup(self) -> None:
488490
worker_lost + terminal event). QUEUED workflow rows never started, so
489491
under at-least-once we re-enqueue them onto this worker's executor with a
490492
reconstructed request. Best-effort per job so one bad row can't block the
491-
rest. Redis backend reconciles via its own watchdog.
493+
rest. The scaled backend reconciles via its worker-side watchdog.
492494
"""
493495
if self._is_scaled_configured:
494496
return

src/backend/base/langflow/services/database/models/jobs/model.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from typing import Any
44
from uuid import UUID, uuid4
55

6-
from sqlalchemy import JSON, Column, DateTime, Integer, String, Text, UniqueConstraint
6+
from sqlalchemy import JSON, Column, DateTime, Index, Integer, String, Text, UniqueConstraint
77
from sqlalchemy import Enum as SQLEnum
88
from sqlalchemy.dialects.postgresql import JSONB
99
from sqlmodel import Field, SQLModel
@@ -92,6 +92,12 @@ class JobBase(SQLModel):
9292

9393
class Job(JobBase, table=True): # type: ignore[call-arg]
9494
__tablename__ = "job"
95+
# The scaled backend's claim poll (claim_next_queued_lease) filters on
96+
# status + type and sorts by created_timestamp on every worker poll; the
97+
# watchdog scans status + type on its interval. The job table has no
98+
# retention, so without this composite the hottest query degrades into an
99+
# ever-growing scan+sort over terminal rows.
100+
__table_args__ = (Index("ix_job_claim_scan", "status", "type", "created_timestamp"),)
95101

96102

97103
class JobEvent(SQLModel, table=True): # type: ignore[call-arg]

src/lfx/src/lfx/services/settings/groups/runtime.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,9 @@ class RuntimeSettings(BaseModel):
9797
the work queue: the API only persists the QUEUED row, and separate
9898
``langflow worker`` processes lease-claim and run jobs against the SAME
9999
database, so background load runs off the API workers and scales
100-
horizontally. No broker is needed — the database is the queue."""
100+
horizontally. No broker is needed — the database is the queue. Every worker
101+
must reach that one database: use Postgres for multi-host fleets (SQLite's
102+
WAL is host-local, so scaled + SQLite only works on a single machine)."""
101103
background_poll_interval_s: float = Field(default=0.5, gt=0)
102104
"""How often a scaled-mode event tail polls ``job_events`` for new durable
103105
frames while a job is live. Bounds the added reattach latency per milestone.

0 commit comments

Comments
 (0)