Commit Graph

14020 Commits

Author SHA1 Message Date
2e66b4a2fd fix(background): close attempt-cap race by bumping attempt and flipping QUEUED atomically 2026-06-04 12:47:01 -03:00
d19ab3d194 fix(background): redact inline globals from the persisted replay request
submit persisted the full request body on the durable job row for faithful
replay, but request-level globals can carry inline secrets (API keys), landing
plaintext in the job table (JSONB on Postgres) and widening the blast radius of
any DB read. Redact globals from the persisted copy (the live in-memory run
still uses them). Tradeoff: a background re-enqueue and a scaled worker run drop
inline globals, so reference stored global variables by name for background runs
rather than passing secrets inline.
2026-06-04 12:32:30 -03:00
67655732bd fix(background): drop the dead pub/sub stop fast-path, durable poll is the path
stop() set a redis cancel marker and PUBLISHed on a cancel channel as a fast
path, but the background worker (run_worker_loop -> WorkerJobRunner -> JobRunner)
never subscribes to that channel or checks the marker — only the v1
RedisJobQueueService dispatcher does, which the worker does not run. So the
marker + PUBLISH were no-ops in production, a misleading dead fast-path. Remove
them: the durable STOP signal polled at each vertex boundary is the single
mechanism. Add a real-redis latency test proving scaled stop is bounded by the
durable poll cadence, and drop the v1-dispatcher fast-path test that gave false
confidence.
2026-06-04 12:29:00 -03:00
895dbb45e6 fix(background): bound _closed via LRU, preserving reattach-to-closed terminate
The first _closed bound evicted the marker on last-subscriber drop and skipped
marking when no subscriber existed, which broke a direct reattach to a finished
job that closed with no subscriber (it blocked on a tail that never produces).
Always record close() and bound the map with an LRU cap instead, so the
standalone reattach-terminates contract holds while a long-lived process still
cannot leak one key per job.
2026-06-04 12:23:36 -03:00
b3b71b675e fix(background): batch the scaled live-bus stream TTL refresh, not per-XADD
RedisStreamLiveBus called expire() on every XADD, doubling redis round-trips per
token frame and capping single-job streaming throughput. Mirror the v1 Streams
bridge: refresh the TTL on the first frame, then every 100 frames or 30s, and
always on close, preserving the TTL semantics at ~1/100 the EXPIRE rate.
2026-06-04 12:21:17 -03:00
7b0214acc9 fix(background): close the scaled backend redis client on facade teardown
In scaled mode the facade built a redis client for the backend but teardown()
only stopped the executor, leaking the API replica's background-execution
connection pool on shutdown. Add a backend teardown() that closes its client and
call it from the facade teardown (the worker process already closes its own).
2026-06-04 12:19:47 -03:00
aef66a3a09 fix(background): bound InMemoryLiveBus._closed so it does not leak per job
close() set _closed[job_id]=True and nothing ever evicted it, so a long-lived
API process leaked one key per completed job. The marker only needs to outlive
close() until existing subscribers drain (a late reattach to a finished job is
gated on the persisted JobStatus by the facade, not this in-process flag), so
set it only when subscribers exist and evict it with the last subscriber drop.
2026-06-04 12:18:59 -03:00
698167cb3f fix(background): make durable replay byte-identical to live per protocol
_row_to_frame serialized durable payloads with json.dumps default (spaced)
separators, but the agui live path uses pydantic model_dump_json (compact
separators), so replayed agui frames were not byte-identical to live frames.
Pass the run's stream protocol (read off the persisted submit request) into
_row_to_frame and pick compact separators for agui, spaced for langflow, so
replay == live byte-for-byte on both wires.
2026-06-04 12:08:50 -03:00
b8209fbd6d fix(background): dedup durable milestones at the cross-replica reattach seam
The scaled events() replayed durable job_events from the DB then tailed the
redis Stream from 0-0 with no seq filter, so every milestone the worker wrote to
BOTH the DB and the Stream was delivered twice on reattach. Track the highest
durable seq replayed and skip any Stream frame whose worker-stamped seq is
<= highest, mirroring the in-memory bus's dedup-at-the-seam rule, so each
milestone is delivered exactly once on both paths.
2026-06-04 11:48:19 -03:00
c2dee9dc9d fix(background): unify Last-Event-ID namespace to durable seq for live frames
The live SSE stream baked its own per-frame stream-seq counter into the id:
line while durable replay used job_events.seq, two different namespaces. A
client reconnecting with a live Last-Event-ID resumed against the wrong cursor
and silently missed or duplicated milestones. The runner now re-stamps every
published frame's id: with its durable seq (the row seq for a milestone, the
last milestone's seq for an ephemeral token) so live ids and durable replay ids
share one cursor and a mid-run reattach resumes exactly after the durable seq.
2026-06-04 11:46:51 -03:00
2e0e5e17fe test(background): explicit no-double-run-under-concurrent-reconcile proofs
Drain the claim queue after a concurrent-reconcile race and assert the lost job
is claimable exactly once (a side-effect-style run counter), so a draining worker
re-runs it at most once more, never twice.
2026-06-04 11:24:38 -03:00
784e5f410c fix(background): default re-enqueue lease-claims QUEUED without flipping IN_PROGRESS
The startup re-enqueue flipped a QUEUED row to IN_PROGRESS at claim time, so a
crash before the runner started left a stranded IN_PROGRESS the next sweep failed
worker_lost -- a job that never ran ended FAILED. claim_queued_lease stamps a
single-flight owner+heartbeat WITHOUT changing status, so the row stays QUEUED
(re-runnable) and the runner's execute_with_status performs the real
QUEUED->IN_PROGRESS flip only when it actually starts.
2026-06-04 11:21:44 -03:00
aada5d2d97 fix(background): terminal events on TIMED_OUT/CANCELLED + late-stop result consistency
execute_with_status writes the TIMED_OUT/CANCELLED status but no durable error
blob or terminal job_events row, breaking the design invariant that every
terminal path writes result/error + a terminal event. The runner now backfills
run_timed_out (+error) / run_cancelled in its finally. A late stop that wins over
a racing completion clears the completed-run result and sets error=cancelled so a
CANCELLED row never carries a completed result.
2026-06-04 11:13:31 -03:00
c67da5c27b fix(background): lease-aware scaled reconcile + periodic watchdog + QUEUED-strand recovery
requeue_lost only reconciles processing-list ids whose lease is stale/absent, so
a booting/scaled-up worker can no longer re-claim and double-run (or fail) a job
a live worker is mid-running. A periodic watchdog runs requeue_lost on an interval
inside run_worker_loop so a dead worker's in-flight job is reaped under a steady
fleet WITHOUT a restart. The worker stamps a heartbeat on claim and the in-flight
runner keeps it fresh. Retry-safe attempt accounting is now an atomic conditional
increment (increment_attempt_if) so concurrent reconcilers cannot exceed
max_attempts; the LREM count is the single-flight token so two watchdogs cannot
double-enqueue. recover_stranded_queued re-enqueues QUEUED rows present on neither
redis list (API-crash window) so a job is never stuck QUEUED forever.
2026-06-04 10:17:05 -03:00
fe77722abe fix(background): liveness-aware + single-flight default orphan sweep
sweep_orphans only fails IN_PROGRESS rows whose heartbeat is stale/absent, so a
booting worker can no longer flip a sibling's actively-running, freshly-
heartbeated job FAILED(worker_lost) under gunicorn -w N. The runner now refreshes
a job-row heartbeat (owner+ts) on an interval while in flight. The startup sweep
is single-flighted with a FileLock (the starter-projects pattern), and its
terminal-event insert reuses append_event so a seq collision can't roll back the
whole sweep.
2026-06-04 09:25:26 -03:00
62d73d0f5d fix(background): scope idempotency_key dedupe per-user
create_job's dedupe count was global, so a client-controlled idempotency_key
let user A collide with / DoS user B's key and the 500 leaked its existence.
Scope the count by user_id (ownerless rows keep a global floor for AUTO_LOGIN),
matching the already-user-scoped lookup. Defense-in-depth: map DuplicateJobError
to a 409 with a generic body in execute_workflow_background so the residual
create/lookup race cannot 500-leak the key.
2026-06-04 09:15:35 -03:00
cdb5d4dcc4 feat(background): add heartbeat/lease + atomic attempt store primitives
Lease+heartbeat liveness in job_metadata (no new column): heartbeat() stamps
owner+timestamp via shallow merge; is_lease_stale() tells a live in-flight run
from a genuinely orphaned one; increment_attempt_if() is an atomic conditional
bump so concurrent reconcilers cannot push a job past max_attempts.
2026-06-04 09:08:45 -03:00
112340c0f5 test(locust): add v2 background execution saturation locustfile
Submits POST /api/v2/workflows with mode=background, polls
GET /api/v2/workflows?job_id=... to a terminal status, and records a
success only when the job reaches COMPLETED. Tracks submitted vs
completed counts in the quitting summary so the run can be cross-checked
against the job table for lost or stuck jobs.
2026-06-04 08:55:12 -03:00
80eea66822 test(background): drop unused processing-key constant from subprocess harness 2026-06-04 07:53:29 -03:00
c90fa34a09 test(background): kill worker process group on teardown + kill9 so 'uv run' child python cannot leak 2026-06-04 07:46:02 -03:00
20f5c6940f test(background): restore manager DB service + settings on subprocess harness teardown to prevent state leak 2026-06-04 07:26:44 -03:00
e2be9f932a test(background): make concurrent-claim exactly-once proof deterministic (direct claim race) 2026-06-04 06:58:20 -03:00
4b99f8c9ab test(background): bounded-concurrency hard proof (N+k submitted, only N run, backlog drains) 2026-06-04 06:50:53 -03:00
fa33900b8c test(background): side-effect-safety crown jewel with a real side-effecting component 2026-06-04 06:49:20 -03:00
c4f31d50ab test(background): head-to-head deltas (bounded pool, durability, sweep claim OFF vs ON) 2026-06-04 06:45:30 -03:00
ff70e10aee test(background): real langflow worker OS subprocess hard proofs (e2e, kill -9 watchdog, pub/sub stop) 2026-06-04 06:42:39 -03:00
57472d9dc7 test(background): prove real worker-death watchdog and scaled stop->CANCELLED on redis 2026-06-04 06:09:31 -03:00
ffdbd1b7fa feat(background): wire scaled redis backend into the facade + e2e claim-run-reattach proof 2026-06-04 06:04:07 -03:00
0f70af1513 feat(background): add 'langflow worker' CLI command and build_worker factory 2026-06-04 05:56:16 -03:00
c2170b6016 feat(background): add WorkerJobRunner that hydrates and runs a durable job 2026-06-04 05:54:18 -03:00
c4f8c0a0ac feat(background): add RedisStreamLiveBus producer for cross-replica worker frames 2026-06-04 05:51:55 -03:00
8265afddd7 feat(background): add redis claim-and-run worker loop 2026-06-04 05:49:27 -03:00
4d8c0708f5 test(background): prove stop() pub/sub fast-path reaches the owning dispatcher 2026-06-04 05:20:09 -03:00
1539b7fba2 feat(background): stop() writes durable STOP signal + redis pub/sub fast-path 2026-06-04 05:15:28 -03:00
a12004b955 feat(background): lease watchdog requeues lost work (at-most-once default, opt-in retry) 2026-06-04 05:12:59 -03:00
26886f3b49 test(background): prove cross-replica reattach over real redis Streams 2026-06-04 05:09:19 -03:00
780672b022 feat(background): add RedisBackgroundQueue (claim queue + DB replay + Streams tail) 2026-06-04 05:08:31 -03:00
a236bdeb7e test(background): prove BRPOPLPUSH blocking handoff against real redis 2026-06-04 05:07:11 -03:00
5ce9b1972e feat(background): add RedisJobClaimQueue (LPUSH/BRPOPLPUSH lease-claim queue) 2026-06-04 05:06:11 -03:00
84b6d23deb feat(settings): add LANGFLOW_TEST_REDIS_URL and background_backend_is_scaled selector 2026-06-04 05:04:20 -03:00
aa872e5bf4 test(background): add real-redis fixture keyed on LANGFLOW_TEST_REDIS_URL 2026-06-04 05:02:36 -03:00
219efa0598 perf(background-execution): poll the STOP signal only on durable frames
The runner read unconsumed_signals (a DB query) on every frame including each
ephemeral token, so stop polling scaled with the token stream. A cooperative
stop is only honored at vertex/milestone boundaries, so poll only on durable
frames; the post-loop check still catches a STOP that lands after the last
frame.
2026-06-04 04:43:39 -03:00
2febd2a44b fix(workflows): do not let a late /stop overwrite a finished job
The v2 stop handler called update_job_status(CANCELLED) unconditionally, so
stopping an already-COMPLETED/FAILED/TIMED_OUT job flipped it to CANCELLED while
its result/error blob stayed put. Return the existing terminal state instead of
transitioning when the row is already finished.
2026-06-04 04:41:54 -03:00
d06020fc87 fix(background-execution): await in-flight job tasks on executor stop
stop() cancelled in-flight tasks but returned without awaiting them, so a job's
shielded terminal reconcile (a DB write) could race a closing engine and leave a
'Task was destroyed but it is pending' warning. Cancel and gather the in-flight
job tasks first, then tear down the workers. Cancelling the job tasks directly
(rather than relying on cancellation propagating through the worker's await)
avoids the worker sitting in cancellation limbo behind a job that swallows its
cancel on the user-stop path, which otherwise stalls teardown.
2026-06-04 04:37:34 -03:00
de86276094 fix(background-execution): stamp STOP signals consumed when acted on
unconsumed_signals filters consumed_at IS NULL but nothing stamped it, so STOP
rows lingered: the table grew and a re-enqueued job self-cancelled off a stale
STOP. Add JobService.consume_signals and stamp the STOP in the runner's
_reconcile_stop, the single point where a stop is finalized.
2026-06-04 04:05:28 -03:00
d76e86416f fix(background-execution): claim QUEUED jobs atomically in the startup sweep
Two uvicorn workers booting against the same DB both re-enqueued the same QUEUED
row, so a non-idempotent flow executed twice. Add JobService.claim_queued_job, a
conditional UPDATE ... WHERE status='QUEUED' that only one racer can win
(rowcount==1), and gate sweep re-enqueue on it. Works on SQLite and Postgres.
2026-06-04 04:03:29 -03:00
9f109320c8 fix(background-execution): enforce background_job_timeout on runs
The background_job_timeout setting was read by nobody, so a runaway run never
timed out. Bound the runner's drive with asyncio.wait_for(timeout=...) when the
setting is configured; execute_with_status already maps asyncio.TimeoutError to
TIMED_OUT, so the run ends TIMED_OUT with a terminal event. None keeps the prior
unbounded behaviour. The facade passes settings.background_job_timeout through.
2026-06-04 04:01:02 -03:00
c5e968a56c fix(background-execution): answer reattach to terminal jobs from durable status
events() decided 'is the job finished' from the process-local live bus, whose
_closed marker is empty after a restart. A reattach to an already-terminal job
replayed durable rows then blocked forever on the live tail. Key the decision off
the persisted job status instead: when terminal, replay the durable log and
return. Re-frame durable rows through the same format_sse_event formatter the
live path uses (with id=str(seq)) so replayed and live frames are byte-compatible
and Last-Event-ID resumes correctly.
2026-06-04 03:59:04 -03:00
49c6b382a7 fix(background-execution): make worker cancel-handling Python 3.10 safe
The worker used asyncio.Task.cancelling() to tell a job-cancel apart from a
worker-cancel, but that API is Python 3.11+. On 3.10 the first job cancellation
raised AttributeError inside the cancel handler and permanently killed the
worker, hanging later jobs QUEUED. Replace it with an explicit _closed flag set
by stop(): a CancelledError while shutting down re-raises to exit the worker,
otherwise it is a per-job cancel that is swallowed so the pool keeps serving.
2026-06-04 03:55:10 -03:00
dcf7ed890d test(lfx): include idempotency_key in WorkflowRunRequest round-trip body 2026-06-04 03:44:37 -03:00