|
| 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