feat(runner): add job poller and result sender - #56
Conversation
Slice 3/4 of the pull-based runner (spec §7, §10): - BackendClient gains next_job / complete / abort (§7.3-§7.5) - PollerThread: capacity gate, claim, code download + submission dir prep with 3 local attempts, then abort(prep_failed) via the result queue; unexpected errors are logged, never kill the loop - ResultSenderThread: single reporting channel with exponential backoff (max 5 retries), 409/404 terminal drop, 400 -> abort(rejected), exhaustion -> local backup; drains the queue on stop() Dark PR: nothing is wired into main.py yet (slice 4).
|
Codex: Review findings[P1] abort / reclaim 有 ABA race
建議讓 相關位置: [P1] dispatch retry 不是 atomic,可能重複執行 task
建議分開 prep retry 與 dispatch failure,並在 retry dispatch 前提供 atomic capacity reservation 或完整 rollback。 相關位置: |
Review follow-ups (Codex review on #56): - result_sender: finalize local state (job dir + tracker entry) BEFORE sending an abort. A 202 means the backend has already requeued the job, so this same runner may immediately re-claim the same job_id; the late cleanup used to delete the new claim's dir and tracking. rejected aborts keep the dir as backup evidence; abort-retry exhaustion no longer backs up (the dir is finalized pre-send). - poller: dispatch (Dispatcher.handle) is no longer retried. A failed handle() may leave partially enqueued task entries; re-registering the same job_id would revive them and duplicate execution. Prep keeps its 3 local attempts; a dispatch failure aborts straight away. Atomic handle() lands with slice 4.
|
Claude: 兩條 P1 已在 b7ab2a4 處理。 P1-1(abort/reclaim ABA race):成立。修法是把 abort 的本地收尾(目錄+tracker)搬到送出之前。202 回來時 backend 已經把 job 推回 pending,同一台 runner 隨時可能重撈;收尾先做完,重撈的瞬間本地就沒有舊狀態可以誤刪。幾個連帶調整:rejected 改走 backup_data,backend 拒收的 payload 目錄留著查 serializer 問題,move 走一樣不擋新 claim;abort 重送耗盡不再 backup,因為目錄在第一次送出前就處理掉了;收尾失敗照樣送 abort,不然 job 會卡到 lease 過期。complete 路徑不動:成功是刪 job 不會 requeue,retry 期間留著 tracker 還能維持續租,晚到的結果不用重算。測試補的是順序斷言(cleanup → tracker.remove → send),沒有做雙 thread 的 interleaving 重現,那種測試非決定性,守住順序不變量就夠了。 P1-2(dispatch 非 atomic):也成立,而且觸發條件比 review 描述的更寬:capacity gate 數的是 job(max_concurrent_jobs=8),queue 裝的是 task(QUEUE_SIZE=16),一題多測資時滿載就會 queue.Full。這個 PR 先止血:dispatch 只呼叫一次,失敗直接 abort(prep_failed),不再重跑 prepare+handle,因為每呼叫一次 handle 就多一次讓部分入列的孤兒 entries 復活的機會。殘餘窗口(abort 後同機重撈、孤兒還在 queue)要等 handle() 原子化才關得掉,那是 dispatcher 的修改,歸 keystone。已經連同 capacity gate 應改以 task 容量為準記到 Normal-OJ/Normal-OJ#70。 |
closes Normal-OJ/Normal-OJ#69
Slice 3/4 of the pull-based runner. Dark PR: purely additive, nothing is wired into the process yet (
main.pyentrypoint is slice 4). The legacy push path is untouched.What
runner/client.py:BackendClientgainsnext_job(200 →JobPayload, 204 →None),complete(expects 204) andabort(expects 202), per spec §7.3–§7.5.JobPayloadnormalizesproblem_idtointat the wire boundary.runner/poller.py:PollerThreadpolls only when there is spare capacity (len(tracker) < max_concurrent_jobs), adds the claimed job to the tracker before prep so the heartbeat renews the lease during downloads, and retries prep locally (3 attempts, backoff 1s/2s) before queueingabort(prep_failed). Dispatch (Dispatcher.handle) gets exactly one shot — see design notes.prepare_jobreuses the existingensure_testdata+file_manager.extracthelpers and downloads code from the presignedcode_url. Unexpected errors in the poll loop are logged and never kill the thread.runner/result_sender.py: single reporting channel. All complete/abort reports retry with exponential backoff (1→2→4→8→16s, max 5 retries); 409/404 are terminal drops; a 400 on complete becomesabort(rejected)(spec §7.5, INV5); complete exhaustion keeps a local backup (file_manager.backup_data) and gives up — lease-expiry reclaim is the safety net.stop()drains whatever is already queued before exiting, which is the building block for slice 4's SIGTERM drain.Design notes
prep_failedabort goes through the result queue instead of calling the backend directly, so there is exactly one reporting channel and the drain semantics of slice 4 cover it for free.job_idimmediately; finalizing late would let the old claim's cleanup delete the new claim's dir and tracking (ABA race, found in review).rejectedaborts move the dir aside viabackup_data(evidence of what the backend refused);prep_failedaborts clean it. Consequence: abort-retry exhaustion no longer backs up — the dir was finalized before the first attempt. The complete path keeps send-then-finalize: success deletes the job backend-side (no requeue), and holding the tracker entry during retries keeps the lease renewed so a delivered-late result needs no re-execution.Dispatcher.handle()is not atomic: onqueue.Fullit releases its bookkeeping but already-enqueued task entries stay in the queue; callinghandle()again for the samejob_idwould revive them and duplicate execution (found in review). A dispatch failure aborts straight away (prep_failed, counts toward attempts per spec §7.5). The remaining exposure — a later re-claim of the same job by the same runner while orphaned entries are still queued — closes in slice 4, which makeshandle()atomic. Slice 4 should also make the capacity gate account for task-queue space (max_concurrent_jobscounts jobs,QUEUE_SIZEcounts tasks), which is what makesqueue.Fullreachable under load in the first place.Testing