---
title: 'Server-Side Scripting'
source: 'https://academia.sh/en/courses/in-memory-stores/server-side-scripting'
course: 'In-Memory Stores and Caching Systems'
language: en
updated: '2026-08-23T07:00:44+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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**.

```js
// 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.

```js
// 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.
