Repository navigation
feat(api): DB-backed scaled background execution backend + langflow worker - #13508
ogabrielluiz wants to merge 95 commits into
Conversation
Rebased onto release-1.10.0. The base independently rebuilt the v2 workflows backend (RBAC, body globals, share-aware fetch); keep our forward design and conform its auth to that work: 1. Auth: keep get_current_user_for_workflow (session-or-API-key authN that does not hold a DB connection during the inline run, avoiding the SQLite lock contention api_key_security would cause) and enforce the base's RBAC on top: ensure_flow_permission(EXECUTE) before run, (READ) before status reconstruct, with widen_for_shares fetch. 2. Port the base's request-body globals onto the v2 WorkflowRunRequest. The X-LANGFLOW-GLOBAL-VAR-* headers stay supported (the Responses API passes globals that way); body globals win on conflict. Converters echo the effective globals via effective_globals. 3. Public endpoint keeps the v1 build_public_tmp posture (access_type==PUBLIC, run-as-owner); RBAC applies to the authenticated endpoint only. 4. Preserve the base's post-build KB-cache invalidation in the AG-UI build path. The endpoint, AG-UI bridge, pluggable stream adapters, public endpoint, and re-attach are unchanged.
The synchronous /api/v2/workflows response keyed every result under its component id, so reading the answer meant knowing an id you can't predict. Surface two additive fields: - output_text: the flow's single text answer (ChatOutput/TextOutput). None when the flow has zero or multiple text outputs, so callers read outputs rather than the shortcut guessing which channel is the answer. - session_id: echoes the resolved session so chat/memory callers can continue the same thread (v1 /run returned this; v2 had dropped it). outputs is unchanged, so this is non-breaking.
…ponse
Pin the sync-response shortcuts on the v2 workflows endpoint:
- output_text surfaces the lone ChatOutput/TextOutput text and stays None for
non-output message nodes, data-only flows, and multi-text flows
- session_id echoes the resolved session; the error response exposes neither
- each outputs entry exposes only {type, status, content, metadata}, with the
component id carried by the dict key
Also drop the component_id kwarg the converter passed to ComponentOutput, which
has no such field and silently dropped it.
Replace the flat output_text shortcut with an `output` object carrying the resolved text answer plus a `reason` that explains why it resolved that way (single/multiple/none/non_string/failed), so a null answer is always diagnosable instead of silently None. `reason` follows the LLM-domain finish_reason/stop_reason convention, distinct from the lifecycle status. Also add `display_name` to each ComponentOutput (the stable component id stays the dict key) and a computed `has_errors` flag derived from errors.
Let a sync caller name the output(s) they want via output_ids so output.text resolves deterministically (reason=single) on multi-output flows instead of going null. Selection is steer-only: it picks the answer among the named outputs without filtering the outputs map. Invalid ids are rejected with 422 before the flow runs (and before any job row is created), so a typo costs no compute. Resolution considers selected outputs that actually fired, so branching flows resolve to whichever candidate ran.
Give v2-workflows sync and the langflow stream protocol one parser. The stream now emits a normalized "output" event per terminal output carrying an OutputEvent (the ComponentOutput shape sync returns in outputs[id], plus component_id). A shared build_component_output() backs both the sync converter and the adapter, and the build loop ships authoritative vertex metadata as an additive output_meta key on end_vertex (existing consumers read build_data and ignore it). This is access-pattern parity (one parser, same fields, same terminal set), not byte-identical content: the stream reuses the v1 build path whose display serialization differs from sync's run_graph output.
…ackend) Turns v2 mode:background into a durable, in-API background execution service behind a BackgroundExecutionService facade. Adds the store layer (result/error columns, job_events durable milestone log, execution_signals control, heartbeat/lease, 3 migrations), the default backend (bounded executor, runner, in-memory live bus, liveness-aware single-flight orphan sweep), the v2 endpoint rewiring, and the real-instance test harness. Needs no new infra; works on the SQLite single-process install. The redis-scaled worker backend is stacked on top in a follow-up PR.
|
✅ Migration Validation Passed All migrations follow the Expand-Contract pattern correctly. |
The hard_proof marker name was a vibe word that said nothing about what the tests need. Rename it to real_services everywhere: the pytest marker registration, the *_hard_proof.py test files, the Makefile target (real_services_tests), the -m selector in migration-validation.yml, and the CI job. real_services says what these tests require: real Postgres + Redis + worker subprocesses. (integration was already taken for the external-API suite under tests/integration.)
…rker Stacks on the default backend. Adds the opt-in redis backend: a langflow worker CLI claiming jobs off a redis list, a redis Streams live bus for cross-replica reattach, and a lease+heartbeat+periodic watchdog (liveness-aware reconcile, atomic retry accounting). Same facade; default behavior unchanged when no redis is configured. Proven on real redis incl. a real worker subprocess, kill-9 watchdog reconcile, and a Locust load test (22,841 req / 0 failures / ~2.5x throughput vs in-process). Includes the v2 background locustfile.
…ting prints The events.quitting listener took _environment as a required positional arg, but locust passes environment as a keyword, so the handler raised TypeError at shutdown and the submitted/completed summary never printed (locust exited 2). Accept the event kwargs via **_kwargs.
… in scaled worker tests Follows the marker rename on the base PR: updates the scaled worker subprocess tests and the subprocess harness to the real_services marker.
e2ccbaf to
2f961f5
Compare
…kflows-agui # Conflicts: # src/frontend/src/stores/flowStore.ts
The v2 public endpoint only ran validate_flow_for_current_settings and skipped validate_public_flow_no_code_execution, which the v1 build_public_tmp path applies. A public flow containing a Python interpreter/REPL (or the legacy Python Code Structured tool, Smart Transform lambda) was therefore an unauthenticated server-side code-execution primitive (report H1-3754930). Mirror v1: import the validator and call it right after the public-access gate. PublicFlowValidationError subclasses CustomComponentValidationError, so the existing handler already sanitizes it to a 400 'This flow cannot be executed.' without leaking the blocked component class names. Add a non-mocking test that builds a public flow with a real PythonREPLComponent and asserts the sanitized 400 (verified RED: returns 200 without the gate). LE-1389
…rtex builds A completed background job's GET status 500'd with 'No vertex builds found for job_id'. The background build path differed from the sync path twice: 1. generate_flow_events minted a fresh run_id instead of using job_id, so vertex builds were keyed by an id the status query never uses. Thread run_id through _stream_event_frames -> generate_flow_events and pass job_id from the background buffer so graph.run_id == job_id (the sync path already does graph.set_run_id(job_id)). 2. The SSE build loop (build_vertices) only persisted builds when log_builds was set and never passed job_id. Tie log_builds to job-tracked runs (run_id present) and pass job_id=graph.run_id on the persist call. Job-tracked runs also persist streaming terminal vertices so reconstruction is complete; the live build path (run_id is None) keeps its original behavior, so the v1 build path is unchanged. Test: a real background run polled to completion, then GET status asserts a reconstructed 200 (verified RED: 500 'No vertex builds found' before the fix). Covers the non-streaming flow. v1 build path unchanged (35 build tests pass); AG-UI suite 46 pass. LE-1389
Adopt 1.11.0's composed group-mixin Settings for lfx settings/base.py (all v2 settings already present in the new groups); port the Python 3.14 cors_origins ['*']->'*' fix into groups/security.py.
…ows-bg-default Port the 6 background-execution settings and background_backend_is_scaled into 1.11.0's RuntimeSettings group mixin; union the BACKGROUND_EXECUTION_SERVICE / TELEMETRY_WRITER_SERVICE additions in schema.py and deps.py.
…workflows-bg-scaled
This comment has been minimized.
This comment has been minimized.
1 similar comment
This comment has been minimized.
This comment has been minimized.
# Conflicts: # src/lfx/src/lfx/services/settings/groups/runtime.py
This comment has been minimized.
This comment has been minimized.
release-1.13.0 added d8f2c3a4b5e6 under the same parent, so the merge left two alembic heads even though git merged cleanly. The index migration now revises it under a fresh revision id (a9e4c1b7d306): reusing an id across a re-parent lets a database already stamped with it skip the migrations in between.
This comment has been minimized.
This comment has been minimized.
# Conflicts: # src/backend/base/langflow/services/background_execution/service.py # src/backend/base/langflow/services/jobs/service.py # src/backend/tests/unit/background_execution/test_real_services_harness.py # src/lfx/src/lfx/services/settings/groups/runtime.py # src/lfx/tests/unit/services/settings/test_settings_composition.py
Retention landed on the release branch in #15316 and is documented on the environment variables page, but the background settings table on the multi-worker page did not mention it. With the scaled backend the job table is the work queue, so every finished run sits under the claim scan until something deletes it, which is worth saying where an operator is already reading about that queue. The wording follows the shipped behavior: the window covers every finished run in the job store, not only background runs.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
release-1.13.0 added the flow version-token migrations and three merge nodes, leaving our index migration's parent with a sibling, so the merge produced two alembic heads even though the content merged cleanly. The index migration now revises 4e7a2b9c1d05 under a fresh revision id (b6f3a8d2c471): reusing an id across a re-parent lets a database already stamped with it skip the migrations in between.
This comment has been minimized.
This comment has been minimized.
# Conflicts: # src/backend/base/langflow/services/background_execution/service.py # src/backend/base/langflow/services/database/models/jobs/model.py # src/backend/base/langflow/services/jobs/service.py # src/backend/tests/unit/services/background_execution/test_standalone_slice.py # src/lfx/tests/unit/services/settings/test_settings_composition.py
This comment has been minimized.
This comment has been minimized.
The extensionless ../Develop/environment-variables was resolved as a route (/Develop/environment-variables), which does not exist because the doc routes are flat. Docusaurus failed the build on it. The two other Deployment pages that link this reference use the .mdx file path, which Docusaurus resolves against the filesystem instead.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
|
Looked at the scaled backend against the usual db-as-a-queue failure modes. Core lease/claim/watchdog looks solid. A few things: Heartbeat and terminal write. The heartbeat is an asyncio task on the same loop as the run ( Clock skew. Retry backoff. A requeued retry_safe job flips straight to QUEUED and is immediately re-claimable, so a deterministic failure retries back-to-back to max_attempts. Default workflows don't set retry_safe so it's narrow, but a small delay would help. A few that look deliberate, want to confirm:
Minor: no single alembic head gate here, worth running |
The watchdog's failure write was conditional but the success write was not, so the two could not both be right. A run whose heartbeat stalled long enough to be reconciled as worker_lost would finish and flip the row back from FAILED to COMPLETED, leaving a completed job still carrying a run_failed event after a reattached client had already been told it failed. The heartbeat is a task on the run's own event loop, so any component that blocks the loop reaches this. The success write is now conditional like the failure one. Guarded on the row not already being terminal rather than on IN_PROGRESS, because several callers pass mark_in_progress=False and own the status themselves; an IN_PROGRESS guard would strand their jobs non-terminal instead. The owner guard covers the requeue case, where the row is IN_PROGRESS again under somebody else and a status check alone cannot tell the difference. An absent owner passes it: the owner is written by the heartbeat rather than at claim time, so a run short enough to finish before its first beat has no name on its row and must still complete. The guard is 'not somebody else's', not 'provably mine'. Losing the write now logs rather than passing silently, since the race firing is worth seeing.
# Conflicts: # docs/docs/Support/troubleshooting.mdx
This comment has been minimized.
This comment has been minimized.
|
Thanks, the terminal write is a real bug and you're right about the cause. Fixed in 38a3ac9. Two deviations from your suggestion worth flagging: Guarded on "not already terminal" rather than IN_PROGRESS. Several callers pass The owner guard is in, but an absent owner has to pass it. Owner is written by the heartbeat rather than at claim time, so a run that finishes before its first beat has no name on its row. My first version required a match and stranded exactly the fastest jobs. It checks "not somebody else's" rather than "provably mine". Losing the write logs a warning now instead of passing silently. On the counters you hadn't checked: no double-count. Clock skew and retry backoff are both real and unfixed. Skew is inherent to comparing the reconciler's clock against a worker's timestamp, and I don't have better than requiring NTP and saying so. Backoff I'd like to do, it's small. The three you asked about are deliberate, and they're in the description as the known ceiling with SKIP LOCKED as the marked upgrade path. The depth cap is the one I'd reconsider soonest, since growing until retention or disk is a worse failure than a rejected submit. Do you think that should come now rather than later? alembic heads is single on the merge, c7d2b9e4f813. Agree it should be gated rather than left to habit. |
release-1.14.0 brings a migration of its own, so the branch had two alembic heads after the merge. The index now follows 1d8d193028c8 and carries a fresh revision id, because reusing the old one lets a database already stamped with it skip the migration entirely.
|
Build successful! ✅ |
Adds the opt-in scaled backend so background flows run off the API workers, decoupled and distributed across replicas. Same
BackgroundExecutionServicefacade, the endpoint never knows which backend is active. The queue is the database itself: no broker, no redis, so deployments need nothing beyond the DB the API already uses.This sits directly on the release branch. The facade refactor that makes the default in-process path a first-class backend (#15018) is stacked on top of it rather than underneath.
What it adds
langflow workerCLI process that lease-claims QUEUED job rows off the shared database (claim_next_queued_lease, the same exact-heartbeat conditional UPDATE every other single-flight guard uses), runs the same runner the default backend uses, and persists durable milestones tojob_eventsso any API replica can reattach to any job.job_eventsfrom the DB, then poll the same table past the replayed boundary (no gap, no double delivery,Last-Event-IDcontract preserved). A grace pass on terminal status covers the paths that flip the status before appending their terminal milestone. Tails end on SUSPENDED too, so a HITL pause never strands a reader.execution_signalsSTOP row that the worker polls at vertex boundaries.(status, type, created_timestamp)index backing the claim poll and watchdog scan.ix_job_status_created, which shipped with the job metrics, cannot serve either query because thetypefilter sits between its two sorted columns.Because the queue and the system of record are one table, the broker failure modes are structurally impossible: there is no pending/processing list to drift from the DB (a QUEUED row is enqueued by definition, so stranded-queue recovery has no equivalent) and no token-exchange protocol (the conditional UPDATE is the single flight).
Selection is the explicit
LANGFLOW_BACKGROUND_BACKEND=scaledsetting, decoupled fromjob_queue_type(redis for v1 build events no longer implies anything about the background backend). The facade logs once at wiring time in scaled mode so a fleet with zero workers is diagnosable. Scaled + SQLite works on a single host; use Postgres for multi-host fleets.Semantics
Same guarantees as the default backend, now across processes: at-least-once for not-yet-started work, at-most-once for in-flight work by default (a crashed worker's job becomes FAILED
worker_lost), and opt-in bounded retry per flow with atomic attempt accounting so concurrent watchdogs can't push a job pastmax_attempts.Tradeoffs made deliberately: claim latency is bounded by the worker poll window (
--idle-block-ms, default 1s) and reattach milestone latency bybackground_poll_interval_s(default 0.5s) instead of a broker's push; inline requestglobalsare dropped on every scaled run (the worker hydrates from the redacted persisted row, so background runs need stored global variables).What's proven
The former real-redis test fleet now runs against the ordinary test DB (no external service), and the real-service tier needs only Postgres:
langflow workerOS subprocess claims, builds, and completes a real graph off a shared Postgres while the API side reattaches; akill -9mid-job is reconciled by the watchdog (fresh-lease guard proven first); a durable stop reaches the separate worker and lands CANCELLED with the signal consumed.run_failedevent, racing lease-claims on one row produce exactly one winner.Three review rounds (specialist + adversarial validation per round) ran on this rework; all findings are fixed in-branch. The redis-era Locust numbers were redone against this backend on real Postgres: the claim path holds single-flight at every fleet size tested (1 to 16 workers), the composite claim-scan index is load-bearing (claim latency degrades 48x without it), and event tails stay lossless through 1000 concurrent jobs. Head-to-head under saturation the scaled backend sustains 14 to 17 completions/s at 50 to 200 concurrent users with zero lost jobs, while the in-process default collapses to 1.4/s with 35% of submissions never completing (about 10x). Known ceiling at this stage: FIFO candidate contention rises with worker count (90.9% lost-claim rate at 16 workers with candidates=5). SKIP LOCKED is the marked upgrade path, and the rest of the P0 hardening list is worker-loop claim resilience plus the worker-fleet gauges. Retention/purge and the queue metrics that were on that list have since shipped on the release branch (#15316, #15314), so this branch carries the claim-scan index against a table that retention now bounds.
Summary by CodeRabbit
New Features
Bug Fixes
Tests