Skip to content

perf(billing): sum reporting-window usage from a shared prefix plus a short tail - #8974

Merged
waleedlatif1 merged 3 commits into
stagingfrom
perf/billing-usage-cache
Oct 13, 2026
Merged

waleedlatif1 merged 3 commits into
stagingfrom
perf/billing-usage-cache

Conversation

@waleedlatif1

@waleedlatif1 waleedlatif1 commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

The reporting-window usage sum (sum(cost) from usage_log where billing_entity_* = … and created_at in [start, end)) is already an index-only scan. It is expensive only because a year-long window covers so many index entries. The soft-gate cache in reporting-usage-cache.ts re-ran it on every 30 s refill, and processes that missed at the same time each ran their own copy.

This PR changes only the soft-gate read path (readSoftGateUsageCost, enterprise reporting windows, default pool).

Prefix plus tail.

  • Redis holds the window's sum before a cutoff (now − 5 min) for each payer and window, tagged with a global prefix epoch and a per-payer prefix epoch.
  • Each refill adds that prefix to a sum of [cutoff, end), a small index range.
  • One MGET reads the prefix and both epochs together. A prefix whose stored epochs differ from the current ones counts as absent.

Single flight across processes.

  • One process re-sums the prefix about every 3 min, under a SET NX PX lease held for one statement timeout (60 s).
  • The lease holder sums its tail in parallel with the prefix, so its read takes about as long as today's one sum.
  • While a refresh runs, other processes serve the stored prefix. It is kept for at most one refresh interval plus one lease.
  • With no prefix stored and the lease held elsewhere, a reader sums the whole window at once, as today. No read waits.

Within one process, coalescing is unchanged: LRU fetchMethod.

Top-ups to old rows.

  • recordCumulativeUsage replaces the payer's epoch (usage:reporting:v1:prefix-epoch:<type>:<id>) after a top-up's transaction commits, when the topped-up row's created_at is older than the cutoff lag (with a 1 min skew margin). Fire-and-forget, Redis-optional, and logged on failure.
  • Any stored cutoff is ≤ its read time − lag, so this bumps for every row that can sit inside a prefix. A row younger than that is still in every tail and needs no bump. A new-row insert never bumps.
  • The next read finds no prefix and sums the whole window under the existing lease, then stores a fresh prefix. So the soft gate sees top-ups with the same ~30 s freshness as before.
  • No race: a refresh that read the epoch before the bump stores under a stale epoch, which nothing reads; one that read it after snapshots after the commit. Nothing increments a stored prefix, so nothing double counts.
  • The epoch keys and invalidation live in reporting-usage-epoch.ts, so usage-log.ts can bump without an import cycle.

Deletions.

  • Account deletion (deleteUserAccount, the only path that deletes user rows, and so the only one that cascades usage_log rows away) replaces the global prefix epoch after its transaction commits.
  • That discards every stored prefix, so a deleted member's usage is never counted from a prefix.
  • A refresh that read the old epoch stores under it, where nothing reads it. One that reads the new epoch sums after the commit.

Unchanged.

  • Without a ready Redis (absent, not ready, throwing, or slow), the read is today's single whole-window sum. The same goes for a window that does not span the cutoff.
  • The exact billing callers (getBillingPeriodUsageCost and its other callers) are untouched. The prefix and tail are calls to that same function with narrower bounds.

Load (largest payer)

Before After
Whole-window sums One per ~35 s refill, in every process that missed One per ~3 min refresh, leased to one process
Full scans per day ~4.5k/day over the last 7 days at p50 937 ms, with same-second duplicates from processes missing together ≤ ~480/day per payer when not constantly topped up; never more than today
Each 30 s refill The whole-window sum An index range of ≤ ~8 min of rows

A payer whose old rows are topped up continuously falls back to today's whole-window cadence, still single-flight for the refresh, so it is never worse than today.

The refresh interval, cutoff lag, lease, and retention are named constants (PREFIX_REFRESH_AFTER_MS, PREFIX_CUTOFF_LAG_MS, PREFIX_LEASE_MS, PREFIX_RETAIN_MS).

Correctness contract

  • Never above the ledger. The prefix and tail split the window at the same bound. The ledger only grows (inserts with cost ≥ 0, plus the monotonic cumulative top-up), and deletion invalidates the prefix.
  • Staleness. Reads lag by up to the 30 s cache, as today, including top-ups to old rows (the payer epoch discards the prefix). The one write that goes unseen until the next refresh (≤ ~3 min, ≤ ~4 min if a refresh fails) is a row whose transaction outlived the 5 min lag; created_at defaults to now(), the DB transaction start. It makes the read low.
  • Lease. The lease value cannot tell a stranded lease from a live one, and doesn't need to. While a lease is held, every other reader serves the stored prefix or runs today's whole-window sum. A stranded lease costs at most 60 s without single flight, never a slower or wrong read.

Tests

Integration tests in reporting-usage-cache.integration.ts run against real Postgres and Redis:

  • Single flight. 5 cold processes hit a due refresh together. Exactly one re-sums the window; the rest serve the stored prefix.
  • No wait. With no prefix and the lease held elsewhere, a read is one immediate whole-window sum (< 1 s).
  • Lower bound. Over random recent inserts, late inserts with old created_at, and old-row top-ups, the served value stays ≤ the exact sum, and equals it after the refresh interval.
  • Deletion. Ledger rows are deleted, the global epoch is replaced, and the next read equals the exact sum.
  • Old-row top-up. A real recordCumulativeUsage top-up to a row inside the stored prefix bumps the payer epoch, and the next read equals the exact sum.
  • Young-row top-up. A top-up to a row still in the tail does not bump, and the next read is still exact via the tail.
  • Top-up racing a refresh. A top-up that commits while the lease holder sums its prefix never makes the read exceed the ledger, and the next read is exact.
  • Stale serving. A stale prefix is served without a window scan while another process holds the lease.
  • No Redis. With Redis absent, the read is one whole-window sum, unchanged from today.
  • Red on the old code. These tests fail on the old implementation and on the previous revision of this PR. Ignoring the epoch makes the deletion test fail.

Residual risks

  • Failed payer-epoch bump. If the bump after a top-up fails (Redis not ready or erroring; logged), that top-up can stay unseen by soft gates for ≤ ~4 min. Invoicing and cycle close are unaffected.
  • Failed epoch bump. If replacing the epoch fails after a deletion (Redis not ready or erroring; logged), the deleted usage can stay counted for ≤ ~4 min. Today's 30 s full-sum cache already counts it for ≤ 30 s.
  • Evicted epoch key. If Redis evicts the epoch key within its 1 h retention, the same ≤ ~4 min window applies.

… short tail

The soft-gate reporting-usage cache re-ran a payer's year-long ledger sum on
every 30 s refill, in every process that missed. Hold the window's sum before
a cutoff (now - 5 min) in Redis, refreshed by one process under a SET NX PX
lease about every 10 minutes, and sum only the rows since the cutoff on each
refill. Readers serve a stale prefix while the lease holder refreshes; a reader
with no prefix waits up to 3 s for the holder's before summing the window.

Without a ready Redis, or for a window that does not span the cutoff, the read
is the exact whole-window sum as before. Exact billing callers are unchanged.
@vercel

vercel Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
docs Skipped Skipped Oct 13, 2026 6:42pm UTC

Request Review

…n account deletion

- Refresh the shared prefix about every 3 minutes instead of 10, so a top-up to
  a row older than the cutoff lags at most that long, and keep a stale prefix
  only for one refresh interval plus one lease.
- With no prefix stored and the lease held elsewhere, sum the whole window at
  once instead of waiting, so no read is slower than before.
- The lease holder sums its tail alongside the prefix.
- Tag each prefix with a shared epoch read in the same MGET, and replace the
  epoch once an account deletion commits, so a deleted member's cascaded rows
  are never counted from a stored prefix.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No issues found across 2 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Heads up: you’ve reached your flex budget. Increase your flex budget or wait for usage to reset.

Turn on auto-fix | Re-trigger cubic

@greptile-apps

greptile-apps Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

[Critical impact] The PR appears safe to merge, with the previous non-blocking refresh-fallback issue still open.

Findings

  1. P2 Failed refresh blocks one reader ▶

Summary

The PR speeds up reporting-window reads by adding a shared prefix to a short tail.

  • Reporting reads add a shared prefix to a short tail.
  • Usage writes invalidate prefixes that no longer match the ledger.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Reporting-window read] --> B{Cached sum exists?}
  B -->|Yes| C[Return cached sum]
  B -->|No| D{Usable prefix exists?}
  D -->|Fresh| E[Sum tail and add prefix]
  D -->|Due or missing| F{Refresh lease acquired?}
  F -->|Yes| G[Sum prefix and tail together]
  F -->|No, prefix exists| E
  F -->|No prefix| H[Sum whole window]
  G --> I[Store prefix with captured epochs]
  G --> J[Cache and return sum]
  E --> J
  H --> J
Loading

Reviews (2) · Last reviewed commit: "fix(billing): discard a payer's usage pr..." · Reviewed by Greptile

Comment thread apps/sim/lib/billing/core/reporting-usage-cache.ts Outdated
Comment thread apps/sim/lib/billing/core/reporting-usage-cache.ts Outdated
…ow inside it

- a committed cumulative top-up to a row older than the prefix cutoff lag bumps a per-payer prefix epoch, so the next soft-gate read sums the window again and sees the top-up as fast as an uncached read
- prefixes are stored under both the global and the payer epoch; a mismatch on either counts as no prefix
- epoch keys, readiness, and invalidation move to reporting-usage-epoch so usage-log can bump without an import cycle
- simplify the prefix-plus-tail sum and drop history from comments
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cubic-dev-ai review this PR

@cubic-dev-ai

cubic-dev-ai Bot commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

@cubic-dev-ai review this PR

@waleedlatif1 I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 5 files

Heads up: you’ve reached your flex budget. Increase your flex budget or wait for usage to reset.

Turn on auto-fix | Re-trigger cubic

Comment thread apps/sim/lib/billing/core/reporting-usage-epoch.ts
@waleedlatif1
waleedlatif1 merged commit 25da162 into staging Oct 13, 2026
52 checks passed
@waleedlatif1
waleedlatif1 deleted the perf/billing-usage-cache branch October 13, 2026 19:12

This branch was previously deployed

1 inactive deployment
Preview — 96385cce Deployed Oct 13, 2026 by vercel[bot]
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.

1 participant