Skip to content
academia.sh

Lesson 09 / 22

Server-Side Scripting

Comparing multi-step work done atomically on the client versus in the store: the number of round trips, the race window, and the extra loans given out measured against concurrency, the memory of lock entries compared against the script cache, and how an unbounded script keeps every client waiting counted out.

Contents

The previous lessons measured each structure on its own, and in every one a single operation was atomic: a counter went up by one, a set gained one member, a stream accepted one entry. The library’s actual work is not one of these but all five done in sequence. When a loan is made, the copy count is read, the reader’s open-loan count is read, the limits are checked, the copy count is decremented by one, and the loan record is written. Each of the five steps is atomic on its own; the five together are not.

This lesson measures that gap: what can slip in between when the steps are sent one at a time from the client, what changes when the same job runs as one piece in the store, and how many bytes each of the two paths holds.

Race Window

The setup below runs the same job in two forms. In the first, every client’s five steps are interleaved with the others’ steps; in the second, one client’s steps run contiguously. Because the scheduler is seeded, the two forms are compared under the same interleaving rule.

// loan-race.mjs — the same multi-step loan job in two forms: step by step on the client and
// as one piece in the store. The scheduler is seeded; every trial is interleaved by the same rule.
const COPIES = 3, TRIALS = 500, SEED = 20260731, PEAK = 800;
const STEPS = ["read the copy count", "read the reader's open loans", "check the limits",
              "decrement the copy count", "write the loan record"];

function trial(C, seed, atomic) {
  let d = seed;
  const rand = () => { d = (d + 0x6D2B79F5) | 0; let t = Math.imul(d ^ (d >>> 15), 1 | d);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 2 ** 32; };
  const store = { copies: COPIES, given: 0 };
  const clients = Array.from({ length: C }, () => ({ step: 0, k: 0, passed: false, readAt: -1, writeAt: -1 }));
  let remaining = C, tick = 0;
  while (remaining > 0) {
    const eligible = clients.filter((c) => c.step < STEPS.length);
    const c = eligible[Math.floor(rand() * eligible.length)];
    const n = atomic ? STEPS.length - c.step : 1;           // atomic: remaining steps run contiguously
    for (let s = 0; s < n; s += 1, c.step += 1, tick += 1) {
      if (c.step === 0) { c.k = store.copies; c.readAt = tick; }
      if (c.step === 2) c.passed = c.k > 0;
      if (c.step === 3) { c.writeAt = tick; if (c.passed) { store.copies = c.k - 1; store.given += 1; } }
    }
    if (c.step === STEPS.length) remaining -= 1;
  }
  const window = clients.reduce((s, c) => s + (c.writeAt - c.readAt - 3), 0) / C;
  return { given: store.given, extra: Math.max(0, store.given - COPIES), counter: store.copies, window };
}

const fmt = (x) => x.toLocaleString("en-US", { maximumFractionDigits: 2 });
console.log(`model: ${COPIES} copies, ${TRIALS} trials/row, seed ${SEED}, ` +
  `${STEPS.length} steps per job`);
console.log(`round trips: ${STEPS.length} on the client, ${STEPS.length + 2} on a locked client, ` +
  `1 atomic in the store`);
console.log(`at peak ${PEAK} loans/s: ${fmt(PEAK * STEPS.length)} / ${fmt(PEAK * (STEPS.length + 2))}` +
  ` / ${fmt(PEAK)} round trips/s\n`);
console.log("clients".padEnd(9) + "window".padStart(9) + "given".padStart(9) +
  "extra".padStart(8) + "counter".padStart(8) + "extra%".padStart(9) +
  "atomic given".padStart(16) + "atomic extra".padStart(14));
for (const C of [2, 4, 8, 16, 32]) {
  let p = 0, v = 0, f = 0, s = 0, races = 0, av = 0, af = 0;
  for (let t = 0; t < TRIALS; t += 1) {
    const r = trial(C, SEED + t * 7919, false), a = trial(C, SEED + t * 7919, true);
    p += r.window; v += r.given; f += r.extra; s += r.counter; if (r.extra > 0) races += 1;
    av += a.given; af += a.extra;
  }
  console.log(`${C}`.padEnd(9) + (p / TRIALS).toFixed(2).padStart(9) +
    (v / TRIALS).toFixed(2).padStart(9) + (f / TRIALS).toFixed(2).padStart(8) +
    (s / TRIALS).toFixed(2).padStart(8) + `%${((races / TRIALS) * 100).toFixed(1)}`.padStart(9) +
    (av / TRIALS).toFixed(2).padStart(16) + (af / TRIALS).toFixed(2).padStart(14));
}
model: 3 copies, 500 trials/row, seed 20260731, 5 steps per job
round trips: 5 on the client, 7 on a locked client, 1 atomic in the store
at peak 800 loans/s: 4,000 / 5,600 / 800 round trips/s

clients     window    given   extra counter   extra%    atomic given  atomic extra
2             2.34     2.00    0.00    1.88     %0.0            2.00          0.00
4             6.80     4.00    1.00    1.69   %100.0            3.00          0.00
8            16.12     7.99    4.99    1.49   %100.0            3.00          0.00
16           34.48    15.99   12.99    1.32   %100.0            3.00          0.00
32           70.94    31.93   28.93    1.14   %100.0            3.00          0.00

These numbers are in the measurement class; the decimal digits depend on the seed, the relationship between the columns does not.

The window column is the number of foreign steps that fall between the read and the write, and it grows linearly with concurrency: 2.34 steps at two clients, 70.94 at thirty-two. As the window grows, the odds that the copy count read is still valid at write time drop, and starting at four clients, 100 percent of trials give out extra loans.

The harshest row is the last one. When thirty-two clients queue up for a three-copy book, 31.93 loans are given out — 28.93 of them without a backing copy; readers end up borrowing books that are not on the shelf. The counter reads 1.14 — anyone looking at the store thinks a copy is still there. The counter being wrong is not a separate defect, it is the same defect’s second face: because every client writes one less than the value it read, the last writer to finish erases every decrement that came before it.

The atomic columns ran under the same interleaving and gave out exactly three loans on every row, never more. The difference is not an optimization — it is a difference in correctness.

Two Ways to Build Atomicity, and Their Cost

There are two remedies for the race. The first is to place a lock on the book’s key: the job goes from five steps to seven round trips (acquire the lock, release the lock), and lock entries take up memory. The second is to send the five steps to the store as one piece: a single round trip, with no step able to slip in between.

Code Assumption Value Rationale
DS22 peak loan job 800/s peak hour across all branches
DS23 open loan record 400,000 the corpus the overdue scan walks
DS24 store’s touch rate 2,000,000 touches/s machine-dependent; the ratios are not
DS25 peak command rate 40,000 commands/s sum across all clients

DS24 is this lesson’s only environment-dependent assumption, used only to convert the touch count to milliseconds; the table’s equivalent-work and blocked-command ratios do not change when divided by the rate.

// script-block.mjs — the entries a script touches and the work it blocks in a
// single-threaded store. Touch counts are counted from the store; converting to time
// relies on DS24's touch rate, and that assumption depends on the machine. The ratios
// (equivalent work, blocked commands) do not depend on the rate.
const RECORDS = 400_000, PEAK_LOANS = 800, PEAK_COMMANDS = 40_000, RATE = 2_000_000, COPY_LIMIT = 5;

class Store {                                  // single-threaded in-memory store
  constructor() { this.m = new Map(); this.touches = 0; }
  read(k) { this.touches += 1; return this.m.get(k); }
  write(k, v) { this.touches += 1; this.m.set(k, v); }
  walk(n, f) { let i = 0; for (const [k, v] of this.m) { if (i >= n) break; this.touches += 1; f(k, v); i += 1; } }
}
const loanScript = (store, book, reader) => {   // bounded: touch count is independent of the data
  const copies = store.read(`copies:${book}`) ?? 0;
  const open = store.read(`open:${reader}`) ?? 0;
  if (copies <= 0 || open >= COPY_LIMIT) return "reject";
  store.write(`copies:${book}`, copies - 1);
  store.write(`open:${reader}`, open + 1);
  store.write(`loan:${reader}:${book}`, 1);
  return "accept";
};
const overdueScan = (store, n, day) => {     // unbounded: touch count grows with the corpus size
  let fee = 0;
  store.walk(n, (k, v) => { if (typeof v === "number" && v < day) fee += (day - v) * 25; });
  return fee;
};

const store = new Store();
for (let i = 0; i < RECORDS; i += 1) store.write(`loan:${i}`, 100 + (i % 60));
store.write("copies:9780000041173", 3);

const fmt = (x) => x.toLocaleString("en-US", { maximumFractionDigits: 2 });
console.log(`model: ${fmt(RECORDS)} open loan records, peak ${fmt(PEAK_LOANS)} loans/s and ` +
  `${fmt(PEAK_COMMANDS)} commands/s`);
console.log(`DS24 touch rate ${fmt(RATE)} touches/s (machine-dependent; the ratios are not)\n`);
console.log("script".padEnd(30) + "touches".padStart(10) + "equivalent loans".padStart(18) +
  "time ms".padStart(10) + "blocked commands".padStart(18) + "total wait command.ms".padStart(25));
const ms3 = (x) => x.toLocaleString("en-US", { maximumFractionDigits: 3 });
const row = (label, touches) => {
  const ms = (touches / RATE) * 1000, blocked = (PEAK_COMMANDS * ms) / 1000;
  console.log(label.padEnd(30) + fmt(touches).padStart(10) + fmt(touches / 5).padStart(18) +
    ms3(ms).padStart(10) + ms3(blocked).padStart(18) + fmt((blocked * ms) / 2).padStart(25));
};
store.touches = 0; loanScript(store, "9780000041173", 418302);
row("loan script (bounded)", store.touches);
for (const n of [10_000, 100_000, RECORDS]) {
  store.touches = 0; overdueScan(store, n, 130);
  row(`overdue scan, ${fmt(n)} records`, store.touches);
}

const body = [loanScript, overdueScan].map((f) => Buffer.byteLength(f.toString()));
const DIGEST = 40;                                      // bytes of the digest naming the script
const lockKey = "lock:copies:9780000041173", lockValue = "client-0000000000000000-0000";
const LOCK_METADATA = 56;                               // expiry, pointers, slot overhead
const lockEntry = Buffer.byteLength(lockKey) + Buffer.byteLength(lockValue) + LOCK_METADATA;
console.log(`\nscript cache: ${body.length} scripts, body ${fmt(body.reduce((a, b) => a + b, 0))}` +
  ` bytes + digest ${body.length * DIGEST} bytes = ${fmt(body.reduce((a, b) => a + b, 0) + body.length * DIGEST)} bytes (constant)`);
console.log(`lock entry: key ${Buffer.byteLength(lockKey)} + value ` +
  `${Buffer.byteLength(lockValue)} + metadata ${LOCK_METADATA} = ${lockEntry} bytes`);
console.log(`lock table for ${fmt(PEAK_LOANS)} concurrent loans at peak: ` +
  `${fmt(PEAK_LOANS * lockEntry)} bytes (grows with concurrency)`);
model: 400,000 open loan records, peak 800 loans/s and 40,000 commands/s
DS24 touch rate 2,000,000 touches/s (machine-dependent; the ratios are not)

script                           touches  equivalent loans   time ms  blocked commands    total wait command.ms
loan script (bounded)                  5                 1     0.003               0.1                        0
overdue scan, 10,000 records      10,000             2,000         5               200                      500
overdue scan, 100,000 records    100,000            20,000        50             2,000                   50,000
overdue scan, 400,000 records    400,000            80,000       200             8,000                  800,000

script cache: 2 scripts, body 593 bytes + digest 80 bytes = 673 bytes (constant)
lock entry: key 25 + value 28 + metadata 56 = 109 bytes
lock table for 800 concurrent loans at peak: 87,200 bytes (grows with concurrency)

The bottom three rows give the memory difference between the two paths. The script cache holds 673 bytes, and this number is constant: no matter how many times a script is called, its body is stored once and does not grow with concurrency. On the lock path, an entry is opened for every concurrent job; a 109-byte entry is held up to 800 times at peak, and the table climbs to 87,200 bytes. The ratio between them is about 130 to 1, and the real difference is not in the ratio but in the slope: one is constant, the other grows linearly with concurrency.

The lock’s second cost does not show up in memory. If the client holding the lock vanishes without responding, the book stays locked until the expiry runs out; if the expiry is kept short, the lock falls before the job finishes and the race comes back. There is no such window on the script path, because there is no lock to hold.

The Script’s Own Cost

The store is single-threaded, and this is exactly what makes atomicity free: while a script runs, no other command can run, so no step can slip in between. The same property writes the bill.

The upper table’s first row is the loan script: five touches, work equivalent to one loan job, keeps no one waiting. The next three rows are the overdue scan sent to the same store, and its touch count grows with the corpus size. On a 400,000-record corpus the script touches 400,000 times; that stands in for a duration equal to 80,000 loan jobs. At DS24’s rate that is 200 milliseconds, during which 8,000 commands pile up in the queue and total wait climbs to 800,000 command-milliseconds. What is slow is not the query — it is everything that arrives while it runs.

The rule the numbers show is this: a script’s correctness does not depend on its step count, but its impact does. The line is not between a short script and a long one — it is between a bounded script and an unbounded one. The loan script always makes five touches; even if the library’s corpus grows tenfold, it stays at five. The overdue scan grows with the corpus, and a delay acceptable today becomes unacceptable tomorrow, because what grows is the data’s size, not the script’s text.

This settles the three paths for multi-step work into a decision table. The lockless client holds zero store bytes, produces 4,000 round trips/s at peak, and loses correctness: 28.93 extra loans at thirty-two clients. The locked client recovers correctness, rises to 5,600 round trips/s, holds 87,200 bytes, and takes on the problem of a client whose lock expires. The atomic store script needs 800 round trips/s and 673 bytes, gives full correctness, and imposes exactly one rule in return: the script’s touch count must not depend on the data. This course’s In-Memory Patterns topic measures the same control by a monitoring-based path as well.

Summary

  • When multi-step work runs on the client, the race window between read and write grows linearly with concurrency: 2.34 steps at two clients, 70.94 at thirty-two; starting at four clients, 100 percent of trials give out extra loans.
  • The loss has two faces: at thirty-two clients, a three-copy book gives out 31.93 loans and the counter reads 1.14 — the store neither knows it overpaid nor knows the remaining count correctly.
  • When the same job runs as one piece in the store, exactly three loans are given out at every concurrency level and round trips drop from 4,000/s at peak to 800/s; the locked path restores correctness but rises to 5,600 round trips/s.
  • Atomicity’s memory cost has a different slope depending on the path chosen: the script cache stays constant at 673 bytes, the lock table wants 109 bytes per concurrent job and climbs to 87,200 bytes at peak.
  • In a single-threaded store, a script’s cost is waiting, and the limit is not the step count but whether the step count depends on the data: a scan touching 400,000 records takes as much space as 80,000 loan jobs and keeps 8,000 commands waiting in the queue.

Next Step

Across nine lessons, this topic built a store. Key-value basics, strings and counters, lists, hashes, sets and sorted sets, bitmaps and probabilistic structures, streams, spatial indexes, and finally the atomicity of multi-step work — in each one, the bytes held were counted, what they bought in return was measured, and what doing it with less memory gives up was written down.

One property common to every one of these structures was never questioned: all of them sit in memory. The counter is in memory, the sorted set is in memory, the stream’s 62 MiB body is in memory, the script cache’s 673 bytes are in memory. When the process stops, none of it survives. This course’s first lesson said that durability is a setting; what that setting is, what options it offers, and how much data loss each option allows was never opened up. The next topic opens that setting, and its first question is this: how many ways are there to write these in-memory structures to disk, and how many writes does each way leave behind when a process crashes.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close