Bound the engine drain so the shutdown after it actually runs (#192) - #202
Merged
icebergai-review-bot[bot] merged 1 commit intoAug 20, 2026
Merged
Conversation
`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>
There was a problem hiding this comment.
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 drain —
apps/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 insideconsumer.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()callsconsumer.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, invokedrain(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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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: noheartbeat.stop(), noclose_api_client(), noserver.shutdown(), noengine_stoppedin the log.Three sets of comments — compose, the chart, and the runner's
except Interrupthandler — described a drain that did not happen.The policy decision
The issue suggested adding
notify_shutdown: Trueso the runner's existingInterruptpath 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_leasesonly reclaims tasks inLEASED/RUNNING, so a task that reportsfailedis terminal: never redelivered, and it makes its scanpartial, 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}/releasewould 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()inworker.pydoes the waiting and names what it gave up on.engine_drain_incompletewarning listing the task ids — the scans about to sit on a lease.runner.pynow say what actually happens. The runner's says why a shutdown does not reach itsInterrupthandler.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 realWorkerwith 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 checkgreen: ruff, mypy, docs check, 1956 passed / 2 skipped.Operator impact
None required. Raising
ICEBERG_DRAIN_SECONDSfor routinely-long fetches means raising both grace periods with it, which the doc says explicitly.Generated by Claude Code