Skip to content
academia.sh

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.

Start typing to search.

↑↓ Esc navigate · open · close