fix(router): reserve-then-settle spend caps to close concurrency gap - #795
fix(router): reserve-then-settle spend caps to close concurrency gap#795rohith500 wants to merge 8 commits into
Conversation
…kweave#793) Signed-off-by: N Rohith Reddy <rohithreddy2202@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: N Rohith Reddy <rohithreddy2202@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…orkweave#793) Signed-off-by: N Rohith Reddy <rohithreddy2202@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: N Rohith Reddy <rohithreddy2202@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…kweave#787) Signed-off-by: N Rohith Reddy <rohithreddy2202@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
|
PR author is not in the allowed authors list. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 8a73a4a. Configure here.
…ndependent SQL NOW() (workweave#793) Bugbot flagged (commit 8a73a4a): TryBumpOrgMonthReserved/ TryBumpUserMonthReserved computed their target month via SQL's own NOW(), while InsertSpendReservation recorded month from Go's utcMonthDate(). A clock disagreement across a UTC month boundary could bump one month's row while recording a different month on the reservation, permanently stranding reserved_usd_micros that settle/release/sweep could never find. Now a single Go-computed month value is passed explicitly into every query in one ReserveSpendCaps invocation, eliminating the possibility of divergence. Signed-off-by: N Rohith Reddy <rohithreddy2202@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
|
Thank you so much for this contribution — the investigation behind it is genuinely impressive. Independently reproducing the TOCTOU three times against real Postgres before writing a line of the fix, the atomic We've re-landed the change as #799 (with full credit to you) to align one thing with our internal conventions — and please know this is not your fault: these conventions are internal, evolving, and genuinely not obvious from the outside. The one convention applied: root Everything else — the reserve-then-settle mechanism, the single-TX multi-scope reservation, the sweeper cadence rationale, the shadow-eval skip parity with #787 — carried over untouched. Thanks again, and we'd love more contributions like this one. |

Fixes #793.
Summary
Org monthly, user monthly, and api-key lifetime spend caps had a check-then-act race:
CheckOrgMonthlySpend(a plainSELECT) followed by an unguarded write. Concurrent requests could all pass the same preflight check before any of their debits landed, allowing spend to blow past a configured "hard cap" by a multiple of the limit, not by a bounded amount. Independently reproduced three times against real Postgres and real production code before any implementation began (see #793 for the investigation, including exact reproduction numbers and the corrected reading of PR #769's actual claim).This PR replaces the check-then-act gate with reserve-then-settle: an atomic conditional reservation at request start, settled (or released) at request end, backed by a TTL sweeper for crash safety.
Design
Core mechanism: a single combined
ArmSpendReservationscall, made once per request after identity resolves and before dispatch, reserves all applicable caps (org-monthly, api-key-lifetime, user-monthly) in one Postgres transaction. Each scope's reservation is a genuinely atomic conditionalUPDATE:Zero rows returned means the cap would be exceeded, mapped to the appropriate
Err*SpendLimitReached/Err*SpendCapReachedsentinel. This is the crux of the fix: the bound check lives inside the write's ownWHEREclause, so Postgres's row-level locking serializes concurrent attempts against the same row rather than two reads-then-writes racing each other.Wrapping all three scopes in one transaction means a failure on any later scope (e.g. user-monthly fails after org+key succeeded) rolls back everything automatically via normal Postgres rollback, no manual multi-scope release logic needed, which is simpler and more clearly correct than the alternative (three independent reserve points) considered and rejected during design.
Settle/release/sweep share one idempotent primitive:
DELETE FROM spend_reservations WHERE id = @id RETURNING ..., only decrementing the denormalizedreserved_usd_microscounter when a row was actually returned. A second delete on an already-consumed reservation is a clean no-op, this is what makes settle, a defer-based release-on-failure, and the TTL sweeper all safe to race against each other.TTL sweeper: every ~1 minute (not the existing session-pin sweeper's 1-hour interval, a stuck reservation has much worse consequences than a stale pin), TTL = request timeout + 5 minutes, following the existing
runSessionPinSweepscheduling pattern (every-replica ticker, no leader election, confirmed this router runs multi-replica in managed deployments, so no in-process-only mechanism could be the real backstop).Reservation amount (R): a fixed, configurable slot (
ROUTER_SPEND_RESERVE_USD_MICROS, default $1), not derived frommax_tokens(which would false-402 legitimate large-context requests).Honest worst-case guarantee
With fixed reservation R, monthly limit, N concurrent request starts, and post-response actual cost per request:
admitted <= min(N, floor(limit / R))
worst-case overshoot <= admitted * max(0, actual - R)
Verified against the real implementation (delayed-settle fixture forcing the true worst case, not a favorable fast-settle timing): $10 limit, R=$1, actual=$6.75, N=20, admitted=10 (exactly floor(limit/R)), final spend $67.50, overshoot $57.50, an exact match to the theoretical bound. Against the original bug's unbounded result on the same fixture (N * actual = $135), this is real but modest (~2x) improvement in this specific fixture, because R=$1 is well below actual=$6.75. The structural fix, overshoot no longer scales with N, is what matters; the numerical tightness of the bound depends entirely on R being tuned close to real per-request cost. R=$1 is a v1 default and should be tuned from real p95 turn cost in production, not left as-is.
Shadow-eval interaction (discovered during rebase, not part of original design)
origin/main moved during this PR's development (#787, agent-shadow evaluation routing) and added an explicit skip for shadow-eval traffic to
WithBalanceCheck,WithOrgMonthlySpendCap, andWithAPIKeySpendCap, synthetic evaluation requests must not mutate production billing state. Since this PR moves org-monthly and api-key enforcement out of the latter two middlewares intoArmSpendReservations, the rebase required adding the equivalent skip there too (internal/proxy/spend_reserve.go), without it, shadow-eval traffic would have silently started consuming real org spend budget on the real inference paths, regressing what #787 had just protected against. Covered by a new test (TestArmSpendReservations_AgentShadowEvalSkipsReserve) with positive and negative controls./v1/route's check-only middleware (unaffected by this PR) retains its own independent shadow-eval skip.Commits
Testing
TestReserveSpendCaps_SingleGoComputedMonthAcrossEnsureBumpInsert) that freezes Go's clock to a different UTC month than Postgres's live NOW() to prove the fix.Known, deliberately unaddressed
Related
#478 (prepaid balance TOCTOU, same bug class, different table/product surface, not yet fixed there), #564 (unrelated, key rotation), #769 (the PR whose "bounded by in-flight cost" claim this issue showed was false under concurrency), #787 (unrelated feature that moved main during this PR's development; required the shadow-eval fix above during rebase), #794 (unrelated feature that also moved main, no file overlap).