Skip to content

fix(selfhost): stop the queue drain from claiming new work during shutdown; add shutdown headroom to compose (#9007) - #9098

Merged
JSONbored merged 1 commit into
mainfrom
fix/9007-graceful-shutdown
Jul 26, 2026
Merged

fix(selfhost): stop the queue drain from claiming new work during shutdown; add shutdown headroom to compose (#9007)#9098
JSONbored merged 1 commit into
mainfrom
fix/9007-graceful-shutdown

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

Problem

Every "PR looks stuck" incident this session (orphaned ai-review-lock, a disposition stranded between publish and execute, etc.) traced back to one root cause: the process cannot shut down cleanly, so a deploy during active review traffic SIGKILLs a pass mid-flight.

Two compounding defects:

  1. pump()'s while (await processOne()) loop (both src/selfhost/pg-queue.ts and src/selfhost/sqlite-queue.ts) never checked shutdown state — once a pump claimed the first due job it kept claiming every subsequent due job for as long as more existed, entirely blind to a concurrent stop(). Graceful shutdown was therefore bounded by "drain the whole backlog", not "finish what's already in flight".
  2. docker-compose.yml had no stop_grace_period for the app service — Docker's 10s default is far shorter than a review pass (AI calls + GitHub round-trips routinely take tens of seconds to minutes).

Combined: docker compose restart/stop/a redeploy during active traffic sends SIGKILL before the (buggy, unbounded) drain ever completes, severing whatever pass is in flight and leaving its Redis locks/DB state exactly where it stood.

Fix

A shuttingDown flag, deliberately distinct from running. The obvious first attempt — gating the drain loop on running itself — is wrong and I caught it via a full-file test run before pushing: running also gates two other legitimate call shapes — binding.send()'s fire-and-forget kickOne(), and drain()'s own direct pump() call — both of which run a pump loop before start() has ever been called (running starts false and isn't set until the first start()). Gating on running makes every pre-start() drain()/send() a silent no-op, which broke ~150 pre-existing tests relying on exactly that pattern in each file.

shuttingDown instead means only "stop() has been called and hasn't been superseded by a later start()": false until stop() sets it (before its own drain-wait), reset by the next start(). Applied identically to both queue backends.

Also adds stop_grace_period: 300s to the loopover service so the now-correct drain actually gets time to run before Docker escalates.

Tests

Two regressions per backend (4 total), plus an invariant guarding the exact mistake caught while building the fix:

  1. Reproduces the bug directly: preload 5 jobs at concurrency:1, start(), let the lone pump claim job 1, stop() mid-consume — must return once job 1 finishes, not after 2-5 also drain.
  2. Guards the running-vs-shuttingDown mistake: a pump kicked via send()/drain() before start() is ever called must be unaffected.

Validation

  • npx vitest run test/unit/selfhost-pg-queue.test.ts test/unit/selfhost-sqlite-queue.test.ts280/280 passed (both full files, not just the new tests).
  • npx vitest run test/unit/selfhost-compose-db-health.test.ts — 4/4 passed (existing compose structural checks).
  • docker compose config --quiet — valid.
  • tsc --noEmit --incremental false — clean.
  • Prettier flags all 5 touched files identically on main (pre-existing repo-wide state), so left unreformatted rather than dragging in an unrelated tree-wide diff.

Closes #9007

@superagent-security

Copy link
Copy Markdown
Contributor

Superagent didn't find any vulnerabilities or security issues in this PR.

@codecov

codecov Bot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.66%. Comparing base (3c1173e) to head (1bc3974).
⚠️ Report is 2 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #9098      +/-   ##
==========================================
- Coverage   93.88%   92.66%   -1.23%     
==========================================
  Files         810      810              
  Lines       80600    80603       +3     
  Branches    24454    24455       +1     
==========================================
- Hits        75675    74689     -986     
- Misses       3560     4840    +1280     
+ Partials     1365     1074     -291     
Flag Coverage Δ
backend 93.47% <100.00%> (-1.71%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/selfhost/sqlite-queue.ts 99.61% <100.00%> (+<0.01%) ⬆️

... and 3 files with indirect coverage changes

@loopover-orb loopover-orb Bot added the gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier. label Jul 26, 2026
…tdown; add shutdown headroom to compose (#9007)

pump()'s `while (await processOne())` loop was blind to shutdown state: once a
pump claimed the first due job it kept claiming every subsequent due job for as
long as more existed, entirely ignoring a concurrent stop(). Graceful shutdown
was therefore bounded by "drain the whole backlog," not "finish what's already
in flight" -- and combined with Docker's 10s default stop_grace_period, every
deploy during active review traffic sent SIGKILL before that drain completed,
severing whatever pass was in flight and leaving its Redis locks/DB state
exactly where they stood. This is the root cause behind the "PR looks stuck"
incident class (orphaned ai-review-lock, stranded disposition, etc.).

Introduces a `shuttingDown` flag distinct from `running`: `running` also gates
pre-start()/direct pump()-drain() usage (binding.send()'s fire-and-forget
kickOne(), and drain()'s own direct pump() call both legitimately run a pump
loop before start() has ever been called, or after stop()) -- gating the drain
loop on `running` itself breaks that path outright (verified: it makes every
pre-start() drain()/send() a no-op). `shuttingDown` instead means only "stop()
has been called and hasn't been superseded by a later start()": false until
stop() sets it, reset by the next start(). Applied identically to both
pg-queue.ts and sqlite-queue.ts.

Also adds `stop_grace_period: 300s` to the app service in docker-compose.yml so
the now-correct drain actually gets to run before Docker escalates to SIGKILL.

Tests: two regressions per backend -- (1) stop() lets only the in-flight job
finish rather than draining a preloaded 5-job backlog, reproducing the bug
directly; (2) a pump kicked via send()/drain() before start() is ever called is
unaffected by shuttingDown, guarding the exact mistake made and caught while
fixing (1).
@loopover-orb

loopover-orb Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Important

🟪🟪🟪🟪🟪🟪🟪🟪🟪🟪🟪🟪

🔍 LoopOver is reviewing…

AI analysis is in progress. This comment will update when the review is complete.

🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed · 🟪 Reviewing

@JSONbored
JSONbored force-pushed the fix/9007-graceful-shutdown branch from 2627c9d to 1bc3974 Compare July 26, 2026 17:39
@JSONbored JSONbored self-assigned this Jul 26, 2026
@JSONbored
JSONbored merged commit a861a4f into main Jul 26, 2026
4 checks passed
@JSONbored
JSONbored deleted the fix/9007-graceful-shutdown branch July 26, 2026 17:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier.

Projects

None yet

1 participant