Skip to content

Commit 5b95fcc

Browse files
committed
feat(api/v2): redis-scaled background execution backend + langflow worker
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.
1 parent d696cae commit 5b95fcc

32 files changed

Lines changed: 3362 additions & 0 deletions

src/backend/base/langflow/__main__.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -833,6 +833,65 @@ def print_banner(host: str, port: int, protocol: str) -> None:
833833
logger.info(f"Open Langflow: {protocol}://{access_host}:{port}")
834834

835835

836+
@app.command()
837+
def worker(
838+
log_level: str | None = typer.Option(None, help="Logging level."),
839+
env_file: Path | None = typer.Option(None, help="Path to the .env file."),
840+
idle_block_ms: int = typer.Option(1000, help="Claim blocking-pop window in milliseconds."),
841+
) -> None:
842+
"""Run a Langflow background worker that drains the redis job-claim queue.
843+
844+
Requires LANGFLOW_JOB_QUEUE_TYPE=redis. Each worker claims queued jobs, runs
845+
them through the background JobRunner (publishing live frames to redis
846+
Streams so any API replica can reattach), and releases the lease. Run as many
847+
worker processes as you need horizontal capacity for.
848+
"""
849+
if env_file:
850+
load_dotenv(env_file, override=True)
851+
if log_level:
852+
configure(log_level=log_level)
853+
854+
async def _run_worker() -> None:
855+
from langflow.services.background_execution.worker import build_worker, run_worker_loop
856+
857+
await initialize_services()
858+
settings = get_settings_service().settings
859+
if not settings.background_backend_is_scaled:
860+
typer.echo("LANGFLOW_JOB_QUEUE_TYPE must be 'redis' to run a worker.")
861+
raise typer.Exit(code=1)
862+
863+
from uuid import uuid4
864+
865+
from langflow.services.deps import get_job_service
866+
867+
# Process-unique owner so this worker's heartbeats are attributable, and
868+
# a periodic watchdog so a dead worker's in-flight job is reaped under a
869+
# steady fleet WITHOUT requiring a restart.
870+
owner = f"worker:{os.getpid()}:{uuid4().hex[:8]}"
871+
backend, worker_runner, teardown = await build_worker(owner=owner)
872+
stop_event = asyncio.Event()
873+
loop = asyncio.get_running_loop()
874+
for sig in (signal.SIGTERM, signal.SIGINT):
875+
with suppress(NotImplementedError):
876+
loop.add_signal_handler(sig, stop_event.set)
877+
logger.info("Langflow worker started; draining the redis job-claim queue.")
878+
try:
879+
await run_worker_loop(
880+
backend,
881+
worker_runner,
882+
stop_event=stop_event,
883+
idle_block_ms=idle_block_ms,
884+
job_service=get_job_service(),
885+
owner=owner,
886+
lease_ttl_s=settings.background_lease_ttl_s,
887+
watchdog_interval_s=settings.background_watchdog_interval_s,
888+
)
889+
finally:
890+
await teardown()
891+
892+
asyncio.run(_run_worker())
893+
894+
836895
@app.command()
837896
def superuser(
838897
username: str = typer.Option(
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
"""Scaled background backend: redis claim queue + Streams live bus + DB replay.
2+
3+
The facade (``BackgroundExecutionService``) delegates to this when
4+
``settings.background_backend_is_scaled`` is True. It composes:
5+
6+
* ``RedisJobClaimQueue`` — hands job ids to a separate ``langflow worker`` process.
7+
* ``JobService`` durable — ``read_events()`` replays milestone events from the DB
8+
so any API replica can reattach from a Last-Event-ID cursor.
9+
* ``RedisQueueWrapper`` — the existing Streams tail for live ephemeral frames,
10+
reused verbatim from the job_queue service (no second bridge built here).
11+
12+
``events()`` is the cross-replica reattach contract: replay durable ``job_events``
13+
(seq > last_event_id) from the DB, then XREAD-tail the redis Stream for live
14+
frames. A replica that never started the job can still serve the full event
15+
history because milestones are durable and live frames are on the shared Stream.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import contextlib
21+
import uuid
22+
from typing import TYPE_CHECKING, Any
23+
24+
from langflow.services.background_execution.redis_queue import RedisJobClaimQueue
25+
from langflow.services.database.models.jobs.model import JobStatus, SignalType
26+
from langflow.services.job_queue.service import RedisQueueWrapper
27+
28+
if TYPE_CHECKING:
29+
from collections.abc import AsyncIterator
30+
31+
from langflow.services.jobs.service import JobService
32+
33+
# Durable statuses that mean the run is over (the reconciler just drops the id).
34+
_TERMINAL_STATUSES = frozenset({JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED, JobStatus.TIMED_OUT})
35+
36+
37+
def _as_signal_job_id(job_id: str) -> Any:
38+
"""Best-effort coerce a job id string to a UUID for the durable signal row.
39+
40+
The facade always passes a real UUID string, so the durable STOP row keys
41+
match ``unconsumed_signals(UUID)`` lookups. A non-UUID id is passed through
42+
unchanged (tolerated so a test with a non-UUID id does not crash the coerce).
43+
"""
44+
if isinstance(job_id, uuid.UUID):
45+
return job_id
46+
with contextlib.suppress(ValueError, AttributeError, TypeError):
47+
return uuid.UUID(job_id)
48+
return job_id
49+
50+
51+
class _StreamFrame:
52+
"""A live (ephemeral) frame pulled off the redis Stream tail.
53+
54+
``seq`` is None because live frames are not durable — only the DB-backed
55+
milestones carry a Last-Event-ID seq.
56+
"""
57+
58+
__slots__ = ("event_type", "payload", "seq")
59+
60+
def __init__(self, *, seq: int | None, event_type: Any, payload: Any) -> None:
61+
self.seq = seq
62+
self.event_type = event_type
63+
self.payload = payload
64+
65+
66+
class RedisBackgroundQueue:
67+
"""Redis-backed backend behind the BackgroundExecutionService facade."""
68+
69+
def __init__(
70+
self,
71+
*,
72+
client: Any,
73+
job_service: JobService,
74+
stream_ttl: int = 3600,
75+
startup_grace_s: float = 30.0,
76+
) -> None:
77+
self._client = client
78+
self._job_service = job_service
79+
self._stream_ttl = stream_ttl
80+
self._startup_grace_s = startup_grace_s
81+
self.claim_queue = RedisJobClaimQueue(client)
82+
83+
async def enqueue(self, job_id: str) -> None:
84+
"""Hand a queued job id to a worker process via the claim queue."""
85+
await self.claim_queue.enqueue(job_id)
86+
87+
async def teardown(self) -> None:
88+
"""Close the redis client so the API replica does not leak its pool.
89+
90+
The worker process closes its own client on shutdown; the API-side facade
91+
must close the one it built for this backend too (matches build_worker's
92+
explicit ``aclose``). Best-effort: a close error must not mask shutdown.
93+
"""
94+
client = self._client
95+
if client is not None and hasattr(client, "aclose"):
96+
with contextlib.suppress(Exception):
97+
await client.aclose()
98+
99+
# ----------------------------------------------------------- worker claim
100+
101+
async def claim(self, *, block_ms: int = 1000) -> str | None:
102+
"""Claim a job id off the queue for a worker (delegates to the claim queue)."""
103+
return await self.claim_queue.claim(block_ms=block_ms)
104+
105+
async def complete(self, job_id: str) -> int:
106+
"""Release a worker's lease on a job. Returns the processing-list count removed."""
107+
return await self.claim_queue.complete(job_id)
108+
109+
# ---------------------------------------------------------------- control
110+
111+
async def stop(self, job_id: str) -> None:
112+
"""Request a cooperative stop via the durable STOP signal.
113+
114+
The ExecutionSignal(STOP) row is the single source of truth: the worker's
115+
JobRunner polls ``unconsumed_signals`` at each durable vertex/milestone
116+
boundary and cooperatively cancels, so a stop lands at the next boundary
117+
and survives a worker restart or late subscribe.
118+
119+
There is deliberately NO redis pub/sub fast-path here. The background
120+
worker (run_worker_loop -> WorkerJobRunner -> JobRunner) does not run the
121+
v1 RedisJobQueueService cancel dispatcher and nothing else subscribes to a
122+
cancel channel or checks a cancel marker, so a PUBLISH/marker would be a
123+
no-op in production — a misleading dead fast-path. Scaled stop latency is
124+
therefore one vertex-boundary poll (bounded by the run's durable-frame
125+
cadence), proven by the scaled-stop real-redis test.
126+
"""
127+
# Coerce to UUID so the DB row keys match unconsumed_signals(UUID)
128+
# lookups; tolerate a non-UUID id by passing it through unchanged.
129+
await self._job_service.write_signal(_as_signal_job_id(job_id), SignalType.STOP)
130+
131+
# ------------------------------------------------------------- watchdog
132+
133+
async def requeue_lost(self, *, lease_ttl_s: float = 45.0) -> list[str]:
134+
"""Reconcile GENUINELY orphaned processing-list ids (stale/absent lease).
135+
136+
Lease-aware: a processing-list id whose job row has a FRESH heartbeat
137+
(younger than ``lease_ttl_s``) belongs to a LIVE worker still running it,
138+
so it is left untouched — this is what stops a booting/scaled-up worker B
139+
from re-claiming and DOUBLE-RUNNING a job worker A is mid-running, and
140+
from failing A's live job. Only an id whose lease is stale or never
141+
recorded is treated as orphaned. A worker stamps a heartbeat on claim,
142+
so even a just-claimed QUEUED row (still in the QUEUED->IN_PROGRESS
143+
window) is protected until its lease actually expires.
144+
145+
For a genuinely orphaned id:
146+
147+
* QUEUED -> never started; safe to requeue (at-least-once).
148+
* IN_PROGRESS -> in-flight when the worker died. By default this is
149+
at-most-once: mark FAILED with error {"type": "worker_lost"}. Flows
150+
that opt in via job_metadata.retry_safe are requeued, atomically
151+
bumping job_metadata.attempt, until attempt reaches max_attempts.
152+
* terminal states -> just drop from the processing list.
153+
154+
Returns the ids that were requeued onto the pending list.
155+
"""
156+
requeued: list[str] = []
157+
for job_id in await self.claim_queue.processing_ids():
158+
job = await self._job_service.get_job_by_job_id(job_id)
159+
if job is None:
160+
# No durable row — nothing to reconcile; drop the stale id.
161+
await self.claim_queue.complete(job_id)
162+
continue
163+
164+
if job.status in _TERMINAL_STATUSES:
165+
# Terminal (COMPLETED / FAILED / CANCELLED / TIMED_OUT): drop.
166+
await self.claim_queue.complete(job_id)
167+
continue
168+
169+
# Liveness gate: a fresh lease means a live worker owns this run.
170+
if not self._job_service.is_lease_stale(job, lease_ttl_s=lease_ttl_s):
171+
continue
172+
173+
if job.status == JobStatus.QUEUED:
174+
# Single-flight via the LREM token so two reconcilers (or a
175+
# reconciler racing the worker that just flipped it to QUEUED)
176+
# cannot both push it back to pending.
177+
if await self._requeue(job_id):
178+
requeued.append(job_id)
179+
continue
180+
181+
# IN_PROGRESS with a stale/absent lease — the worker died mid-run.
182+
meta = job.job_metadata or {}
183+
if meta.get("retry_safe"):
184+
attempt = int(meta.get("attempt", 1))
185+
max_attempts = int(meta.get("max_attempts", 1))
186+
if attempt < max_attempts:
187+
# Atomic bump+flip in ONE conditional UPDATE guarded by both
188+
# attempt==expected AND status==IN_PROGRESS, so two watchdogs
189+
# racing the same lost job cannot both bump it past the cap (the
190+
# loser sees status already QUEUED -> rowcount 0). Closes the
191+
# window a separate increment + status flip left open.
192+
if await self._job_service.retry_requeue_claim(
193+
job.job_id, expected_attempt=attempt
194+
) and await self._requeue(job_id):
195+
requeued.append(job_id)
196+
# Lost the race: another reconciler already handled it; skip.
197+
continue
198+
# Default at-most-once, or retries exhausted: fail worker_lost.
199+
# set_error only stores the blob, so flip the status to FAILED
200+
# (with a finished timestamp) explicitly.
201+
await self._job_service.update_job_status(job.job_id, JobStatus.FAILED, finished_timestamp=True)
202+
await self._job_service.set_error(job.job_id, {"type": "worker_lost"})
203+
await self.claim_queue.complete(job_id)
204+
return requeued
205+
206+
async def _requeue(self, job_id: str) -> bool:
207+
"""Move an id from processing back to pending. Returns True if we owned it.
208+
209+
The ``complete`` LREM is the single-flight token: only the reconciler
210+
whose LREM actually removed the id (count > 0) re-enqueues it, so two
211+
watchdogs racing the same orphaned id cannot push two pending entries.
212+
"""
213+
removed = await self.claim_queue.complete(job_id)
214+
if removed <= 0:
215+
return False
216+
await self.claim_queue.enqueue(job_id)
217+
return True
218+
219+
async def recover_stranded_queued(self) -> list[str]:
220+
"""Re-enqueue QUEUED workflow rows present on NEITHER redis list.
221+
222+
Covers the API-crash window between persisting a QUEUED row and the
223+
``enqueue`` LPUSH (and a redis pending-list loss): such a row is in the
224+
DB but invisible to ``requeue_lost`` (which scans only the processing
225+
list). Without this the job is stuck QUEUED forever in scaled mode.
226+
227+
Each stranded id is claimed atomically (``claim_queued_job`` flips it to
228+
IN_PROGRESS only if it wins), then immediately put back to QUEUED and
229+
LPUSHed so a real worker re-runs it. The flip-and-restore is the
230+
single-flight guard so two workers' watchdogs cannot both LPUSH it.
231+
Returns the ids re-enqueued.
232+
"""
233+
on_redis = set(await self.claim_queue.processing_ids())
234+
pending = await self._client.lrange(self.claim_queue.pending_key, 0, -1)
235+
on_redis.update(p.decode() if isinstance(p, bytes) else p for p in pending)
236+
237+
recovered: list[str] = []
238+
for job_id in await self._job_service.queued_workflow_job_ids():
239+
sid = str(job_id)
240+
if sid in on_redis:
241+
continue
242+
# Claim atomically: only the winner re-enqueues. Restore to QUEUED so
243+
# a real runner re-runs it (claim flips it IN_PROGRESS as the guard).
244+
if not await self._job_service.claim_queued_job(job_id):
245+
continue
246+
await self._job_service.update_job_status(job_id, JobStatus.QUEUED)
247+
await self.claim_queue.enqueue(sid)
248+
recovered.append(sid)
249+
return recovered
250+
251+
async def events(self, job_id: str, last_event_id: int = 0) -> AsyncIterator[Any]:
252+
"""Replay durable events after last_event_id, then tail the live Stream.
253+
254+
Any API replica can call this: durable milestones come from the DB so a
255+
replica that didn't start the job still serves full history; live
256+
ephemeral frames come off the shared redis Stream via RedisQueueWrapper.
257+
258+
Dedup-at-the-seam: the worker publishes every durable milestone to BOTH
259+
the DB and the Stream, so a Stream tail from ``0-0`` would re-deliver each
260+
milestone already replayed from the DB. We track the highest durable seq
261+
replayed and skip any Stream frame whose stamped seq is ``<= highest`` —
262+
the SAME rule the in-memory bus uses (``item.seq <= highest: continue``),
263+
so the default and scaled reattach paths agree: each milestone is
264+
delivered exactly once, and an ephemeral frame is passed only when its seq
265+
is strictly newer than everything already seen.
266+
"""
267+
# 1. Durable replay from the DB (the Last-Event-ID cursor is seq).
268+
highest = last_event_id
269+
for event in await self._job_service.read_events(job_id, after_seq=last_event_id):
270+
seq = getattr(event, "seq", None)
271+
if seq is not None:
272+
highest = max(highest, seq)
273+
yield event
274+
275+
# 2. Live tail of the redis Stream. Each Stream frame carries the durable
276+
# seq the worker stamped (``event_id`` field); skip any frame already
277+
# covered by the DB replay so it is not delivered twice. The wrapper
278+
# self-terminates on the end-of-stream sentinel or when the stream key
279+
# is gone (job finished + cleaned up).
280+
wrapper = RedisQueueWrapper(job_id, self._client, self._stream_ttl, startup_grace_s=self._startup_grace_s)
281+
try:
282+
while True:
283+
event_id, data, _ts = await wrapper.get()
284+
if data is None:
285+
return # end-of-stream sentinel
286+
frame_seq = self._parse_stream_seq(event_id)
287+
if frame_seq is not None and frame_seq <= highest:
288+
# Already replayed from the DB (or seen before the reattach
289+
# cursor) — drop so the milestone is delivered exactly once.
290+
continue
291+
if frame_seq is not None:
292+
highest = frame_seq
293+
yield _StreamFrame(seq=None, event_type=event_id, payload=data)
294+
finally:
295+
await wrapper.cancel()
296+
297+
@staticmethod
298+
def _parse_stream_seq(event_id: Any) -> int | None:
299+
"""Parse the durable seq the worker stamped on a Stream frame's ``event_id``.
300+
301+
``RedisStreamLiveBus`` sets ``event_id = str(frame.seq)``; a non-numeric
302+
value (a legacy/foreign frame) has no durable seq, so it is never deduped.
303+
"""
304+
try:
305+
return int(event_id)
306+
except (TypeError, ValueError):
307+
return None

0 commit comments

Comments
 (0)