0005 — Prepaid credit ledger: integer micro-dollars, row lock, repo-direct
View Source- Status: Accepted
- Date: 2026-09-10
Context
Plan counters answer "how many of X this period"; a growing share of the audience prices per unit instead (AI tokens, requests, gigabytes) and needs "how much money is on account". That is a different data structure — a balance with reservations and a log — with different guarantees: it must never overspend under concurrency, must survive retried webhooks without double funding, and must be auditable. It is also the thing Stripe top-ups (Pro) need to land in.
Options considered
- Units in cents. Familiar, but a token costs thousandths of a cent, so every AI settlement would round to zero or need a float.
- Units as
Decimal. Exact, but slower, andDecimalin every hot struct and telemetry measurement; Postgresnumericarithmetic on the row lock path. - Integer micro-dollars (1e-6 USD) as
bigint. Exact, cheap, and abigintholds ±9.2 trillion dollars. Convert at the edges. - Balance in ETS like the counters. Fast, but money must not live in a buffer that a crash can lose, and the ETS substrate is per-node.
- Optimistic concurrency (
versioncolumn, retry). Works, but retries under contention and every caller needs a retry loop. SELECT ... FOR UPDATEon the tenant's balance row. Serialises all of a tenant's writes on one lock; no retries; the transaction is a few statements long.- Through the
Storagebehaviour. Keeps the "never call Ecto outside an adapter" rule, but the callback would be "run this locked read-compute-write atomically", which only a SQL adapter can implement.
Decision
- (3) Integer micro-dollars everywhere;
AuroraMeter.Credits.Moneyconverts cents,Decimaldollars and display strings. - (6) Every write is one transaction: ensure the balance row (
insert_all ... on_conflict: :nothing), lock itFOR UPDATE, check sufficiency against the locked row, append anaurora_meter_credit_transactionsentry that snapshotsbalance_after/held_after, update the row. Settle and release also lock the hold row so two settlements of one hold serialise. - (7 rejected) The ledger talks to the configured repo directly and is documented as requiring the Ecto storage. The rule in AGENTS.md exists so the metering path can be swapped; a ledger is not that path.
- Side effects (telemetry, PubSub, the low-balance handler) run after commit, never inside the transaction.
referenceis a caller-supplied idempotency key, unique per kind: a replayed grant returns the original entry, a replayed hold or debit is refused.- Promotional credit is tracked as one figure per balance row with the
invariant
0 <= promotional <= max(balance, 0), consumed before paid credit. Expiry removesmin(promotional, grant.amount), so it can never push a balance below zero. - A settlement never fails for lack of credit; the balance may go negative and the overrun is reported. Refusing to settle would leave real cost unbilled.
Consequences
- A tenant's ledger throughput is bounded by its row lock: hundreds of writes per second per tenant, which is far above any per-tenant billing rate and irrelevant across tenants.
- One
promotionalfigure per row means overlapping promotional grants are not tracked separately (documented limitation; a per-grant remainder column is the upgrade path if it is ever needed). - Credits are unavailable on a non-Ecto storage adapter, should one ever exist. Everything else in the core still works there.
- Pro's Stripe top-ups and auto-recharge are grants and a low-balance handler respectively; nothing in the core needs to change for them.