RFC-117: Pipeline supervision — containment, recovery, and absence-detection¶
- Status: Draft — Phase 1 partially landed on
fix/pipeline-resilience-supervision, unverified by CI - Authors: Marko Dragoljevic (chipi), Claude
- Stakeholders: Operator (sign-off), pipeline + platform maintainers
- Related ADRs: ADR-150 (the decisions this RFC argues for), ADR-119 (pipeline is an events source), ADR-142 (prod-local gateway)
- Related Documents:
docs/wip/INCREMENTAL-ROLLOUT-FOLLOWUPS-2026-08-11.md(incident log, F1–F15 + G1/G2) - Anchors: #1620 (this programme), #379 (the timeout that never worked), #429 (fail-fast / max-failures)
Written after two production outages on 2026-08-12 that turned out to be one defect with two endings. The proximate bugs matter less than the fact that nothing bounded them and nothing noticed them.
1. Context¶
A corpus backfill drove the pipeline from 180 to ~500 episodes via the operator API. Two runs failed. Both had the same origin.
CostCapExceeded is raised in the main thread by check_cost_soft_cap_at_stage
(orchestration.py:2207). That call sits inside a ~150-line region between
processing_thread.start() (2110) and processing_thread.join() (2251) which has no
try/finally. When it raises, control unwinds past transcription_complete_event.set()
(2246), leaving a non-daemon ProcessingProcessor thread alive with a continue-predicate,
_should_continue_processing (processing.py:1555-1562), that ends in return True.
| Trigger | Ending | |
|---|---|---|
| Incident 1 | 09:28:30Z $12.4599 > $5.0000 |
nothing left to submit → spin at 0.05 s/iter for 4 h 15 m, live pid, ~2.5 % CPU, zero log output, cancelled by hand |
| Incident 2 | 16:29:13Z $5.6425 > $5.0000 |
12 s later one job was queued → executor.submit into a shutting-down interpreter → RuntimeError: cannot schedule new futures after interpreter shutdown |
Which ending occurs is decided by whether a job happens to be queued when teardown begins. There is no third, healthy branch.
The guardrail was working. A $5.00 per-run soft cap
(config/profiles/cloud_balanced.yaml:177-178, action: abort) correctly detected an
overrun. The defect is that a planned outcome — budget exhaustion — is expressed as an
exception thrown across a three-thread pipeline with no coordinated shutdown.
1.1 What did not go wrong¶
Worth stating, because it constrains the design. No data was lost in either incident.
Corpus coverage after both was with_gi == with_kg == total_episodes, with_neither: 0.
GUID-keyed skip_existing made cancel-and-relaunch free and idempotent; 32/36 and 12/15
episodes were intact and fully enriched.
That primitive is the foundation everything below is built on, and must not be traded away.
2. The reframing¶
The instinct after an outage is to add defenses. That instinct is wrong here.
This codebase already contains: a per-job try/except with per-episode failure recording
(processing.py:1726-1745), a retryable/terminal classifier (processing.py:175), a
timeout_context on every episode (processing.py:1684-1688), a stale job status, a
reconcile endpoint, a .pipeline_status.json writer (monitor/status.py:57-90),
run.jsonl episode_finished events, a pipeline_progress event (ADR-119), and a job
webhook (server/jobs.py:893-895).
Every one of them is gated off, non-enforcing, pull-only, or terminal-only.
The sharpest example: utils/timeout.py starts a threading.Timer that sets a flag; the
TimeoutError is raised after the yield returns, i.e. only once the operation has
already finished on its own. It cannot interrupt a blocked socket read. Issue #379 built it
"to prevent hangs". A 1200 s per-episode deadline was active throughout both incidents and
prevented neither.
The system is not undefended. It is defensively decorated. It does not need more defenses — it needs a supervisor that makes the ones it already has fire.
This roughly halves the cost of the programme: most of the work is wiring, un-gating, and enforcement, not new subsystems.
3. Three pillars¶
They fail independently and must be designed independently.
| Pillar | Question it answers | How it failed on 2026-08-12 |
|---|---|---|
| Containment | does one unit's failure stay local? | nothing had an enforced deadline; one raise killed a whole run |
| Recovery | do we get back to work? | recovery was a human noticing and re-POSTing |
| Detection | do we know, and are we told? | 4 h 15 m of silence read as healthy |
A perfectly contained pipeline that freezes silently is still a four-hour outage.
3.1 Containment¶
The atomic unit for retry is the episode — GUID-keyed, proven. The atomic unit for deadlines is not: episodes legitimately run 4–90 minutes, so an episode-level deadline must be so loose it detects a hang an hour late.
Hangs live in network calls, which should complete in seconds-to-minutes regardless of episode length. The deadline hierarchy is therefore:
- transport timeout on every outbound call — tight; kills the hang class at source
- episode budget — loose; catches pathological loops
- job budget — looser; catches everything else
Isolation already exists. What is missing is cancellation: an isolated unit that can hang forever isolates nothing.
3.2 Recovery¶
Crash-only. skip_existing already makes "kill and resubmit" safe, so the supervisor's only
recovery verb should be exactly that, bounded by an attempt count. The corpus is the
ledger; no queue broker is required on a single VPS.
This collapses a large amount of would-be design. It also creates one new hazard — see §5 poison pill.
3.3 Detection¶
The generalised failure: every detection mechanism here is exception-triggered. Logs record what raises; Sentry records what raises; job status changes when something raises or exits. Nothing is absence-triggered. A process that stops working while staying alive is invisible, and silence is indistinguishable from healthy quiet operation.
Measured, during the wedge versus a healthy run:
{job="podcast-pipeline"} 10:30–14:30Z (wedged) -> 0 log lines
{job="podcast-pipeline"} 15:00–15:35Z (healthy) -> 66 log lines
Liveness is not enough, and this is the subtle part. During the wedge the loop was iterating the entire time. A heartbeat emitted by that loop would have reported "alive" cheerfully for four hours. Two distinct signals are required:
- liveness — the process/loop is running (catches death and hard blocks)
- progress watermark — a monotonic count of completed units, plus
last_progress_at(catches alive but stuck, which is what actually happened)
Note also that podcast_pipeline_run_cost_usd_total increments only at terminal state,
so increase(...[6h]) == 0 is the expected reading for any in-flight run. It looked like
corroborating evidence during the incident and was not. There is currently zero
first-party in-flight signal.
4. Design¶
4.1 Shutdown coordination¶
Orchestration owns a stop_event. A try/finally around the worker-thread lifecycle sets
both stop_event and transcription_complete_event on any exit path, then joins with a
bound. Workers check stop_event in their continue-predicate.
The predicate must evaluate supervision bounds first and unconditionally — not as another branch reachable only through queue state, since the incident was precisely the case where queue-state branches could never fire.
4.2 Progress watermark¶
The child emits (it owns the truth), the api sweeper evaluates (it survives the child), systemd + external alerting cover the api itself. Each layer watched by a dumber, more reliable one.
Un-gate .pipeline_status.json (drop the cfg.monitor condition) and extend it with
episodes_done, episodes_total, last_progress_at, and in_flight (idx + stage +
started_at). Day one can degrade to run.jsonl episode_finished events plus file mtime with
no child changes at all.
4.3 Absence alerting¶
The api exports podcast_job_running, podcast_job_episodes_done, and
podcast_job_last_progress_timestamp_seconds. Then:
- alert: PipelineJobStalled
expr: podcast_job_running == 1
and (time() - podcast_job_last_progress_timestamp_seconds) > 1800
for: 5m
Do not key the threshold to episode-completion times — that is what forces per-feed tuning against a 4–90 minute spread. Once transport timeouts exist, every healthy in-flight episode emits sub-episode watermark movement at minutes-scale regardless of episode length, so a flat 30-minute progress-age threshold is safe. Transport timeouts are a prerequisite for cheap alerting, not parallel work.
4.4 Notification¶
emit_job_state_change already fires a webhook on terminal transitions
(PODCAST_JOB_WEBHOOK_URL). Route stale/failed through it to ntfy or Telegram for a phone
push; add vmalert → Alertmanager as an api-independent second channel. No new dashboards —
the failure mode being fixed is "a human had to go looking."
4.5 Who sets stale¶
The api sweeper — a 60 s asyncio task extending reconcile with progress-age logic — sets
stale and SIGTERMs per policy. The child never self-marks; it is the thing that dies. When
the api is itself the dead thing, systemd Restart=always plus startup-reconcile covers
resurrection and an absent()/scrape-staleness rule covers detection.
4.6 Budget exhaustion is not an error¶
CostCapExceeded must become a graceful stop: set stop_event, drain in-flight work,
finalize, and report a distinct terminal reason (budget_exhausted) — not an exception racing
three threads. This is the direct fix for the trigger shared by both incidents.
5. Categories that must not be forgotten¶
- Supervisor death / orphans. An api restart leaves rows
runningforever; nothing at startup reconciles or drains. The child's stdout pipe loses its pump, and once the ~64 KB buffer fills the child blocks on write — a fresh wedge. Worse, when a live orphan later succeeds, the next reconcile sees a dead pid and recordsfailed / orphan_reconciled_dead_pid— success recorded as failure. Needs startup reconcile + drain, and kill-orphan-group-on-boot (start_new_session=Truealready gives a process group) followed by auto-resubmit. - Poison pill. Auto-resubmit plus a deterministically-fatal episode is an infinite loop:
completed episodes skip, the poison one always re-enters. Needs a per-GUID failure memo
(attempts, last error) persisted in the corpus and a
quarantinedmark after N. - Partial-artifact atomicity. Individual writes are atomic but an episode is several
artifacts (transcript, metadata, summary, GI, KG) with no commit marker. Behaviour of
skip_existingagainst a half-written episode depends on which artifact its predicate checks — unverified, and whoever implements must verify it.corpus_completeness.pyexists and should be promoted to a post-run gate and crash-test oracle. - Error taxonomy.
error_reasonis free text parsed from logs. Resubmit decisions need machine-readable classes:retryable,terminal,quarantine,budget. - Backpressure. Real but lowest priority —
_submit_new_jobsdumps everything available into a bounded pool, harmless at current scale.
6. Testing¶
Both incidents reproduce deterministically. No chaos tooling is warranted at this scale.
Build:
- Loop-contract unit tests — drive the loop with a
process_job_functhat blocks on an Event; assert exit within budget and the unit marked failed. Test the predicate truth table directly, includingtranscription_complete_event=Noneand never-set. Blocked on extracting_run_parallel_processing_loopto module scope — it is currently nested insideprocess_processing_jobs_concurrentand cannot be imported. - Interpreter-shutdown repro — spawn a mini-main that starts the worker then raises
mid-window; assert clean exit and no
cannot schedule new futureson stderr. Red before the fix, green after; pins incident 2 forever. - Scripted-fault provider at the provider seam (hang / slow / retryable / terminal, selected per episode index) — exercises deadline enforcement and quarantine.
- Enumerated crash-point kill test — SIGKILL at each stage boundary on a 3-episode
fixture, rerun with
skip_existing, assertcorpus_completenesspasses and nothing is duplicated or double-charged. Enumerated beats property-based for CI determinism. - Supervisor lifecycle test — kill the api mid-run, restart, assert startup reconcile kills the orphan group, marks the row, and resubmits within budget.
- A no-timeout lint — CI gate that every outbound HTTP call in pipeline paths carries an
explicit
timeout=. One un-timeouted call is the entire hang class.
Skip: chaos frameworks, toxiproxy/network-namespace injection, automated alert-rule integration tests (unit-test exporter values; validate the rule once by hand), load testing at this scale.
7. Sequencing¶
Phase 0 — evidence. Done. Root cause confirmed from VictoriaLogs; CostCapExceeded
identified as the shared trigger. For the next wedge, py-spy dump --pid is the one-command
diagnosis and belongs in the runbook now.
Phase 1 — cheap, high leverage. Bound the amplifier and gain eyes.
- Loop bounds + guarded submits + explicit executor lifecycle — landed, unverified
- Orchestration
try/finally+stop_event; gracefulbudget_exhausted - Transport timeouts on every outbound call + the no-timeout lint; retire or fix
timeout_context— documented honestly, landed - Progress watermark; surface on
GET /api/jobs/{id}; export the three gauges - API sweeper (60 s): reconcile + progress-age →
stale→ SIGTERM → webhook; one vmalert rule - Startup reconcile + drain; kill-orphan-group-on-boot;
attempts; auto-resubmit ≤ 2
—— cut line ——
Phase 2 — structural. Poison-pill memo + quarantined; episode commit marker +
corpus_completeness as gate; crash-point tests + scripted-fault provider; job-level error
taxonomy; bounded submission; extract the loop to module scope; index content fingerprint
(C2).
The cut is defensible: everything above the line either removes the amplifier both incidents shared or makes the next unknown failure visible within 30 minutes instead of four hours — using primitives the codebase already owns.
8. Open questions¶
- Is
stalea terminal state or a transition? If the sweeper SIGTERMs and the child then exits cleanly, does the row becomestaleorfailed? Affects the resubmit decision. - What is the per-job wall-clock budget? The current 24 h stale threshold exceeds any
plausible run. A budget derived from
max_episodesis tempting but episode length varies ~20×. - How many auto-resubmits? Two is proposed. Interacts with poison-pill quarantine; the wrong pair burns budget silently.
- Does
skip_existingtreat a half-written episode as present? Unverified and load-bearing for crash-only recovery. - Should the cost cap remain
abort?abortis currently the broken path;warnwould make the bug unreachable at the cost of losing the stop. Resolved for now by raising the cap to$10on the box and keepingabort— see ADR-150.