Repository navigation
fix: reconcile usage_events.hive_credit_delta with the ledger (#1180) - #1194
Conversation
…1180) Two independent bugs made the two money surfaces disagree for the same request. First, every reservation-backed request wrote TWO usage_events rows with event_type='completed' for one attempt: control-plane's own finalizeLocked writes the authoritative row (hive_credit_delta matches the ledger charge by construction), and edge-api's separate, unconditional POST to /internal/usage/events writes a second row whose hive_credit_delta is a raw token count, not a credit figure at all. Confirmed live: 576 duplicate pairs across full history, 576/576 with the same shape. Second, usage_events.hive_credit_delta was missing from the 20260823_40 credit unit rescale migration's column list, so every authoritative row written before that migration is off from the ledger by exactly the 10000x rescale factor, a stale-unit bug distinct from the duplicate-row bug. The migration merges duplicate completed rows (keeping the earlier, authoritative one and folding in the later row's real token counts), backfills the missed rescale using the same flag convention 20260823_40 already established, and adds a partial unique index so a future duplicate POST from edge-api folds into the existing row instead of inserting a second one. usage/repository.go's RecordEvent gained the matching ON CONFLICT DO UPDATE, deliberately never touching hive_credit_delta so the ledger-matching value always wins. A new live guard test proves the two surfaces agree after both writes land; two existing test fixtures that inserted multiple completed events against one shared attempt id (a shape that never occurs in production, and that the new constraint correctly rejects) were fixed to seed one real attempt per event. Buglog entry (to be appended to main separately, per repo convention): {"error_message": "usage_events.hive_credit_delta does not match credit_ledger_entries.credits_delta for the same request", "root_cause": "edge-api writes a redundant, unconditional second usage_events row per completed request with hive_credit_delta set to a raw token count instead of a credit amount, duplicating control-plane's own authoritative write from finalizeLocked; separately, the 20260823_40 credit unit rescale migration omitted usage_events.hive_credit_delta from its column list, leaving pre-rescale rows off by the 10000x factor", "fix": "supabase/migrations/20260825_03_usage_events_completed_dedup_and_rescale_backfill.sql merges duplicate completed rows and backfills the missed rescale; usage/repository.go RecordEvent gained ON CONFLICT DO UPDATE on a new partial unique index that folds a future duplicate write's token counts without touching hive_credit_delta", "tags": ["billing", "ledger", "usage-events", "credit-rescale", "duplicate-write"]} Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Warning Review limit reachedNext included review available in 28 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
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 review summary, PR #1194Read the pushed diff directly ( Verdict: do not block, ship with two follow-ups tracked before or shortly after merge. The core diagnosis (two independent root causes, duplicate write plus missed rescale column) is correct and well-evidenced. The merge is safe on the pair shape that is actually live today (576/576, confirmed pattern), the rescale backfill cannot double-apply (confirmed empirically: replay changes 0 rows), and the new unique index does not break the live serving path (confirmed: Two real, reproduced gaps, both non-blocking for money correctness but worth fixing:
Also verified, not blocking:
Nothing here found a way this migration corrupts the ledger-matching |
… timestamp bound Review of #1194 reproduced a deploy-gap straggler the created_at < applied_at bound permanently misses: 20260823_40 itself documents that the old binary keeps writing old-unit rows for a window after the rescale migration's COMMIT, so a straggler can carry created_at > applied_at while still holding a stale-unit value, and usage_events has no positive new-unit stamp that could later distinguish it from a genuinely small delta. Replaced the timestamp-bound backfill with reconciliation against credit_ledger_entries, which 20260823_40 did correctly backfill and which this migration already treats as authoritative: any surviving 'completed' row whose hive_credit_delta disagrees with the matching ledger charge by exactly a factor of 10000 gets the ledger's value copied onto it. No dependency on created_at or credit_unit_rescale.applied_at, so it catches every stale-unit row including deploy-gap stragglers, with no heuristic beyond the exact-factor discriminator that also guards against "correcting" some other, unrelated disagreement this migration has no evidence about. Also added a step-0 guard that RAISEs and rolls back the whole transaction if any attempt has three or more completed rows, before the dedup UPDATE runs. The pairwise merge assumes at most two (576 pairs, 0 triples measured live); a triple would let Postgres pick an arbitrary source row for the folded token columns, which should stop the migration for a human to look at rather than guess silently. Verified against a real Postgres 17: full 107-migration chain still applies cleanly; a fabricated deploy-gap straggler (old-unit value, created_at after the rescale marker) is now caught and corrected to the ledger's figure; rerunning the file a second time changes 0 rows (idempotent); a fabricated triple raises and rolls back with all three rows left untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review comment: ORDER BY created_at ASC alone leaves the keeper choice unspecified if two completed rows for one attempt ever share an identical timestamp. The two writers are always separated by a real HTTP round trip today, so this has never fired, but the tiebreaker is free and removes the ambiguity entirely rather than leaving it theoretically open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rphaned request_attempts, issue #1102) (#1201) ## Summary PR #1194's migration `20260825_03_usage_events_completed_dedup_and_rescale_backfill.sql` has failed on every deploy since it merged. `deploy-demo-box` run 32912034013 (and one run since) hit: ``` psql:.../20260825_03_...sql:227: ERROR: insert or update on table "usage_events" violates foreign key constraint "usage_events_request_attempt_id_fkey" DETAIL: Key (request_attempt_id)=(819cc2ad-d657-4419-9731-349b43675ecf) is not present in table "request_attempts". ``` Nothing has reached the box since, including PR #1193 (Cowork composer mode). ## Root cause The live database already holds `usage_events` rows whose `request_attempt_id` no longer has a matching `request_attempts` row. This is **issue #1102**, not a new defect: a retention purge deletes `request_attempts` rows without going through the FK's own `ON DELETE CASCADE` trigger (it bypasses it rather than the FK being misconfigured). Live count 2026-08-25: 483 orphaned `usage_events` rows spanning 2026-04-01 through 2026-08-18 — an ongoing, ordinary state of this table, not a one-off. The migration was validated against a throwaway database with none of these orphans, so the defect never showed up before merge. Step 2 (the ledger-reconciliation UPDATE) is what trips it: reproducibly isolated via a rolled-back replay against the live data (confirmed twice, byte-identical error and row). ## Was the live database left in a bad state? No. Verified directly against the live box before writing any fix: - The migration wraps `BEGIN`/`COMMIT` around the whole file and every failed deploy attempt rolled back cleanly: the 604 duplicate `'completed'` pairs Step 1 processes were still fully present afterward (unmerged), and the Step 3 unique index (`ux_usage_events_completed_attempt`) did not exist. - The file was never recorded in `public.hive_schema_migrations` (empty result on every check), so it was safe to amend in place rather than ship as a new migration. ## Fix Step 2's `WHERE` clause now requires the row's `request_attempt_id` to still have a live `request_attempts` parent: ```sql AND EXISTS ( SELECT 1 FROM public.request_attempts ra WHERE ra.id = ue.request_attempt_id ) ``` A row with no live parent is skipped, not silently reconciled — there is nothing left to reconcile it against with confidence either way. Step 1's dedup UPDATE/DELETE needed no change: it was proven safe against the same live orphaned data in two independent rolled-back replays before this fix was written. Fixing the retention purge itself is issue #1102's job, tracked separately (extend the purge to cascade/null the referencing rows, or an owner decision to drop constraint enforcement). Out of scope here. ## Verification - Isolated the failing statement on live production data via rolled-back transactions (`BEGIN; ...; ROLLBACK;`), never committing a probe. - Confirmed the fixed Step 2 (with the `EXISTS` guard) completes the full Step 1 + Step 2 sequence against the real orphaned live data without error, in a rolled-back replay. - Added `TestMigrationSurvivesOrphanedRequestAttempt`: builds a real reservation + duplicate `'completed'` write + stale pre-rescale credit delta through the actual services, deletes its `request_attempts` row while bypassing the cascade trigger (reproducing issue #1102's exact live shape), then executes the real on-disk migration file via the Postgres simple query protocol (same execution path `psql -f` uses) against a from-scratch, fully-migrated local Postgres 17 test database. Asserts no error and that the orphaned row is left untouched, not reconciled. - Negative-controlled the new test twice: against the original unpatched migration it fails, first because the guard predicate text is missing (static check), and — before that check was tightened to a precise string — because the orphaned row silently got reconciled instead of skipped. Restored the fixed file and reran; the full `internal/accounting` suite (30 tests) and the full `apps/control-plane` short suite (55 packages) pass. ## Test plan - [x] `go build ./apps/control-plane/...` - [x] `go vet ./apps/control-plane/...` - [x] `go test ./apps/control-plane/internal/accounting/... -v` against a from-scratch, fully-migrated local Postgres 17 (all 30 tests pass, including the new one and its #1180 sibling) - [x] `go test -short ./apps/control-plane/...` (55 packages, all pass) - [x] Fixed Step 1 + Step 2 sequence replayed against real live orphaned data on the demo box in a rolled-back transaction, reaches completion with no FK error - [x] Negative control: new test fails against the original unpatched migration file - [ ] Live deploy: merging should unblock `deploy-demo-box`, confirm the triggered run succeeds Buglog entry included in the commit message body per `.wolf/`'s buglog-lands-on-main convention; will be appended to `main` in a separate buglog-only PR once this merges. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Summary
Fixes #1180.
usage_events.hive_credit_deltaandcredit_ledger_entries.credits_deltadisagreed for the same request. Investigation found two independent, confirmed-live root causes, not one:Root cause A (the primary defect, not a rescale/rounding artifact): a genuine duplicate write. Every reservation-backed request writes TWO
usage_eventsrows withevent_type='completed'for the same attempt:accounting.finalizeLocked(apps/control-plane/internal/accounting/service.go) writes the authoritative row duringFinalizeReservation, in-process.hive_credit_delta = -actualCredits, the same figure charged to the ledger in the same call.recordCompletedEvent(apps/edge-api/internal/inference/orchestrator.go,stream.go) then makes a separate, unconditional HTTP POST to/internal/usage/eventsthat writes a second row for the identical attempt. Itshive_credit_deltais set tousage.TotalTokens— a raw token count, not a credit amount, not even negative.Live measurement on the demo box (2026-08-25, full history, read-only bounded queries): 576 duplicate pairs, zero triples. 576/576 have the earlier row's delta <= 0 (ledger-matching); 575/576 have the later row's delta equal to
input_tokens + output_tokensexactly. The ordering is deterministic by the code path, not a race: edge-api's POST only fires after the synchronousFinalizeReservationcall returns.Root cause B (a real stale-unit bug, distinct from A): a missed backfill.
usage_events.hive_credit_deltawas omitted from the column list in20260823_40_credit_unit_rescale_billion.sql(D-046, factor 10000).credit_ledger_entries.credits_deltawas backfilled by that migration;usage_events.hive_credit_deltawas not. Every authoritative row written before the rescale boundary is off from the ledger by exactly 10000x. Confirmed live (masked attempt id):-12in usage_events vs-120000in the ledger for the identical attempt,-12 * 10000 = -120000exactly.Which surface is authoritative:
credit_ledger_entries, confirmed from code (it's what actually posts against the account balance) rather than assumed.usage_events's correct row is a mirror of the sameactualCreditsvalue, not an independent computation.Does anything bill from the wrong row: no live billing/invoicing path reads
usage_events.hive_credit_delta.GetSpendSummaryandGetUsageSummary.total_credits_spentfilterWHERE hive_credit_delta < 0, so the always-positive wrong duplicate was silently excluded from those two figures by accident of the sign filter — not corrupted, butGetUsageSummary.request_count(unfilteredCOUNT(*)) was inflated up to 2x, and the rawListEventslisting showed two contradictory rows per request.Fix
Chose to make the two surfaces agree (not merely document the estimate), since the authoritative value is cheaply recoverable:
supabase/migrations/20260825_03_usage_events_completed_dedup_and_rescale_backfill.sql:'completed'rows per attempt (keep the earlier/authoritative row, fold the later row's real token counts in, stamp merged ids for forensics)20260823_40already established (credit_unit: legacy-1usd-100k-credits)ux_usage_events_completed_attemptso a future duplicate POST folds instead of insertingapps/control-plane/internal/usage/repository.go:RecordEvent's INSERT gained the matchingON CONFLICT ... DO UPDATE, deliberately never touchinghive_credit_delta/event_type/status. edge-api is unchanged (out of this fix's scope) — its redundant POST now folds harmlessly.apps/control-plane/internal/accounting/usage_ledger_reconciliation_live_test.go: reproduces both writes against a real DB and asserts the surviving row'shive_credit_deltaequals the ledger'scredits_delta. Verified failing before the fix, passing after.repository_live_test.go) inserted multiple'completed'events against one shared attempt id to simulate multiple requests — a shape that never occurs in production and that the new constraint correctly rejects. Fixed to seed one real attempt per event (what the tests were actually meant to exercise).Known residual gaps, out of scope (edge-api not in this task's allowlist), noted for the record:
FinalizeReservationInput.InputTokens/OutputTokensdespite Console overview and analytics read zero after live chat traffic on the same workspace #856 already wiring the field end to end; this fix's merge compensates without needing it fixed, but it's a one-line edge-api bug worth its own ticket.reservation.ID == ""fallback path (no reservation ever created) still writes a lone wrong-value row with nothing to reconcile against, since nothing was charged.api_key_usage_rollups): same family of defect, different table/path. Theusage_eventsrow this fix produces does carry cache tokens correctly;api_key_usage_rollupsis the surface still missing them.Review round 2 (independent review, DO NOT BLOCK, confirmed by reproduction)
Reviewer confirmed the merge logic, backfill idempotency, and index safety by
reproducing rather than reading, and confirmed edge-api's write path only
logs
RecordUsageEventerrors and never propagates them to the HTTPresponse, so the migrate-then-deploy window is not customer-facing. One
finding required a fix before merge.
Finding: the backfill under-applied. The first version bounded the
rescale backfill on
usage_events.created_at < credit_unit_rescale.applied_at.20260823_40 documents a deploy-gap race of its own: the old binary keeps
writing old-unit rows for a window after that migration's COMMIT (until the
container recreate), so a straggler row can carry
created_at > applied_atwhile still holding a stale-unit value. A timestamp bound skips exactly
those rows, permanently —
usage_eventscarries no positive new-unit stamp,so nothing could ever later tell a missed straggler apart from a
legitimately small delta.
Fix chosen: option 3, reconcile against the ledger.
credit_ledger_entrieswas already established as authoritative and was correctly backfilled by
20260823_40. The rescale step now reconciles directly against it instead of
inferring the unit from a timestamp: for every surviving
'completed'row,if its
hive_credit_deltadisagrees with the matchingcredit_ledger_entries.credits_delta(same account, same attempt,entry_type = 'usage_charge') by exactly a factor of 10000, the ledger'svalue is copied onto it. A disagreement that is not exactly 10000x is left
untouched — that would be a different, unexplained mismatch this migration
has no evidence about, and "fixing" it would be a guess. This needed no new
column and no timestamp heuristic (option 1/2 both would have), uses truth
already established, and has no dependency on
created_atorcredit_unit_rescale.applied_atat all, so it catches every stale-unit rowincluding deploy-gap stragglers a timestamp bound structurally cannot see.
Verified by reproduction, not just code-reading: seeded a fabricated
straggler (old-unit
usage_eventsrow,created_at90s after therescale marker's
applied_at, matching ledger entry in new units) on athrowaway Postgres 17. Before:
hive_credit_delta = -72vs ledger-720000, a 10000x miss the old bound would never have touched (itscreated_atis afterapplied_at). After running the revised migration:hive_credit_delta = -720000,internal_metadatastamped{"credit_unit": "legacy-1usd-100k-credits", "ledger_reconciled_from": -72}.Re-ran the file a second time:
UPDATE 0on the reconciliation step, valueunchanged — idempotent.
Second finding: triple-row guard (accepted, cheap guard over a fix). The
dedup merge is a many-to-one join; on an (unobserved, live evidence is 0
triples) 3+-row shape Postgres would pick an arbitrary source row for the
folded token columns. Added a step-0 check that raises and rolls back the
whole transaction if any attempt has 3+
'completed'rows, before anymutation. Verified by reproduction: seeded a genuine triple, ran the
migration, got a loud
ERRORnaming the attempt count and the reason, andconfirmed via a fresh
count(*)that all three rows were left untouched(transaction rolled back, no partial state, index also not created).
Rollback asymmetry (documented in the migration header, not fixed — no
action needed per reviewer): this migration has no down-migration. Rolling
it back while control-plane's binary stays on the version that assumes
ux_usage_events_completed_attemptexists (theON CONFLICTtarget inusage/repository.go'sRecordEvent) breaks every'completed'usage_eventswrite — Postgres rejects anON CONFLICTclause naming amissing index. That fails loudly as errors, not as silent money corruption,
which is why it's acceptable, but a migration-only rollback must never be
attempted without rolling the binary back first.
Test plan
go build ./apps/control-plane/...andgo veton touched packages: cleango test ./apps/control-plane/internal/accounting/... ./apps/control-plane/internal/usage/... ./apps/control-plane/internal/ledger/...against real Postgres 17 (scripts/ci-throwaway-db.sh, 107/107 migrations applied): all green, including the new guard testgofmt -lclean on every touched file🤖 Generated with Claude Code