Skip to content

fix(billing): reserve-then-settle spend caps to close concurrency gap#799

Open
devin-ai-integration[bot] wants to merge 5 commits into
mainfrom
devin/spend-reserve-then-settle
Open

fix(billing): reserve-then-settle spend caps to close concurrency gap#799
devin-ai-integration[bot] wants to merge 5 commits into
mainfrom
devin/spend-reserve-then-settle

Conversation

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Summary

Compliant rewrite of #795 by @rohith500 (fixes #793) — production logic, migrations, and SQLC queries preserved verbatim; only the DB-backed tests were relocated to comply with repo conventions.

Spend caps (org monthly, user monthly, api-key lifetime) had a check-then-act race: concurrent requests could all pass the preflight SELECT before any debit landed, overshooting a hard cap by a multiple of the limit. This replaces the gate with reserve-then-settle:

  • billing.Service.ArmSpendReservations reserves all applicable caps once per request (after identity, before dispatch) in one Postgres TX; each scope is an atomic conditional UPDATE ... SET reserved = reserved + R WHERE spent + reserved + R <= limit RETURNING id — zero rows maps to the scope's Err*SpendLimitReached/Err*SpendCapReached sentinel.
  • Settle/release/sweep share one idempotent primitive: DELETE FROM spend_reservations WHERE id = @id RETURNING ..., decrementing the denormalized reserved_usd_micros only when a row returns. Settle happens in the same TX as DebitInference; a defer-based release covers early exits; a 1-minute TTL sweeper (runSpendReservationSweep, TTL = request timeout + 5m) covers crashes.
  • Reservation slot R is fixed and configurable (ROUTER_SPEND_RESERVE_USD_MICROS, default $1; ROUTER_SPEND_RESERVE_TTL, default 15m). Worst-case overshoot is admitted * max(0, actual - R) with admitted <= floor(limit/R) — bounded, no longer scaling with concurrency.
  • Shadow-eval traffic skips reservations entirely (parity with the feat: add isolated agent shadow routing #787 billing-middleware skips).

Delta from #795: the three live-Postgres *_test.go files under internal/postgres/ were moved to a runnable script, scripts/spend_reservation_check/ (concurrency regression, Go-computed-month consistency, TTL sweep), per the root AGENTS.md rule that DB-backed integration checks live under scripts/ rather than internal/ test files. make precommit and make generate pass clean.

Credit: original design, implementation, and investigation by @rohith500 in #795.

Link to Devin session: https://app.devin.ai/sessions/e5f91192d0f64062b5113b1c94b5928f
Requested by: @steventohme

Co-authored-by: rohith500 <rohith500@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@greptile-apps

greptile-apps Bot commented Jul 20, 2026

Copy link
Copy Markdown

PR author is not in the allowed authors list.

@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Claude finished @devin-ai-integration[bot]'s task —— View job


🤖 Claude Code

  • Gathered diff and analyzed comment blocks
  • Posted review with 6 comment-length suggestions

Posted one advisory PR review (#pullrequestreview-4735207438) flagging six 3+ line comment blocks that could be trimmed without losing genuine WHY:

File Lines Issue
cmd/router/main.go 1393–1396 Session-pin comparison + "every replica" restate the code
internal/proxy/spend_reserve.go 12–17 Lines 2–3 restate the signature
internal/proxy/spend_limit.go 12–14 Architecture narration; WHY fits in two lines
internal/billing/service.go 278–280 Scope names already in params; return type restated
internal/billing/service.go 318–320 Call-order instruction visible from context
internal/postgres/billing_repo.go 407–409 Second sentence restates the first


expires := time.Now().UTC().Add(p.TTL)
expiresTS := pgtype.Timestamptz{Time: expires, Valid: true}
month := utcMonthDate()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reservation month differs from debit

High Severity

Spend caps are reserved against the UTC month from Go at arm time, but DebitOrgCredits still buckets org/user/key spend using Postgres NOW(). Long requests (up to 600s) can arm in one month, settle the hold on that month’s reserved_usd_micros, and record spend in the next month without a matching reservation there.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit bbe828c. Configure here.

if ok {
proxy.LogDispatchErrorClass(log, cls, armErr)
writeGeminiError(c, cls.Status, "RESOURCE_EXHAUSTED", cls.Message)
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gemini arm maps all errors quota

Low Severity

When ArmSpendReservations fails with a classified dispatch error, the Gemini handler always sets the JSON error.status to RESOURCE_EXHAUSTED instead of geminiErrorStatus(cls.Kind), unlike the proxy error path below. Billing-unavailable (503) responses can carry a quota-style status while the HTTP code is not 429.

Fix in Cursor Fix in Web

Triggered by learned rule: New sentinel errors must be mapped in all API surface handlers

Reviewed by Cursor Bugbot for commit bbe828c. Configure here.

@workweave-bot workweave-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Advisory only — comment-length nits. Won't block merge.

Comment thread cmd/router/main.go Outdated
Comment on lines +1393 to +1396
// runSpendReservationSweep releases expired spend-cap reservations every
// minute. Stuck reserved incorrectly blocks spend, so this is much more
// aggressive than the session-pin GC. Every replica runs its own ticker;
// DELETE … RETURNING is idempotent across replicas.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// runSpendReservationSweep releases expired spend-cap reservations every
// minute. Stuck reserved incorrectly blocks spend, so this is much more
// aggressive than the session-pin GC. Every replica runs its own ticker;
// DELETE … RETURNING is idempotent across replicas.
// runSpendReservationSweep releases expired spend reservations every minute.
// Stuck reserved headroom incorrectly blocks spend; DELETE … RETURNING is idempotent across replicas.

Was 4 lines; the session-pin comparison and "every replica" observation restate the code — the non-obvious WHY (stuck reservations = blocked headroom) fits in two.

Comment thread internal/proxy/spend_reserve.go Outdated
Comment on lines +12 to +17
// ArmSpendReservations reserves all applicable spend caps in one transaction
// after identity is on ctx and before Proxy*. Returns a release func for
// defer (no-ops after DebitForInference settles). Nil billing is a no-op.
// Agent-shadow evaluation traffic skips entirely (no reservation, no gate),
// matching the billing middleware skips from #787 — synthetic eval must not
// consume production spend budget.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// ArmSpendReservations reserves all applicable spend caps in one transaction
// after identity is on ctx and before Proxy*. Returns a release func for
// defer (no-ops after DebitForInference settles). Nil billing is a no-op.
// Agent-shadow evaluation traffic skips entirely (no reservation, no gate),
// matching the billing middleware skips from #787 — synthetic eval must not
// consume production spend budget.
// ArmSpendReservations reserves all applicable spend caps in one transaction
// before Proxy*. Shadow eval skips entirely — synthetic traffic must not consume
// production spend budget (parity with billing middleware, #787).

Was 6 lines; lines 2-3 restated the signature; collapsed to three without losing the shadow-eval WHY.

Comment thread internal/proxy/spend_limit.go Outdated
Comment on lines +12 to +14
// checkUserMonthlySpendLimit is retained for unit tests and as a read-only
// helper. Request-path enforcement uses billing.ArmSpendReservations (combined
// reserve of org/key/user caps) in API handlers before Proxy*.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// checkUserMonthlySpendLimit is retained for unit tests and as a read-only
// helper. Request-path enforcement uses billing.ArmSpendReservations (combined
// reserve of org/key/user caps) in API handlers before Proxy*.
// checkUserMonthlySpendLimit is retained for unit tests only; request-path
// enforcement moved to ArmSpendReservations in API handlers.

Was 3 lines narrating the architecture; the WHY fits in two.

Comment thread internal/billing/service.go Outdated
Comment on lines +278 to +280
// ReserveApplicableCaps reserves every applicable spend cap (org monthly,
// api-key lifetime, user monthly) in one transaction. Returns a SpendHold
// (possibly with empty IDs when no caps apply). Billing override skips all.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// ReserveApplicableCaps reserves every applicable spend cap (org monthly,
// api-key lifetime, user monthly) in one transaction. Returns a SpendHold
// (possibly with empty IDs when no caps apply). Billing override skips all.
// ReserveApplicableCaps reserves all applicable caps in one TX. Billing override skips all.

Was 3 lines listing scope names (already in param names) and restating return type; one line keeps the non-obvious override note.

Comment thread internal/billing/service.go Outdated
Comment on lines +318 to +320
// ArmSpendReservations reserves applicable caps and returns a release func
// for defer. Call after identity is resolved and before Proxy*. The release
// func no-ops after a successful DebitForInference (MarkSettled).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// ArmSpendReservations reserves applicable caps and returns a release func
// for defer. Call after identity is resolved and before Proxy*. The release
// func no-ops after a successful DebitForInference (MarkSettled).
// ArmSpendReservations reserves applicable caps and returns a defer-safe release func
// that no-ops once MarkSettled is called by DebitForInference.

Was 3 lines; "Call after identity is resolved and before Proxy*" narrates call order visible from context — trimmed to two.

Comment thread internal/postgres/billing_repo.go Outdated
Comment on lines +407 to +409
// utcNow is the clock for reservation month bucketing. Tests override it to
// prove Ensure/Bump/Insert share one Go-computed month even when Postgres
// NOW() would land in a different UTC month.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// utcNow is the clock for reservation month bucketing. Tests override it to
// prove Ensure/Bump/Insert share one Go-computed month even when Postgres
// NOW() would land in a different UTC month.
// utcNow is the clock for reservation month bucketing; tests override it so
// Ensure/Bump/Insert share one Go-computed month even across a UTC midnight.

Was 3 lines; second sentence restated the first — condensed to two without losing the non-obvious invariant.

@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Claude finished @devin-ai-integration[bot]'s task —— View job


🤖 Claude Code

  • Gathered diff and analyzed comment blocks
  • Posted review with 3 comment-length suggestions

Posted one advisory PR review (#pullrequestreview-4735243865) flagging three 3-line comment blocks that can be trimmed:

File Lines Issue
internal/billing/reservation.go 18–20 Third line restates env var visible from the constant name; fits in two
internal/billing/repo.go 44–46 Third line restates the return type; non-obvious TX-rollback invariant keeps
internal/billing/repo.go 49–51 "and decrements…" clause can merge with line 1; second line is the non-obvious idempotency note

View job

}
if HasOverrideFromContext(ctx) {
return &SpendHold{}, nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Override skips API key cap

Medium Severity

When a billing override is active, ReserveApplicableCaps returns an empty hold and never reserves the API-key lifetime cap. Inference routes no longer run WithAPIKeySpendCap, so capped keys on override orgs can keep serving until spent_usd_micros exceeds the cap without a gate.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1df6581. Configure here.

@workweave-bot workweave-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Advisory only — comment-length nits. Won't block merge.

Comment thread internal/billing/reservation.go Outdated
Comment on lines +18 to +20
// DefaultReserveAmountMicros is the v1 fixed reservation slot ($1). Tuned as a
// p95-ish turn cost approximation — not exact. Override via
// ROUTER_SPEND_RESERVE_USD_MICROS.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// DefaultReserveAmountMicros is the v1 fixed reservation slot ($1). Tuned as a
// p95-ish turn cost approximation — not exact. Override via
// ROUTER_SPEND_RESERVE_USD_MICROS.
// DefaultReserveAmountMicros is the v1 fixed reservation slot ($1) — p95-ish turn cost,
// not exact. Override via ROUTER_SPEND_RESERVE_USD_MICROS.

Was 3 lines; collapses to two without losing the tuning rationale.

Comment thread internal/billing/repo.go Outdated
Comment on lines +44 to +46
// ReserveSpendCaps atomically reserves every applicable scope in one
// transaction. On any scope failure the whole TX rolls back (no partial
// holds). Returns reservation ids (possibly empty when no caps apply).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// ReserveSpendCaps atomically reserves every applicable scope in one
// transaction. On any scope failure the whole TX rolls back (no partial
// holds). Returns reservation ids (possibly empty when no caps apply).
// ReserveSpendCaps atomically reserves every applicable scope in one TX;
// any scope failure rolls back the whole TX (no partial holds).

Was 3 lines; the "Returns reservation ids" sentence restates the signature — dropped.

Comment thread internal/billing/repo.go Outdated
Comment on lines +49 to +51
// ReleaseSpendReservations consumes reservation ids via DELETE … RETURNING
// and decrements denormalized reserved only when a row was returned.
// Idempotent: already-gone ids are no-ops.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// ReleaseSpendReservations consumes reservation ids via DELETE … RETURNING
// and decrements denormalized reserved only when a row was returned.
// Idempotent: already-gone ids are no-ops.
// ReleaseSpendReservations deletes reservation ids and decrements denormalized
// reserved. Idempotent: already-gone ids are no-ops.

Was 3 lines; collapsed to two.

@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Claude finished @devin-ai-integration[bot]'s task —— View job


🤖 Claude Code

  • Gathered diff and analyzed comment blocks
  • Posted review with 1 comment-length suggestion

Posted one advisory PR review (#pullrequestreview-4735276279) flagging one 3-line comment block:

File Lines Issue
internal/billing/errors.go 24–26 "Mapped to HTTP 402." is a standalone third line that fits on line 2, matching the adjacent ErrUserMonthlySpendLimitReached pattern

All other added comment blocks in the diff were 2 lines or fewer (the earlier 3+ line blocks were addressed by commits 1df6581 and cb876f6).

View job

@workweave-bot workweave-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Advisory only — comment-length nit. Won't block merge.

@workweave-bot workweave-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Advisory only — comment-length nit. Won't block merge.

Comment thread internal/billing/errors.go Outdated
Comment on lines +24 to +26
// ErrOrgMonthlySpendLimitReached is returned when the org's current UTC-month
// spend (including in-flight reservations) cannot fit another reserve slot.
// Mapped to HTTP 402.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// ErrOrgMonthlySpendLimitReached is returned when the org's current UTC-month
// spend (including in-flight reservations) cannot fit another reserve slot.
// Mapped to HTTP 402.
// ErrOrgMonthlySpendLimitReached is returned when the org's current UTC-month
// spend (including in-flight reservations) cannot fit another reserve slot. Mapped to HTTP 402.

Was 3 lines; "Mapped to HTTP 402." fits on line 2, matching the adjacent ErrUserMonthlySpendLimitReached pattern.

@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Claude finished @devin-ai-integration[bot]'s task —— View job


🤖 Claude Code

  • Gathered diff and analyzed comment blocks
  • Posted review with 1 comment-length suggestion

Posted one advisory PR review (#pullrequestreview-4735322230) flagging one 3-line comment block:

File Lines Issue
internal/proxy/spend_reserve.go 12–14 "in one transaction" and the #787 cross-reference add words without WHY that isn't already in context; shadow-eval reason fits in two lines

All other added comment blocks in the diff were 2 lines or fewer (the earlier 3+ line blocks were addressed by commits 1df6581, cb876f6, and 4701229).

View job

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 4 total unresolved issues (including 3 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 4701229. Configure here.

Comment thread cmd/router/main.go
safeGo(logger, "spend-reservation-sweep", func() {
runSpendReservationSweep(context.Background(), billingSvc)
})
logger.Info("Spend reservation TTL sweeper enabled", "interval", "1m")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Billing boot check misses reservations

Medium Severity

The PR adds reserve-then-settle paths that require migration 0040_spend-reservations, but CheckBillingTablesExist still only verifies the seven pre-0040 billing tables. Managed boot can enable billing and start the reservation sweeper while 0040 is missing, so ArmSpendReservations hits SQL errors and inference routes fail closed with 503 until the migration is applied manually.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4701229. Configure here.

@workweave-bot workweave-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Advisory only — comment-length nit. Won't block merge.

Comment thread internal/proxy/spend_reserve.go Outdated
Comment on lines +12 to +14
// ArmSpendReservations reserves all applicable spend caps in one transaction
// before Proxy*. Shadow eval skips entirely — synthetic traffic must not consume
// production spend budget (parity with billing middleware, #787).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was 3 lines; fits in two without losing the shadow-eval WHY.

@workweave-bot workweave-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Advisory only — comment-length nit. Won't block merge.

Comment thread internal/proxy/spend_reserve.go Outdated
Comment on lines +12 to +14
// ArmSpendReservations reserves all applicable spend caps in one transaction
// before Proxy*. Shadow eval skips entirely — synthetic traffic must not consume
// production spend budget (parity with billing middleware, #787).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// ArmSpendReservations reserves all applicable spend caps in one transaction
// before Proxy*. Shadow eval skips entirely — synthetic traffic must not consume
// production spend budget (parity with billing middleware, #787).
// ArmSpendReservations reserves applicable spend caps before Proxy*.
// Shadow eval skips — synthetic traffic must not consume production budget.

Was 3 lines; "in one transaction" and the #787 cross-reference add words without adding WHY that isn't already in context — fits in two.

@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Claude finished @devin-ai-integration[bot]'s task —— View job


🤖 Claude Code

  • Gathered diff and analyzed comment blocks
  • No 3+ line comment blocks remain in non-generated Go files — exiting silently

All previously flagged blocks were addressed by commits 1df6581, cb876f6, 4701229, and 073aa05. No further suggestions.

View job

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

billing: org monthly spend-limit TOCTOU — concurrent preflights can overshoot a hard cap by N× request cost

2 participants