Skip to content

Bound the engine drain so the shutdown after it actually runs (#192) - #202

Merged
icebergai-review-bot[bot] merged 1 commit into
mainfrom
claude/codebase-review-cleanup-ovlli4
Aug 20, 2026
Merged

Bound the engine drain so the shutdown after it actually runs (#192)#202
icebergai-review-bot[bot] merged 1 commit into
mainfrom
claude/codebase-review-cleanup-ovlli4

Conversation

@richardmhope

Copy link
Copy Markdown
Contributor

Closes #192.

The defect

dramatiq.Worker.stop() waits ten minutes by default. Both grace periods this project ships are two (stop_grace_period: 120s, terminationGracePeriodSeconds: 120). So an engine holding a long fetch at SIGTERM was still inside that wait when SIGKILL arrived, and none of the shutdown after it ran: no heartbeat.stop(), no close_api_client(), no server.shutdown(), no engine_stopped in the log.

Three sets of comments — compose, the chart, and the runner's except Interrupt handler — described a drain that did not happen.

The policy decision

The issue suggested adding notify_shutdown: True so the runner's existing Interrupt path reports partial work, on the assumption that "checkpointed connectors resume cleanly". They don't, on that path, and I've decided the other way:

REPORTABLE_STATUSES = (COMPLETED, FAILED) — an engine has no third thing to say. reclaim_expired_leases only reclaims tasks in LEASED/RUNNING, so a task that reports failed is terminal: never redelivered, and it makes its scan partial, which may not auto-resolve findings (ADR 0009 §4).

So interrupting would trade one lease TTL of latency for a scan that cannot close a secret somebody has already fixed — on every rolling deploy that lands mid-scan. Waiting and then letting the lease lapse costs latency instead, and keeps API reclaim the single re-delivery authority (ADR 0009 §2). A checkpointing connector genuinely does resume from its last flushed batch on that path.

The policy is now written down in docs/deployment.md § Draining an engine, including the rejected alternative, so the next person doesn't have to re-derive it.

Considered and not done here: an explicit POST /scan-tasks/{id}/release would give both — no lease wait and no terminal failure. It also adds a second path back onto the queue, which is exactly what ADR 0009 §2 forbids in spirit, so it's an ADR-level decision rather than something to slip into a drain fix. Happy to raise it as a follow-up if you want the latency back.

The change

  • ICEBERG_DRAIN_SECONDS (default 90) bounds the wait; drain() in worker.py does the waiting and names what it gave up on.
  • A task still running at the deadline produces an engine_drain_incomplete warning listing the task ids — the scans about to sit on a lease.
  • Comments in compose, the chart and runner.py now say what actually happens. The runner's says why a shutdown does not reach its Interrupt handler.

Tests

  • tests/test_deploy_invariants.py — the default budget is strictly below both grace periods. This is the property that makes the shutdown reachable at all, so it belongs with the other deploy invariants rather than in prose.
  • apps/engine/tests/test_worker.py — a real Worker with a task in flight: the drain returns on a 0.25s budget and reports the task as abandoned. Verified to fail against the unbounded call (it waits out the fixture and reports nothing abandoned). A second test pins that the budget is a ceiling, not a delay — an idle drain returns immediately.

make check green: ruff, mypy, docs check, 1956 passed / 2 skipped.

Operator impact

None required. Raising ICEBERG_DRAIN_SECONDS for routinely-long fetches means raising both grace periods with it, which the doc says explicitly.


Generated by Claude Code

`Worker.stop()` waits ten minutes by default. Both grace periods this project
ships are two, so an engine holding a long fetch at SIGTERM was still inside
that wait when SIGKILL arrived: `heartbeat.stop()`, `close_api_client()`,
`server.shutdown()` and the `engine_stopped` log never ran. The comments in
compose, the chart and the runner all described a drain that did not happen.

The wait is now bounded by ICEBERG_DRAIN_SECONDS (default 90), and a deploy
invariant holds that default below both `stop_grace_period` and
`terminationGracePeriodSeconds` — the property that makes the shutdown after
the wait reachable at all. A task still running at the deadline is named in an
`engine_drain_incomplete` warning before the engine goes.

The policy question the issue raised is decided the other way from its own
suggestion, and written down in docs/deployment.md § Draining an engine.
Interrupting in-flight work via `notify_shutdown` would let the runner report
partial results — but an engine may only report `completed` or `failed`, and a
failed task is terminal: never reclaimed, and it makes its scan `partial`,
which may not auto-resolve findings (ADR 0009 §4). That trades one lease TTL
of latency for a scan that cannot close a secret somebody has already fixed,
on every rolling deploy landing mid-scan. Waiting and then letting the lease
lapse costs latency instead, and keeps API reclaim the single re-delivery
authority (ADR 0009 §2). The runner comment that claimed a shutdown reaches
its `Interrupt` handler now says why it does not.

Refs ADR 0009.

Claude-Session: https://claude.ai/code/session_012sohE85sRDt6t2w3936rGJ

Co-authored-by: Claude <noreply@anthropic.com>

@icebergai-review-bot icebergai-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict

APPROVE

Completed bounded review across 1 immutable scope(s). One high-severity shutdown regression found.

Scope health

Convergence: healthy. Review mode: initial.
Recommended action: CONTINUE_INCREMENTAL.

  • No escalation signals.

Prior findings

Finding Status
No prior finding state

New findings

Root cause: A per-thread join timeout is treated as a total shutdown deadline.

  • FYI · medium: Drain timeout is applied per worker thread, not to the overall drainapps/engine/src/iceberg_engine/worker.py
    Status: NEW. Attribution: new_in_scope.
    dramatiq.Worker.stop(timeout=...) joins each consumer/worker thread with the supplied timeout. Passing the full drain budget once therefore permits multiple concurrently running actors to consume successive full budgets. With the shipped four worker threads and a 90s budget, two blocking actors can keep shutdown inside consumer.stop() for about 180s, exceeding the 120s Docker/Kubernetes grace period.
    Invariant: The total drain wait must finish before every shipped 120-second termination grace period.
    Ownership: Engine shutdown / Dramatiq worker lifecycle. Behaviour: Rolling deploys and container shutdowns with more than one long-running task.
    Evidence: drain() calls consumer.stop(timeout=int(seconds * 1000)) once. Dramatiq applies that timeout to each thread join; the new test covers only one running actor, so it does not exercise the cumulative wait. Reproduce by running two actors that block longer than 90s on the default four-thread worker, invoke drain(consumer, 90), and observe that cleanup has not run by the 120s termination grace.
    Independent assessment: Downgraded to advisory: The pinned Dramatiq 2.2.0 implementation passes worker threads to join_all(), which deducts elapsed time after each join; the 90-second timeout is a total budget for the worker-thread group, not a separate 90-second wait per actor. The proposed cumulative 180-second behavior is therefore not established.

Fix-induced regressions

  • None evidenced.

Uncertainty

  • No material uncertainty recorded.

Validation

  • Reviewed the supplied immutable diff only.
  • Exact-head CI was reported as passed.

Residual risks

  • None identified.

@icebergai-review-bot
icebergai-review-bot Bot merged commit 5506c7d into main Aug 20, 2026
6 checks passed
@icebergai-review-bot
icebergai-review-bot Bot deleted the claude/codebase-review-cleanup-ovlli4 branch August 20, 2026 00:46
icebergai-review-bot Bot pushed a commit that referenced this pull request Aug 20, 2026
**A crafted link could put words in the console's own chrome.** A failed save
redirects carrying its reason, and that reason travelled as plain query text.
Autoescaped, so never script — but `/login?error=Your account is locked, call
555-0100` renders inside this console's frame and reads exactly as though this
console said it. The reason is now a signed two-minute token under the session
key, minted by the same mechanism the login state already uses, and a page shows
nothing at all for anything it cannot verify. Signed rather than replaced by an
enum of codes because the API's own sentence is the useful half, and rather than
a server-side flash because this deployment keeps no per-user state between
requests.

**A successful triage left the rest of the page stale.** The swap updated
`#triage` only, so the header's state chips and the Record card's assignee,
owner and due date went on showing pre-save values until somebody reloaded. It
redirects now, which is what docs/web.md already said a change spanning more
than one region should do. A *rejected* triage still answers the panel: nothing
moved, and navigating away would take the analyst off the explanation.

**The "assigned to you" tile counted one capped page** and, unlike its
"unassigned" sibling, did not admit the cap — so an analyst with 300 findings
assigned read a number that was simply wrong. It is a query now, with the same
`+` every other tile uses.

**The gitleaks image is pinned by digest.** The invariant test that requires
every CI action to be pinned to a commit said in its own docstring that this
repository pins "the gitleaks image", and it did not: a mutable tag, running
with the whole repository history mounted into it. The test now covers images
as well as actions.

**verify-chart.sh printed its green line even after recording failures**, and
inspected only `containers[0]` — so a sidecar would have walked past every
hardening check. Both fixed; a failure now names which container, and the
"every workload is hardened" line only prints when it is true.

Also, from the review of #202: `Worker.stop` spends the drain budget on the
worker threads as a group and then joins its consumers under a second budget of
the same size. The deploy invariant asserted only that the budget was under the
grace period; it now requires a margin, and both the docstring and
docs/deployment.md say why.

Refs #145, #146.

Claude-Session: https://claude.ai/code/session_012sohE85sRDt6t2w3936rGJ

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Engine graceful shutdown never interrupts long tasks: anything past the grace period is SIGKILLed unreported

2 participants