fix(spurctld): retry accounting writes and reconcile drift from job store#428
Conversation
…tore notify_job_start/notify_job_end fired a single fire-and-forget Postgres write with no retry, so a transient DB blip or a crash right after spawn permanently diverged accounting (sacct) from live job state (squeue). Add bounded retry with backoff (3 attempts) around both writes, escalating to error! once retries are exhausted so the gap is visible. Add a periodic leader-only reconciliation pass that diffs the in-memory job store against accounting DB state and re-issues record_job_start/record_job_end for any job that's missing or stale, closing gaps retries couldn't fix.
Reconciliation only diffed the state column, so a bare row left by a missed notify_job_start (record_job_end inserting from scratch) was never repaired, permanently losing metadata and usage accrual. A failed accounting read was also indistinguishable from a confirmed-absent row, letting a resync clobber a correct terminal record back to RUNNING. And since accounting only ever models RUNNING/terminal, suspended (and completing) jobs were flagged stale and rewritten on every single pass for as long as they stayed in that state. Track a needs_start_backfill flag per row (missing user_name/start_time) so matching-state bare rows still get repaired; track failed reads as a distinct "unknown" outcome that's skipped rather than treated as missing; and map non-terminal states accounting can't represent to RUNNING before diffing. Also swap the reconciliation loop's leadership check for Raft::ensure_linearizable(), which confirms leadership against a quorum instead of a locally cached view, batch the per-job accounting query into one round trip, and bound each retried write with a per-attempt timeout so a hung connection can't pin a pool slot indefinitely.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #428 +/- ##
==========================================
- Coverage 70.85% 70.32% -0.53%
==========================================
Files 140 141 +1
Lines 41849 42382 +533
==========================================
+ Hits 29650 29801 +151
- Misses 12199 12581 +382 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
Improves Spur controller accounting robustness by adding bounded retries to notifier writes and a periodic reconciliation loop that re-syncs accounting DB state from the in-memory job store, guarded by a consensus-backed leadership check.
Changes:
- Add per-notification bounded retry with exponential backoff + per-attempt timeout for accounting DB writes.
- Introduce a periodic reconciliation loop to detect and repair missing/stale accounting rows using job-store state, gated by
ensure_linearizable()-backed leadership. - Add DB batch query helper (
job_accounting_states) plus unit/integration tests for reconciliation and retry behavior.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/spurctld/src/raft.rs | Adds ensure_leader() (consensus-backed) alongside cached is_leader(). |
| crates/spurctld/src/main.rs | Wires up the periodic accounting reconciliation loop on successful DB init. |
| crates/spurctld/src/accounting/reconcile.rs | New reconciliation loop + diff/backfill logic + tests. |
| crates/spurctld/src/accounting/notifier.rs | Adds retry/backoff + per-attempt timeout for DB write notifications + unit tests. |
| crates/spurctld/src/accounting/mod.rs | Exposes the reconciliation loop entrypoint from the accounting module. |
| crates/spurctld/src/accounting/db.rs | Adds AccountingRowState + job_accounting_states() batch query for reconciliation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…d it with a timeout record_job_start unconditionally sets state='RUNNING' with end_time/exit fields wiped, and reconciliation's resync_job relied on a second, separate write_end call to fix that back up. Any failure between the two writes (or a concurrent reader landing in the gap) could observe or permanently persist a correct terminal accounting record clobbered back to RUNNING; if write_start itself failed, the function returned early and write_end never ran at all. record_job_start/record_job_end now take a &mut PgConnection instead of a hard &PgPool, so reconciliation can run both under one transaction (pool.begin, commit only on full success) while the notifier and gRPC handlers keep using a pool-acquired connection per call. The whole resync attempt is also bounded by notifier.rs's existing ATTEMPT_TIMEOUT, since a wedged (not fully down) connection during a resync pass would otherwise stall run_once forever and silently disable every future reconciliation tick. Added a DB-backed regression test that drives write_start to completion for real (proving it does flip a correct terminal row to the corrupted RUNNING shape), then forces a real SQL failure on the same transaction before commit and confirms only the transaction's rollback — not any extra fixup — protects the original record. Verified against a live Postgres instance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
reconcile.rs compared job state via is_terminal(), which deliberately excludes Preempted. A durably-Preempted job (cluster.rs's own comments and tests acknowledge this can happen on a partial-proposal failure) would be treated as "should still be RUNNING", clobbering a correct PREEMPTED accounting record with RUNNING/no-end_time on every reconciliation cycle. Swapped to is_finalized(), matching the convention cluster.rs already uses for this same "is this job's lifecycle actually over" check, and added pure-logic tests covering the Preempted comparison and resync-flagging paths. Also dropped the "Finding N:" comment prefixes left over from a prior review round — AGENTS.md asks that review context live in commit history, not narrated in source comments. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Live testing performedVerified on a real 2-node cluster (not simulated/mocked), across two rounds:
|
shiv-tyagi
left a comment
There was a problem hiding this comment.
Found a few small nits (hardcoded 120s interval vs a config knob, a full job-store clone per cycle, an avoidable String alloc in accounting_expected_state), ignoring for now. LGTM.
What
Fixes SPUR-29: the accounting notifier fired Postgres writes fire-and-forget with no retry, so
sacctandsqueuewould permanently diverge on any transient DB blip, with no visibility that it happened.Approach
notify_job_start/notify_job_end's DB writes, escalating toerror!only after retries are exhausted.ensure_linearizable, not just a locally-cached leader flag).record_job_endthat ran without a precedingrecord_job_start, transient read failures during reconciliation (treated as "unknown, skip" rather than "confirmed missing," to avoid clobbering a correct record), and non-terminal states accounting doesn't model.resync_jobwas non-atomic —record_job_start's upsert unconditionally resets a row tostate='RUNNING', end_time=NULLeven when only backfilling missing metadata on an already-terminal record, and a failure partway through left that corrupted state in place for a full reconciliation cycle. Fixed by wrapping the backfill-then-terminal-write sequence in a single database transaction, so any failure rolls back cleanly instead of leaving a partially-applied, incorrect state.Preempted-job misclassification in reconciliation's state comparison (was usingis_terminal(), which excludesPreempted; switched tois_finalized(), matching the convention used elsewhere in the codebase) — closes a narrow but real gap where a durably-preempted job's correct accounting record could get clobbered every cycle.Known limitations, documented not fixed
#[ignore]-gated DB tests inreconcile.rsshare a test username derived only from process ID rather than a per-test discriminator (unlike the rest of the file's test-naming convention) — confirmed this causes no actual collision today (different job IDs, only one test touches the sharedusagetable) and these tests aren't part of the default CI run, so deferred rather than fixed in this already-substantial pass.Testing
#[ignore]-gated, matching the existing convention) validated against a real Postgres instance, including a test that specifically reproduces the transaction-rollback fix: forces a failure mid-transaction and confirms the original terminal record is left untouched rather than corrupted.is_finalized()state-comparison fix, with no DB dependency.Review
3 rounds of independent adversarial review (including GitHub Copilot's automated review, cross-validated — one round empirically verified the core bug against a live Postgres instance) + cross-validation passes, which found and required fixing real correctness bugs before this was mergeable — see commit history for details.