Scale Interview
System DesignHard

Design a GPU Credits System

Usage Metering & Billing System Design

Overview

A GPU credits system sits underneath a GPU compute platform — a cloud where users launch jobs (training runs, inference servers, notebooks) that occupy expensive GPUs. Every GPU-second a job consumes costs real money, so the platform must meter that usage accurately, deduct credits (or accrue a bill) for it, and stop or throttle a job when the account runs out. Think of it as the electricity meter, the utility bill, and the shut-off valve for a fleet of GPUs — all at once, and all at scale.

It is "Hard" because three genuinely different subsystems collide and each is subtle. First, metering: thousands of GPU nodes emit a continuous stream of usage events, and you must aggregate them per account with billing-grade accuracy — not the "close enough" accuracy an analytics dashboard tolerates. Second, the credit ledger: credits granted and consumed are money, so the same append-only, idempotent, auditable discipline a wallet needs applies here — a double-charged deduction or a mutated balance with no history is a real financial bug. Third, and the part most candidates fumble: quota enforcement with a cutoff, where the killer detail is that metering lags real usage. Events take seconds to aggregate, so between two balance checks a job can burn credits the system hasn't accounted for yet — an overspend window you must design around, not wish away.

In this walkthrough you'll scope the system, size it, model the ledger, design the API, draw the metering and billing paths, then go deep on the three hard parts: metering GPU usage at scale, the credit ledger and billing, and quota enforcement with a graceful cutoff.

This problem deliberately braids together three patterns you may have seen alone. The metering pipeline is the ad-click-aggregator's stream-aggregation problem, but billing-grade instead of analytics-grade. The credit ledger is the digital-wallet's ledger-and-balance problem, applied to compute credits instead of cash. And the cutoff is the rate-limiter's quota-enforcement problem, complicated by the metering lag. We'll cross-link each and focus on what's different here.

Out of scope (say so): scheduling and placing jobs onto GPUs (the cluster scheduler), the GPU virtualization/isolation layer, funding the credit purchase itself (the card charge — that's the payment-system), and fraud ML. We own metering, credits, and cutoff.

Functional requirements

  • Meter GPU usage. Record how many GPU-seconds (and which GPU type, memory, count) each job consumes, attributed to a user and account, continuously as the job runs.
  • Maintain a credit balance. Each account has a credit balance; granting credits raises it, metered usage lowers it. The balance must be derivable and auditable.
  • Deduct credits for usage. Convert metered usage into credit deductions at the account's price, idempotently, so a job is charged exactly once for what it ran.
  • Enforce a quota / cut off. When an account runs out of credits, stop launching new jobs and stop or throttle running ones — without silently letting a job run for free.
  • Bill. Support prepaid credits (deduct as you go) and/or postpaid invoicing (accrue usage, invoice at period end).

Out of scope (state it): job scheduling/placement, GPU isolation, the credit-purchase payment rail (cross-link payment-system), and abuse ML. This system owns usage metering, the credit ledger/billing, and quota enforcement.

Non-functional requirements

  • Billing-grade metering accuracy. The GPU-seconds an account is charged for must match what it actually ran, to the second, and be auditable. This is not analytics where a 2% error is fine — a systematic under-count gives away compute for free, an over-count overcharges customers. Correct eventually and exactly.
  • Exactly-once effect on deduction. A given slice of usage must deduct credits exactly once even though the event stream is delivered at-least-once and deductions get retried. No double-charging.
  • Tolerate late / out-of-order usage events. A node buffers events during a network blip and flushes them late; usage from 10:04 can arrive at 10:06. Per-account totals must still settle correctly.
  • Money correctness for credits. Credits are money. The ledger is append-only and auditable; the balance is derived or reconciled, never a naked mutable number. Credits are stored as integer units, never floats.
  • Near-real-time balance, bounded overspend. The cutoff can't wait for a nightly batch — a job on 8 H100s burns credits fast. The balance must be fresh enough that overspend is bounded to a known, acceptable window, not unbounded.
  • High write volume, spiky. Thousands of nodes each heartbeating usage every few seconds is a large, steady event stream; job start/stop storms are spiky.

Estimations

State assumptions; the goal is to justify the pipeline, the ledger store, and the cutoff design — not to hit exact numbers.

Rounded assumptions

  • Accounts / users: ~1M accounts, ~100K concurrently active. Modest by web standards — this is a compute platform, not a social feed.
  • GPU nodes: ~100K GPUs across the fleet, grouped into nodes. Each running GPU is a source of continuous usage.
  • Usage events: each GPU emits a heartbeat every ~5 s while busy. 100K GPUs ÷ 5 s ≈ ~20K usage events/sec steady, spiking higher on mass job starts. Small events (~200 bytes) ⇒ ~4 MB/sec, ~350 GB/day of raw usage events — big enough to stream and archive, not to scan per query.
  • Credit deductions: batched, not per-event. You don't write a ledger row per 5-second heartbeat (20K ledger writes/sec of tiny amounts is wasteful and noisy). Aggregate per (account, minute) and deduct per batch ⇒ ~100K active accounts × a deduction every minute ≈ ~1.6K deductions/sec — a fraction of the event rate. Ledger rows: ~150M/day upper bound (100K × 1440), in practice far fewer (idle accounts write nothing).
  • Balance checks: on job admission + periodically per running job. Admission checks are modest; periodic re-checks scale with running jobs (~100K) every, say, 30 s ⇒ ~3K balance reads/sec. These must be fast and fresh.
  • Skew: a few whale accounts run thousands of GPUs — their usage concentrates on one account key, a hot key for both metering and the ledger.

How the numbers affect the design

Signal from the numbers Design decision
~20K usage events/sec, ~350 GB/day raw Ingest into a durable partitioned log/stream and archive raw events; never a DB row per heartbeat.
Billing-grade, must be exact & auditable Pre-aggregate per (account, window) with watermarks, and reconcile against the raw log.
At-least-once stream + retried deductions Dedup by event/batch id; make deduction idempotent.
Credits are money Append-only credit ledger, balance derived + cached, reconciliation job.
Metering lags usage; jobs burn fast Near-real-time balance + reservations/holds to bound overspend; soft + hard limits.
A few whale accounts, thousands of GPUs Shard the hot account's metering counter and serialize its ledger by account.

Important

Store credits as integer units (e.g. micro-credits, or credits × 10⁶), never floats. GPU pricing has many decimal places (fractions of a cent per GPU-second); 0.1 + 0.2 != 0.3 in floating point, and a rounding drift on money silently loses or conjures value. Every credit amount here is an integer.

Warning

Do not answer "how many credits has this account used?" with a SELECT SUM() over raw usage heartbeats. At ~350 GB/day of events, scanning raw usage per balance check is hopeless — you must serve balance from a derived, cached total kept fresh by the metering pipeline, and keep the raw events only for archival and reconciliation.

Core entities / data model

Five things are stored: the raw usage event (immutable source of truth for metering), the per-(account, window) usage aggregate (billing-grade rollup), the credit ledger (append-only money movements), the account balance (derived + cached), and the reservation/hold (pre-authorized spend).

Entity Key fields
UsageEvent (raw) event_id (unique), job_id, account_id, gpu_type, gpu_count, gpu_seconds, event_time
UsageAggregate (account_id, minute_bucket) → summed gpu_seconds per gpu_type (the billable rollup)
LedgerEntry entry_id (PK), account_id, type (grant / deduction / hold / release / reversal), amount (integer, signed), usage_batch_id, posted_at
AccountBalance account_id (PK), cached_balance, held_credits, version, status
Reservation reservation_id (PK), account_id, job_id, held_amount, status, expires_at

The raw UsageEvent is the single source of truth for metering — immutable and append-only, carrying a unique event_id (minted at the node) for dedup and an event_time (when the GPU actually ran those seconds) for correct windowing. Raw events are archived to cheap durable storage so any billed total can be recomputed and audited.

The UsageAggregate is derived from raw events: for each account, each one-minute event-time bucket, the summed GPU-seconds by GPU type. This is what the pipeline produces and what deductions are computed from — the billing-grade rollup. It's stored in a wide-column/time-series store keyed by (account_id, minute_bucket) — a point write per closed window, a range read per account per billing period.

The LedgerEntry rows are the credit ledger, exactly the append-only pattern from the digital-wallet. Every credit movement is a row: a grant (+), a deduction (−) for a metered usage batch, a hold and its release, a reversal. Each deduction carries the usage_batch_id it settled, so re-processing that batch is a no-op (idempotent). The AccountBalance.cached_balance is a running total updated in the same transaction as the ledger write — a fast-read cache, not the truth; the ledger is the truth, and a reconciliation job re-sums it.

The Reservation holds credits a running job is expected to spend before the deduction lands, so spendable = cached_balance − held_credits. This is the mechanism that bounds overspend (deep dive 3).

Caution

Do not treat the running balance as the source of truth and throw usage events away. The raw UsageEvent log and the append-only ledger are authoritative; the aggregate and cached balance are derived. If you keep only a mutable balance number, you can't dedup, can't audit a disputed bill, and can't recompute the exact billable total.

API design

Two planes: nodes report usage (data plane), and the platform grants credits, checks quota, and reads balance (control/read plane).

Report usage (from a GPU node)

POST /internal/usage
Body: { "event_id": "e-9f2a…", "job_id": "j_88", "account_id": "acct_42",
        "gpu_type": "h100", "gpu_count": 8, "gpu_seconds": 40,
        "event_time": "2026-07-12T10:04:05Z" }
202 Accepted

Report a slice of GPU usage (here 8 GPUs × 5 s = 40 GPU-seconds). The event_id is unique per slice so the pipeline dedups a re-sent heartbeat; event_time is when the GPUs actually ran (carried on the event, not assigned on arrival) so the pipeline buckets by event-time. Returns 202 only after the event is durably appended to the log — it's the metering/aggregation that's async, not the append (an ack before a durable write would lose billable usage on a crash). The node retries on any non-202, which is exactly why every event carries a unique event_id for dedup.

Check quota / admit a job

POST /api/accounts/{account_id}/admission
Body: { "job_id": "j_88", "estimated_gpu_seconds": 28800, "gpu_type": "h100", "gpu_count": 8 }
200 OK
Returns: { "admit": true, "reservation_id": "r_1", "held_credits": 900000 }

Called before a job launches. It checks spendable ≥ estimated cost and, if so, places a reservation/hold for the job's expected spend, returning a reservation_id. If credits are short it returns admit: false (the launch is blocked). This is the quota check with a pre-authorization (deep dive 3).

Grant credits

POST /api/accounts/{account_id}/credits
Header: Idempotency-Key: <client-generated-uuid>
Body: { "amount": 100000000, "reason": "purchase", "source": "invoice_771" }
201 Created
Returns: { "entry_id": "le_5", "balance": 100000000 }

Add credits (a purchase, a promo, a refund). Writes a grant ledger entry; the Idempotency-Key makes a retried purchase land once, not twice. Funding the purchase itself is the payment-system's job — this endpoint records the granted credits once the payment confirms.

Get balance

GET /api/accounts/{account_id}/balance
200 OK
Returns: { "account_id": "acct_42", "balance": 4210000, "held": 900000, "spendable": 3310000 }

Returns the current balance from the cached running total (consistent with the ledger), plus held credits and spendable. Read-heavy; can use a replica. Never a scan of usage or ledger.

Get usage / invoice

GET /api/accounts/{account_id}/usage?from=2026-07-01&to=2026-07-31&granularity=day
200 OK
Returns: { "total_gpu_seconds": 8640000, "credits_charged": 42000000,
           "series": [ { "day": "2026-07-01", "gpu_seconds": 288000 }, … ] }

The billable usage over a range, summed from the UsageAggregate rollups — the itemized bill an account disputes against. For postpaid, this is what the period-end invoice is built from.

High-level architecture

Three paths matter: the metering path (usage events → aggregates → deductions), the billing/ledger path (deductions and grants against the credit ledger), and the enforcement path (admission + periodic checks + cutoff). We'll draw each, then combine.

1. Metering path

Usage events land in a durable stream; a stream processor dedups, buckets by event-time, aggregates per (account, window), and emits billable rollups.

flowchart LR
    Node["GPU node<br/>usage heartbeat"] -->|"POST /usage"| Ing["Usage Ingest"]
    Ing -->|"append (partition by account_id)"| Log[["Durable usage log<br/>(partitioned stream)"]]
    Log --> SP["Stream processor<br/>dedup → event-time window → aggregate"]
    SP -->|"per-(account, minute) GPU-seconds"| Agg[("Usage aggregate store")]
    Log -.->|"archive raw events"| Lake[("Raw usage store<br/>(data lake)")]
    Lake -.->|"periodic exact recompute"| Recon["Reconciliation"]
    Recon -.->|"corrected usage"| Agg
  1. A node's usage heartbeat hits Usage Ingest, which validates and appends it to a durable partitioned usage log. Partitioning by account_id keeps one account's usage together and scales across partitions (a whale account's key is sub-sharded — deep dive 1).
  2. Every raw event is archived to the data lake so billed totals can be recomputed and audited.
  3. The stream processor dedups by event_id, assigns each event to its event-time minute bucket, and sums GPU-seconds per (account, minute, gpu_type).
  4. As each window closes on a watermark, the per-(account, minute) rollup is written to the aggregate store — the billable usage.
  5. A reconciliation job periodically recomputes exact usage from the archived raw log and corrects any drift (deep dive 1).

2. Billing / ledger path

Billable usage rollups are converted to idempotent credit deductions against the append-only ledger; grants come in the same way.

flowchart LR
    Agg[("Usage aggregate store")] -->|"closed (account, minute) batch"| Bill["Billing Service<br/>price → deduct"]
    Bill -->|"deduction keyed by usage_batch_id (idempotent)"| Ledger[(Credit ledger + balance)]
    Grant["Grant credits / purchase"] -->|"grant entry (Idempotency-Key)"| Ledger
    Ledger --> Recon2["Reconciliation job<br/>re-sum ledger vs cached balance"]
    Recon2 -.->|"flag mismatch"| Alert["Exceptions queue"]
  1. When a (account, minute) usage batch is finalized, the Billing Service prices it (GPU-seconds × per-second rate for that GPU type) and writes a deduction ledger entry, keyed by the usage_batch_id so re-delivery is a no-op (deep dive 2).
  2. Grants (purchases, promos) write grant entries under an idempotency key.
  3. The cached_balance is updated in the same transaction as the ledger entry.
  4. A reconciliation job re-sums the ledger and checks it against cached balances, routing mismatches to an exceptions queue.

3. Combined architecture

flowchart LR
    Node["GPU node"] -->|"usage"| Ing["Usage Ingest"]
    Ing --> Log[["Durable usage log"]]
    Log --> SP["Stream processor<br/>dedup → window → aggregate"]
    SP --> Agg[("Usage aggregate")]
    Log -.->|"archive"| Lake[("Raw usage store")]
    Agg -->|"closed batch"| Bill["Billing Service"]
    Bill -->|"idempotent deduction"| Ledger[(Credit ledger + balance)]
    Lake -.-> Recon["Reconciliation"]
    Recon -.-> Agg
    Recon -.-> Ledger
    Ctrl["Control plane<br/>admission + periodic checks"] -->|"reserve / read spendable"| Ledger
    Ctrl -->|"cutoff / throttle"| Sched["Job scheduler (external)"]
    App["Admin / user"] -->|"grant / read balance / usage"| Ledger

The usage log decouples spiky ingest from steady processing; the raw usage store is the audit record; the stream path produces near-real-time billable rollups; the billing service deducts idempotently; the ledger is the money truth with a derived cached balance; the control plane reserves credits, reads spendable balance, and issues cutoffs to the (external) scheduler. Metering, billing, and enforcement are decoupled — a whale's usage storm hits the log and metering shards, never the balance-read path.

Tip

Keep metering, the ledger, and enforcement as three loosely coupled subsystems joined by the aggregate store and the balance. The whole difficulty is that they run at different speeds — usage streams in continuously, deductions land per closed window, and enforcement must decide now despite the lag between them.

Deep dives

1. Metering GPU usage at scale

The metering pipeline is the ad-click-aggregator's stream-aggregation problem — ingest a high-volume event stream, aggregate per key with event-time windowing and watermarks, count each event once. What's different here is the accuracy bar: an ad aggregator serves an approximate live number and settles it later for billing; here every rollup feeds a credit deduction, so the whole pipeline is billing-grade. Under-count and you give GPUs away free; over-count and you overcharge. So the same machinery is tuned tighter and always paired with reconciliation.

Why aggregate at all. A GPU emits a usage heartbeat every ~5 s; a balance check or an invoice can't scan billions of heartbeats. The pipeline sums each (account, minute) GPU-seconds once as events stream in, and every deduction and every invoice reads cheap rollups. The expensive work happens once on write.

Event-time windowing + watermarks. Nodes buffer events during a network blip and flush them late and out of order — usage from 10:04 can arrive at 10:06. You must bucket by event-time (when the GPUs ran), not arrival time. Slice event-time into one-minute tumbling windows; close each window when a watermark — the pipeline's estimate that no event older than T will still arrive — passes it, trailing real time by a chosen lag (say 30–60 s) to catch stragglers before billing that window. An event later than the watermark lands within an allowed-lateness grace period and corrects the already-emitted window (a new signed delta entry keyed by the corrected batch id — never an in-place edit); anything later is folded in by reconciliation. This same watermark lag is what sets the overspend window in enforcement (deep dive 3): a longer lag catches more stragglers but widens how far usage can outrun the balance.

Exactly-once effect via dedup. The log is at-least-once — a re-flushed heartbeat can arrive twice. Dedup by the unique event_id so a slice of GPU-seconds is metered once (exactly-once effect, not exactly-once delivery). Combine three legs, same as the ad aggregator: dedup by event_id (bounded per-window, TTL'd to the watermark + allowed-lateness horizon), checkpointed stream offsets so a crash replays only the un-checkpointed tail, and an idempotent sink write (overwrite the bucket to its computed value, never blind-increment).

Reconciliation is not optional here. Because the number is money, a batch job periodically recomputes exact per-account usage from the archived raw log and reconciles — correcting any streaming drift and folding in very-late events. Reconciliation dedups by event_id too (COUNT(DISTINCT event_id)/GROUP BY event_id semantics) — the raw log is the dedup authority, so a duplicate heartbeat that outran the stream's TTL'd dedup window is still counted exactly once. In the ad aggregator this makes the billing number exact while the live dashboard stays approximate; here there is no "approximate is fine" tier — the reconciled total is the billable truth, and the live rollups are a fast estimate that must already be very close.

Meter best-effort like analytics — sample or tolerate a few percent drift (avoid)

Treat usage like clickstream analytics: sample heartbeats, skip dedup, close windows on wall-clock, and accept "roughly right" totals.

  • Con: every metering error is a money error. Dropped or double-counted heartbeats systematically under- or over-bill; wall-clock windows misplace late usage; without dedup a re-flushed heartbeat charges twice. There's no reconciliation to catch it because you assumed you didn't need one.
  • Verdict: avoid. Billing-grade metering is the whole point — analytics tolerances are disqualifying when the output is a bill.
Event-time aggregation with watermarks + dedup + reconciliation (recommended)

Aggregate per (account, minute) by event-time with watermarks and allowed-lateness, dedup by event_id for exactly-once effect, and reconcile the streamed rollups against the archived raw log so the billed total is exact and auditable.

flowchart LR
    Log[["Raw usage log<br/>(authoritative)"]] --> Stream["Streaming rollups<br/>near-real-time, tight"]
    Log --> Batch["Reconciliation<br/>periodic, exact"]
    Stream -->|"per-(account,min) GPU-seconds"| Agg[("Aggregate store")]
    Batch -->|"corrected totals"| Agg
    Agg --> Bill["Billing → deduction"]
    classDef good fill:#dcfce7,stroke:#16a34a,color:#14532d;
    class Batch good
  • Pro: billing-grade accuracy — each GPU-second metered once, late usage placed in its true minute, drift caught and corrected against the authoritative raw log; fully auditable when a customer disputes a bill.
  • Con: the streaming and reconciled numbers differ for a short settling window (explain it as "usage finalizes shortly after it runs"); dedup state (seen event_ids over the horizon) is real memory.
  • Verdict: the standard. Stream tight rollups for near-real-time enforcement, reconcile from the raw log for the exact bill.

Hot account (whale) skew. A whale running thousands of GPUs concentrates all its usage on one account_id — a hot key that pins one partition/aggregator task. Fix it exactly as the ad aggregator shards a viral ad's counter: split the hot account's per-window counter into N partial counters keyed by hash(event_id) % N (so a retried event always routes to the same shard and dedup still holds), and sum the N partials for the account's minute total. Only the whales need sharding; the long tail stays on a single counter.

Caution

Do not bucket usage by arrival time and close windows the instant wall-clock rolls over. A node that buffered 90 seconds of heartbeats during a blip would have all that GPU-time land in the wrong minute or be dropped — and since this drives a bill, that's a billing error, not a chart glitch. Bucket by event-time, close on a watermark, and reconcile.

2. Credit ledger + billing

Credits are money, so the ledger is the digital-wallet's append-only ledger applied to compute credits. The tempting shortcut — a credits column you UPDATE balance = balance − cost per usage batch — is the one to avoid: no history (you can't answer "why is my balance this?"), every bug is silent, and a retried deduction double-charges. Store movements, derive the balance.

flowchart LR
    G["Grant 100 credits"] -->|"+100 grant entry"| L[(Append-only credit ledger)]
    U["Usage batch (account,minute)"] -->|"-cost deduction<br/>keyed by usage_batch_id"| L
    L -->|"sum entries"| Bal["Derived balance"]
    L -->|"updated in same tx"| Cache["Cached balance (fast reads)"]

Idempotent deduction — the key discipline. Each closed (account, minute) usage batch has a stable usage_batch_id. The deduction entry is written keyed by that id under a unique constraint, so re-processing the same batch (retry, replay after a crash) hits a duplicate-key and is a no-op, not a second charge. This is the wallet's atomic-claim idempotency, applied to the usage→credit conversion.

Mutable balance column, blind decrement per usage batch (avoid — double-charges on retry, no audit)

Keep one credits number per account and UPDATE credits = credits − cost each time a usage batch is processed.

process batch (acct_42, 10:04)  cost = 15000   (480 GPU-sec × 31.25 micro-credits)
  UPDATE accounts SET credits = credits - 15000 WHERE id = acct_42
  -- retry after a timeout runs it again → charged 30000 for one batch
  • Con: the deduction isn't idempotent, so an at-least-once retry double-charges; there's no ledger to audit a disputed bill or to recompute the balance; a wrong price or a bug silently corrupts credits with no trace.
  • Verdict: avoid — mutating a balance with no ledger is disqualifying for anything that bills real money.
Append-only ledger, idempotent deduction keyed by usage_batch_id, derived balance (recommended)

Write every movement as an immutable signed ledger entry — grants (+), deductions (−) keyed by usage_batch_id, holds/releases, reversals — and derive the balance, with a cached running total updated in the same transaction as the entry.

grant:                INSERT LedgerEntry(acct_42, +100000000, type=grant, key=purchase_771)
deduct batch 10:04:   INSERT LedgerEntry(acct_42, -15000, type=deduction, usage_batch_id=acct_42:2026-07-11T10:04)
  -- UNIQUE(usage_batch_id) → a replay of the same batch is a no-op, not a second charge
  -- the id carries the full date, so 10:04 on different days never collide
balance(acct_42) = SUM(amount) over acct_42's entries   (cached, reconciled)
  • Pro: every credit movement is auditable and provable; a retried or replayed deduction is a no-op via the unique usage_batch_id; the cached balance keeps reads fast; corrections are reversing entries, so even fixes are auditable; a reconciliation job re-sums the ledger to catch drift.
  • Con: a second write per movement and cache maintenance in-transaction; you must reconcile the cache against the ledger.
  • Verdict: recommended — the money standard. Append-only ledger as truth, idempotent deduction keyed by usage batch, cached balance for speed.

Prepaid vs postpaid. Two billing models, same ledger:

  • Prepaid credits. The account buys credits up front (a grant entry); usage deducts as it's metered; when credits hit zero, enforcement cuts off. Lower financial risk — you never extend more compute than the customer has paid for — but the cutoff must be timely, which is deep dive 3.
  • Postpaid invoicing. Usage accrues against a credit line; at period end you sum the UsageAggregate rollups and issue an invoice. Simpler on the hot path (no per-batch cutoff pressure) but you carry credit risk (a customer can run up a bill and not pay) and typically enforce a credit limit rather than a hard zero.

Many platforms do both: prepaid credits for self-serve, postpaid invoicing for enterprise with a credit limit.

Important

The deduction must be idempotent, keyed by the usage batch id. Usage rollups get re-delivered and re-processed; without the unique key, every retry charges again. This is the single most common ledger bug in this problem.

Note

The cached balance and the ledger can drift from a code bug even when both commit cleanly (same-transaction writes only close the crash class, not the logic class). That's why a reconciliation job re-sums the ledger against the cached balance — and, for postpaid, reconciles credits granted against payments received from the payment-system.

3. Quota enforcement + cutoff

Here is the part unique to this problem and the part most candidates get wrong: metering lags real usage. A job on 8 H100s is burning credits right now, but the usage event for this second won't be aggregated and deducted until the window closes seconds later. So between two balance checks the account can spend credits the balance doesn't yet reflect — the overspend window. You can't eliminate the lag; you design to bound it. This is the rate-limiter's quota problem with a delay baked in.

The core tension: strict real-time cutoff vs eventual metering.

sequenceDiagram
    participant J as Job on 8 GPUs
    participant M as Metering pipeline
    participant B as Balance
    J->>M: usage events stream in continuously
    Note over M,B: aggregate + deduct lags by seconds
    J->>B: burns credits in real time
    B-->>J: balance still shows old value
    Note over J,B: OVERSPEND window — job runs on credits already gone
    M->>B: deduction lands, balance goes negative
    B->>J: cutoff — but seconds of GPU already burned

Soft and hard limits. Borrow the rate-limiter's posture: a soft limit (credits low: warn the account, stop admitting new jobs, optionally throttle) reached well before a hard limit (credits exhausted: terminate running jobs). The soft limit exists precisely because the hard cutoff isn't instant — you start slowing spend early so the lag doesn't blow a big hole.

Reservations / holds — the overspend bound. The key mechanism: when a job is admitted, pre-authorize the credits it's expected to spend and place a holdspendable = cached_balance − held_credits. Admission checks spendable, not raw balance, so you can't admit ten jobs against credits only enough for one. As usage is metered, deductions draw down the real balance and the hold is released/settled in step. Settlement is atomic: when a batch's usage_batch_id is deducted (−cost), the same transaction reduces that job's hold by the same amount, so cached_balance − held_credits moves smoothly and each slice is subtracted exactly once — never double-counted, and the hold is never released ahead of its matching deduction. On job end any residual hold is released; an abandoned job's hold is reclaimed by its expires_at. The hold size is an estimate (jobs don't always run their full expected time), so periodically true it up against actual metered usage; the residual overspend is bounded by the watermark lag + the re-check interval + the termination grace — all of them chosen numbers, so the worst case is small and known, not open-ended.

Near-real-time balance checks. Re-check spendable periodically per running job (every, say, 30 s) against the freshest balance, not just at admission. Combined with the soft limit and the hold, this catches a job whose actual burn outran its reservation and lets you throttle or terminate before overspend grows.

Graceful termination. When the hard limit trips, don't hard-kill instantly — checkpoint if the job supports it, drain in-flight work, then reclaim the GPU. A grace period costs a little more overspend but avoids destroying hours of a customer's training run over a few cents of lag.

Check the balance only at job admission, then let it run (avoid — unbounded overspend)

Verify credits once when the job launches; after that, let it run and rely on the eventual deduction to notice.

admit job (needs 8 H100s) → balance ok at launch
  job runs 6 hours, balance never re-checked
  credits ran out at hour 2 → job burned 4 hours of GPUs for free
  • Con: a long job outlives its admission check by hours; with metering lag and no re-check, overspend is effectively unbounded — the account runs deep negative on compute already consumed and irreversible.
  • Verdict: avoid — a one-time admission check ignores that jobs are long-lived and credits deplete during the run.
Strict synchronous per-second cutoff — meter and deduct inline, block the GPU on the balance (works, but expensive and brittle)

Make metering synchronous: every second, the node deducts credits inline and only continues if the balance clears — no lag, no overspend.

  • Pro: essentially zero overspend — the balance is always current because usage is charged the instant it happens.
  • Con: you've put a money transaction on the GPU's critical path every second across 100K GPUs — enormous write load, and any hiccup in the ledger stalls or kills live jobs. It kills jobs unfairly on transient balance-service blips, and it doesn't even work cleanly under the at-least-once, out-of-order reality of a distributed fleet.
  • Verdict: defensible only in narrow cases — a single-node/toy deployment, or a targeted abuse-cutoff mode for a flagged account. As the default it's the wrong call: the cost and fragility of strict real-time deduction across the fleet outweigh the small overspend that reservations already bound.
Reservations + soft/hard limits + periodic near-real-time checks + graceful cutoff (recommended)

Admit against spendable (balance − holds) and place a reservation for expected spend; deduct asynchronously from metering and release the hold in step; re-check spendable periodically per running job; trip a soft limit early (warn, stop new admissions, throttle) and a hard limit at exhaustion with graceful termination.

flowchart TB
    Adm["Admit job"] -->|"spendable ≥ est? hold expected spend"| Run["Job running"]
    Run -->|"usage metered → deduction<br/>hold released in step"| Bal["Balance drops"]
    Run -->|"re-check spendable every ~30s"| Chk{"spendable low?"}
    Chk -->|"soft limit"| Soft["warn · stop new jobs · throttle"]
    Chk -->|"hard limit"| Hard["checkpoint · drain · reclaim GPU"]
    Chk -->|"ok"| Run
    classDef good fill:#dcfce7,stroke:#16a34a,color:#14532d;
    class Adm,Chk good
  • Pro: overspend is bounded and known — the reservation stops over-admission, periodic checks + the soft limit slow spend before the hard stop, and metering stays cheap and asynchronous; graceful termination avoids killing jobs unfairly.
  • Con: the hold is an estimate needing true-up; you accept a small, bounded overspend (roughly one window's burn) rather than zero; more moving parts than a single admission check.
  • Verdict: recommended — the accuracy-vs-latency sweet spot. Bound overspend with reservations and near-real-time checks instead of chasing an expensive, brittle zero.

Warning

Metering is not instant — this is the mistake that sinks this deep dive. Between a usage second and its deduction there's an aggregation lag, so a naive "check balance, run" leaks free compute. Bound the overspend with reservations/holds, a soft limit that slows spend early, periodic re-checks, and a graceful hard cutoff — you can't make the lag zero, so make its cost bounded.

Tradeoffs & bottlenecks

  • Real-time strict cutoff vs eventual metering. A synchronous per-second cutoff nears zero overspend but puts a money write on every GPU-second and kills jobs on transient blips; eventual metering is cheap and robust but risks overspend. Reservations + soft/hard limits bound the overspend so you can keep metering asynchronous.
  • Exactly-once vs at-least-once + dedup. True exactly-once delivery isn't on offer; engineer exactly-once effect via event_id dedup (metering) and usage_batch_id-keyed idempotent deduction (billing), at the cost of tracking seen ids and dedup state.
  • Prepaid vs postpaid. Prepaid caps financial risk (never extend more than paid) but demands a timely cutoff; postpaid is simpler on the hot path but carries credit risk and needs a credit limit and dunning.
  • Watermark lag: latency vs completeness. A longer watermark/allowed-lateness catches more late usage before billing a window (more accurate) but delays the deduction (larger overspend window); shorter is snappier but re-bills more late deltas.
  • Derived vs cached balance. Summing the ledger per check is always correct but slow; a cached running total is fast but can drift — update it in the same transaction and reconcile against the ledger.
  • Hot account (whale) contention. A whale's usage is a hot key for metering (shard the counter) and its ledger a contended row (serialize per account); both stay contained to that account by sharding/partitioning on account_id.

Extensions if asked

Add only the extension that changes the design discussion:

  • Tiered pricing / committed-use discounts. Price GPU-seconds by tier or apply a discount above a committed volume — a pricing function over the UsageAggregate before the deduction; the ledger absorbs it as a smaller deduction plus a discount entry.
  • Spot / preemptible pricing. Cheaper, preemptible GPUs priced dynamically; usage carries a price tag per event, and preemption is a cutoff triggered by supply rather than credits.
  • Per-team budgets + alerts. Sub-accounts with their own budgets rolling up to an org — a hierarchy of balances with per-team soft limits and threshold alerts (50/80/100%), reusing the reservation machinery per team.
  • Chargeback / showback. Attribute usage to cost centers for internal billing — a reporting rollup dimension (team × project × gpu_type) over the aggregates, no new ledger mechanics.
  • Fraud / abuse detection. Detect credit-farming or stolen-card grants — a scoring service that can freeze an account's status (blocking admission) at the enforcement layer, orthogonal to ledger correctness.

What interviewers look for & common mistakes

What interviewers usually reward:

  • Separating the three subsystems — metering, ledger/billing, enforcement — and recognizing they run at different speeds, with the metering lag as the crux.
  • Billing-grade metering: event-time windows with watermarks, event_id dedup for exactly-once effect, and reconciliation against the raw log — explicitly tighter than analytics.
  • An append-only credit ledger with the balance derived + cached, and idempotent deduction keyed by the usage batch id so retries never double-charge.
  • Bounding overspend with reservations/holds, soft and hard limits, near-real-time re-checks, and graceful termination — instead of a naive one-time admission check or an expensive strict per-second cutoff.
  • Credits as integer units, prepaid vs postpaid tradeoff stated, hot whale account sharded/serialized.

Before you finish, do a quick mistake check:

  • Did you treat metering as lagging, not instant, and bound the resulting overspend with reservations + soft/hard limits + periodic checks?
  • Did you make the deduction idempotent, keyed by the usage batch id, so a retry doesn't double-charge?
  • Did you use an append-only ledger with a derived/cached balance, not a mutated credits column?
  • Did you meter billing-grade — event-time windows, watermarks, event_id dedup, reconciliation — rather than best-effort analytics?
  • Did you store credits as integer units, and state prepaid vs postpaid?
  • Did you shard/serialize the hot whale account for both metering and the ledger?

Practice this live

Run this exact question with our AI voice interviewer and get feedback.

Start the interview →