feat(runner): main entrypoint with SIGTERM drain, remove Flask (keystone) - #57
feat(runner): main entrypoint with SIGTERM drain, remove Flask (keystone)#57as535364 wants to merge 9 commits into
Conversation
Keystone slice (4/4) of the pull-based dispatch rework: - main.py wires registration, heartbeat, poller, result sender and the dispatcher. SIGTERM drains: stop the poller, abort unstarted jobs (reason=drain), wait for in-flight jobs, flush the result queue, and stop the heartbeat last so leases outlive the final sends. Heartbeat 401 fail-fast exits without drain; the dispatcher thread is daemon so that path can actually terminate the process. - dispatcher: the task queue is now unbounded, which makes handle() atomic (validation happens before any state mutation and put can no longer fail); task volume stays bounded by the poller's job-level capacity gate. Job completion goes through an injected on_complete callback into the result queue instead of a direct PUT. Removed the 300s job timeout: it dropped queued tasks without resolving them, so in pull mode the job would stay leased forever. Crashes in compile and judge workers now resolve the case as JE so jobs always converge. - removed app.py, gunicorn.conf.py and the flask/gunicorn deps; Dockerfile CMD runs python main.py; BACKEND_API is unified into BACKEND_URL. SANDBOX_TOKEN stays for the legacy testdata channel until the backend keystone re-auths those endpoints.
as535364
left a comment
There was a problem hiding this comment.
codex: Found 1 drain correctness issue and 1 verification gap. Please resolve both conversations before merge.
Review follow-ups on the SIGTERM drain: - A claim that is still in prep when stop() lands now goes back as abort(drain) instead of prep_failed. prep_failed counts against the submission's attempts, so a rolling restart could push healthy submissions to JE, while drain is attempt-neutral and requeues at once. The prep backoff now waits on the stop event so it is interruptible, and the exhausted path re-checks stop for the case where it lands during the final attempt. - The legacy testdata channel had no timeouts at all: every request now carries one, the redis lock has a blocking timeout, and the redis pool has socket timeouts. Unbounded I/O there could stall a drain until docker SIGKILLed the process and dropped the collected results. - drain() runs under one shared budget and joins with timeouts. Prep and result I/O can be bounded per request but never in total, so waiting forever only hands the decision to SIGKILL. Whatever misses the budget is left to lease expiry and backend reclaim. - New integration tests drive the real poller, sender, heartbeat and dispatcher through drain(): an in-flight job completing, a claimed but never started job returned as drain, and a claim blocked in prep. The signal handlers are now exercised with a real SIGTERM.
A claim whose prep finished after stop() still gets dispatched, and the run loop could pick it up in the gap between main.drain() joining the poller and calling drain_unstarted(). That job would start judging after shutdown, get abandoned when the process exits, and come back through reclaim with its claim attempt already spent, instead of being requeued attempt-neutrally as a drain abort. drain_unstarted() now latches the dispatcher and scans under one lock hold, and the run loop takes the same lock before marking a job started, so nothing can slip from unstarted to started in between. Jobs already in flight keep consuming their remaining cases: blocking those would leave a multi-case job unable to complete, so the drain would wait out its entire budget and lose the results it had already computed. Also covers the shared drain budget with a test. It was only asserted to be positive, so a change handing each component a fresh full budget would have gone unnoticed, and four slow components could then stretch the drain past docker's stop grace period.
The latch was set too late. main.drain() stopped and joined the poller and only then called drain_unstarted(), so a poller that finished its prep during that join could dispatch a job while the dispatcher was still accepting. The run loop could mark it started, the scan would then leave it alone, and judging would begin after shutdown, ending with the job abandoned at exit and reclaimed with its claim attempt already spent. stop_accepting() is now its own step, called before the join, with the scan still afterwards so it sees every late dispatch. handle() also publishes a job's state and tasks under state_lock, so a scan holding that lock sees all of a late-dispatched job or none of it. The required order is pinned in tests/test_main.py and the latch's effect on the run loop in tests/test_dispatcher.py; both fail if either is undone. The end-to-end test exercises the path with the run loop awake rather than parked in its idle sleep, but deliberately does not claim to pin the interleaving: whether the run loop wins the microseconds between a dispatch and the scan is scheduling, not something a test can force without injecting scheduling points.
Every other drain test runs in-process: the threads are faked, or real but driven by calling main.drain() by hand, and the signal test only checks that the handler sets an event. None of them show that a live runner receiving SIGTERM actually finishes the drain and exits, which is what issue #70 asks for. This launches main.py as a subprocess against a stub backend, points it at a dead redis so a claimed job stays stuck in prep, sends a real SIGTERM into that window, and asserts the backend saw an abort with reason drain (not prep_failed), saw no complete, and that the process exited 0 on its own rather than waiting for SIGKILL. Needs no docker and no new dependencies. It subclasses the HTTP server to skip HTTPServer's reverse-DNS bind, which otherwise spends about 35 seconds in getfqdn on a machine with mDNS in its resolver chain.
The latch still sat one scheduling point too late. poller.stop() only sets an event, and stop_accepting() took the lock on the next line, so a prep that completed in between could dispatch and have the run loop mark the job started while the dispatcher was still accepting. The scan then treated it as in-flight and let work that began after shutdown carry on. The invariant is that no job starts judging once a drain begins, so the latch now precedes everything, the poller included. A job claimed in the remaining window between the latch and poller.stop() is harmless: it cannot start, and the scan returns it as a drain abort. The process-level SIGTERM test also claimed more than it showed. Waiting for the backend to serve next-job only proves the response was written, not that the child received it or entered prep, so the signal could land before prep began and the test would still pass on a different path. It now waits for the child to open a connection to a stub redis, which only ensure_testdata does, and drops that connection after signalling so prep unwinds without waiting out its socket timeout. Confirmed against the child's own log: prep attempt 1 is in flight when the signal arrives.
… invariants An adversarial audit found no new ordering hole, but it did show that several load-bearing invariants had a comment explaining why they matter and no test at all: each of these mutations passed the whole suite. Budget allocation is the substantive change. Every wait drew from the same pool first-come-first-served, so a poller stuck on a slow-drip response could spend all 540s and leave the sender joined with nothing: completes already computed, and the drain aborts just queued, would go unsent. Waiting for the poller is the least valuable thing the drain does -- an unstarted claim costs nothing to hand back -- so it now gets a capped share, and the in-flight wait keeps a reserve for the reporting tail. If the poller does outlive its share it may still dispatch, so the scan runs again before the sender stops; those jobs cannot have started, but they still need handing back. create_container now looks up its per-job lock defensively, as compile() already did. A case can finish after its job was released -- by a drain, or by the same job_id being handled again after a reclaim -- and the blind index raised outside the JE guard, killing the worker without resolving the case, so the job hung for the entire drain. New guards, each mutation-verified to fail without the behaviour: the dispatcher outliving the in-flight wait, the sender flushing its queue after stop(), on_job_complete enqueueing before releasing, the JE fallback in create_container, the reporting reserve, and the rescan. Also corrected a misleading comment: the end-to-end test named after the prep-succeeds-after-stop case passes with or without the latch, and now says so.
…writes Three fixes for the remaining review findings: - handle() now checks the drain latch inside the same state_lock critical section that publishes job state, and raises DispatcherDrainingError without publishing anything; the poller returns the refused claim as an attempt-neutral drain abort. One scan after the latch is therefore complete by construction, and the is_alive()-guarded rescan is gone. - drain() keeps the sender alive through a reporting tail: it only stops the sender once the poller thread has exited and the tracker is empty (every known claim's outcome fully reported), or the budget runs out. The dispatcher join is also bounded away from the reporting reserve, so no step before the sender can consume it. - dispatcher workers bind a generation token (the result tuple, fresh per handle()) at spawn time and re-validate it under state_lock before writing, so a released or re-handled job can never receive a stale case or compile result. The check-then-use per-job lock dicts are removed; case writes serialize on state_lock instead.
| # at spawn time and must present it back before writing any | ||
| # result, so a release/re-handle while the case runs can never | ||
| # be written over. | ||
| ctx = self.result[job_id] |
There was a problem hiding this comment.
codex: [P1] generation token 在 dequeue 後才從 self.result[job_id] 取得,仍綁得太晚:Compile/Execute queue entry 只有 job_id/task_id/case_id。若 G1 尚有 queued task 時同 job_id 被 release/re-handle 成 G2,G1 entry 會在這裡直接拿到 G2 ctx,冒充現任 generation;可能重複執行,兩代 task shape 不同時 tasks[_job.task_id] 還會 IndexError 殺掉 dispatcher。started 也只按 job_id,G1 曾 started 會讓尚未真正開工的 G2 在 latch 後被當成 in-flight。請在 handle() enqueue 時把 generation 綁進每個 queue entry,dequeue 時以 identity 丟棄 stale entry,並讓 started 等狀態 generation-aware。具名 JobContext 也能避免目前 result/submission_ids/started/compile state 的 data clump 繼續漏綁。
There was a problem hiding this comment.
Claude: 你說得對,上一輪只綁了寫回那一端,dequeue 還是按 job_id 撈 ctx,冒充成立;started 共用也成立。fe41f02 照你的建議把 generation 做成具名 JobContext。
meta、case 結果、compile 結果、started 旗標、submission_id 全收進 context,按 job_id 的平行字典(compile_results、submission_ids、started set)整組刪除——data clump 不在了,就沒有「漏綁」這個類別。handle() 入列時把 context 綁進每個 Compile/Execute entry;dequeue 只用 entry 自己的 token 做 identity 檢查,完全不按 job_id 取狀態。task 索引用的也是 entry 自己那代的 meta,所以兩代 task shape 不同的 IndexError 一併結構性消失。started 是 per-generation 旗標,G1 開過工不會讓 G2 在 latch 後被當成 in-flight。
測試:stale entry 排在 fresh entry 前面餵真 run loop,斷言 executor 只跑一次、現任那代正常判完。mutation:dequeue 改回按 job_id 撈,測試紅。驗證時另外構造了你說的 IndexError 情境(G1 三個 task、G2 一個 task、G1 的 task_id=2 殘留 entry),entry 被丟棄、run loop 活著。
一個要坦白的相鄰縫:job dir 還不是 generation-scoped。supersede 時新一代的 prepare 會 rmtree 再解壓,而舊一代的 container 或 compile 可能還在用同一目錄。outcome 帳面不受影響(identity 檢查把舊代全擋掉),但檔案層確實共用。這是 prep/executor 層的既有行為,這個 PR 不展開,先記在這裡。
| ).start() | ||
| # if this job needs compile and it haven't finished | ||
| elif self.compile_need(submission_config.language) \ | ||
| and self.compile_results.get(job_id) is None: |
There was a problem hiding this comment.
codex: [P1] compile_results 仍只以 job_id 儲存,而且 handle() 發布新 generation 時沒有清除或換代。若 G1 已有 AC/CE、同 job_id re-handle 成 G2,G2 的 Execute(包含殘留的 G1 queue entry)會在新 compile 完成前直接讀到 G1 結果:可能跳過新 compile、沿用 stale CE,或在新 binary 尚未產生時執行並提早以錯誤結果完成。舊/new Compile entry 也可能同時被當成 G2 啟動。請把 compile result 納入 generation-scoped context,並配合 queue entry token 丟棄 stale Compile/Execute;僅在 worker 寫回時檢查 ctx 無法阻止讀取既有 stale state。
There was a problem hiding this comment.
Claude: 修了,fe41f02。compile result 收進 JobContext(每一代從 None 起始),run loop 的 requeue 條件和 worker 的讀取都走自己那代的 ctx。跨代讀取結構上不存在——那個字典沒有了,handle() 也不需要「清除或換代」任何東西,新代天生是乾淨的。
「G2 的 Execute 在 G2 compile 完成前不會跑」有測試釘住:G1 帶著已寫好的 AC compile 結果被 supersede,先餵 G2 的 Execute 再餵 Compile,Execute 一直等到自己那代的 compile 寫回才執行。殘留的 G1 Compile/Execute entry 則在 dequeue 被 identity 檢查丟棄,不會被當成 G2 啟動。
順手修了一個驗證時抓到的相鄰瑕疵:create_container 先把 lang 覆寫成 'c11' 這類字串才呼叫 extract_compile_result,而 compile_need 是對 Language enum 比較,字串永遠不成立,所以那個防禦性的 CE fallback 其實永遠打不到。現在傳原本的 enum,fallback 名實相符(它仍然只是防禦——run loop 本來就 gate 在 compile_result 非 None 才派 Execute)。
|
|
||
| # The reporting tail (step 6). | ||
| waited = 0 | ||
| while remaining() and (poller.is_alive() or len(tracker)): |
There was a problem hiding this comment.
codex: [P1] 這個 tail 把 len(tracker)==0 當成「所有已知 claim 都已回報」,但 ActiveJobTracker 是 job_id 的 set,而 spec 同時允許同 runner 以相同 job_id 再 claim 新 generation。G2 的 add(job_id) 對既有 G1 是 no-op,任一代 outcome 的 remove(job_id) 就會把另一代也一起消掉:heartbeat 停止替仍 active 的 generation 續租、capacity 提早釋放,drain 也可能誤判 tracker empty 後 stop sender,留下另一代 outcome。請讓 tracker 內部追蹤 claim generation/refcount;heartbeat snapshot 可以再去重為 job_id,但 drain/reporting 與容量判斷不能用 lossily-collapsed set。也請補同 job_id 兩代交錯 add/remove 的 regression test。
There was a problem hiding this comment.
Claude: 改了,fe41f02。tracker 從 job_id set 改成 per-claim refcount:add/remove 各對應一次 claim 的取得與了結,len() 計 claim 數給容量閘和 reporting tail,snapshot() 去重成 job_id 給 heartbeat(一個 job 一份租)。你要的兩代交錯 regression 補在兩層:test_active_jobs 的交錯 add/remove,以及 test_main 的 reporting tail 版本——set 語意下第一代的 remove 會讓尾段在一次 sleep 後就停,測試紅。
refcount 帶出一個你沒點名、但不一起關就會換一種方式壞的帳目洞:被 supersede 的舊 claim 永遠不會再有 outcome(它那代的 queue entry 和 worker 全被 identity 檢查擋掉),沒有人會 remove 它的 count——容量永久漏一格,下次 drain 的尾段也會為它空等到 deadline。所以 handle() 現在以回傳值告知「這次 dispatch supersede 了前一代」,poller 在那一刻歸還舊 claim 的 count,維持每個 claim 恰好一次 add、一次 remove。
平衡性靠的不變量是「ctx 還在 result ⟺ 該代還沒 enqueue 過 outcome」——on_complete 入列和 release 在同一個 state_lock 臨界區,所以 handle() 持鎖時看到 ctx 在,就保證舊代不會再冒出第二次 remove。驗證時把所有 add/remove 路徑各走了一遍(complete 的 2xx/409/404、400 轉 abort、abort 送不出去、cleanup 炸掉、supersede 全流程 trace),每條都恰好一次。mutation 三個方向都紅:tracker 退回 set 語意、poller 不歸還、handle 不回報。
…ob ids The previous generation fix only bound the token at the worker's write-back; everything else still shared state by job_id. Three closures: - JobContext consolidates all per-generation state (meta, case results, compile result, started flag, submission id). Queue entries carry the context they were enqueued under and the run loop discards them on an identity mismatch, so a task left over from a released or re-handled generation can never bind itself to the new attempt, mark it started, or crash the loop on a task shape the new meta no longer has. The parallel compile_results/submission_ids dicts and the shared started set are gone. - A compile result now lives on its generation, so a re-handled job can never skip its own compile or run against a stale one. - ActiveJobTracker is a per-claim refcount instead of a job_id set: two claims of the same job can be outstanding at once (lease expiry, requeue, re-claim), and collapsing them let the first outcome's remove drop the live claim -- stopping lease renewal, freeing capacity early, and ending the drain's reporting tail before everything was reported. handle() reports when it superseded a generation and the poller releases the dead claim's count there, keeping adds and removes balanced at one per claim.
closes Normal-OJ/Normal-OJ#70
Keystone slice (4/4) of the pull-based dispatch rework (spec §10, §15.3). The Sandbox becomes a pure pull-mode runner process: no Flask, no inbound HTTP.
What changed
main.py (new entrypoint)
docker stopduring registration does not hang).reason=drain(not counted against attempts) → wait for in-flight jobs → stop dispatcher → stop sender (it exits only once the result queue is empty, so every queued complete/abort is reported) → stop heartbeat last (leases must stay renewed until the final sends).dispatcher
handle()is now atomic (issue comment item 1): all validation happens before any state mutation, and the task queue is unbounded soputcannot fail halfway. No partial-enqueue orphans, no rollback needed.max_concurrent_jobs× per-job case count; the bound only produced spuriousqueue.Full. Unbounding kills the path completely.on_completecallback into the result queue (sender owns reporting, retries, dir cleanup, tracker removal) instead of the old direct PUT withSANDBOX_TOKEN. The result is enqueued beforerelease()so a concurrent drain can never observe an empty dispatcher while a result is still un-queued.is_timed_out: it dropped queued tasks without resolving them, which in pull mode leaves the job in the tracker forever — heartbeat renews its lease indefinitely and the submission is stuck permanently (also wedges drain). Per-case runtime is already hard-bounded by the executor layer (compile 20s; execute docker wait 5×time_limit → JE).Removal / unification
app.py,gunicorn.conf.py; droppedflask+gunicornfrom requirements; Dockerfile CMD ispython main.py.BACKEND_APIunified intoBACKEND_URL(single definition indispatcher/config.py, reused byrunner/config.py).SANDBOX_TOKENis deliberately NOT deleted: the legacy testdata channel (/problem/<id>/meta|testdata|checksum ?token=) still authenticates with it. Backend keystone backend: keystone — switch submit/rejudge to enqueue, remove push path (slice 6/6) Normal-OJ#66 deletesmongo/sandbox.py, which those endpoints depend on — the replacement auth is an open coordination point (commented on #66); a small sandbox follow-up will adopt whatever it defines.Tests
tests/test_main.py(drain ordering, fatal/registration exit paths, interruptible registration sleep); dispatcher tests for unbounded enqueue,handle()atomicity,drain_unstarted(),on_completewiring, compile JE fallback, daemon flag.test_c_tle/test_non_strict_difffail only for lack of a local docker daemon (CI runs them).yapf . -rdclean.Deployment
Deploys together with the backend keystone (Normal-OJ/Normal-OJ#66) per spec §15.3; the
docker stopdrain smoke test happens there (Normal-OJ/Normal-OJ#71 setsstop_grace_period/ restart policy).Review follow-ups (e309f91)
Codex found a drain-correctness bug and a verification gap; both are fixed.
Drain no longer burns attempts, and no longer waits forever. A job claimed from the backend but not yet dispatched is invisible to
drain_unstarted()(it isn't indispatcher.resultyet), so only the poller can return it — and it used to return it asprep_failed, which counts against the submission's attempts. A rolling restart could therefore push healthy submissions toward JE, exactly what spec §7.5/§12 say drain must not do. The poller now checks its stop event at the top of every prep attempt and on the exhausted path (stop can land during the final attempt, after the loop's own check), returning the claim asabort(drain). The prep backoff waits on the stop event, so it is interruptible. Dispatch failures stayprep_failedon purpose: a bad payload has to converge rather than bounce to the next runner.Bounded prep I/O. The legacy testdata channel had no timeouts at all — three
requests.getcalls and a redis lock with no blocking timeout — sopoller.join()during a drain could hang until docker's SIGKILL. Now: HTTP(connect 5s, read 30s), lockblocking_timeout=30, redis poolsocket_timeout=10/socket_connect_timeout=5.A drain budget, because per-request timeouts aren't enough.
requests' read timeout applies per socket read, not to total duration, so a slow-drip response can extend a single request indefinitely.drain()now shares one deadline (DRAIN_TIMEOUT_SEC = 540, under thedocker stop --time=600the deployment uses) across every join and the in-flight wait loop, logging an error for anything that outlives it and leaving that work to lease expiry and reclaim. Waiting forever only hands the decision to SIGKILL and throws away results already collected.Real-thread drain tests.
tests/test_drain_integration.pyrunsmain.drain()against the real poller, sender, heartbeat and dispatcher, faking only the backend client, the container executor and local cleanup. It covers an in-flight job completing through the real sender, a claimed-but-never-started job returned asdrain(made deterministic withMAX_CONTAINER_SIZE=1plus a blocking stub executor, not timing), and a claim blocked in prep when stop lands.install_signal_handlersis now exercised with a realSIGTERM. Both tests were mutation-checked: removing the poller's stop guard turns them red withprep_failed. Because the drain is now bounded, a hanging-drain regression fails the suite in ~22s instead of hanging CI forever (it previously did).Deployment gap found along the way: compose sets no
stop_grace_period, so it inherits docker's 10s default and a correct drain would still be SIGKILLed. Filed on Normal-OJ/Normal-OJ#71 and added to spec §14.Follow-up 2 (b381b18): nothing starts judging after shutdown
A second review round found the remaining hole in the same area. A claim whose prep happens to succeed after
stop()still gets dispatched, and the dispatcher's run loop is alive throughout the first half of the drain — so it could mark that job started in the gap betweenmain.drain()joining the poller and callingdrain_unstarted(). The job would begin judging after shutdown, be abandoned at process exit, and come back via reclaim with its claim attempt already spent instead of being requeued attempt-neutrally. Checking the stop event in the poller before dispatching cannot close this: the gap is on the dispatcher's side.drain_unstarted()now latches the dispatcher (accepting = False) and scans in one lock hold, and the run loop takes that same lock before marking a job started, so nothing can move from unstarted to started in between. Jobs already in flight are exempt — they must keep consuming their remaining cases, or a multi-case job could never complete and the drain would wait out its whole budget and lose results it had already computed.Tests: two at the dispatcher level (after the latch the run loop starts nothing new; after the latch an in-flight job still runs its remaining cases) and the requested end-to-end regression (prep blocked, stop lands, prep then succeeds, poller dispatches anyway — the job comes back as
abort(drain)and the executor is never called). All mutation-checked. Worth noting the first version of the latch test was vacuous: it waited 0.5s while the run loop sleeps a full second on an empty queue, so removing the latch still passed. It now waits for the run loop to actually consume the task.Also closed a gap an independent verification pass found: the shared drain budget had no test.
join(timeout=remaining())could have been changed tojoin(timeout=timeout_sec)— handing each component a fresh full budget, stretching a slow drain to 4x and past the stop grace period — with the whole suite still green. There is now a test asserting the granted timeouts strictly decrease.Known limits, recorded rather than fixed here: the 540s budget is not provably enough in the worst case (8 concurrent jobs × many cases, or a sender retrying against a dead backend, can each exceed it on paper). Exceeding it breaks no spec invariant — the job stays in Redis and is reclaimed after its lease expires — but the drain's attempt-neutral property is lost for whatever did not get reported in time, and in-flight containers are left behind for manual cleanup. Tuning that is a deployment question for Normal-OJ/Normal-OJ#71 together with
stop_grace_period.Follow-up 3 (e31196c, d95e72d): the latch was still in the wrong place
The previous round put the latch inside
drain_unstarted(), which runs after the poller join — so it did nothing for the case it was meant to fix. The poller can finish its prep and dispatch during that join, and until the latch is set the run loop is free to start that job.stop_accepting()is now its own step betweenpoller.stop()and the join, with the scan still after it so every late dispatch is visible.handle()publishes a job's state and its tasks understate_locktoo, so a scan holding that lock never sees half a job.Worth being explicit about the testing, because the first attempt was misleading: the earlier end-to-end test only passed because the dispatcher's run loop sleeps a full second on an empty queue and never woke up during the test at all. I tried to make the interleaving deterministic — feeding the loop junk tasks to keep it awake, then widening the dispatch-to-poller-exit window to 300ms — and it still passed with the latch moved back to the wrong place. Whether the run loop wins those microseconds is scheduling, not something a test can force without injecting scheduling points. So the guarantees are pinned in layers instead, both mutation-checked: the required call order in
tests/test_main.py(deterministic, with fakes) and the latch's actual effect on the run loop intests/test_dispatcher.py(including that in-flight jobs still get to finish their remaining cases — blocking those would leave a multi-case job unable to complete). The end-to-end test stays as a path check, with a comment saying plainly what it does not pin.tests/test_sigterm_process.pycloses the last verification gap: it runsmain.pyas a real subprocess against a stub backend, points it at a dead redis so the claimed job sits in prep, sends a realSIGTERMinto that window, and asserts the backend sawabort(reason='drain'), saw nocomplete, and that the process exited 0 by itself. Nomain.drain()call anywhere in it — the signal handler drives the whole thing. 1.6s, no docker, no new dependencies. Mutation-checked in both directions (report the claim asprep_failed→ fails; skip the drain on SIGTERM → fails). Realdocker stopwithstop_grace_periodstill belongs to the deployment smoke test in Normal-OJ/Normal-OJ#71, but "process gets SIGTERM, drains, exits" is now checked on every CI run.Follow-up 4 (95890ec, 5ca0d71): latch first, and guards for what was only commented
The latch was still one scheduling point late:
poller.stop()only sets an event, andstop_accepting()took the lock on the next line, so a prep completing in between could dispatch and have the run loop start the job while the dispatcher was still accepting. Chasing the window one statement at a time was the wrong approach — the invariant is "once a drain begins, nothing starts judging", so the latch is now the drain's first act, before the poller is even asked to stop. The window that remains between the latch andpoller.stop()is harmless by construction: a job claimed there cannot start, and the scan hands it back.The process-level SIGTERM test also claimed more than it proved. Waiting for the backend to serve next-job only shows the response was written, not that the child received it or entered prep, so the signal could land earlier and the test would pass on a different path. It now waits for the child to connect to a stub redis — something only
ensure_testdatadoes — and drops that connection after signalling. Verified against the child's own log:prep attempt 1/3is in flight when the signal arrives.I then ran an adversarial audit over the whole drain looking for a fifth hole. It found none live, but it did find that several invariants with explanatory comments had no test at all — each of these mutations passed the entire suite: stopping the dispatcher before the in-flight wait (which strands a multi-case job, burns the whole budget and discards results already computed), the sender dropping its queue on
stop(),on_job_completereleasing before enqueueing, and the JE fallback increate_container. All four now have guards, each verified to fail without the behaviour.It also surfaced a real design gap: every wait drew from the budget first-come-first-served, so a poller stuck on a slow-drip response could spend all 540s and leave the sender joined with nothing — completes already computed, and the drain aborts just queued, unsent. Waiting for the poller is the least valuable thing the drain does, so it now takes a capped share and the in-flight wait keeps a reserve for reporting. If the poller does outlive its share it can still dispatch, so the scan runs a second time before the sender stops.
Two smaller things:
create_containernow looks up its per-job lock defensively likecompile()already did (a case finishing after its job was released — a drain, or the samejob_idhandled again after a reclaim — raised outside the JE guard and killed the worker without resolving the case, hanging the job for the whole drain); and the end-to-end test named after the prep-succeeds-after-stop case now says plainly that it passes with or without the latch, because it does —tests/test_dispatcher.pyis what pins the latch.