Source: https://www.hack2hire.com/question-bank/questions/system-design/69d850f4c4c1bc791ee230c5
Company: Snowflake, Microsoft, Google, Amazon
Difficulty: Medium
Problem statement
Track and enforce how much of each resource (API calls, storage, compute) every tenant may use, with fast checks and limits that stay accurate across regions.
Requirements
We are building a shared quota service: every product service (API gateway, object storage, compute scheduler) asks it before consuming a tenant's resource, and it says yes or no against that tenant's limit. Unlike a rate limiter, which caps a rate over a short window, a quota caps an amount: 10 TB of storage, 500 vCPUs running at once, 50M API calls per month. Operations that consume quota can take a while and can fail, so the core contract is reserve → commit or release, not a single increment.
Clarifying questions worth asking up front:
- Which resources? Some are allocated (storage bytes, running VMs: usage goes up and down), some are consumed (API calls per month: usage only goes up until the period resets). Both need support.
- Hard or soft limits? Hard limits reject; soft limits allow but alert and bill overage.
- How accurate across regions? Is a brief 1% overshoot on a monthly API-call quota fine? (Usually yes.) Is overshoot on a paid storage cap fine? (Usually small overshoot, yes; unbounded, no.)
- Who sets limits? Billing plans, plus admin overrides and support-granted increases.
Functional requirements
- Define limits per (tenant, resource, scope), where scope is global, a region, or a project inside the tenant.
-
Reservean amount before doing work; the reservation holds quota for a TTL. -
Commita reservation when the work succeeds (quota becomes used) orReleaseit when the work fails. -
Adjustusage directly for decrements (a file deleted, a VM stopped) and for corrections from reconciliation. - Read current usage, reserved amount, and limit for a tenant, for dashboards and pre-flight checks.
- Limits change when a tenant's billing plan changes, within about a minute.
- Notify tenants at 80% and 100% of a limit.
Non-functional requirements
- Reserve p99 under 10 ms in-region; it sits on the request path of create calls.
- Never over-commit hard quotas by more than a bounded amount (target: zero within a region, under 1% globally).
- No lost commits: once a commit is acknowledged, usage includes it, even if the reservation's TTL then fires.
- 99.99% availability; when the service is down, callers degrade predictably (see deep dives).
- Idempotent operations: callers retry on timeouts and must not double-count.
- Auditable: every change to usage is explained by a ledger entry.
Back-of-the-envelope estimation
- 5M tenants × ~20 metered resources ≈ 100M quota counters. Each counter row ~200 B → ~20 GB. Small; fits in a sharded relational store with room to spare.
- Reserve traffic: API-call quotas dominate. 2M requests/s through gateways, but gateways batch: they reserve blocks of 1,000 calls locally, so the service sees ~2,000 reserves/s for API calls.
- Allocation resources (storage writes, VM creates, job submits): ~50k reserves/s peak, plus the same number of commits/releases → ~100k writes/s.
- Reads (dashboards, pre-flight checks): ~200k/s, served from caches.
- Reservations live seconds to minutes; at 50k/s with a 60 s mean life, ~3M open reservations at any time, ~1 KB each → 3 GB.
- Ledger: 100k entries/s × 150 B ≈ 1.3 TB/day; keep 30 days hot, then roll into cold storage.
- Hot tenants: the largest tenant may do 5k reserves/s on one counter; a single row lock tops out around 1–3k updates/s, so hot counters need sharding (deep dive 1).
API design
gRPC, internal only, mTLS between services. All mutating calls take a caller-generated request_id for idempotency.
Reserve(request_id, tenant_id, resource, scope, amount, ttl_s=60)
→ {reservation_id, status: GRANTED | DENIED, remaining, limit, expires_at}
DENIED carries reason: OVER_LIMIT | TENANT_SUSPENDED
Commit(request_id, reservation_id, actual_amount?)
→ {status: COMMITTED, used}
actual_amount ≤ reserved; the difference is returned to the pool.
Commit after expiry still succeeds if the work happened (see deep dive 2).
Release(request_id, reservation_id) → {status: RELEASED}
Extend(reservation_id, ttl_s) → {expires_at} # long uploads, slow VM boots
Adjust(request_id, tenant_id, resource, scope, delta, reason)
→ {used} # deletes, reconciliation fixes
GetUsage(tenant_id, resource?, scope?)
→ [{resource, scope, limit, used, reserved, period_end}]
SetLimit(tenant_id, resource, scope, limit, source: PLAN | OVERRIDE, version)
→ {effective_at} # called by billing and admin tools
REST mirror for tenant consoles: GET /v1/tenants/{id}/quotas returns the GetUsage shape.
Data model
Store: a sharded, strongly consistent SQL database (Spanner, CockroachDB, or Postgres/Vitess sharded by tenant_id). We need row-level transactions across a counter and its reservation in one shard; a single tenant's rows never span shards.
quota_counters, primary key (tenant_id, resource, scope, shard_no)
| Column | Notes |
|---|---|
| limit | Copy of the effective limit for this shard's slice (see deep dive 1) |
| used | Committed usage |
| reserved | Sum of open reservations |
| period_start | For consumable resources; counters roll on period change |
| version | Optimistic concurrency token |
reservations, primary key (tenant_id, reservation_id), secondary index (shard_bucket, expires_at) for the expiry sweeper
| Column | Notes |
|---|---|
| resource, scope, shard_no, amount | What is held and where |
| state |
PENDING, COMMITTED, RELEASED, EXPIRED
|
| request_id | Unique per tenant; idempotency |
| expires_at, created_by | Lease and calling service |
limits, primary key (tenant_id, resource, scope): plan_limit, override_limit, effective_limit, billing_version. Effective limit = override if present, else plan.
usage_ledger, append-only, partitioned by day, key (tenant_id, ts, entry_id): delta, kind (commit, release, expire, adjust, reconcile), reservation_id, actor. This is the audit trail and the input for billing.
Redis holds a read-through cache of GetUsage results (q:{tenant}:{resource}:{scope} with a 5 s TTL) for dashboard reads.
High-level design
flowchart LR
SVC[Product services] --> CL[Quota client library]
CL --> API[Quota API servers]
API --> DB[(Sharded SQL counters and reservations)]
API --> LED[(Usage ledger)]
API --> RC[(Redis read cache)]
SW[Expiry sweeper] --> DB
BILL[Billing plans] --> LP[Limit propagator]
LP --> DB
REC[Reconciler] --> DB
STO[Storage usage scans] --> REC
LED --> BI[Billing and alerts]
-
Reserve. The client library (embedded in each product service) sends
Reserveto an API server in the region. The server picks a counter shard for the tenant, then in one transaction: reads the counter, checksused + reserved + amount ≤ limit, incrementsreserved, and inserts the reservation row inPENDING. Ifrequest_idalready exists, it returns the stored result instead. - Do the work. The product service writes the object, starts the VM, or runs the job.
-
Commit or release. On success,
Commitmoves the amount fromreservedtoused(and returns any unused portion), flips the reservation toCOMMITTED, and appends a ledger entry, all in one transaction. On failure,Releasesubtracts fromreservedand flips toRELEASED. -
Lifecycle.
PENDING → COMMITTED | RELEASED | EXPIRED.EXPIRED → COMMITTEDis also legal (a late commit), and is the only transition out of a terminal-looking state. Every transition is a compare-and-set onstate, so retries and races resolve to one winner. -
Expiry. A sweeper per shard scans
expires_at < now()and expires stalePENDINGreservations in batches, returning their amount. -
Reads at scale.
GetUsagesums the counter shards and is cached in Redis for 5 s. Pre-flight checks in UIs tolerate that staleness; enforcement always goes throughReserveand never trusts the cache. -
Limits. Billing emits plan-change events; the propagator writes
limitsand redistributes per-shard slices.
Deep dives
1. Preventing over-commitment under concurrent reservations. The invariant is used + reserved ≤ limit per counter. Doing the check and the increment in one serializable transaction (or UPDATE ... SET reserved = reserved + :amt WHERE used + reserved + :amt <= limit, checking affected rows) makes it hold under any concurrency. The cost is contention: one row serializes all reservations for a tenant-resource, about 1–3k/s. For hot tenants, split the counter into N shards, each owning limit / N of headroom. A reserve picks a random shard; if it is short, it tries one more, then falls back to a slow path that rebalances. A rebalancer moves unused headroom from idle shards to busy ones every few seconds, transactionally (decrease one shard's slice, increase another's). Sharding is driven by observed write rate: most tenants use 1 shard; the top 0.1% get 8–64.
| Approach | Over-commit | Throughput per counter | Cost |
|---|---|---|---|
| Single row, conditional update | None | ~2k/s | Simplest; enough for 99.9% of tenants |
| Sharded counter with slices | None | N × 2k/s | Rejections can occur while another shard has headroom; rebalance fixes it |
| Client-side token blocks | None (blocks are reserved) | Unbounded locally | Unused block quota is stranded until returned; size blocks to ~1 s of traffic |
| Check-then-write without a lock | Unbounded under races | High | Do not do this |
Client-side blocks are how the API gateway handles 2M req/s: it reserves 1,000 calls, spends them locally, commits the count used, and reserves another block when 20% remain.
2. Reclaiming expired reservations without losing committed work. A reservation can expire while its work is still finishing: a 5 GB upload takes longer than the 60 s TTL. Rules that keep this safe:
- Long operations call
Extendas a heartbeat (the client library does it at TTL/3). - The sweeper expires with a compare-and-set:
UPDATE reservations SET state='EXPIRED' WHERE id=? AND state='PENDING', then subtractsamountfromreservedin the same transaction. A concurrent commit either wins first (sweeper sees 0 rows, does nothing) or loses (commit seesEXPIRED). - A late commit on an
EXPIREDreservation is accepted: the work happened, so usage must show it. It addsamountstraight tousedwithout checking the limit, logs alate_commitledger entry, and raises a metric. This can push a tenant slightly over the limit; that is bounded by the TTL and is better than undercounting. - Terminal states are kept for 7 days so retries of
Commit/Releasestay idempotent, then reservations are deleted. - Sweeper sizing: 3M open reservations, ~50k expiring/s worst case, spread over 1,000 shard buckets → ~50 rows/s per sweeper. A crashed sweeper only delays reclaim; correctness does not depend on it.
3. Reconciling tracked quota with actual usage. Counters drift: a service commits then the write is rolled back, a delete event is lost, a bug double-releases. For allocated resources the source of truth is the resource itself (bytes in the object store, VMs in the fleet), so a reconciler compares them daily (hourly for paid storage tiers). Storage publishes per-tenant byte totals from its metadata scan; the reconciler computes actual − (used as of scan snapshot time), using the ledger to discount commits that landed after the snapshot, and writes an Adjust with kind reconcile when the difference exceeds 0.1% or 1 GB. Large corrections (over 5%) page a human instead of auto-applying, because they usually mean a bug, and a big downward correction can mask one. Consumable quotas (API calls) reconcile against request logs at period close, and billing uses the ledger, not the counter.
4. Limit propagation and graceful degradation. Billing is the source of plan limits. Plan changes go through an outbox to a stream; the propagator applies them with billing_version so an older event never overwrites a newer one, and redistributes shard slices. Lowering a limit below current usage does not delete anything: new reserves are denied until usage drops, and the tenant is notified. When the quota service is unreachable, the client library falls back per resource policy:
| Resource | Policy when quota is down |
|---|---|
| API calls | Fail open; count locally and replay commits later |
| Storage writes | Fail open up to a local allowance (e.g. 1% of limit, from the last good read), then fail closed |
| Expensive compute (GPUs) | Fail closed; capacity is costly and scarce |
| Deletes, releases | Always proceed; queue the decrement in a durable local log |
5. Multi-service fairness and multi-region. When several services share one tenant quota (e.g. compute used by batch jobs and interactive notebooks), a batch job can drain the pool. Allow sub-scopes with guaranteed minimums: notebooks reserve against min=20% that batch cannot touch, and borrow from the shared rest. For multiple regions, give each region a slice of a global limit, sized by its recent usage share, with a global rebalancer that moves headroom every 10–30 s through a small consistent store. Reserves stay region-local (fast, no cross-region hop); the maximum global overshoot is zero as long as slices sum to the limit. The cost: a region can reject while another has headroom, until the next rebalance. Region-scoped quotas (VMs in us-east-1) need no global coordination at all.
Trade-offs
- Reserve/commit instead of a single increment costs a second call and reservation state, but makes failed work return quota and keeps
usedhonest. - Strongly consistent SQL per counter gives exact enforcement at the cost of per-row throughput; counter sharding buys throughput but introduces rejections while headroom sits on another shard.
- Client-side blocks remove the service from the hot path for API calls but strand up to one block per gateway pod; with 200 pods × 1,000 calls that is 200k calls of slack on a monthly quota, negligible.
- Accepting late commits keeps billing correct but means hard limits can be exceeded by at most one TTL of in-flight work.
- Regional slices keep reserves local and bounded, but a region can deny while the tenant still has global headroom; a single global store would be exact but adds 50–150 ms cross-region latency.
- Failing open on outages protects customer traffic and risks short overspend; failing closed protects scarce capacity and risks outages spreading. The choice is per resource, not global.
- Auto-reconciliation fixes drift without humans, but capping automatic corrections means some drift waits for a person.
Interviewer expectations
Mid-level (L4)
- Separates limits, usage, and reservations, and stores counters per tenant and resource.
- Does the check and increment atomically, and can explain the race if they aren't.
- Gives a working reserve/commit/release API with idempotent request IDs.
Senior (L5)
- Defines the reservation state machine, TTL expiry with compare-and-set, and the late-commit rule.
- Handles hot tenants with counter shards or client-side blocks, with throughput numbers.
- Adds reconciliation against real storage usage and per-resource fail-open/fail-closed policies.
Staff (L6+)
- Separates allocated and consumable resources and states the accuracy bound each policy gives.
- Designs multi-region slices with rebalancing, limit propagation from billing with versioning, and fairness between services sharing a quota.
- Treats the ledger as the audit and billing source of truth, and plans operations: drift alerts, caps on auto-correction, sweeper lag metrics.