-
Notifications
You must be signed in to change notification settings - Fork 40
fix(billing): reserve-then-settle spend caps to close concurrency gap #799
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
bbe828c
1df6581
cb876f6
4701229
073aa05
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| BEGIN; | ||
|
|
||
| ALTER TABLE router.model_router_api_keys | ||
| DROP COLUMN reserved_usd_micros; | ||
|
|
||
| ALTER TABLE router.model_router_user_monthly_spend | ||
| DROP COLUMN reserved_usd_micros; | ||
|
|
||
| ALTER TABLE router.organization_monthly_spend | ||
| DROP COLUMN reserved_usd_micros; | ||
|
|
||
| DROP TABLE router.spend_reservations; | ||
|
|
||
| COMMIT; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| BEGIN; | ||
|
|
||
| -- Open spend-cap reservations for reserve-then-settle (#793). One row per | ||
| -- in-flight request per applicable scope. Sweeper deletes expired rows and | ||
| -- decrements the denormalized reserved_usd_micros counters; settle/release | ||
| -- delete by id and only decrement when DELETE … RETURNING yields a row. | ||
| CREATE TABLE router.spend_reservations ( | ||
| id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | ||
| scope_kind VARCHAR(16) NOT NULL | ||
| CHECK (scope_kind IN ('org_month', 'user_month', 'api_key')), | ||
| scope_id VARCHAR(64) NOT NULL, | ||
| -- First day of the UTC month for *_month scopes; NULL for lifetime api_key. | ||
| month DATE, | ||
| amount_usd_micros BIGINT NOT NULL CHECK (amount_usd_micros > 0), | ||
| expires_at TIMESTAMPTZ NOT NULL, | ||
| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | ||
| router_request_id VARCHAR(64), | ||
| CONSTRAINT spend_reservations_month_null_for_api_key CHECK ( | ||
| (scope_kind = 'api_key' AND month IS NULL) | ||
| OR (scope_kind IN ('org_month', 'user_month') AND month IS NOT NULL) | ||
| ) | ||
| ); | ||
|
|
||
| CREATE INDEX spend_reservations_expires_at_idx | ||
| ON router.spend_reservations (expires_at); | ||
|
|
||
| CREATE INDEX spend_reservations_scope_idx | ||
| ON router.spend_reservations (scope_kind, scope_id, month); | ||
|
|
||
| -- Denormalized in-flight reserved totals so the gate is a single-row | ||
| -- spent + reserved + R <= limit check without summing open reservations. | ||
| ALTER TABLE router.organization_monthly_spend | ||
| ADD COLUMN reserved_usd_micros BIGINT NOT NULL DEFAULT 0; | ||
|
|
||
| ALTER TABLE router.model_router_user_monthly_spend | ||
| ADD COLUMN reserved_usd_micros BIGINT NOT NULL DEFAULT 0; | ||
|
|
||
| ALTER TABLE router.model_router_api_keys | ||
| ADD COLUMN reserved_usd_micros BIGINT NOT NULL DEFAULT 0; | ||
|
|
||
| COMMIT; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| -- Ensures the org spend row for @month exists so a reserve UPDATE can target | ||
| -- it. Month is supplied by Go (utcMonthDate) so Ensure/Bump/Insert share one | ||
| -- clock reading for the whole ReserveSpendCaps transaction. | ||
| -- name: EnsureOrgMonthlySpendRow :exec | ||
| INSERT INTO router.organization_monthly_spend (organization_id, month, spent_usd_micros, reserved_usd_micros) | ||
| VALUES ( | ||
| @organization_id::varchar, | ||
| @month::date, | ||
| 0, | ||
| 0 | ||
| ) | ||
| ON CONFLICT (organization_id, month) DO NOTHING; | ||
|
|
||
| -- Atomically bumps org-month reserved when spent+reserved+amount still fits | ||
| -- under the configured org monthly limit. Returns the organization_id on | ||
| -- success; zero rows means limit reached (or no limit configured — caller | ||
| -- must skip reserve when limit is NULL before calling this). Month must be | ||
| -- the same Go-computed value passed to Ensure and InsertSpendReservation. | ||
| -- name: TryBumpOrgMonthReserved :one | ||
| UPDATE router.organization_monthly_spend sp | ||
| SET reserved_usd_micros = sp.reserved_usd_micros + @amount_usd_micros::bigint, | ||
| updated_at = NOW() | ||
| FROM router.organization_spend_limits lim | ||
| WHERE sp.organization_id = @organization_id::varchar | ||
| AND sp.month = @month::date | ||
| AND lim.organization_id = sp.organization_id | ||
| AND lim.org_monthly_limit_usd_micros IS NOT NULL | ||
| AND sp.spent_usd_micros + sp.reserved_usd_micros + @amount_usd_micros::bigint | ||
| <= lim.org_monthly_limit_usd_micros | ||
| RETURNING sp.organization_id; | ||
|
|
||
| -- name: EnsureUserMonthlySpendRow :exec | ||
| INSERT INTO router.model_router_user_monthly_spend (router_user_id, month, spent_usd_micros, reserved_usd_micros) | ||
| VALUES ( | ||
| @router_user_id::uuid, | ||
| @month::date, | ||
| 0, | ||
| 0 | ||
| ) | ||
| ON CONFLICT (router_user_id, month) DO NOTHING; | ||
|
|
||
| -- Bumps user-month reserved under the effective limit (per-user override when | ||
| -- present, else org default). Caller supplies the already-resolved effective | ||
| -- limit; NULL limit means the caller should skip this scope. Month must match | ||
| -- the Go-computed value used for Ensure and InsertSpendReservation. | ||
| -- name: TryBumpUserMonthReserved :one | ||
| UPDATE router.model_router_user_monthly_spend sp | ||
| SET reserved_usd_micros = sp.reserved_usd_micros + @amount_usd_micros::bigint, | ||
| updated_at = NOW() | ||
| WHERE sp.router_user_id = @router_user_id::uuid | ||
| AND sp.month = @month::date | ||
| AND sp.spent_usd_micros + sp.reserved_usd_micros + @amount_usd_micros::bigint | ||
| <= @limit_usd_micros::bigint | ||
| RETURNING sp.router_user_id; | ||
|
|
||
| -- Bumps api-key lifetime reserved under the key's spend_cap. Zero rows when | ||
| -- the key is missing, uncapped, or the bump would exceed the cap. | ||
| -- name: TryBumpAPIKeyReserved :one | ||
| UPDATE router.model_router_api_keys k | ||
| SET reserved_usd_micros = k.reserved_usd_micros + @amount_usd_micros::bigint | ||
| WHERE k.id = @api_key_id::uuid | ||
| AND k.deleted_at IS NULL | ||
| AND k.spend_cap_usd_micros IS NOT NULL | ||
| AND k.spent_usd_micros + k.reserved_usd_micros + @amount_usd_micros::bigint | ||
| <= k.spend_cap_usd_micros | ||
| RETURNING k.id; | ||
|
|
||
| -- name: InsertSpendReservation :one | ||
| INSERT INTO router.spend_reservations ( | ||
| scope_kind, | ||
| scope_id, | ||
| month, | ||
| amount_usd_micros, | ||
| expires_at, | ||
| router_request_id | ||
| ) VALUES ( | ||
| @scope_kind::varchar, | ||
| @scope_id::varchar, | ||
| sqlc.narg('month')::date, | ||
| @amount_usd_micros::bigint, | ||
| @expires_at::timestamptz, | ||
| sqlc.narg('router_request_id')::varchar | ||
| ) | ||
| RETURNING id, scope_kind, scope_id, month, amount_usd_micros, expires_at; | ||
|
|
||
| -- Atomic consume: DELETE … RETURNING is the sole settle/release/sweep | ||
| -- primitive. Zero rows = already released/settled/swept (idempotent no-op). | ||
| -- name: DeleteSpendReservation :one | ||
| DELETE FROM router.spend_reservations | ||
| WHERE id = @id::uuid | ||
| RETURNING id, scope_kind, scope_id, month, amount_usd_micros; | ||
|
|
||
| -- name: DecrementOrgMonthReserved :exec | ||
| UPDATE router.organization_monthly_spend | ||
| SET reserved_usd_micros = GREATEST(0, reserved_usd_micros - @amount_usd_micros::bigint), | ||
| updated_at = NOW() | ||
| WHERE organization_id = @organization_id::varchar | ||
| AND month = @month::date; | ||
|
|
||
| -- name: DecrementUserMonthReserved :exec | ||
| UPDATE router.model_router_user_monthly_spend | ||
| SET reserved_usd_micros = GREATEST(0, reserved_usd_micros - @amount_usd_micros::bigint), | ||
| updated_at = NOW() | ||
| WHERE router_user_id = @router_user_id::uuid | ||
| AND month = @month::date; | ||
|
|
||
| -- name: DecrementAPIKeyReserved :exec | ||
| UPDATE router.model_router_api_keys | ||
| SET reserved_usd_micros = GREATEST(0, reserved_usd_micros - @amount_usd_micros::bigint) | ||
| WHERE id = @api_key_id::uuid; | ||
|
|
||
| -- Deletes every expired reservation and returns the doomed rows so the | ||
| -- adapter can decrement denormalized reserved counters. Prefer calling | ||
| -- DeleteSpendReservation per id from Go when settling a known hold; this | ||
| -- batch path is for the TTL sweeper only. | ||
| -- name: DeleteExpiredSpendReservations :many | ||
| DELETE FROM router.spend_reservations | ||
| WHERE expires_at < @now::timestamptz | ||
| RETURNING id, scope_kind, scope_id, month, amount_usd_micros; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -57,6 +57,19 @@ func GenerateContentHandler(svc *proxy.Service, authSvc *auth.Service) gin.Handl | |
|
|
||
| ctx := context.WithValue(c.Request.Context(), proxy.ClientIdentityContextKey{}, proxy.ClientIdentityFromHeaders(c.Request.Header)) | ||
| ctx = proxy.ResolveUserFromContext(ctx, authSvc, middleware.InstallationFrom(c)) | ||
| ctx, release, armErr := svc.ArmSpendReservations(ctx) | ||
| if armErr != nil { | ||
| cls, ok := proxy.ClassifyDispatchError(armErr) | ||
| if ok { | ||
| proxy.LogDispatchErrorClass(log, cls, armErr) | ||
| writeGeminiError(c, cls.Status, "RESOURCE_EXHAUSTED", cls.Message) | ||
| return | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Gemini arm maps all errors quotaLow Severity When Triggered by learned rule: New sentinel errors must be mapped in all API surface handlers Reviewed by Cursor Bugbot for commit bbe828c. Configure here. |
||
| } | ||
| log.Error("Spend reservation failed", "err", armErr) | ||
| writeGeminiError(c, http.StatusServiceUnavailable, "UNAVAILABLE", "Billing system is temporarily unavailable. Retry in a few moments.") | ||
| return | ||
| } | ||
| defer release() | ||
| c.Request = c.Request.WithContext(ctx) | ||
|
|
||
| if err := svc.ProxyGeminiGenerateContent(c.Request.Context(), body, c.Writer, c.Request); err != nil { | ||
|
|
||


There was a problem hiding this comment.
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, butCheckBillingTablesExiststill only verifies the seven pre-0040 billing tables. Managed boot can enable billing and start the reservation sweeper while0040is missing, soArmSpendReservationshits SQL errors and inference routes fail closed with 503 until the migration is applied manually.Reviewed by Cursor Bugbot for commit 4701229. Configure here.