fix(keeper): persist budget circuit-breaker halt across restarts - #367
fix(keeper): persist budget circuit-breaker halt across restarts#367Morenikeoa wants to merge 1 commit into
Conversation
KeeperBudget's latched halt (_haltKind/_haltReason) was purely in-memory, with zero persistence. The class's own design intent is that day-cap breaches and operator halts "never auto-recover" and require an explicit POST /admin/budget/resume -- but a crash, OOM, or orchestrator-driven restart re-initializes KeeperBudget with a clean slate, fully un-halting the breaker with no record that it was ever tripped and no re-validation that the underlying condition (a runaway spend bug, a stuck retry loop) is resolved. An auto-restarting orchestrator (Railway/Docker/k8s) silently resumes spending into the same unresolved incident, with operators seeing no alert that the breaker reset. Adds opt-in halt-state persistence via a new haltStatePath dep: a halt is written to a local JSON file and restored (re-firing onHalt so paging/metrics react as if it just happened) at construction. Resuming clears the file. haltStatePath is undefined by default, so every existing KeeperBudget consumer/test is completely unaffected; the production singleton in keeper-send.ts opts in via KEEPER_BUDGET_HALT_STATE_PATH (default /tmp/keeper-budget-halt.json). This covers process-level restarts that reuse the same filesystem (the common automatic-restart case) -- a full container recreate on an ephemeral filesystem is not covered, and operators wanting that can point the env var at a mounted persistent volume. BUG-106 from a clean-room Phase 4 audit pass.
|
Warning Review limit reached
More reviews will be available in 47 minutes and 54 seconds. Learn how PR review limits work. Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file). ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits. 🚦 How do rate limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Independent verification — not an approval (QA/Security own that), just evidence for whoever reviews. Verdict: genuine, and the persistence loop is tested in both directions. Method: ran the PR's own tests first (62 passed), then neutralised each half of the loop independently rather than both at once, so each is shown to bind on its own. A — restore on construction removed: B — clear-on-resume removed ( Both halves bind, and each fails only its own test — no overlap, which is what you want from a two-directional feature. Why B is the one I went looking forThe obvious failure mode of "persist the halt" isn't the persisting — it's forgetting to un-persist. A It's covered, and the test name says exactly what it protects. Good. Details worth calling out
No changes requested from me. For contrast with the other keeper PRs I've looked at recently — #363, #357 and #359 all changed security-relevant behaviour with no new tests — this one ships the coverage with the change, and it's the shape I'd point at as the standard. |
Problem
KeeperBudget's latched halt (_haltKind/_haltReason) is purely in-memory, with zero persistence — confirmed: nothing inbudget.tstouches Redis, Supabase, or disk.The class's own design intent (file header) is that day-cap breaches and operator halts "never auto-recover" and require an explicit
POST /admin/budget/resume. But a crash, OOM, or orchestrator-driven restart re-initializesKeeperBudgetwith_haltKind = undefined, fully un-halting the breaker with no record that it was ever tripped and no re-validation that the underlying condition (a runaway spend bug, a stuck retry loop draining the wallet) is resolved.Production Impact
An auto-restarting orchestrator (Railway/Docker/k8s) silently resumes spending into the same unresolved incident that caused the original breach, with operators seeing no alert that the breaker reset. A day-cap halt exists specifically to stop wallet drainage during an active incident — this gap defeats that purpose across any restart.
Fix
Added opt-in halt-state persistence via a new
haltStatePathdependency onKeeperBudget:_halt(), the kind/reason are written to a local JSON file.canSpend()returnsfalsebefore any sends happen) andonHaltis re-fired so paging/metrics react exactly as if the halt had just occurred.resume(), the file is removed.haltStatePathisundefinedby default — every existingKeeperBudgetconsumer and test is completely unaffected unless it opts in explicitly. The production singleton (sharedBudgetinsrc/lib/keeper-send.ts) opts in viaKEEPER_BUDGET_HALT_STATE_PATH(default/tmp/keeper-budget-halt.json).Scope note: this covers process-level restarts that reuse the same filesystem — the common automatic-restart case (uncaught exception, orchestrator-driven OOM-kill-then-restart). A full container recreate on an ephemeral filesystem is not covered; operators wanting that level of durability can point the env var at a mounted persistent volume.
Proof of Fix
New tests in
tests/lib/budget.test.ts(KeeperBudget — BUG-106: halt-state persistence):haltStatePathis unset (confirms zero behavior change for existing consumers)onHaltresume()clears the persisted file — a later restart starts cleanTest Output
Also ran the dependent suites that construct
KeeperBudget/sharedBudget(tests/lib/keeper-send.test.ts,tests/services/crank.test.ts,tests/services/liquidation.test.ts): 98 passed, 9 skipped, zero regressions.pnpm build— clean, zero errors.