Skip to content

feat(api): DB-backed scaled background execution backend + langflow worker - #13508

Open
ogabrielluiz wants to merge 62 commits into
release-1.11.0from
feat/v2-workflows-bg-scaled
Open

feat(api): DB-backed scaled background execution backend + langflow worker#13508
ogabrielluiz wants to merge 62 commits into
release-1.11.0from
feat/v2-workflows-bg-scaled

Conversation

@ogabrielluiz

@ogabrielluiz ogabrielluiz commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Second of a two-PR stack, stacked on the durable default backend PR. This adds the opt-in scaled backend so background flows run off the API workers, decoupled and distributed across replicas. Same BackgroundExecutionService facade, the endpoint never knows which backend is active. The queue is the database itself: no broker, no redis — deployments need nothing beyond the DB the API already uses.

What it adds

  • A langflow worker CLI 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 to job_events so any API replica can reattach to any job.
  • A lease + heartbeat + periodic watchdog: a worker stamps a heartbeat while it runs, and a dead worker's in-flight job is reconciled by the watchdog without needing a restart. Reconciliation is liveness-aware, so a booting or scaled-up worker never re-claims or fails a job another worker is mid-running, and the worker_lost transition is a conditional UPDATE so concurrent watchdogs cannot write duplicate terminal events.
  • Cross-replica reattach: replay the durable job_events from the DB, then poll the same table past the replayed boundary (no gap, no double delivery, Last-Event-ID contract 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.
  • Stop is the durable execution_signals STOP row that the worker polls at vertex boundaries.
  • A composite (status, type, created_timestamp) index backing the claim poll and watchdog scan (the job table has no retention, so the hottest query must not degrade into a growing scan).

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=scaled setting, decoupled from job_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 past max_attempts.

Tradeoffs made deliberately: claim latency is bounded by the worker poll window (--idle-block-ms, default 1s) and reattach milestone latency by background_poll_interval_s (default 0.5s) instead of a broker's push; inline request globals are dropped on every scaled run (the worker hydrates from the redacted persisted row — use stored global variables for background runs).

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:

  • A real langflow worker OS subprocess claims, builds, and completes a real graph off a shared Postgres while the API side reattaches; a kill -9 mid-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.
  • Concurrency proofs: racing claimers get distinct jobs, racing watchdogs bump retry attempts exactly once and write exactly one terminal run_failed event, racing lease-claims on one row produce exactly one winner.
  • 249 background-execution tests green on real Postgres with zero skips; migration consistency green on SQLite and Postgres with a single alembic head.

Three review rounds (specialist + adversarial validation per round) ran on this rework; all findings are fixed in-branch. Not yet redone: the Locust saturation run from the redis era — those numbers no longer apply and a fresh head-to-head against the in-process default is pending.

Summary by CodeRabbit

  • New Features

    • Added a database-backed scaled background execution mode.
    • Added a worker command for processing queued jobs in separate processes.
    • Added durable job events, cancellation signals, lease recovery, and watchdog handling.
    • Added explicit settings for backend selection and polling intervals.
    • Added load-testing support for background workflows.
  • Bug Fixes

    • Improved recovery of jobs interrupted by worker failures.
    • Ensured cancellation requests are persisted and honored reliably.
  • Tests

    • Added comprehensive coverage for worker execution, recovery, cancellation, concurrency, and end-to-end workflows.

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.
@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The PR replaces Redis-based scaled background execution with a PostgreSQL-backed queue. It adds explicit backend settings, lease-based workers, durable event streaming, watchdog recovery, a worker CLI command, migrations, load testing, and real-service validation.

Changes

Scaled background execution

Layer / File(s) Summary
Runtime and job contracts
src/lfx/src/lfx/services/settings/groups/runtime.py, src/backend/base/langflow/services/jobs/service.py, src/backend/base/langflow/services/database/models/jobs/model.py, src/backend/base/langflow/alembic/versions/...
Scaled execution is selected explicitly. Job claiming, lease recovery, atomic failure, and the claim-scan index are added.
Database backend and facade wiring
src/backend/base/langflow/services/background_execution/db_backend.py, factory.py, service.py, live_bus.py, src/backend/tests/unit/services/background_execution/test_db_backend.py, test_lease_watchdog.py, test_scaled_end_to_end.py, test_scaled_stop.py
DBBackgroundQueue persists stop signals, claims jobs, reconciles stale leases, and streams durable events. The service and factory use it for scaled execution.
Worker process execution
src/backend/base/langflow/__main__.py, src/backend/base/langflow/services/background_execution/worker.py, src/backend/tests/unit/services/background_execution/test_worker_*.py, _subprocess_harness.py
The langflow worker command runs database-backed jobs with lease heartbeats, watchdog reconciliation, signal handling, request hydration, and teardown.
Operational validation and load testing
.github/workflows/migration-validation.yml, src/backend/tests/locust/v2_background_locustfile.py, src/backend/tests/unit/background_execution/*
CI and real-service tests use PostgreSQL without Redis. Side-effect safety, subprocess recovery, and background workflow load testing are added.

Estimated code review effort: 4 (Complex) | ~60 minutes

Suggested labels: refactor

Suggested reviewers: erichare

Sequence Diagram(s)

sequenceDiagram
  participant API as BackgroundExecutionService
  participant DB as PostgreSQL
  participant Worker as langflow worker
  participant Runner as WorkerJobRunner
  API->>DB: Store queued job
  Worker->>DB: Claim lease
  Worker->>Runner: Hydrate and execute job
  Runner->>DB: Store status and events
  API->>DB: Replay durable events
Loading
🚥 Pre-merge checks | ✅ 6 | ❌ 3

❌ Failed checks (3 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.59% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Test Quality And Coverage ⚠️ Warning Core queue, runner, stop, lease, replay, and API success/error paths are tested, but periodic watchdog and CLI signal paths lack tests; retry safety runs the graph directly, not through the worker. Add tests for run_worker_loop with watchdog_interval_s, CLI backend and signal handling, migration index creation, and retry-safe side effects through actual claim and worker execution.
Test File Naming And Structure ⚠️ Warning The subprocess and end-to-end tests are under tests/unit; the project marks that path as unit, and no changed file uses pytest.mark.integration. Move true integration/E2E tests to tests/integration or add the integration marker under an agreed directory; add try/finally cleanup for created worker tasks.
✅ Passed checks (6 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main changes: a database-backed scaled execution backend and the new langflow worker command.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Test Coverage For New Implementations ✅ Passed Added focused DB queue, lease watchdog, worker, factory, stop, settings, and facade tests, plus scaled end-to-end and real langflow worker subprocess tests; files use test_*.py.
Excessive Mock Usage Warning ✅ Passed Changed background-execution tests use no Mock or patch APIs; real database fixtures and worker subprocesses cover core behavior, with only small contract-focused fakes and environment monkeypatching.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/v2-workflows-bg-scaled

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ogabrielluiz

Copy link
Copy Markdown
Contributor Author

Stacked PR 2 of 2, based on #13507 (durable default backend). Review #13507 first. Combined view: #13505.

@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

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.
@ogabrielluiz
ogabrielluiz force-pushed the feat/v2-workflows-bg-scaled branch from e2ccbaf to 2f961f5 Compare June 6, 2026 02:48
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Jun 6, 2026
…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.
@github-actions github-actions Bot removed the enhancement New feature or request label Jun 10, 2026
@github-actions github-actions Bot added the enhancement New feature or request label Jun 24, 2026
…thon 3.10

The bounded executor's worker awaited the in-flight job task with a bare
`await task`. On Python 3.10, when stop() cancels a worker while its job
task is finishing, the awaiter's wakeup is lost and the event loop idles
forever in select(), deadlocking stop(). Await via a done-callback Event
(the same mechanism stop()'s own asyncio.gather already uses), which
delivers the wakeup reliably; task.result() preserves the cancellation
and exception semantics of the bare await.
@dkaushik94

Copy link
Copy Markdown
Member

@ogabrielluiz
I am pushing for us to design this in a way that aligns with the distributed deployment of LF that we are going to be reviewing together. For that reality to be true, we might need to tighten some of the following sharding and logical grouping on Redis, since mostly in a scaled setup, we should support Redis in cluster mode reliably. Please take the time to mull over the following suggestion and I can help with these as well since you have multiple streams going on:

PR Review — scaled background execution (Redis claim queue + Streams live bus + DB replay)

Overall this is careful work — the durable-milestone/Stream dedup is correct and well-tested, the lease/watchdog reconciliation is thought through, and the cross-replica reattach contract is sound. My concerns are about how it behaves on a single shared Redis cluster used by the whole fleet (every lfx serve and executor), and about the layer being Redis-specific rather than sitting behind a swappable port. Grouped by severity.


🔴 Blocking

1. BRPOPLPUSH will fail with CROSSSLOT on a cluster-mode Redis.
claim() does BRPOPLPUSH pending_key processing_key over two distinct keys (redis_queue.py:55). On Redis Cluster, a multi-key command is only valid if all keys hash to the same slot; langflow:bg:pending and langflow:bg:processing have no hash tag, so they land on different slots, and every claim raises CROSSSLOT Keys don't hash to the same slot. This is latent on a primary/replica setup but becomes a hard failure the moment the deployment is cluster-mode-enabled — the worker loop can't claim any work.

Fix: co-locate the two keys with a hash tag, e.g., langflow:bg:{bg}: pending / langflow:bg:{bg}: processing. Please also audit any future multi-key Lua/MULTI for the same constraint.

2. A single global pending key caps the entire fleet's throughput on one shard.
Once #1 is fixed by co-locating pending+processing onto one slot, that slot lives on a single shard. Every executor in the fleet blocking-pops the same key, so claim throughput is bounded by one shard's command rate regardless of how many shards or how much memory the cluster has — scaling Redis up won't move it. (The per-job event streams langflow:queue:{job_id} do distribute across slots, so those are fine.)

Shard the claim queue into N logical lists, each with its own hash tag (langflow:bg:{q0}: pending{qN}), and spread workers across them (pod ordinal/random). pending_i/processing_i co-locate per shard while the N shards are spread across the cluster.

3. Keys aren't namespaced, which is the only isolation boundary on a shared cluster.
langflow:bg:pending is one queue for every deployed workflow, tenant, and executor sharing the cluster (redis_queue.py:26). If executors are pinned to a deployment, a pod will claim another deployment's job and fail to load its asset. If executors are generic warm pods that bind any asset on demand, a global pool is defensible — but tenant isolation then rests entirely on the Workflows-API auth edge, and #2 still applies. Either way the keys need a deployment/realm prefix from settings, and that prefix doubles as the shard-routing unit.


🟡 Should fix

4. Every executor runs its own reconciler against the same shard + DB.
_watchdog_loop (worker.py:108) LRANGEs the global processing list and scans QUEUED rows on an interval, in every pod. On a shared cluster, the whole fleet is hammering one slot and one RunStore at once — and it gets worse under scale-to-zero churn. Reconciliation should be a single leader/control loop, not a per-pod responsibility; the startup sweep already has a file-lock primitive that could be reused. Disposable executors should claim-and-run only.

5. append_event does a read-modify-write per durable event.
SELECT max(seq)+1 then INSERT with optimistic retry on the unique collision (jobs/service.py:260). Correct, but it's a transaction pair per milestone under ephemeral connections; the max() needs a (job_id, seq) index to avoid a scan, and it won't port to a non-SQL store. Worth lifting seq allocation into a store-level atomic counter so it survives both a pool-proxied Postgres and a future NoSQL backend.

6. Heartbeat writes to the durable store.
Each in-flight job UPDATEs its row every ~15s (runner.py:160). At fleet concurrency, that's a constant liveness write load on a shared DB competing with append_event. Leases/heartbeats are a natural fit for the cache (TTL key per job) rather than the run store.

7. Shared-cluster memory / noisy neighbor.
maxlen≈10_000 bounds each stream, but the total footprint is 10k × all concurrent jobs fleet-wide on one cluster, and one tenant's event flood consumes shared eviction budget. TTL (redis_queue_ttl) is the main backstop. Please confirm maxlen + TTL are tuned for fleet-wide concurrency, and consider isolating the background keyspace (separate logical DB) from the cache so eviction policies don't cross-contaminate.

8. read_events materializes full history on reattach; a bad cursor replays everything.
list(result.all()) loads every milestone since the cursor, and _parse_last_event_id silently falls back to 0 on a malformed header (service.py:491) — so a client looping with a bad Last-Event-ID triggers a full replay each reconnect. Reject/log a bad cursor instead of replaying from the start.


🟢 Minor/follow-up

9. /events delivers milestones but not token deltas — please document.
Ephemeral frames are stamped with the preceding milestone's seq, and the reattach dedup is seq <= highest, so tokens are dropped on the /events path even for a fresh connection (runner.py:225, redis_backend.py:287). This is reasonable for background/reattach (milestones are the durable contract; live token streaming is the mode=stream path), but it isn't obvious, and the tests only assert milestones. Please document the granularity guarantee. If tokens-on-reattach is ever required, the dedup needs a strictly increasing live counter as a secondary key.

10. In-process state in the live bus. RedisStreamLiveBus._ttl_state is keyed per job and only popped in close() (redis_live_bus.py:79); it leaks if close()'s XADD/expire raises. Pop in a finally or LRU-bound it.

11. At least once retry appends a second run's milestones. A retried job continues append_event from max(seq)+1, so a reattacher sees a duplicate run_started. Probably fine, but the duplicate-on-retry semantics (and idempotent resume for HITL) should be called out.

12. No startup health-check / fail-fast. build_worker / _build_redis_client resolve a client and start claiming with no connectivity check and no guard against a dev/local adapter in a prod deployment (worker.py:240-285).

13. Cosmetic. Dead InProcessExecutor/InMemoryLiveBus allocation on the scaled path (service.py:86-87); duplicated comment block in executor.py:55-63.


Summary: #1 and #2 are the ones I'd want resolved before this runs on the shared cluster — both are fixed by the same change (hash-tagged, sharded, deployment-namespaced queue keys, ideally behind a queue abstraction so Redis is swappable), which also restores logical isolation (#3) when Redis itself isn't isolated. After that, moving reconciliation to a single leader (#4) and leases to the cache (#6) are the next most impactful. Happy to pair on the key scheme — I can sketch the sharded/hash-tagged layout and a queue port interface that wraps the current implementation as the first adapter if useful.

@dkaushik94

Copy link
Copy Markdown
Member

Upon investigating the setup, here is a quick excerpt distilled to explain the cross-slot issue failing claims and how we can support a sharded cluster setup with dedicated consumer groups:

Point 1 in plain terms

The one-sentence version: the claim() operation touches two Redis keys at once, and on a clustered Redis those two keys can live on two different machines — Redis refuses to run a command that spans two machines, so the claim errors out.

To decide which shard owns a key, Redis doesn't use random assignment. It defines exactly 16,384 "slots" and gives each shard a contiguous range of them:

        Redis Cluster (3 shards)
 ┌───────────────┬───────────────┬───────────────┐
 │   Shard A     │   Shard B     │   Shard C     │
 │ slots 0–5460  │ slots 5461–   │ slots 10923–  │
 │               │      10922    │      16383    │
 └───────────────┴───────────────┴───────────────┘

When you read or write a key, Redis computes:

slot = CRC16(key_name) % 16384

CRC16(...) is just a hash function — it turns the key's text into a number. That number mod 16384 picks the slot, and the slot tells you which shard. Same key name → always the same slot → always the same shard.

The rule that bites us

A command that touches multiple keys (like BRPOPLPUSH source dest, which pops from one list and pushes to another) can only run if all its keys are in the same slot — i.e. on the same shard. That makes sense: shard A can't atomically move an item into a list that physically lives on shard C; they're different machines.

If the keys land on different slots, Redis doesn't try to be clever — it just rejects the command:

(error) CROSSSLOT Keys in request don't hash to the same slot

Why this code trips it

claim() runs:

BRPOPLPUSH  langflow:bg:pending   langflow:bg:processing
            └──────── key 1 ──────┘ └────────── key 2 ─────────┘

Two different key names → two different CRC16 values → almost certainly two different slots:

CRC16("langflow:bg:pending")    % 16384  →  slot  3072   →  Shard A
CRC16("langflow:bg:processing") % 16384  →  slot 14001   →  Shard C
                                                              ↑
                          BRPOPLPUSH spans Shard A and Shard C  →  CROSSSLOT error

So on a real cluster, every claim fails and no worker can pick up a job. (On a single non-clustered Redis there are no slots to span, so it works — which is why this can pass in dev and break in production.)

The fix: hash tags

Redis gives you an escape hatch. If a key name contains text inside { }, Redis hashes only the part inside the braces instead of the whole key. So if two keys share the same {...} substring, they're guaranteed to hash to the same slot:

langflow:bg:{bg}:pending      →  CRC16("bg") % 16384  →  slot 8500  →  Shard B
langflow:bg:{bg}:processing   →  CRC16("bg") % 16384  →  slot 8500  →  Shard B
                                                  same slot ↑  →  BRPOPLPUSH is legal ✅

The {bg} forces both keys onto one shard, so the two-key move is allowed.


The sharding problem, visualized

Here's the subtlety that a single shared cluster (scaled up for capacity) makes important: fixing CROSSSLOT and getting good throughput pull in opposite directions. Here are both states.

State 1 — after the hash-tag fix (correct, but everything on one shard)

We forced pending and processing to share {bg}, so both live on whichever single shard {bg} hashes to. Now picture the whole fleet of executor pods trying to claim work:

   100 executor pods, all doing BRPOPLPUSH on the same {bg} keys
        │      │      │      │      │      │      │
        ▼      ▼      ▼      ▼      ▼      ▼      ▼
 ┌─────────────────────┐   ┌──────────────┐   ┌──────────────┐
 │      Shard B        │   │   Shard A    │   │   Shard C    │
 │  ███████████████    │   │              │   │              │
 │  langflow:bg:{bg}:* │   │   (idle)     │   │   (idle)     │
 │   🔥 ALL traffic    │   │              │   │              │
 └─────────────────────┘   └──────────────┘   └──────────────┘
         100%                     0%                  0%

Correctness is fine, but the whole fleet's claim traffic funnels through one shard. Adding shards (scaling the cluster up) does nothing — slots A and C sit idle because no key ever routes there. You've bought a 3-machine cluster and you're using 1 machine for the queue. This is the "single hot key" ceiling.

State 2 — sharded queue (correct AND uses the whole cluster)

Instead of one queue, create N queues, each with its own hash tag, and have each pod pick one (by pod number, or at random). Crucially, each queue's own pending+processing still share a tag, so each individual BRPOPLPUSH is still single-shard and legal — but the different queues scatter across the cluster:

 pods 1–33 → q0        pods 34–66 → q1        pods 67–100 → q2
      │                      │                       │
      ▼                      ▼                       ▼
 ┌──────────────┐      ┌──────────────┐      ┌──────────────┐
 │   Shard A    │      │   Shard B    │      │   Shard C    │
 │  ████████    │      │  ████████    │      │  ████████    │
 │ bg:{q0}:pend │      │ bg:{q1}:pend │      │ bg:{q2}:pend │
 │ bg:{q0}:proc │      │ bg:{q1}:proc │      │ bg:{q2}:proc │
 │  (q0 pair    │      │  (q1 pair    │      │  (q2 pair    │
 │  co-located) │      │  co-located) │      │  co-located) │
 └──────────────┘      └──────────────┘      └──────────────┘
       33%                   33%                    33%

Two invariants at once:

  • Within a queue: bg:{q0}:pending and bg:{q0}:processing share {q0} → same shard → BRPOPLPUSH legal. ✅
  • Across queues: {q0}, {q1}, {q2} hash to different slots → different shards → load spreads, and now adding shards actually adds capacity. ✅

Why the event streams are already fine

Worth noting the per-job event streams don't have this problem, because each one is a single key touched by single-key commands (XADD/XREAD on langflow:queue:{job_id}). The job id varies per job, so different jobs' streams naturally scatter across all shards — no hash tag needed, no CROSSSLOT risk:

 langflow:queue:abc... → Shard A
 langflow:queue:def... → Shard C     ← naturally spread, single-key ops
 langflow:queue:ghi... → Shard B

So the streams already do what we want; it's only the two-key claim queue that needs the hash-tag-plus-sharding treatment.

The mental model to keep

  • CRC16 of the key name picks the shard. Same name → same shard, always.
  • Multi-key command → all keys must be on one shard, or you get CROSSSLOT.
  • {...} in a key name = "only hash this part" — the tool for forcing keys together or deliberately spreading them apart.
  • The trick is using hash tags to pull each queue's pair together while pushing the set of queues apart.

Brings the v2 workflow router seam (#13816) and the rest of release onto the
durable background execution branch. Same rule as the stacked branches: take
release's structure and hygiene, keep the durable semantics on top.

api/v2/workflow.py is now release's seam skeleton (resolve_flow_for_execution,
authorize_flow_action, run_sync_with_mapping, build_stream_response) with the
durable route bodies (status reconstruct with the Job.result fallback, the stop
signal ordering, service.events replay) and the durable facade on background
submit. Helpers moved to release's workflow_execution.py.

Two things the plain merge would have dropped:
- Release's workflow_execution.py does not pass tweaks to generate_flow_events,
  so taking it wholesale silently reverted 02e06fe (apply request tweaks on
  the streaming and background paths). Restored.
- idempotency_key never survived the parse boundary: ParsedWorkflowRun did not
  carry the field, so the dedupe and DuplicateJobError -> 409 could never fire
  through the seam. Threaded through.

Durable runs also key vertex builds by job_id (run_id=str(job_id)) so a
completed job's status reconstructs its outputs and recovers the session_id
instead of falling back to the leaner Job.result rebuild. Migration chain is
re-parented onto release's head, so there is a single alembic head.
@ogabrielluiz
ogabrielluiz requested a review from Copilot July 8, 2026 18:48
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Jul 8, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an opt-in Redis-scaled background execution backend plus a langflow worker CLI process so background workflows execute out-of-process and can be distributed across replicas, while keeping the API surface behind the existing BackgroundExecutionService facade.

Changes:

  • Implemented a Redis-backed claim queue + worker loop with periodic lease/watchdog reconciliation and DB+Stream reattach semantics.
  • Added a Redis Streams live bus producer for cross-replica event tailing with batched TTL refresh.
  • Added extensive real-Redis / real-Postgres / real-subprocess proofs and a Locust load test for background execution.

Reviewed changes

Copilot reviewed 32 out of 32 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/backend/base/langflow/main.py Adds langflow worker CLI command wiring services + worker loop.
src/backend/base/langflow/services/background_execution/worker.py Implements worker job runner, claim loop, and periodic watchdog.
src/backend/base/langflow/services/background_execution/redis_backend.py Adds scaled backend: claim queue, durable replay, Stream tail + dedup, stop via durable signals, watchdog reconcile.
src/backend/base/langflow/services/background_execution/redis_queue.py Adds BRPOPLPUSH-based claim queue with processing list semantics.
src/backend/base/langflow/services/background_execution/redis_live_bus.py Adds Redis Streams producer (XADD) with batched TTL refresh.
src/backend/tests/unit/services/background_execution/_subprocess_harness.py Adds real-process harness for spawning langflow worker and seeding real flows.
src/backend/tests/unit/services/background_execution/test_build_worker.py Tests build_worker wiring and shared redis client between backend and bus.
src/backend/tests/unit/services/background_execution/test_control_fastpath.py Verifies stop writes only durable STOP signal (no pub/sub marker).
src/backend/tests/unit/services/background_execution/test_facade_teardown_realredis.py Tests scaled facade teardown closes the redis client.
src/backend/tests/unit/services/background_execution/test_factory_selection.py Tests backend factory selection based on scaled settings.
src/backend/tests/unit/services/background_execution/test_lease_watchdog.py Unit-tests watchdog reconciliation semantics (fakeredis).
src/backend/tests/unit/services/background_execution/test_live_bus_ttl_realredis.py Proves TTL refresh is batched (real Redis).
src/backend/tests/unit/services/background_execution/test_periodic_watchdog_realredis.py Proves periodic watchdog reaps stale leases without restarts (real Redis/DB).
src/backend/tests/unit/services/background_execution/test_queued_strand_realredis.py Proves recovery of stranded QUEUED rows not present on redis lists.
src/backend/tests/unit/services/background_execution/test_reattach_dedup_realredis.py Proves reattach dedup across DB replay + Stream tail (exactly-once milestones).
src/backend/tests/unit/services/background_execution/test_reattach_realredis.py Proves DB replay + Stream tail works cross-replica (real Redis).
src/backend/tests/unit/services/background_execution/test_redis_backend.py Unit-tests enqueue + replay behavior (fakeredis + fake JobService).
src/backend/tests/unit/services/background_execution/test_redis_claim_queue.py Unit-tests enqueue/claim/complete FIFO semantics (fakeredis).
src/backend/tests/unit/services/background_execution/test_redis_claim_queue_realredis.py Proves BRPOPLPUSH blocking claim semantics + disjoint workers (real Redis).
src/backend/tests/unit/services/background_execution/test_redis_fixture.py Documents/validates real-Redis fixture skip/roundtrip behavior.
src/backend/tests/unit/services/background_execution/test_redis_live_bus_realredis.py Proves published stream frames are consumable by a reattaching backend (real Redis).
src/backend/tests/unit/services/background_execution/test_scaled_end_to_end_realredis.py End-to-end scaled submit → worker run → cross-replica reattach (real Redis + DB).
src/backend/tests/unit/services/background_execution/test_scaled_liveness_realredis.py Proves lease-aware liveness guard + concurrent reconcile correctness (real Redis + DB).
src/backend/tests/unit/services/background_execution/test_scaled_stop_latency_realredis.py Proves STOP latency bounded by vertex-boundary polling cadence.
src/backend/tests/unit/services/background_execution/test_scaled_stop_realredis.py Proves durable STOP leads to CANCELLED and signal consumption (real Redis + DB).
src/backend/tests/unit/services/background_execution/test_worker_death_realredis.py Proves watchdog recovery behavior when worker dies after claim (real Redis + DB).
src/backend/tests/unit/services/background_execution/test_worker_loop.py Tests worker claim loop behavior and lease release on runner errors.
src/backend/tests/unit/services/background_execution/test_worker_runner.py Tests WorkerJobRunner hydrates a durable job and drives JobRunner to terminal state.
src/backend/tests/unit/services/background_execution/test_worker_subprocess_e2e.py Real subprocess E2E: worker completes job; kill -9 reconciled by watchdog.
src/backend/tests/unit/services/background_execution/test_worker_subprocess_stop.py Real subprocess proof: API-side stop_job cancels a job in a separate worker.
src/backend/tests/unit/background_execution/test_side_effect_safety.py Proves at-most-once vs retry-safe semantics with a real side-effecting component.
src/backend/tests/locust/v2_background_locustfile.py Adds Locust load test for v2 background workflows submit/poll accounting.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +40 to +43
@pytest.mark.real_services
async def test_real_worker_subprocess_honors_pubsub_stop():
"""API-side stop_job CANCELs a job running in a SEPARATE worker OS process."""
pg, redis = _require_real_instances()
Comment on lines +6 to +12
* Real Postgres (``LANGFLOW_TEST_DATABASE_URI``) as the durable store, shared
between the test-side API facade and the worker subprocess.
* Real Redis (``LANGFLOW_TEST_REDIS_URL``, a dedicated DB index) as the claim
queue + Streams live bus + cancel pub/sub.
* A real no-LLM flow (ChatInput -> ChatOutput) seeded into the shared DB, so the
worker's PRODUCTION ``_default_frame_source_factory`` builds and runs a real
graph — not a scripted source.
Comment on lines +18 to +22
def test_real_redis_skips_without_env(real_redis_url):
# real_redis_url returns None when env is unset; documents the skip contract.
if real_redis_url is None:
pytest.skip("LANGFLOW_TEST_REDIS_URL not set")
assert real_redis_url.startswith("redis://") or os.environ.get("LANGFLOW_TEST_REDIS_URL")
Comment on lines +25 to +28
# Capture the live pooled connection so we can prove it was disconnected.
pooled = client.connection_pool._available_connections[0]
assert pooled._writer is not None

Base automatically changed from feat/v2-workflows-bg-default to release-1.11.0 July 10, 2026 20:33
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Jul 10, 2026
@erichare
erichare deleted the branch release-1.11.0 July 15, 2026 18:36
@erichare erichare closed this Jul 15, 2026
@erichare erichare reopened this Jul 15, 2026
@Cristhianzl

Copy link
Copy Markdown
Member

On the CROSSSLOT blocker — I think there's a fix that's structural rather than a hash-tag patch, and it also happens to answer the single-shard cap and the "swappable port" concern at the same time: move the claim queue from lists to Streams with a consumer group.

The claim protocol becomes:

enqueue   XADD        langflow:bg:{q0} * job_id <id>
claim     XREADGROUP  GROUP bg bg-<consumer> COUNT 1 BLOCK <ms> STREAMS langflow:bg:{q0} >
complete  XACK        langflow:bg:{q0} bg <msg-id>
recover   XAUTOCLAIM  langflow:bg:{q0} bg bg-<consumer> <min-idle-ms> 0 COUNT <n>

Why this kills CROSSSLOT for good: every one of those commands touches exactly one key. There is no multi-key operation left to span slots, so the error isn't "fixed" — it becomes unrepresentable. A hash tag on pending/processing would work too, but it's a constraint someone has to remember forever; this removes the constraint.

On the single-shard cap: shard into N streams, each with its own tag — langflow:bg:{q0}langflow:bg:{qN}. Each tag hashes to its own slot, so the claim load spreads across shards. Worth naming the tradeoff honestly: a worker can't BLOCK on multiple streams in one XREADGROUP on a cluster (that's multi-key again), so it's either one blocked read per shard per worker, or pin worker→shard by pod ordinal. With small N the former is fine; the latter is simpler but can idle workers on an empty shard.

The part I'd want to get right — this must not quietly become at-least-once. The PR deliberately picks at-most-once for in-flight work, and XAUTOCLAIM exists to redeliver, so a naive reclaim would re-run a half-executed flow and double its side effects. The reclaim should stay a message-level primitive with the durable row still deciding:

  • QUEUED (never started) → run it. Safe, and this is where at-least-once actually applies.
  • IN_PROGRESS + stale heartbeat → FAILED worker_lost + XACK. Don't re-run, unless retry is opted in and attempts < max_attempts.
  • terminal → XACK only. Natural dedupe.

So the lease/heartbeat/attempt accounting doesn't go away — Redis stops owning the recovery trigger, and the DB keeps owning the recovery decision. XAUTOCLAIM replaces requeue_lost's processing-list scan, not the semantics on top of it.

What comes for free: the PEL gives per-consumer attribution and idle time via XPENDING (useful for #13517 — you can see which worker is stuck, not just that something is), and KEDA ships a redis-cluster-streams scaler that targets a stream + consumer group directly. lagCount there measures entries not yet delivered to consumers, which stays an honest backlog signal as long as claims use COUNT 1 and nothing prefetches into worker memory.

Happy to help with the redis_queue.py swap if useful — the surface looks contained, and the runner/lease/watchdog work above it shouldn't need to move.

The durable job table becomes the work queue itself: submit persists the
QUEUED row and separate langflow worker processes lease-claim it off the
shared database via the existing exact-heartbeat conditional UPDATE, so no
broker is involved anywhere. Cross-replica reattach replays job_events and
polls the same table for new durable milestones; stop stays the durable
ExecutionSignal row.

Deleting the broker deletes its failure modes: there is no pending or
processing list to drift from the DB (stranded-QUEUED recovery has no
equivalent, a QUEUED row is enqueued by definition) and no LREM token
protocol (the conditional UPDATE is the single flight). The lease watchdog
now scans stale IN_PROGRESS rows directly, keeping the same at-most-once
default and opt-in bounded retry semantics.

Backend selection is the explicit background_backend setting (default or
scaled) instead of riding job_queue_type=redis; scaled needs nothing but
the database the API already uses. The real-service proofs (a real worker
OS subprocess completing a real graph, kill -9 reconciliation, cross-process
durable stop) now run against Postgres alone, and the former real-redis
test fleet runs against the ordinary test DB.
# Conflicts:
#	.github/workflows/migration-validation.yml
#	.secrets.baseline
#	src/backend/base/langflow/api/v2/workflow.py
#	src/backend/base/langflow/services/background_execution/executor.py
#	src/backend/base/langflow/services/background_execution/factory.py
#	src/backend/base/langflow/services/background_execution/live_bus.py
#	src/backend/base/langflow/services/background_execution/runner.py
#	src/backend/base/langflow/services/background_execution/service.py
#	src/backend/base/langflow/services/database/models/__init__.py
#	src/backend/base/langflow/services/database/models/jobs/__init__.py
#	src/backend/base/langflow/services/database/models/jobs/model.py
#	src/backend/base/langflow/services/jobs/service.py
#	src/backend/tests/unit/api/v2/test_workflow_background.py
#	src/backend/tests/unit/background_execution/conftest.py
#	src/backend/tests/unit/background_execution/test_head_to_head_deltas.py
#	src/backend/tests/unit/background_execution/test_real_services_ci_wiring.py
#	src/backend/tests/unit/background_execution/test_real_services_harness.py
#	src/backend/tests/unit/services/background_execution/test_live_bus.py
#	src/backend/tests/unit/services/background_execution/test_runner.py
#	src/backend/tests/unit/services/background_execution/test_service.py
#	src/lfx/src/lfx/services/settings/groups/runtime.py
#	src/lfx/src/lfx/workflow/adapters/langflow.py
#	src/lfx/tests/unit/services/settings/test_background_scaled_settings.py
#	src/lfx/tests/unit/services/settings/test_settings_composition.py
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.
@ogabrielluiz ogabrielluiz changed the title feat(api): redis-scaled background execution backend + langflow worker feat(api): DB-backed scaled background execution backend + langflow worker Jul 31, 2026
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Jul 31, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (20)
src/backend/tests/unit/services/background_execution/_subprocess_harness.py (3)

157-161: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use asyncio.get_running_loop() inside the coroutine.

get_event_loop() is the legacy accessor and its behavior outside a running loop is deprecated. This code always runs inside a running loop, so call get_running_loop() once and reuse it.

♻️ Proposed change
-        import asyncio
-
-        deadline = asyncio.get_event_loop().time() + timeout
+        import asyncio
+
+        loop = asyncio.get_running_loop()
+        deadline = loop.time() + timeout
         last = None
-        while asyncio.get_event_loop().time() < deadline:
+        while loop.time() < deadline:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/tests/unit/services/background_execution/_subprocess_harness.py`
around lines 157 - 161, Update the coroutine’s deadline loop to obtain the
running event loop once with asyncio.get_running_loop(), then reuse that loop
for both time checks instead of repeatedly calling asyncio.get_event_loop().

191-201: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Move the blocking process waits off the event loop.

proc.wait(timeout=10) and proc.wait(timeout=5) are synchronous. In an async teardown they block the loop for up to 15 seconds per process, which also stalls any other pending task. Wrap them with asyncio.to_thread.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/tests/unit/services/background_execution/_subprocess_harness.py`
around lines 191 - 201, Update the async teardown flow around the two proc.wait
calls in the process cleanup loop to execute each blocking wait via
asyncio.to_thread, preserving the existing timeouts, exception suppression, and
SIGKILL fallback behavior.

174-186: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

drain_worker_output terminates live workers as a side effect.

The function is documented as best-effort diagnostics, but it sends terminate() to any running worker so that read() can reach EOF. A caller that drains output for context therefore changes the test state. The file-redirect change proposed for spawn_worker removes the need for this. Until then, rename the method or state the side effect in the docstring.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/tests/unit/services/background_execution/_subprocess_harness.py`
around lines 174 - 186, The drain_worker_output method currently terminates live
worker processes as a diagnostic side effect. Remove the proc.terminate()
behavior so draining output does not alter test state, relying on the
file-redirect output path introduced by spawn_worker; preserve the existing
best-effort collection and fallback behavior.
src/backend/tests/unit/services/background_execution/test_worker_loop.py (1)

53-53: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Bound the loop await so a regression fails instead of hanging.

Both tests rely on the runner to set stop_event. If a change breaks that path, run_worker_loop never returns and the test blocks until the CI job times out. Wrap the call with asyncio.wait_for.

💚 Proposed change
-    await run_worker_loop(backend, runner, stop_event=stop_event, idle_block_ms=10)
+    await asyncio.wait_for(
+        run_worker_loop(backend, runner, stop_event=stop_event, idle_block_ms=10), timeout=10.0
+    )

Also applies to: 72-72

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/tests/unit/services/background_execution/test_worker_loop.py` at
line 53, Bound the await of run_worker_loop in both affected tests with
asyncio.wait_for, using an appropriate timeout so the tests fail promptly if
stop_event is never set. Preserve the existing backend, runner, stop_event, and
idle_block_ms arguments.
src/backend/base/langflow/__main__.py (1)

865-872: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Check the backend setting before you initialize services.

initialize_services() runs first, then the command exits when the backend is not scaled. The early exit leaves the initialized services without teardown. The setting is readable from get_settings_service() before initialization, so the guard can run first and the process can exit without side effects.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/base/langflow/__main__.py` around lines 865 - 872, In
_run_worker, retrieve and validate
get_settings_service().settings.background_backend_is_scaled before calling
initialize_services(). Preserve the existing message and typer.Exit(code=1) for
non-scaled backends, and only initialize services after the guard passes.
src/backend/base/langflow/services/background_execution/worker.py (6)

161-162: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Guard the startup reconcile so a transient database error does not abort worker boot.

The periodic pass at Line 127 suppresses errors, but the startup pass does not. If the database is briefly unreachable while the worker starts, this raises and the langflow worker process exits with a traceback before it claims any job. The periodic watchdog would have reconciled the same rows on its first interval.

♻️ Proposed change
-    # Startup reconcile: requeue work lost by a previously-crashed worker.
-    await backend.requeue_lost(lease_ttl_s=lease_ttl_s)
+    # Startup reconcile: requeue work lost by a previously-crashed worker.
+    # Best-effort: a transient database error must not stop the worker from
+    # booting, because the periodic watchdog reconciles the same rows.
+    try:
+        await backend.requeue_lost(lease_ttl_s=lease_ttl_s)
+    except Exception as exc:  # noqa: BLE001
+        await logger.aexception(f"Worker: startup reconcile failed: {exc}")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/base/langflow/services/background_execution/worker.py` around
lines 161 - 162, Guard the startup requeue_lost call in the worker boot flow so
transient database errors are caught and logged without aborting worker startup.
Match the existing error-suppression behavior used by the periodic
reconciliation pass, while preserving the startup reconciliation attempt and
allowing the worker to continue claiming jobs.

78-82: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the request/user hydration helpers out of the service class.

WorkerJobRunner.run reaches into two private statics of BackgroundExecutionService. Both are pure functions over a Job row. Move them to a module-level helper (for example background_execution/hydrate.py) and let both the facade and the worker call the public function. This removes the two noqa: SLF001 suppressions and makes the contract explicit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/base/langflow/services/background_execution/worker.py` around
lines 78 - 82, Move the request and user hydration logic currently exposed
through BackgroundExecutionService._reconstruct_request and _user_stub into
public module-level helpers in a dedicated hydration module. Update
BackgroundExecutionService and WorkerJobRunner.run to call those helpers
directly, preserving their existing behavior and removing both SLF001
suppressions.

176-198: 🩺 Stability & Availability | 🔵 Trivial

Document the shutdown drain semantics.

The loop checks stop_event only between claims, so SIGTERM lets the current job finish. This is the correct choice for at-most-once work. Under an orchestrator with a fixed termination grace period, a long job is killed mid-flight and the row stays IN_PROGRESS until the watchdog reaps it. State the expected relationship between the deployment grace period and background_job_timeout in the operator documentation, so terminationGracePeriodSeconds can be set accordingly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/base/langflow/services/background_execution/worker.py` around
lines 176 - 198, Document the worker shutdown behavior around the loop
containing runner.run: stopping prevents new claims but allows the current job
to finish, while forced termination may leave it IN_PROGRESS until the watchdog
reaps it. In the operator documentation, state that
terminationGracePeriodSeconds should be set longer than background_job_timeout
so the configured grace period accommodates job completion and recovery.

206-206: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the return type annotation.

build_worker returns a 3-tuple, but the signature has no return type, so callers get Any. Annotate it so mypy checks the unpack at the call sites.

♻️ Proposed change
-async def build_worker(*, owner: str | None = None):
+async def build_worker(
+    *, owner: str | None = None
+) -> tuple[Any, WorkerJobRunner, Callable[[], Awaitable[None]]]:

Add Awaitable to the TYPE_CHECKING import from collections.abc.

As per coding guidelines: "Python code must pass mypy type checking via make lint".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/base/langflow/services/background_execution/worker.py` at line
206, Annotate the async build_worker function with its concrete three-tuple
return type so mypy can validate callers’ unpacking. Add Awaitable to the
TYPE_CHECKING collections.abc imports as suggested, and express the returned
tuple’s element types using the existing worker symbols without changing runtime
behavior.

Source: Coding guidelines


109-132: 🩺 Stability & Availability | 🔵 Trivial

Consider bounding watchdog concurrency across the fleet.

Every worker process runs this loop, so each requeue_lost pass scans all IN_PROGRESS workflow jobs on the shared database at watchdog_interval_s. The reconcile writes are single-flight, so correctness holds. The read cost grows linearly with the worker count. With a large fleet and a short interval this becomes a repeated full scan of in-flight rows.

Options: elect one reconciler (advisory lock or leader row), stagger the first pass with a random offset per worker, or scale the effective interval by fleet size.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/base/langflow/services/background_execution/worker.py` around
lines 109 - 132, Bound concurrent watchdog reconciliation across workers by
adding coordination around the requeue_lost call in _watchdog_loop, using an
advisory lock or leader-row mechanism so only one worker performs each scan at a
time. Preserve the existing periodic loop, stop-event handling, and best-effort
error suppression.

19-19: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Promote _coerce_uuid to a shared public helper.

worker.py imports a private symbol from db_backend.py. The worker is a separate module boundary, so this couples it to a private detail. Rename the helper to coerce_uuid in a shared module (or re-export it) and import the public name.

Also applies to: 187-189

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/base/langflow/services/background_execution/worker.py` at line
19, Promote the private _coerce_uuid helper used by worker.py to the public name
coerce_uuid in a shared module or via an explicit re-export from db_backend.py.
Update the helper definition and every reference, including the import and
usages in worker.py, so no cross-module code depends on the underscored symbol.
src/backend/tests/unit/services/background_execution/test_worker_runner.py (1)

50-74: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the error paths of WorkerJobRunner.run.

The test covers the success path only. Two reachable branches stay untested: an unknown job_id (the runner logs and returns without raising) and a frame source that raises mid-stream (the durable row must reach FAILED). Both are short tests against the same real JobService.

As per path instructions: tests should include "coverage for positive, negative, edge, and error cases".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/tests/unit/services/background_execution/test_worker_runner.py`
around lines 50 - 74, Extend the tests around WorkerJobRunner.run with separate
cases for an unknown job_id and a frame source that raises during streaming.
Assert the unknown-job path returns without raising, and verify the mid-stream
failure updates the durable job row to JobStatus.FAILED using the same real
JobService setup.

Source: Path instructions

src/backend/tests/unit/services/background_execution/test_build_worker.py (1)

17-25: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the settings-derived lease and poll wiring.

build_worker reads background_lease_ttl_s and background_poll_interval_s from settings and passes them to DBBackgroundQueue. The test does not check those two values, so a swapped or dropped argument would still pass. Compare both against get_settings_service().settings.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/tests/unit/services/background_execution/test_build_worker.py`
around lines 17 - 25, Extend the build_worker test assertions to validate the
settings-derived lease and poll values passed to DBBackgroundQueue. Compare the
backend’s corresponding lease-TTL and poll-interval attributes with
get_settings_service().settings.background_lease_ttl_s and
background_poll_interval_s, while preserving the existing backend, runner,
teardown, bus, and owner assertions.
src/backend/tests/unit/services/background_execution/test_lease_watchdog.py (1)

28-29: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Redundant @pytest.mark.asyncio markers. Based on learnings, this repository configures pytest-asyncio with asyncio_mode = 'auto' in pyproject.toml, so async tests are auto-detected and the markers add no behavior.

  • src/backend/tests/unit/services/background_execution/test_lease_watchdog.py#L28-L29: remove the @pytest.mark.asyncio decorator here and on the other five tests in the file (Lines 48, 63, 84, 116, 140).
  • src/backend/tests/unit/services/background_execution/test_worker_loop.py#L47-L47: remove the @pytest.mark.asyncio decorator here and on Line 59.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/tests/unit/services/background_execution/test_lease_watchdog.py`
around lines 28 - 29, Remove the redundant pytest.mark.asyncio decorators from
all six async tests in
src/backend/tests/unit/services/background_execution/test_lease_watchdog.py,
including the tests anchored by the existing decorators and those at lines 48,
63, 84, 116, and 140. Also remove the decorators from both affected tests in
src/backend/tests/unit/services/background_execution/test_worker_loop.py, at
lines 47 and 59; leave the async test implementations unchanged.

Source: Learnings

src/backend/tests/unit/services/background_execution/test_control_fastpath.py (1)

13-15: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the redundant @pytest.mark.asyncio decorator.

pyproject.toml sets asyncio_mode = "auto", so async tests are detected automatically and this marker is unnecessary.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/backend/tests/unit/services/background_execution/test_control_fastpath.py`
around lines 13 - 15, Remove the redundant `@pytest.mark.asyncio` decorator from
test_stop_writes_durable_signal, while keeping the client fixture marker and
test function unchanged.

Source: Learnings

src/backend/tests/unit/services/background_execution/test_db_backend.py (1)

18-18: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop the redundant pytest.mark.asyncio.

This repository configures asyncio_mode = 'auto' in pyproject.toml, so async tests are auto-detected without pytest.mark.asyncio. Other test files in this suite (for example test_lease_watchdog.py) define async test functions without this marker. Remove it from pytestmark for consistency.

🧹 Proposed fix
-pytestmark = [pytest.mark.usefixtures("client"), pytest.mark.asyncio]
+pytestmark = [pytest.mark.usefixtures("client")]

Based on learnings, "pytest-asyncio is configured with asyncio_mode = 'auto' in pyproject.toml... you do not need to decorate test functions or classes with pytest.mark.asyncio."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/tests/unit/services/background_execution/test_db_backend.py` at
line 18, Update the module-level pytestmark in test_db_backend.py to remove the
redundant pytest.mark.asyncio entry, while preserving the client fixture marker
and automatic async test detection.

Source: Learnings

src/backend/base/langflow/services/jobs/service.py (1)

591-599: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid the N+1 read pattern in the watchdog scan.

in_progress_workflow_job_ids returns only job_id values. Its only consumer, DBBackgroundQueue.requeue_lost in src/backend/base/langflow/services/background_execution/db_backend.py, re-fetches each Job individually with get_job_by_job_id right after. Return full Job rows here (select(Job).where(...)) instead of ids-only, so the watchdog scan does one query instead of 1 + N queries per reconcile pass. This runs on every watchdog interval across the worker fleet, so the extra round trips scale with both job count and worker count.

♻️ Proposed refactor
-    async def in_progress_workflow_job_ids(self) -> list[UUID]:
-        """Return the ids of every IN_PROGRESS workflow job (for the watchdog)."""
-        async with session_scope() as session:
-            stmt = select(Job.job_id).where(
-                Job.status == JobStatus.IN_PROGRESS,
-                Job.type == JobType.WORKFLOW,
-            )
-            result = await session.exec(stmt)
-            return list(result.all())
+    async def in_progress_workflow_jobs(self) -> list[Job]:
+        """Return every IN_PROGRESS workflow Job row (for the watchdog)."""
+        async with session_scope() as session:
+            stmt = select(Job).where(
+                Job.status == JobStatus.IN_PROGRESS,
+                Job.type == JobType.WORKFLOW,
+            )
+            result = await session.exec(stmt)
+            return list(result.all())
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/base/langflow/services/jobs/service.py` around lines 591 - 599,
Update in_progress_workflow_job_ids to select and return full Job entities
rather than only Job.job_id values, preserving the existing IN_PROGRESS and
WORKFLOW filters. Adjust its return annotation and ensure
DBBackgroundQueue.requeue_lost consumes the returned Job objects directly
instead of re-fetching each job with get_job_by_job_id.
src/backend/base/langflow/services/background_execution/db_backend.py (1)

91-109: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Make the FIFO claim candidate window scale with the worker fleet. claim_next_queued_lease's hardcoded candidates=5 default is never overridden anywhere in the call chain, even though its own docstring says the candidate count should be "slightly larger than the worker fleet." With more than ~5 concurrently polling workers, a claim round can return None even while further QUEUED rows exist, capping throughput in exactly the multi-worker deployments this PR targets.

  • src/backend/base/langflow/services/background_execution/db_backend.py#L91-L109: thread a candidates value (e.g. a new DBBackgroundQueue.__init__ parameter sourced from a setting) into the claim_next_queued_lease(...) call instead of relying on the hardcoded default.
  • src/backend/base/langflow/services/jobs/service.py#L601-L631: keep candidates as the tunable parameter it already is; the fix is exposing it through the call chain, not changing this signature.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/base/langflow/services/background_execution/db_backend.py` around
lines 91 - 109, Expose a configurable candidates value through
DBBackgroundQueue.__init__ and pass it to claim_next_queued_lease from claim,
sourcing it from the appropriate setting and defaulting safely for existing
callers. In
src/backend/base/langflow/services/background_execution/db_backend.py lines
91-109, update the queue configuration and claim call; in
src/backend/base/langflow/services/jobs/service.py lines 601-631, make no direct
change because its existing candidates parameter is already tunable and should
remain unchanged.
src/backend/tests/unit/background_execution/test_real_services_ci_wiring.py (1)

30-36: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the absence of a redis service to pin the stated intent.

The comment states that no redis service belongs in this job. Add the negative assertion so a re-added broker service fails the test.

♻️ Proposed addition
     assert any(img.startswith("postgres:") for img in images), images
+    assert not any("redis" in img for img in images), images
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/tests/unit/background_execution/test_real_services_ci_wiring.py`
around lines 30 - 36, Update test_real_services_job_has_postgres_service to
explicitly assert that no service image starts with “redis:”, preserving the
existing PostgreSQL assertion and the stated real-services job wiring intent.
src/backend/tests/locust/v2_background_locustfile.py (1)

136-147: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Do not send an extra request to record the deadline failure.

The block sends one more real GET under the same request name only to call poll.failure. That extra sample changes the poll latency and count statistics. Fire a request event directly instead, or reuse the last poll context manager to mark the failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/tests/locust/v2_background_locustfile.py` around lines 136 - 147,
Update the deadline-exceeded handling in the locust task to avoid issuing
another GET request to record the failure. Reuse the most recent poll response
context to call failure, or fire the appropriate request event directly, while
preserving the deadline_exceeded counter and failure reporting.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/backend/base/langflow/__main__.py`:
- Around line 848-850: Update the idle_block_ms Typer option to enforce a
minimum value of 1, so --idle-block-ms rejects zero and negative inputs while
preserving the existing default and help text.

In
`@src/backend/base/langflow/alembic/versions/4b8f2c6d9e1a_add_job_claim_scan_index.py`:
- Around line 32-35: Update the upgrade logic around the existing index check to
use PostgreSQL’s concurrent index creation outside the Alembic transaction,
while retaining a safe non-PostgreSQL fallback using the normal index operation.
Preserve the existing _INDEX name, job table, columns, uniqueness, and
idempotent existing-index check.

In `@src/backend/tests/locust/v2_background_locustfile.py`:
- Around line 1-16: The docstrings contain stale Redis references after the
database-backed migration. In
src/backend/tests/locust/v2_background_locustfile.py lines 1-16, describe scaled
mode as using the database-backed worker claim path; in
src/backend/tests/unit/background_execution/test_side_effect_safety.py lines
13-16, replace the real Redis backend wording with DBBackgroundQueue, matching
the instance constructed by the test.
- Around line 112-133: Guard the 200-response body parsing in the polling flow
around poll.json() so malformed or non-JSON responses are handled as poll errors
and increment poll_errors instead of escaping the with-block. Also make the
failure message in the fallback branch safe when poll.text is None by using an
empty-string fallback before slicing.
- Around line 78-85: The workflow request in the locust user should not pass
per-request timeout arguments to self.client.post or self.client.get. Set the
FastHttpUser class attributes network_timeout and connection_timeout to
REQUEST_TIMEOUT, then remove the timeout keyword from both request calls while
preserving their existing response handling.

In `@src/backend/tests/unit/background_execution/conftest.py`:
- Around line 37-40: Update the URL normalization logic around the raw URL
checks so psycopg2 URLs are not mistaken for psycopg URLs: match the exact async
psycopg dialect prefix, and normalize postgresql+psycopg2 URLs to the
async-compatible postgresql+psycopg form before returning. Preserve unchanged
URLs that already use the exact psycopg prefix.

In `@src/backend/tests/unit/services/background_execution/_subprocess_harness.py`:
- Around line 134-142: Update the subprocess setup around Popen and
drain_worker_output so worker stdout/stderr is redirected to a temporary log
file instead of an unread PIPE, preventing the worker from blocking on pipe
capacity. Track the file handle or path per process, and have
drain_worker_output read the available log-file tail for diagnostics without
terminating a live worker merely to reach EOF.

In `@src/backend/tests/unit/services/background_execution/test_scaled_stop.py`:
- Around line 76-98: Ensure both helper tasks always set stop_event in a finally
block and bound the worker-loop await with a 30-second timeout. In
src/backend/tests/unit/services/background_execution/test_scaled_stop.py lines
76-98, update drive_stop_then_wait and wrap run_worker_loop with
asyncio.wait_for; apply the same changes to stop_when_done and run_worker_loop
in
src/backend/tests/unit/services/background_execution/test_scaled_end_to_end.py
lines 71-89.

In `@src/lfx/tests/unit/services/settings/test_background_scaled_settings.py`:
- Around line 31-34: Extend test_background_poll_interval_default to verify that
LANGFLOW_BACKGROUND_POLL_INTERVAL_S values of 0 and a negative number are
rejected by Settings, preserving the existing assertions for the default and
valid 0.1 interval.

---

Nitpick comments:
In `@src/backend/base/langflow/__main__.py`:
- Around line 865-872: In _run_worker, retrieve and validate
get_settings_service().settings.background_backend_is_scaled before calling
initialize_services(). Preserve the existing message and typer.Exit(code=1) for
non-scaled backends, and only initialize services after the guard passes.

In `@src/backend/base/langflow/services/background_execution/db_backend.py`:
- Around line 91-109: Expose a configurable candidates value through
DBBackgroundQueue.__init__ and pass it to claim_next_queued_lease from claim,
sourcing it from the appropriate setting and defaulting safely for existing
callers. In
src/backend/base/langflow/services/background_execution/db_backend.py lines
91-109, update the queue configuration and claim call; in
src/backend/base/langflow/services/jobs/service.py lines 601-631, make no direct
change because its existing candidates parameter is already tunable and should
remain unchanged.

In `@src/backend/base/langflow/services/background_execution/worker.py`:
- Around line 161-162: Guard the startup requeue_lost call in the worker boot
flow so transient database errors are caught and logged without aborting worker
startup. Match the existing error-suppression behavior used by the periodic
reconciliation pass, while preserving the startup reconciliation attempt and
allowing the worker to continue claiming jobs.
- Around line 78-82: Move the request and user hydration logic currently exposed
through BackgroundExecutionService._reconstruct_request and _user_stub into
public module-level helpers in a dedicated hydration module. Update
BackgroundExecutionService and WorkerJobRunner.run to call those helpers
directly, preserving their existing behavior and removing both SLF001
suppressions.
- Around line 176-198: Document the worker shutdown behavior around the loop
containing runner.run: stopping prevents new claims but allows the current job
to finish, while forced termination may leave it IN_PROGRESS until the watchdog
reaps it. In the operator documentation, state that
terminationGracePeriodSeconds should be set longer than background_job_timeout
so the configured grace period accommodates job completion and recovery.
- Line 206: Annotate the async build_worker function with its concrete
three-tuple return type so mypy can validate callers’ unpacking. Add Awaitable
to the TYPE_CHECKING collections.abc imports as suggested, and express the
returned tuple’s element types using the existing worker symbols without
changing runtime behavior.
- Around line 109-132: Bound concurrent watchdog reconciliation across workers
by adding coordination around the requeue_lost call in _watchdog_loop, using an
advisory lock or leader-row mechanism so only one worker performs each scan at a
time. Preserve the existing periodic loop, stop-event handling, and best-effort
error suppression.
- Line 19: Promote the private _coerce_uuid helper used by worker.py to the
public name coerce_uuid in a shared module or via an explicit re-export from
db_backend.py. Update the helper definition and every reference, including the
import and usages in worker.py, so no cross-module code depends on the
underscored symbol.

In `@src/backend/base/langflow/services/jobs/service.py`:
- Around line 591-599: Update in_progress_workflow_job_ids to select and return
full Job entities rather than only Job.job_id values, preserving the existing
IN_PROGRESS and WORKFLOW filters. Adjust its return annotation and ensure
DBBackgroundQueue.requeue_lost consumes the returned Job objects directly
instead of re-fetching each job with get_job_by_job_id.

In `@src/backend/tests/locust/v2_background_locustfile.py`:
- Around line 136-147: Update the deadline-exceeded handling in the locust task
to avoid issuing another GET request to record the failure. Reuse the most
recent poll response context to call failure, or fire the appropriate request
event directly, while preserving the deadline_exceeded counter and failure
reporting.

In `@src/backend/tests/unit/background_execution/test_real_services_ci_wiring.py`:
- Around line 30-36: Update test_real_services_job_has_postgres_service to
explicitly assert that no service image starts with “redis:”, preserving the
existing PostgreSQL assertion and the stated real-services job wiring intent.

In `@src/backend/tests/unit/services/background_execution/_subprocess_harness.py`:
- Around line 157-161: Update the coroutine’s deadline loop to obtain the
running event loop once with asyncio.get_running_loop(), then reuse that loop
for both time checks instead of repeatedly calling asyncio.get_event_loop().
- Around line 191-201: Update the async teardown flow around the two proc.wait
calls in the process cleanup loop to execute each blocking wait via
asyncio.to_thread, preserving the existing timeouts, exception suppression, and
SIGKILL fallback behavior.
- Around line 174-186: The drain_worker_output method currently terminates live
worker processes as a diagnostic side effect. Remove the proc.terminate()
behavior so draining output does not alter test state, relying on the
file-redirect output path introduced by spawn_worker; preserve the existing
best-effort collection and fallback behavior.

In `@src/backend/tests/unit/services/background_execution/test_build_worker.py`:
- Around line 17-25: Extend the build_worker test assertions to validate the
settings-derived lease and poll values passed to DBBackgroundQueue. Compare the
backend’s corresponding lease-TTL and poll-interval attributes with
get_settings_service().settings.background_lease_ttl_s and
background_poll_interval_s, while preserving the existing backend, runner,
teardown, bus, and owner assertions.

In
`@src/backend/tests/unit/services/background_execution/test_control_fastpath.py`:
- Around line 13-15: Remove the redundant `@pytest.mark.asyncio` decorator from
test_stop_writes_durable_signal, while keeping the client fixture marker and
test function unchanged.

In `@src/backend/tests/unit/services/background_execution/test_db_backend.py`:
- Line 18: Update the module-level pytestmark in test_db_backend.py to remove
the redundant pytest.mark.asyncio entry, while preserving the client fixture
marker and automatic async test detection.

In `@src/backend/tests/unit/services/background_execution/test_lease_watchdog.py`:
- Around line 28-29: Remove the redundant pytest.mark.asyncio decorators from
all six async tests in
src/backend/tests/unit/services/background_execution/test_lease_watchdog.py,
including the tests anchored by the existing decorators and those at lines 48,
63, 84, 116, and 140. Also remove the decorators from both affected tests in
src/backend/tests/unit/services/background_execution/test_worker_loop.py, at
lines 47 and 59; leave the async test implementations unchanged.

In `@src/backend/tests/unit/services/background_execution/test_worker_loop.py`:
- Line 53: Bound the await of run_worker_loop in both affected tests with
asyncio.wait_for, using an appropriate timeout so the tests fail promptly if
stop_event is never set. Preserve the existing backend, runner, stop_event, and
idle_block_ms arguments.

In `@src/backend/tests/unit/services/background_execution/test_worker_runner.py`:
- Around line 50-74: Extend the tests around WorkerJobRunner.run with separate
cases for an unknown job_id and a frame source that raises during streaming.
Assert the unknown-job path returns without raising, and verify the mid-stream
failure updates the durable job row to JobStatus.FAILED using the same real
JobService setup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: badef48e-1646-4e0c-bf40-046b82ef4a35

📥 Commits

Reviewing files that changed from the base of the PR and between 562e5f2 and 6eb45cb.

📒 Files selected for processing (34)
  • .github/workflows/migration-validation.yml
  • src/backend/base/langflow/__main__.py
  • src/backend/base/langflow/alembic/versions/4b8f2c6d9e1a_add_job_claim_scan_index.py
  • src/backend/base/langflow/services/background_execution/db_backend.py
  • src/backend/base/langflow/services/background_execution/factory.py
  • src/backend/base/langflow/services/background_execution/live_bus.py
  • src/backend/base/langflow/services/background_execution/service.py
  • src/backend/base/langflow/services/background_execution/worker.py
  • src/backend/base/langflow/services/database/models/jobs/model.py
  • src/backend/base/langflow/services/jobs/service.py
  • src/backend/tests/locust/v2_background_locustfile.py
  • src/backend/tests/unit/background_execution/conftest.py
  • src/backend/tests/unit/background_execution/test_head_to_head_deltas.py
  • src/backend/tests/unit/background_execution/test_real_services_ci_wiring.py
  • src/backend/tests/unit/background_execution/test_real_services_harness.py
  • src/backend/tests/unit/background_execution/test_side_effect_safety.py
  • src/backend/tests/unit/services/background_execution/_subprocess_harness.py
  • src/backend/tests/unit/services/background_execution/conftest.py
  • src/backend/tests/unit/services/background_execution/test_build_worker.py
  • src/backend/tests/unit/services/background_execution/test_control_fastpath.py
  • src/backend/tests/unit/services/background_execution/test_db_backend.py
  • src/backend/tests/unit/services/background_execution/test_factory_selection.py
  • src/backend/tests/unit/services/background_execution/test_lease_watchdog.py
  • src/backend/tests/unit/services/background_execution/test_scaled_end_to_end.py
  • src/backend/tests/unit/services/background_execution/test_scaled_stop.py
  • src/backend/tests/unit/services/background_execution/test_service.py
  • src/backend/tests/unit/services/background_execution/test_standalone_slice.py
  • src/backend/tests/unit/services/background_execution/test_worker_loop.py
  • src/backend/tests/unit/services/background_execution/test_worker_runner.py
  • src/backend/tests/unit/services/background_execution/test_worker_subprocess_e2e.py
  • src/backend/tests/unit/services/background_execution/test_worker_subprocess_stop.py
  • src/lfx/src/lfx/services/settings/groups/runtime.py
  • src/lfx/tests/unit/services/settings/test_background_scaled_settings.py
  • src/lfx/tests/unit/services/settings/test_settings_composition.py
💤 Files with no reviewable changes (1)
  • src/backend/tests/unit/services/background_execution/conftest.py

Comment on lines +848 to +850
idle_block_ms: int = typer.Option(
1000, help="Claim poll window in milliseconds (idle sleep between claim attempts)."
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Typer typer.Option min max integer range validation

💡 Result:

In Typer, you can validate numeric ranges for CLI options and arguments by passing min and max parameters directly to typer.Option or typer.Argument [1][2]. Typer handles these constraints automatically, displaying an error message if the input falls outside the specified bounds [1][3]. Example usage: from typing import Annotated import typer def main( user_id: Annotated[int, typer.Option(min=1, max=1000)]): print(f"ID is {user_id}") If you want to automatically adjust out-of-bounds values to the nearest valid boundary (minimum or maximum) instead of raising an error, you can enable the clamp parameter [1][3]: def main( rank: Annotated[int, typer.Option(min=1, max=10, clamp=True)]): print(f"Rank is {rank}") Because Typer is built on top of Click, these numeric validations are implemented via Click's range handling [4][5]. While you can use Click's explicit click.IntRange or click.FloatRange types within Typer, the idiomatic Typer approach is to use the min, max, and clamp parameters directly within the Annotated type hint [1][2]. For more complex validation logic that falls outside of simple range checking, you can implement custom parameter callbacks [6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file =="
fd -a '__main__.py$' . | sed 's#^\./##' | head -20

echo "== relevant lines =="
sed -n '830,865p' src/backend/base/langflow/__main__.py

echo "== dependency versions for typer/click =="
for f in pyproject.toml uv.lock poetry.lock requirements*.txt; do
  if [ -f "$f" ]; then
    echo "-- $f --"
    rg -n "typer|click" "$f" | head -40 || true
  fi
done

echo "== option signature context across file =="
rg -n "idle_block_ms|def .*worker|`@app.command`|typer\.Option|Annotated\[" src/backend/base/langflow/__main__.py | head -80

Repository: langflow-ai/langflow

Length of output: 18711


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== worker loop and idle usage =="
sed -n '865,898p' src/backend/base/langflow/__main__.py

echo "== claim implementation references =="
rg -n "async def .*claim|def .*claim|idle_block_ms|idle_block" src/backend -S | head -120

echo "== packages installed in sandbox =="
python3 - <<'PY'
try:
    import typer
    import click
    print("typer", typer.__version__)
    print("click", click.__version__)
    import inspect
    print("typer.Option signature:")
    print(inspect.signature(typer.Option))
    print("click types IntRange:")
    print(callable(getattr(click.types, "IntRange", None)))
except Exception as e:
    print("IMPORT_ERROR", repr(e))
PY

echo "== static verifier: option signatures have range constraints =="
python3 - <<'PY'
from pathlib import Path
import ast

path = Path("src/backend/base/langflow/__main__.py")
tree = ast.parse(path.read_text())

options_no_constraints = []
options_with_constraints = []

for n in ast.walk(tree):
    if isinstance(n, ast.arguments):
        for name in ("log_level", "env_file", "idle_block_ms"):
            for arg in n.args + n.kwonlyargs:
                if arg.arg == name:
                    defn = None
                    for p in n.defaults + n.kw_defaults:
                        if isinstance(p, ast.Call) and getattr(p.func, "attr", "") == "Option":
                            defn = p
                            break
                    if defn is None:
                        continue
                    constraints = []
                    for kw in defn.keywords:
                        if kw.arg in ("min", "max"):
                            constraints.append(kw.arg)
                    line = getattr(defn, "lineno", 0)
                    label = f"{arg.arg}: line {line}"
                    (options_with_constraints if constraints else options_no_constraints).append((label, constraints, ast.get_source_segment(path.read_text(), defn)))
                    break

print("with constraints:")
for item in options_with_constraints: print(item[0], item[1], item[2])
print("no constraints at typer.Option:")
for item in options_no_constraints: print(item[0], item[2].replace("\\n", " ")[:200])
PY

Repository: langflow-ai/langflow

Length of output: 10713


🌐 Web query:

pydantic v2 model_config validate_assignment false default value assigned to parameter __init__ extra ignore

💡 Result:

In Pydantic v2, the validate_assignment configuration and extra configuration (specifically extra='ignore') operate on different stages of the model lifecycle, and validate_assignment=False (which is the default) does not disable validation during model initialization [1][2][3]. 1. validate_assignment: This setting specifically controls whether validation occurs when an attribute is modified after the model instance has already been created [1][3]. It does not affect the initial validation that occurs when calling the model's constructor [1][3]. Even when set to False, Pydantic will still enforce all field validations during the initial instantiation [1][3]. 2. extra="ignore": When configured as model_config = ConfigDict(extra='ignore'), this setting dictates how the model handles keyword arguments passed to the constructor that do not correspond to defined model fields [2][4]. With 'ignore' (which is the default behavior), any such extra parameters are silently discarded and are not stored in the model instance [2][4]. In summary, setting validate_assignment=False does not stop Pydantic from validating required fields or types during the init call, nor does it affect how the model handles extra parameters [1][3]. If you pass extra parameters to a model with extra='ignore', they are simply dropped during initialization, regardless of your validate_assignment setting [2][4].

Citations:


Bound --idle-block-ms to a positive value.

This CLI option accepts 0 and negative values, and the worker loop passes it directly to claim(). With a non-positive poll window, claim() can return immediately when work is available and then sleep for zero, which makes the worker loop poll the shared job table without throttling. Add min=1 so Typer rejects invalid values.

🛡️ Proposed fix
     idle_block_ms: int = typer.Option(
-        1000, help="Claim poll window in milliseconds (idle sleep between claim attempts)."
+        1000, min=1, help="Claim poll window in milliseconds (idle sleep между claim attempts)."
     ),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
idle_block_ms: int = typer.Option(
1000, help="Claim poll window in milliseconds (idle sleep between claim attempts)."
),
idle_block_ms: int = typer.Option(
1000, min=1, help="Claim poll window in milliseconds (idle sleep between claim attempts)."
),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/base/langflow/__main__.py` around lines 848 - 850, Update the
idle_block_ms Typer option to enforce a minimum value of 1, so --idle-block-ms
rejects zero and negative inputs while preserving the existing default and help
text.

Comment on lines +32 to +35
conn = op.get_bind()
existing = {ix["name"] for ix in sa.inspect(conn).get_indexes("job")}
if _INDEX not in existing:
op.create_index(_INDEX, "job", ["status", "type", "created_timestamp"], unique=False)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 5 \
  'autocommit_block|CREATE INDEX CONCURRENTLY|postgresql_concurrently|create_index' \
  src/backend/base/langflow/alembic

rg -n -C 5 \
  'transaction_per_migration|transactional_ddl' \
  alembic.ini pyproject.toml src/backend

Repository: langflow-ai/langflow

Length of output: 50378


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== target migration =="
cat -n src/backend/base/langflow/alembic/versions/4b8f2c6d9e1a_add_job_claim_scan_index.py

echo
echo "== alembic config migration settings =="
for f in alembic.ini pyproject.toml setup.py setup.cfg tox.ini; do
  if [ -f "$f" ]; then
    echo "-- $f --"
    rg -n 'transaction_per_migration|sqlalchemy.url|setenv|pythonpath|script_location|exclude_tag_prefix|sqlalchemy.url|sqlite|postgresql' "$f" || true
  fi
done

echo
echo "== references to job table in services/migration models =="
rg -n '"job"|table_name=.job|class .*Job|job\.(status|type|created_timestamp)|status.*type.*created|claim|lease|heartbeat' src/backend -g '*.py' | head -n 200

echo
echo "== create_index conventions using index existence checks =="
python3 - <<'PY'
from pathlib import Path
import re
target = Path('src/backend/base/langflow/alembic/versions/4b8f2c6d9e1a_add_job_claim_scan_index.py')
text = target.read_text()
print('target file exists:', target.exists())
print('has get_bind:', 'op.get_bind()' in text)
print('has inspect get_indexes:', 'sa.inspect(conn).get_indexes("job")' in text)
print('has op.create_index on job:', bool(re.search(r'op\.create_index\s*\(\s*_INDEX\s*,\s*"job"', text)))
print('has concurrent keywords:', any(k in text for k in ['CREATE INDEX CONCURRENTLY', 'postgresql_concurrently', 'postgresql_with']))
PY

Repository: langflow-ai/langflow

Length of output: 27687


Build the job claim index concurrently for PostgreSQL.

op.create_index issues a normal CREATE INDEX, which holds locks that can block writes to the active job table while the index builds. Use a PostgreSQL-specific concurrent build outside an Alembic transaction block, with a safe non-PostgreSQL alternative, because normal PostgreSQL index creation blocks INSERT, UPDATE, and DELETE until it completes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/backend/base/langflow/alembic/versions/4b8f2c6d9e1a_add_job_claim_scan_index.py`
around lines 32 - 35, Update the upgrade logic around the existing index check
to use PostgreSQL’s concurrent index creation outside the Alembic transaction,
while retaining a safe non-PostgreSQL fallback using the normal index operation.
Preserve the existing _INDEX name, job table, columns, uniqueness, and
idempotent existing-index check.

Comment on lines +1 to +16
"""Locust load test for the v2 background execution service.

Each task submits a background workflow job and polls it to a terminal state,
proving the background API holds under load with real ``langflow worker``
processes draining a redis claim queue (scaled mode) -- or the in-process
executor (default mode) for a head-to-head comparison.

Submit: POST /api/v2/workflows {"flow_id", "input_value", "mode": "background"}
-> {"job_id", "flow_id", "status": "queued"}
Poll: GET /api/v2/workflows?job_id=<job_id>
-> 200 with status in {queued, in_progress, completed} ; 500 on failure.

A request is recorded as a success only when its job reaches COMPLETED inside
the bounded deadline. Submitted vs completed counts are tracked and printed in
the final summary so correctness can be cross-checked against the ``job`` table.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Stale Redis wording remains in two docstrings after the database-backed migration. The scaled backend now uses the database as the queue, but both docstrings still describe Redis.

  • src/backend/tests/locust/v2_background_locustfile.py#L1-L16: replace "draining a redis claim queue (scaled mode)" with the database-backed worker claim path.
  • src/backend/tests/unit/background_execution/test_side_effect_safety.py#L13-L16: replace "the retry-safe requeue branch uses the real redis backend" with DBBackgroundQueue, which line 89 constructs.
📍 Affects 2 files
  • src/backend/tests/locust/v2_background_locustfile.py#L1-L16 (this comment)
  • src/backend/tests/unit/background_execution/test_side_effect_safety.py#L13-L16
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/tests/locust/v2_background_locustfile.py` around lines 1 - 16,
The docstrings contain stale Redis references after the database-backed
migration. In src/backend/tests/locust/v2_background_locustfile.py lines 1-16,
describe scaled mode as using the database-backed worker claim path; in
src/backend/tests/unit/background_execution/test_side_effect_safety.py lines
13-16, replace the real Redis backend wording with DBBackgroundQueue, matching
the instance constructed by the test.

Comment on lines +78 to +85
with self.client.post(
"/api/v2/workflows",
json=body,
headers=self.headers,
name="POST /api/v2/workflows (submit)",
timeout=REQUEST_TIMEOUT,
catch_response=True,
) as resp:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect the installed/pinned locust version and FastHttpSession.request signature.
fd -t f 'pyproject.toml|uv.lock|requirements.*txt' | xargs rg -n 'locust' | head -50
pip download locust==2.43.4 --no-deps --no-binary :all: -d /tmp/locust_src 2>/dev/null | tail -2
fd -t f 'fasthttp.py' /tmp/locust_src 2>/dev/null | head -5

Repository: langflow-ai/langflow

Length of output: 1161


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
try:
    import locust.__version__
    from locust import FastHttpUser
    from inspect import signature
    print("LOCUST_VERSION", locust.__version__)
    print("FASTHTTPUSER_SIG", signature(FastHttpUser.client.get))
    print("FASTHTTPUSER_SIG_POST", signature(FastHttpUser.client.post))
    print("HAS_REQUEST", hasattr(FastHttpUser.client, "request"))
    if hasattr(FastHttpUser.client, "request"):
        print("FASTHTTPSESSION_SIG", signature(FastHttpUser.client.request))
except Exception as e:
    print("ERROR", type(e).__name__, str(e))
PY

python3 - <<'PY'
import inspect
try:
    from locust.contrib.fasthttp import FastHttpUser
    cls = FastHttpUser
    print("client_type", cls.client_type)
    print("cls attrs network/connection:", (getattr(cls, "network_timeout", getattr(cls, "timeout", None)), getattr(cls, "connection_timeout", None)))
    print("client_module", type(cls.client).__module__)
    print("client_class", type(cls.client).__name__)
    print("client_request", type(cls.client).request)
    print("client_request_sig", inspect.signature(type(cls.client).request))
    for name in dir(type(cls.client)):
        if name.startswith("request"):
            print("request_attr", name, getattr(type(cls.client), name, None))
except Exception as e:
    print("ERR", type(e).__name__, e)
PY

Repository: langflow-ai/langflow

Length of output: 258


🏁 Script executed:

#!/bin/bash
set -euo pipefail

tmp="/tmp/locust-check"
rm -rf "$tmp"
python3 - <<'PY' "$tmp" locust==2.43.4
import sys, os, tarfile, tempfile, urllib.request

tmpdir = sys.argv[1]
version = sys.argv[2]
url = "https://files.pythonhosted.org/packages/52/be/6df1c778f673e1e2d785f262d20a4e11d985f62442466d7ae434b66a587/locust-2.43.4.tar.gz"
tarball = os.path.join(tmpdir, version + ".tar.gz")
print("DOWNLOAD", url)
urllib.request.urlretrieve(url, tarball)
os.makedirs(tarball + ".extracted", exist_ok=True)
print("DOWNLOAD_OK", os.path.exists(tarball))
PY

T="$tmp/locust-check/${2:-locust==2.43.4}.tar.gz.extracted"
python3 <<'PY' "$T"
import sys, ast, inspect

path = sys.argv[1]
# Locate installed-like module code from the unpack tarball without import.
importers = []
for dirpath, _, filenames in os.walk(path):
    if "__init__.py" in filenames:
        rel = os.path.relpath(os.path.join(dirpath, "__init__.py"), path)
        importers.append(rel.replace(os.sep, ".").replace(".__init__", ""))
root_import = next((i for i in importers if i.endswith("locust") or i == "locust"), None)
print("ROOT_IMPORT", root_import)
if not root_import:
    raise SystemExit("no locust package")

# Use AST to find FastHttpSession request function signature and relevant request forwarding lines.
from pathlib import Path
src = Path(path).expanduser()
fasthttp = None
for py in src.rglob("fasthttp.py"):
    if "locust" in str(py):
        fasthttp = py
        break
print("FASTHTTP", fasthttp)
if not fasthttp:
    raise SystemExit("no fasthttp.py")

code = fasthttp.read_text()
tree = ast.parse(code)
for node in ast.walk(tree):
    if isinstance(node, ast.ClassDef) and node.name == "FastHttpSession":
        print("CLASS", node.name, node.lineno)
        for item in node.body:
            if isinstance(item, ast.FunctionDef) and item.name == "request":
                print("request_lineno", item.lineno)
                print("request_args", ast.Module(body=[item], type_ignores=[]).ast_dump())
                for stmt in ast.walk(item):
                    if isinstance(stmt, ast.Call):
                        func = stmt
                        if isinstance(func, ast.Attribute) and func.attr in ("request", "get", "post", "head", "put", "patch", "delete", "json"):
                            # Print request calls with kwargs or **kwargs
                            if stmt.keywords or any(isinstance(a, ast.Starred) for a in stmt.args):
                                snip = ast.get_source_segment(code, stmt)
                                print("REQUEST_CALL", stmt.lineno, stmt.col_offset, snip)
PY

Repository: langflow-ai/langflow

Length of output: 2709


🏁 Script executed:

#!/bin/bash
set -euo pipefail

tmp="/tmp/locust-check"
rm -rf "$tmp"
mkdir -p "$tmp"
python3 - <<'PY' "$tmp" locust==2.43.4
import sys, os, urllib.request, tarfile

tmpdir = sys.argv[1]
version = sys.argv[2]
url = "https://files.pythonhosted.org/packages/52/be/6df1c778f673e1e2d785f262d20a4e11d985f62442466d7ae434b66a587/locust-2.43.4.tar.gz"
req = urllib.request.Request(url)
req.add_header("User-Agent", "curious-verifier")
urllib.request.urlretrieve(url, tmpdir + "/" + version + ".tar.gz")
print("toggled")
PY

T="$tmp/locust-check/locust==2.43.4.tar.gz.extracted"
python3 <<'PY' "$T"
import sys, ast, os
path = sys.argv[1]
os.makedirs(path, exist_ok=True)
with open(path+"/"+os.listdir(tmp)[0]) as f: pass
PY

python3 <<'PY'
import tarfile, ast, os
tmp = "/tmp/locust-check"
for fn in os.listdir(tmp):
    if fn.endswith(".tar.gz"):
        tb = tarfile.open(os.path.join(tmp, fn), "r:gz")
        members = [m for m in tb.getmembers() if m.name.endswith(("locust/contrib/fasthttp.py", "locust/contrib/fasthttp.pyi"))]
        print(members)
        for m in members:
            data = tb.extractfile(m).read().decode("utf-8", "replace")
            print("---", m.name, "lines", len(data.splitlines()))
            tree = ast.parse(data)
            for node in ast.walk(tree):
                if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == "request":
                    line = data.splitlines()[node.lineno-1] if node.lineno <= len(data.splitlines()) else "<missing line>"
                    print("FOUND_REQUEST", m.name, node.lineno, "args=", ast.dump(node.args))
                    print("FIRST_LINE", line[:200])
            # search for timeout/params usage
            for i,l in enumerate(data.splitlines(), 1):
                if "timeout" in l or "params" in l:
                    print("MATCH", m.name, i, l.strip())
        break
PY

Repository: langflow-ai/langflow

Length of output: 2573


🌐 Web query:

Locust 2.43.4 FastHttpSession request signature timeout params

💡 Result:

In Locust 2.43.4, the FastHttpSession class (part of locust.contrib.fasthttp) does not utilize a specific signature parameter named timeout directly within its request method signature to manage request timeouts. Instead, FastHttpSession manages timeouts via instance-level attributes that are passed during initialization [1][2]. Specifically, the following timeout-related parameters are available on the FastHttpSession instance: connection_timeout: A float value representing the time in seconds to wait for a connection to be established (default is 60.0) [1][2]. network_timeout: A float value representing the time in seconds to wait for data (default is 60.0) [1][2]. These parameters are typically configured on the FastHttpUser class or instance, which then passes them to the underlying FastHttpSession [2][3][4]. If you need to override these settings, you should set them as attributes on your FastHttpUser class: class MyUser(FastHttpUser): connection_timeout = 30.0 network_timeout = 30.0 Note that while the standard HttpSession.request method (from the requests library) accepts a timeout argument directly, FastHttpSession is designed for higher performance and uses a different client implementation (gevent-based) that relies on the aforementioned instance attributes for timeout control [1][5][2].

Citations:


🌐 Web query:

Locust FastHttpUser client accept **kwargs request timeout params

💡 Result:

Yes, the Locust FastHttpUser client supports passing a timeout parameter through the kwargs in its request methods [1][2][3]. The FastHttpUser class is designed to provide a similar API to HttpUser [1][2]. Its request methods (such as get, post, request, etc.) accept additional arguments via kwargs, which are passed to the underlying HTTP client implementation [2][3]. For the timeout parameter specifically, the documentation indicates it can be provided as a float (representing seconds) or as a tuple (connect timeout, read timeout) [1][4][5]. Example usage: self.client.get("/endpoint", timeout=(5.0, 10.0)) While FastHttpUser uses geventhttpclient instead of python-requests, the Locust wrapper maps these keyword arguments to the appropriate configuration for the underlying client [1][2]. You can also configure default timeouts for the entire User class by setting the connection_timeout and network_timeout class attributes [2][6].

Citations:


🌐 Web query:

Locust FastHttpSession source kwargs timeout params 2.43.3 FastHttpSession request

💡 Result:

In Locust 2.43.3, the FastHttpSession.request method accepts a kwargs parameter to handle extra arguments [1]. While the documented explicit parameters for request (such as method, url, name, data, json, headers, auth, stream, allow_redirects, and catch_response) do not include an explicit timeout parameter, the kwargs are passed down to the underlying HTTP client implementation [1][2]. Specifically, the FastHttpSession.request method calls self._send_request_safe_mode(..., kwargs), which forwards these arguments [2]. The underlying HTTP library used by FastHttpSession is typically geventhttpclient, which uses different timeout mechanisms (e.g., network_timeout and connection_timeout) configured during the initialization of FastHttpSession [3][4]. If you are looking to set request-specific timeouts, note that unlike the standard HttpSession (which wraps the requests library), FastHttpSession is designed for high performance using geventhttpclient and may not support the same per-request timeout keyword arguments that requests.request does. In FastHttpSession, timeouts are generally managed at the session level via parameters like network_timeout and connection_timeout provided during the instantiation of the FastHttpUser or the FastHttpSession object itself [3][4]. Passing a timeout argument via kwargs in request may have no effect depending on the underlying implementation constraints [5].

Citations:


🌐 Web query:

Locust 2.43.4 locust clients py FastHttpSession request timeout params kwargs

💡 Result:

In Locust 2.43.4, the FastHttpSession class (used by FastHttpUser) acts as a high-performance alternative to the standard HttpSession [1][2]. For request timeouts when using FastHttpSession, note that the timeout parameter found in the standard requests-based HttpSession is not directly supported in the same way within the FastHttpSession.request method [3][4]. Instead, FastHttpUser handles connection and network timeouts at the client level using specific attributes that are passed to the underlying FastHttpSession upon instantiation [5][6][1]: 1. connection_timeout: Controls the time allowed to establish a connection (default is 60.0 seconds) [6][1]. 2. network_timeout: Controls the time allowed for network operations (default is 60.0 seconds) [6][1]. These attributes can be configured in your FastHttpUser subclass before it is instantiated [6][1]: class MyUser(FastHttpUser): connection_timeout = 10.0 network_timeout = 20.0 While the request method accepts kwargs, these are typically passed to the underlying geventhttpclient and do not support the same timeout dictionary or tuple structures used by the requests library [3][4]. Users requiring per-request timeout flexibility should be aware that FastHttpSession is optimized for performance by using a persistent connection pool, and per-request timeout overrides are not natively exposed through the request method signature in the same manner as the standard requests client [6][1][2].

Citations:


Use class-level timeout attributes for FastHttpUser requests.

Locust’s FastHttpSession does not support per-request timeout; unsupported kwargs can be forwarded to geventhttpclient without applying Locust’s configured network_timeout / connection_timeout. Set both class attributes to REQUEST_TIMEOUT instead of passing timeout= into self.client.get / self.client.post.

Also applies to: 104-111

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/tests/locust/v2_background_locustfile.py` around lines 78 - 85,
The workflow request in the locust user should not pass per-request timeout
arguments to self.client.post or self.client.get. Set the FastHttpUser class
attributes network_timeout and connection_timeout to REQUEST_TIMEOUT, then
remove the timeout keyword from both request calls while preserving their
existing response handling.

Comment on lines +112 to +133
code = poll.status_code
if code == 200:
status = (poll.json() or {}).get("status")
if status == "completed":
poll.success()
_bump("completed")
return
# queued / in_progress: still working, keep polling.
poll.success()
elif code == 500:
# Failed job surfaces as 500 JOB_FAILED on the status route.
poll.success() # the poll call itself worked; the job failed
_bump("failed")
return
elif code == 408:
poll.success()
_bump("failed")
return
else:
_bump("poll_errors")
poll.failure(f"poll {code}: {poll.text[:200]}")
return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard the poll body parse like the submit body parse.

Line 114 calls poll.json() without a guard. A 200 response with a non-JSON body raises inside the with-block. The task then ends without a poll_errors bump, so the final accounting under-reports. Line 132 also indexes poll.text, which can be None on an empty body.

🛡️ Proposed fix
                 code = poll.status_code
                 if code == 200:
-                    status = (poll.json() or {}).get("status")
+                    try:
+                        status = (poll.json() or {}).get("status")
+                    except Exception as exc:  # noqa: BLE001
+                        _bump("poll_errors")
+                        poll.failure(f"poll body parse error: {exc}")
+                        return
                     if status == "completed":
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/tests/locust/v2_background_locustfile.py` around lines 112 - 133,
Guard the 200-response body parsing in the polling flow around poll.json() so
malformed or non-JSON responses are handled as poll errors and increment
poll_errors instead of escaping the with-block. Also make the failure message in
the fallback branch safe when poll.text is None by using an empty-string
fallback before slicing.

Comment on lines +37 to +40
if "+psycopg" in raw:
return raw
if raw.startswith("postgresql+asyncpg://"):
return raw.replace("postgresql+asyncpg://", "postgresql+psycopg://", 1)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The substring check also matches postgresql+psycopg2://.

"+psycopg" in raw is true for a psycopg2 URL. psycopg2 is a sync driver, so create_async_engine(real_services_db_url) in test_real_services_harness.py fails for that input. Match the exact dialect prefix and normalize psycopg2 as well.

🛠️ Proposed fix
-    if "+psycopg" in raw:
+    if raw.startswith("postgresql+psycopg://"):
         return raw
+    if raw.startswith("postgresql+psycopg2://"):
+        return raw.replace("postgresql+psycopg2://", "postgresql+psycopg://", 1)
     if raw.startswith("postgresql+asyncpg://"):
         return raw.replace("postgresql+asyncpg://", "postgresql+psycopg://", 1)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if "+psycopg" in raw:
return raw
if raw.startswith("postgresql+asyncpg://"):
return raw.replace("postgresql+asyncpg://", "postgresql+psycopg://", 1)
if raw.startswith("postgresql+psycopg://"):
return raw
if raw.startswith("postgresql+psycopg2://"):
return raw.replace("postgresql+psycopg2://", "postgresql+psycopg://", 1)
if raw.startswith("postgresql+asyncpg://"):
return raw.replace("postgresql+asyncpg://", "postgresql+psycopg://", 1)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/tests/unit/background_execution/conftest.py` around lines 37 -
40, Update the URL normalization logic around the raw URL checks so psycopg2
URLs are not mistaken for psycopg URLs: match the exact async psycopg dialect
prefix, and normalize postgresql+psycopg2 URLs to the async-compatible
postgresql+psycopg form before returning. Preserve unchanged URLs that already
use the exact psycopg prefix.

Comment on lines +134 to +142
proc = subprocess.Popen( # noqa: S603
["uv", "run", "langflow", "worker", "--idle-block-ms", str(idle_block_ms)], # noqa: S607
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
start_new_session=True,
)
self.procs.append(proc)
return proc

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

The unread stdout pipe can block the worker subprocess.

Nothing reads proc.stdout while the worker runs. drain_worker_output reads it only on failure or teardown. When the worker writes more than the OS pipe buffer (commonly 64 KiB), its write blocks, the claim loop stops, and wait_for_status fails with a timeout that hides the real cause. A real graph build with logging reaches that volume quickly.

Redirect the worker output to a temporary file, then read the file for diagnostics. A reader thread per process is the alternative.

🐛 Proposed direction
+import tempfile
...
-        proc = subprocess.Popen(  # noqa: S603
-            ["uv", "run", "langflow", "worker", "--idle-block-ms", str(idle_block_ms)],  # noqa: S607
-            env=env,
-            stdout=subprocess.PIPE,
-            stderr=subprocess.STDOUT,
-            start_new_session=True,
-        )
-        self.procs.append(proc)
+        log = tempfile.NamedTemporaryFile(suffix=".worker.log", delete=False)  # noqa: SIM115
+        proc = subprocess.Popen(  # noqa: S603
+            ["uv", "run", "langflow", "worker", "--idle-block-ms", str(idle_block_ms)],  # noqa: S607
+            env=env,
+            stdout=log,
+            stderr=subprocess.STDOUT,
+            start_new_session=True,
+        )
+        self.logs[proc.pid] = log.name
+        self.procs.append(proc)

drain_worker_output then reads the tail of each log file and no longer needs to terminate a live worker to reach EOF.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/tests/unit/services/background_execution/_subprocess_harness.py`
around lines 134 - 142, Update the subprocess setup around Popen and
drain_worker_output so worker stdout/stderr is redirected to a temporary log
file instead of an unread PIPE, preventing the worker from blocking on pipe
capacity. Track the file handle or path per process, and have
drain_worker_output read the available log-file tail for diagnostics without
terminating a live worker merely to reach EOF.

Comment on lines +76 to +98
async def drive_stop_then_wait():
# Wait until the run is in-flight, then stop it through the facade.
await asyncio.wait_for(in_flight.wait(), timeout=10.0)
await facade.stop_job(job_id, active_user)
# Wait until the durable row reaches a terminal state, then end the loop.
for _ in range(200):
refreshed = await jobs.get_job_by_job_id(job_id)
if refreshed.status in {JobStatus.CANCELLED, JobStatus.COMPLETED, JobStatus.FAILED}:
stop_event.set()
return
await asyncio.sleep(0.05)
stop_event.set()

driver = asyncio.create_task(drive_stop_then_wait())
await run_worker_loop(
worker_backend,
worker_runner,
stop_event=stop_event,
idle_block_ms=50,
job_service=jobs,
owner="worker:w1",
)
await driver

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

A failing driver task hangs the worker-loop tests. In both tests a helper task is the only writer of stop_event, and the test awaits run_worker_loop without a timeout. If the helper raises, stop_event stays clear, the awaited loop never returns, and the helper's exception is never reported. The fix is the same at both sites: set stop_event in a finally block and bound the loop await.

  • src/backend/tests/unit/services/background_execution/test_scaled_stop.py#L76-L98: wrap the body of drive_stop_then_wait in try/finally with stop_event.set() in the finally, and call await asyncio.wait_for(run_worker_loop(...), timeout=30.0).
  • src/backend/tests/unit/services/background_execution/test_scaled_end_to_end.py#L71-L89: apply the same try/finally to stop_when_done and the same asyncio.wait_for bound on the run_worker_loop call.
📍 Affects 2 files
  • src/backend/tests/unit/services/background_execution/test_scaled_stop.py#L76-L98 (this comment)
  • src/backend/tests/unit/services/background_execution/test_scaled_end_to_end.py#L71-L89
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/tests/unit/services/background_execution/test_scaled_stop.py`
around lines 76 - 98, Ensure both helper tasks always set stop_event in a
finally block and bound the worker-loop await with a 30-second timeout. In
src/backend/tests/unit/services/background_execution/test_scaled_stop.py lines
76-98, update drive_stop_then_wait and wrap run_worker_loop with
asyncio.wait_for; apply the same changes to stop_when_done and run_worker_loop
in
src/backend/tests/unit/services/background_execution/test_scaled_end_to_end.py
lines 71-89.

Comment on lines +31 to +34
def test_background_poll_interval_default(monkeypatch):
assert Settings().background_poll_interval_s == 0.5
monkeypatch.setenv("LANGFLOW_BACKGROUND_POLL_INTERVAL_S", "0.1")
assert Settings().background_poll_interval_s == 0.1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate relevant files =="
git ls-files | rg '(^|/)test_background_scaled_settings\.py$|(^|/)settings.*\.py$|runtime.*settings.*\.py$|background.*settings.*\.py$' || true

echo
echo "== target test excerpt =="
target="src/lfx/tests/unit/services/settings/test_background_scaled_settings.py"
if [ -f "$target" ]; then
  nl -ba "$target" | sed -n '1,120p'
fi

echo
echo "== search RuntimeSettings / background_poll_interval_s =="
rg -n "RuntimeSettings|background_poll_interval_s|LANGFLOW_BACKGROUND_POLL_INTERVAL_S" -S .

Repository: langflow-ai/langflow

Length of output: 2553


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== target test excerpt =="
target="src/lfx/tests/unit/services/settings/test_background_scaled_settings.py"
if [ -f "$target" ]; then
  awk '{printf "%6d  %s\n", NR, $0}' "$target" | sed -n '1,120p'
fi

echo
echo "== search RuntimeSettings / background_poll_interval_s =="
rg -n "RuntimeSettings|background_poll_interval_s|LANGFLOW_BACKGROUND_POLL_INTERVAL_S" -S src/lfx src/backend || true

echo
echo "== inspect candidate settings files around relevant symbols =="
pgrep -f "RuntimeSettings|background_poll_interval_s" $(git ls-files 'src/lfx/src/lfx/services/settings/*.py' 'src/lfx/src/lfx/settings.py' | tr '\n' ' ') || true

Repository: langflow-ai/langflow

Length of output: 4660


Test rejected polling intervals.

background_poll_interval_s requires gt=0, so add tests for LANGFLOW_BACKGROUND_POLL_INTERVAL_S=0 and a negative value. The current test covers only valid values.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lfx/tests/unit/services/settings/test_background_scaled_settings.py`
around lines 31 - 34, Extend test_background_poll_interval_default to verify
that LANGFLOW_BACKGROUND_POLL_INTERVAL_S values of 0 and a negative number are
rejected by Settings, preserving the existing assertions for the default and
valid 0.1 interval.

Source: Coding guidelines

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request scaling-up

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants