Lesson 08 / 14
Ticketing and Inventory System
The case where the counted thing cannot be recovered: the allotment exhausting in 0.179 seconds at peak rate, the unprotected read–decide–write flow overselling the allotment by 5.59x under 16 concurrent workers, optimistic and pessimistic locking measured in oversold items versus rejected valid requests, and the reservation window's effect on wasted item-seconds.
Contents
In the previous case, a counter guarded a threshold, and what was lost when the threshold was exceeded was a request — the client retries. This case poses the same counting problem where the counted thing is unrecoverable. There is a limited number of seats, and when the counter overcounts by two, the cost is not a rejected request but an item sold that does not exist.
Two differences follow from this. First, the unit of overshoot changes: in the rate limiter, 1.1x was acceptable; here, not even a single unit has an equivalent. Second, holding a resource and selling it are separate operations, and there is a window between them.
Constraints and Scope
Functional requirements: reporting the remaining allotment, opening a reservation, converting a reservation into a sale, releasing an expired reservation, and processing a cancellation.
Out of scope: payment execution (the subject of the next case), rendering the seat map, pricing, and resale.
The non-functional requirements are written with a threshold and its source: oversold items are zero (source: the obligation that every sold item has a match), valid requests rejected while stock remains are zero (source: a buyer must not leave empty-handed before the allotment runs out), and the reservation decision does not exceed 100 milliseconds (source: the page must not go unresponsive at the moment of launch).
Assumptions and Scale
| Code | Assumption | Value | Rationale |
|---|---|---|---|
| BS1 | allotment | 5,000 items | the limited resource opened for a single sale |
| BS2 | requests at launch | 200,000 / 10 seconds | demand piling up at the moment of launch |
| BS3 | items per request | 1.4 | the average of single- and double-item purchases |
| BS4 | reservation window | 120 seconds | time left for the payment step |
| BS5 | completion rate by window | 0.45 / 0.65 / 0.72 | share of payments completed within a 30-, 120-, and 300-second window |
| BS6 | concurrent write worker | 16 | number of jobs touching the allotment row at once |
| BS7 | stock record | 64 bytes | item ID, status, reservation timestamp |
| BS8 | retry budget under optimistic conflict | 3 | upper bound on retries |
// ticketing/measure.mjs — rough sizing derived from the BS assumption table const BS = { allotment: 5_000, launchRequests: 200_000, launchSeconds: 10, items: 1.4, window: 120, completion: { 30: 0.45, 120: 0.65, 300: 0.72 }, workers: 16, recordBytes: 64 }; const peak = BS.launchRequests / BS.launchSeconds; const demand = BS.launchRequests * BS.items; const drain = BS.allotment / (peak * BS.items); console.log(`peak requests/s ${peak}`); console.log(`total demand at launch ${demand} items`); console.log(`demand / allotment ${(demand / BS.allotment).toFixed(1)}x`); console.log(`allotment drain time ${drain.toFixed(3)} s (at peak rate)`); console.log(`requests arriving by drain ${Math.round(peak * drain)}`); console.log(`stock table ${((BS.allotment * BS.recordBytes) / 1e3).toFixed(1)} KB`); console.log(`reservation count ${(BS.allotment / BS.items).toFixed(0)} (allotment / items)`); console.log(`\n${"window".padStart(9)}${"completion rate".padStart(17)}${"sold in round one".padStart(20)}` + `${"items returned".padStart(17)}${"wasted item-seconds".padStart(22)}`); for (const [w, c] of Object.entries(BS.completion)) { const back = BS.allotment * (1 - c); console.log(`${`${w} s`.padStart(9)}${c.toFixed(2).padStart(17)}${(BS.allotment * c).toFixed(0).padStart(20)}` + `${back.toFixed(0).padStart(17)}${(back * Number(w)).toFixed(0).padStart(22)}`); } const c120 = BS.completion[BS.window], c30 = BS.completion[30]; console.log(`\nwindow 30 s -> 120 s: sold in round one ${(BS.allotment * c30).toFixed(0)} -> ` + `${(BS.allotment * c120).toFixed(0)} (+${((c120 - c30) * BS.allotment).toFixed(0)} items)`); console.log(`the same change moves wasted item-seconds ${(BS.allotment * (1 - c30) * 30).toFixed(0)} -> ` + `${(BS.allotment * (1 - c120) * BS.window).toFixed(0)} (` + `${((BS.allotment * (1 - c120) * BS.window) / (BS.allotment * (1 - c30) * 30)).toFixed(2)}x)`);
peak requests/s 20000
total demand at launch 280000 items
demand / allotment 56.0x
allotment drain time 0.179 s (at peak rate)
requests arriving by drain 3571
stock table 320.0 KB
reservation count 3571 (allotment / items)
window completion rate sold in round one items returned wasted item-seconds
30 s 0.45 2250 2750 82500
120 s 0.65 3250 1750 210000
300 s 0.72 3600 1400 420000
window 30 s -> 120 s: sold in round one 2250 -> 3250 (+1000 items)
the same change moves wasted item-seconds 82500 -> 210000 (2.55x)
These numbers are of the computed class. Three of them determine the design. First, demand is 56x the allotment, and the allotment drains at peak rate in 0.179 seconds; the problem the design solves is not capacity but counting correctly within a 0.179-second interval. Second, only 3,571 requests arrive within that interval; the job for the remaining 196,429 requests is to be rejected, and that job must happen without ever touching the allotment row. Third, the reservation window carries a cost in both directions: raising it from 30 seconds to 120 seconds increases the items sold in round one by 1,000 but raises wasted item-seconds by 2.55x.
Oversell Is Measured
The measurement is an in-process model: there is no real thread, database, or lock; 16 workers are advanced step by step by a seeded scheduler, and contention arises from other workers stepping in between one worker’s read and its write. Three regimes are compared: the unprotected read–decide–write flow, optimistic locking that checks a version column, and pessimistic locking that makes the decision under a lock. The mechanics were built in the Data Access Layer and Business Logic course’s Optimistic and Pessimistic Locking lesson; what is measured here is the number of oversold items in this case.
// ticketing/contention.mjs — an in-process model of contention over a limited resource. There // is no real thread, database, or lock: 16 workers are advanced step by step by a scheduler. const ALLOTMENT = 5_000, REQUESTS = 20_000, RETRY_BUDGET = 3; // BS1, BS8 function rng(seed) { // 32-bit linear congruential generator let s = seed >>> 0; return () => { s = (Math.imul(s, 1103515245) + 12345) >>> 0; return s / 4294967296; }; } export function run({ regime, seed, WORKERS = 16 }) { // BS6: concurrent write workers const rnd = rng(seed); const shared = { stock: ALLOTMENT, version: 0, locked: false }; const queue = Array.from({ length: REQUESTS }, () => (rnd() < 0.6 ? 1 : 2)); // BS3: average 1.4 const workers = Array.from({ length: WORKERS }, () => ({ phase: "idle" })); const s = { sold: 0, rejected: 0, validRejected: 0, conflict: 0, wait: 0, step: 0 }; let cursor = 0; while (cursor < REQUESTS || workers.some((w) => w.phase !== "idle")) { const w = workers[Math.floor(rnd() * WORKERS)]; s.step += 1; if (w.phase === "idle") { if (cursor >= REQUESTS) continue; w.qty = queue[cursor]; cursor += 1; w.attempt = 0; w.phase = "read"; } if (w.phase === "read") { if (regime === "pessimistic") { if (shared.locked) { s.wait += 1; continue; } shared.locked = true; } w.readStock = shared.stock; w.readVersion = shared.version; w.phase = "write"; continue; } if (w.readStock < w.qty) { // the stock read is not enough s.rejected += 1; if (shared.stock >= w.qty) s.validRejected += 1; if (regime === "pessimistic") shared.locked = false; w.phase = "idle"; continue; } if (regime === "optimistic" && shared.version !== w.readVersion) { // version changed s.conflict += 1; w.attempt += 1; if (w.attempt >= RETRY_BUDGET) { s.rejected += 1; if (shared.stock >= w.qty) s.validRejected += 1; w.phase = "idle"; } else w.phase = "read"; continue; } shared.stock = w.readStock - w.qty; shared.version += 1; s.sold += w.qty; if (regime === "pessimistic") shared.locked = false; w.phase = "idle"; } s.oversold = Math.max(0, s.sold - ALLOTMENT); s.stockRemaining = shared.stock; return s; } console.log(`model: allotment ${ALLOTMENT}, 16 workers, ${REQUESTS} requests, retry budget ${RETRY_BUDGET}, seed 20260730`); console.log(`\n${"regime".padEnd(12)}${"sold".padStart(9)}${"oversold".padStart(13)}` + `${"stock left".padStart(12)}${"conflict".padStart(9)}${"rejected (valid)".padStart(18)}` + `${"wait steps".padStart(15)}${"total steps".padStart(13)}`); const R = {}; for (const d of ["unprotected", "optimistic", "pessimistic"]) { const r = (R[d] = run({ regime: d, seed: 20260730 })); console.log(`${d.padEnd(12)}${String(r.sold).padStart(9)}${String(r.oversold).padStart(13)}` + `${String(r.stockRemaining).padStart(12)}${String(r.conflict).padStart(9)}` + `${`${r.rejected} (${r.validRejected})`.padStart(18)}${String(r.wait).padStart(15)}${String(r.step).padStart(13)}`); } const k = R.unprotected, i = R.optimistic, p = R.pessimistic; console.log(`\nunprotected regime sold ${(k.sold / ALLOTMENT).toFixed(3)}x the allotment; ` + `oversold / allotment = ${((100 * k.oversold) / ALLOTMENT).toFixed(2)}%`); console.log(`optimistic: oversold ${i.oversold}, ${i.conflict} conflicts, ` + `${i.validRejected} valid requests rejected once the retry budget ran out`); console.log(`pessimistic: oversold ${p.oversold}, valid rejections ${p.validRejected}, ` + `step count ${(p.step / i.step).toFixed(2)}x the optimistic count`); console.log(`\n${"concurrent workers".padStart(19)}${"optimistic conflict".padStart(21)}${"valid rejected".padStart(16)}` + `${"pessimistic wait".padStart(18)}`); for (const n of [2, 4, 16]) { const a = run({ regime: "optimistic", seed: 20260730, WORKERS: n }); const b = run({ regime: "pessimistic", seed: 20260730, WORKERS: n }); console.log(`${String(n).padStart(19)}${String(a.conflict).padStart(21)}${String(a.validRejected).padStart(16)}` + `${String(b.wait).padStart(18)}`); }
model: allotment 5000, 16 workers, 20000 requests, retry budget 3, seed 20260730
regime sold oversold stock left conflict rejected (valid) wait steps total steps
unprotected 27963 22963 1798 0 0 (0) 0 40047
optimistic 5000 0 0 10038 16397 (2409) 0 55267
pessimistic 5000 0 0 0 16427 (0) 304276 344446
unprotected regime sold 5.593x the allotment; oversold / allotment = 459.26%
optimistic: oversold 0, 10038 conflicts, 2409 valid requests rejected once the retry budget ran out
pessimistic: oversold 0, valid rejections 0, step count 6.23x the optimistic count
concurrent workers optimistic conflict valid rejected pessimistic wait
2 1519 130 20043
4 3577 520 60109
16 10038 2409 304276
This is of the measured class; it reproduces with seed 20260730.
The first row is the measurement of the unprotected regime, and it carries two numbers. 5.593x the allotment was sold, and oversold items came to 22,963. The second number is more instructive: stock remaining shows 1,798. The counter is not merely wrong, it is wrong in the wrong direction — the system still reports room to sell. No request was rejected, because no worker ever saw the stock as insufficient; lost writes pushed the counter back up.
The second and third rows show that both protections zero out overselling. The difference is in the other columns. Optimistic locking produces 10,038 conflicts and rejects 2,409 valid requests once the retry budget runs out: a buyer who leaves empty-handed while stock remains. This is a violation of the second non-functional requirement. Pessimistic locking produces no valid rejections, at the cost of 304,276 wait steps and 6.23x the optimistic regime’s total step count.
The fourth table shows that both costs depend on the same variable: the number of concurrent workers. At 2 workers, optimistic locking’s valid rejections drop to 130, and pessimistic locking’s wait steps drop to 20,043. Contention is not a constant, it is a design parameter.
Design and the Eliminated Alternative
The allotment row is protected with a pessimistic lock; the reason for the choice is 2,409 against 0 valid rejections. The lock’s hold time is given as a lease, and the lease mechanics from the Application Layer and Service Interaction course’s Leader Election lesson are applied here to lock ownership; a stuck lock drops once the lease expires.
The reservation window is 120 seconds, and the supervisor that releases an expired reservation is the supervisor from the same course’s Scheduler–Agent–Supervisor lesson. To keep the 20,000 requests/s at launch from reaching the allotment row, two layers stand in front: the previous case’s rate limiter and the queue from the same course’s Queue-Based Load Leveling lesson. In the previous case, the queue was deliberately not used, because the contract there was to reject; here the contract is to enqueue, and the same pattern changes place for that reason.
The idempotency key’s scope is the triple (buyer, allotment, client request ID); a buyer’s
retry does not open a second reservation.
Pattern deliberately not used. Splitting the allotment counter into shards (Scaling the Data Layer, Sharding) is not used: this is the same thing as the split quota measured in the previous case, and it produces rejections while items remain in another shard — there, it rejected 4.17 percent of a client running right at the limit.
The eliminated alternative is optimistic locking, and the number that eliminates it is 2,409 valid rejections. What it wins is written down too: 6.23x fewer steps, that is, a write path that holds no lock. What it would take to change the verdict is also clear: with 2 concurrent workers instead of 16, valid rejections would drop to 130, or 2.6 percent of the allotment, and raising the retry budget from 3 to 6 could lower that further.
Failure Behavior and What Is Sacrificed
If the worker holding the lock fails, the allotment row stays locked for the lease duration and no sale can happen during that time; if the lease is longer than the 0.179-second drain window, the entire launch stalls. On the queue side, the Resilience and Reliability course’s Graceful Degradation lesson governs: once the allotment is exhausted, requests are rejected without ever touching the allotment row, and that is the normal path for the 196,429 requests.
What is sacrificed fits in one sentence: this design sacrifices throughput to zero out overselling — 304,276 wait steps and 6.23x the processing are the price paid so that not even a single item is sold in excess.
Summary
- Demand is 56x the allotment and it drains at peak rate in 0.179 seconds; only 3,571 requests arrive within that interval, and the job for the remaining 196,429 requests is to be rejected.
- The unprotected regime sold 5.593x the allotment, oversold by 22,963; and worse, the counter ends up showing 1,798 units of stock remaining — wrong in the wrong direction.
- Optimistic locking zeroed out overselling but produced 10,038 conflicts and rejected 2,409 valid requests once the retry budget ran out.
- Pessimistic locking zeroed out both overselling and valid rejections; the price is 304,276 wait steps and 6.23x the optimistic regime’s total steps.
- Both costs depend on the number of concurrent workers: at 2 workers instead of 16, valid rejections drop from 2,409 to 130, and wait steps drop from 304,276 to 20,043.
- Raising the reservation window from 30 seconds to 120 seconds increases items sold in round one by 1,000 but raises wasted item-seconds by 2.55x.
Next Step
The reservation made a promise, but the step that keeps it was left out of this case: the payment itself. That step is a transaction with two sides, and neither side can see the other’s records. The next case takes up that split. The number to ask is how far the two records diverge from each other: the number of unmatched records, where the gap comes from, and how long it takes a late-arriving confirmation to close it.
To keep your progress and take notes, Log in
My notes
Log in to take notes.