---
title: 'In-Memory Stores and Caching Systems'
source: 'https://academia.sh/en/courses/in-memory-stores'
language: en
updated: '2026-08-23T07:00:42+00:00'
license: 'CC BY-SA 4.0'
lessons: 22
outcomes:
    - 'Maps in-memory data structures to the use cases that suit them'
    - 'Configures persistence and memory eviction policies'
    - 'Raises availability with clustering and replication'
---

# In-Memory Stores and Caching Systems

Stores that keep all data in memory, where durability becomes a setting: key-value basics, counters, lists, hashes, sets, sorted sets, bitmaps, probabilistic structures, streams, and spatial indexes mapped to the scenarios that suit them, the durability and performance trade-off between snapshot and append-log persistence, the memory limit and eviction policies, expiration management, replication, automatic failover, clustering, optimistic locking, publish-subscribe, and typical use cases.

## Data Structures and Commands

Mapping the structures an in-memory store offers to scenarios: key setting, reading, and expiration, counters built on atomic increment, lists as queues and stacks, hash structures that manage object fields separately, sets and sorted sets in membership and ranking scenarios, bitmaps and probabilistic structures for memory-efficient counting, streams and consumer groups as an ordered event log, spatial indexes for location-based queries, and server-side scripting for atomic multi-step operations.

### Key-Value Basics

Source: https://academia.sh/en/courses/in-memory-stores/key-value-basics

The contract and the cost of an in-memory store: building set, get, and expiration on a hand-written store, counting bytes held per entry as key, value, and overhead, comparing no-expiration, fixed-expiration, and sliding-expiration policies on peak memory and re-entry count, the effect of key and value shape on bytes held, and how overhead scales with entry count.

The Non-Relational Data Models course closed on a habit shared by every store it covered: data
was written to disk sooner or later, and durability was a given part of the model. This course
takes up the stores that drop that habit. In an in-memory store, all of the data sits in process
memory, disk is an optional setting, and the real constraint is not capacity but the **memory
budget**.

Every lesson in this course asks the same three questions: how many **bytes** does a structure
hold in memory, what does that buy in return, and what does an alternative that does the same
job with less memory give up. "It is fast" answers none of these questions; a fast in-memory
store is this course's assumption, not its subject. This lesson opens with the contract itself:
a key is set, read, and given a lifetime.

#### The Contract and Bytes per Entry

The store defines three operations. **Set** binds a value to a key and gives it an optional
lifetime. **Get** takes the key and returns the value; it treats an expired entry as if it were
absent. **Renewal** moves the expiration timestamp forward. The basic key-value contract still
holds here: the store does not look inside the value. The only thing that changes is where the
record sits — and that single change rewrites the cost calculation, because space on disk is a
capacity line item while space in memory is a budget.

The measurement uses the library's session record. **DS1:** the catalog carries 20,000 members,
the day is 12 hours (43,200 seconds), each member touches the system 1 to 5 times during the day,
and the gap between touches is 60 to 2,459 seconds; touch times come from a linear congruential
generator with a visible seed. **DS2:** overhead per entry is counted as 56 bytes — 8 for the
hash bucket pointer, 16 for the entry structure's pointers, 16 for length fields, 8 for the
expiration timestamp, 8 for alignment. Held bytes are always computed as `key + value + 56`.
**DS3:** a live entry is sampled every 300 seconds, and the peak is the largest of these samples;
when an expired entry is actually removed from memory is a separate question — only
**unexpired** entries are counted here.

```js
// memory/store.mjs — a hand-written in-memory store: key space, expiration, and
// counting the bytes held. Overhead per entry is reported in DS2; a live entry is
// counted once every 300 s.
const OVERHEAD = 56;

class InMemoryStore {
  #table = new Map();
  reads = 0; misses = 0;
  set(key, value, now, lifetime) {
    const s = JSON.stringify(value);
    this.#table.set(key, { s, expiresAt: lifetime === null ? null : now + lifetime });
  }
  get(key, now) {                              // an expired entry is treated as absent
    this.reads += 1;
    const entry = this.#table.get(key);
    if (entry === undefined || (entry.expiresAt !== null && entry.expiresAt <= now)) { this.misses += 1; return undefined; }
    return JSON.parse(entry.s);
  }
  renew(key, now, lifetime) { const entry = this.#table.get(key); if (entry) entry.expiresAt = now + lifetime; }
  live(now) {                                  // per entry: key + value + overhead
    let n = 0, b = 0;
    for (const [key, entry] of this.#table)
      if (entry.expiresAt === null || entry.expiresAt > now) { n += 1; b += Buffer.byteLength(key) + Buffer.byteLength(entry.s) + OVERHEAD; }
    return { n, b }; }
}

// --- event generation: a 12-hour day, 20,000 members, touches from a seeded generator ---
const MEMBERS = 20_000, DAY = 43_200, LIFETIME = 1800, SAMPLE = 300, SEED = 20240115;
let state = SEED;
const rand = () => (state = (state * 1103515245 + 12345) % 2147483648) / 2147483648;
const BRANCH = ["central", "shore", "university", "children", "archive"];
const events = [];
for (let m = 1; m <= MEMBERS; m += 1) {
  let t = Math.floor(rand() * (DAY - 7200));
  const touches = 1 + Math.floor(rand() * 5);
  for (let k = 0; k < touches && t < DAY; k += 1) { events.push([t, m]); t += 60 + Math.floor(rand() * 2400); }
}
events.sort((x, y) => x[0] - y[0] || x[1] - y[1]);
const sessionKey = (m) => `session:${m}:${(m * 2654435761) % 4294967296}`;
const session = (m) => ({ member: m, branch: BRANCH[m % 5], role: m % 97 === 0 ? "clerk" : "reader" });

function run(name, lifetime, sliding) {
  const store = new InMemoryStore();
  let peakEntries = 0, peakBytes = 0, newEntries = 0, nextSample = SAMPLE;
  for (const [t, m] of events) {
    while (t >= nextSample) { const snapshot = store.live(nextSample); if (snapshot.n > peakEntries) { peakEntries = snapshot.n; peakBytes = snapshot.b; } nextSample += SAMPLE; }
    const key = sessionKey(m);
    if (store.get(key, t) === undefined) { store.set(key, session(m), t, lifetime); newEntries += 1; }
    else if (sliding) store.renew(key, t, lifetime);
  }
  const final = store.live(DAY);
  if (final.n > peakEntries) { peakEntries = final.n; peakBytes = final.b; }
  return [name, peakEntries, peakBytes, final.n, final.b, store.misses, newEntries];
}

console.log(`seed ${SEED}; ${MEMBERS} members, ${events.length} touches, day ${DAY} s, lifetime ${LIFETIME} s`);
console.log(`sample session value ${Buffer.byteLength(JSON.stringify(session(4242)))} bytes, ` +
  `key ${Buffer.byteLength(sessionKey(4242))} bytes, overhead ${OVERHEAD} bytes`);
const rows = [run("no expiration", null, false), run("fixed expiration", LIFETIME, false),
  run("sliding expiration", LIFETIME, true)];
console.log(`\n${"policy".padEnd(16)}${"peak entries".padStart(12)}${"peak bytes".padStart(11)}` +
  `${"end-of-day entries".padStart(19)}${"end-of-day bytes".padStart(17)}${"misses".padStart(7)}${"extra re-entries".padStart(18)}`);
for (const [name, pe, pb, ee, eb, ms] of rows)
  console.log(name.padEnd(16) + String(pe).padStart(12) + String(pb).padStart(11) +
    String(ee).padStart(19) + String(eb).padStart(17) + String(ms).padStart(7) + String(ms - MEMBERS).padStart(18));
```

```
seed 20240115; 20000 members, 59337 touches, day 43200 s, lifetime 1800 s
sample session value 53 bytes, key 23 bytes, overhead 56 bytes

policy          peak entries peak bytes end-of-day entries end-of-day bytes misses  extra re-entries
no expiration          20000    2600406              20000          2600406  20000                 0
fixed expiration        2193     285185                 28             3644  36951             16951
sliding expiration        2658     345617                 29             3769  31182             11182
```

#### Expiration Is a Memory Policy

All three runs process the same 59,337 touches; the only thing that differs is the lifetime
setting. With no expiration given, the store carries 20,000 entries and 2,600,406 bytes by the
end of the day, and that number never shrinks: once a member logs in, the record sits there for
as long as the process lives. In exchange, every touch finds its record in place; the 20,000
misses are only the day's first touches and are unavoidable — nobody finds a session without
logging in for the first time. Extra re-entries are zero.

Fixed expiration does the same job with a peak of 2,193 entries and 285,185 bytes: roughly nine
times less memory. What that buys is plain to see — memory now scales with the count of
concurrently active members, not the member count, and only 28 entries remain at the end of the
day. What it gives up is just as countable: 16,951 extra re-entries. A member whose record has
dropped has to re-authenticate.

Sliding expiration sits between the two and makes the purchase rate visible. The peak climbs to
345,617 bytes, 60,432 bytes more than the fixed policy; in exchange, extra re-entries drop from
16,951 to 11,182. Each of the 5,769 saved entries costs 10.5 bytes of peak memory. The lesson
foregrounds this number: policy choice is not a matter of style, it is a purchase priced in
bytes. The same arithmetic looks completely different in end-of-day memory — the no-expiration
policy holds onto 2.6 MB permanently, while the two expiring policies close out around
3.6-3.8 KB; the difference is roughly 700-fold.

#### The Shape of the Key and the Value

Entry count is not the only lever. The same session can be held in different key and value
shapes, and each shape directly changes the bytes held. The second setup counts the same member
in six shapes and, at the end, compares the structural count against an environment-dependent
measurement.

```js
// memory/shape.mjs — key and value representations of the same session: bytes held
// per format. OVERHEAD is the same constant; the structural count is run-independent.
const OVERHEAD = 56, ENTRIES = 20_000, BRANCH = ["central", "shore", "university", "children", "archive"];
const session = (m) => ({ member: m, branch: BRANCH[m % 5], role: m % 97 === 0 ? "clerk" : "reader" });
const member = 4242, token = (member * 2654435761) % 4294967296, rec = session(member);

const formats = [
  ["session:token:4242:...", `session:token:${member}:${token}`],
  ["session:4242:...", `session:${member}:${token}`],
  ["s:...", `s:${token}`],
];
const values = [["self-describing", JSON.stringify(rec)], ["positional", `${rec.member}|${rec.branch}|${rec.role}`]];

console.log(`${"key format".padEnd(23)}${"key".padStart(8)}${"value format".padStart(20)}` +
  `${"value".padStart(7)}${"entry".padStart(7)}${`at ${ENTRIES}`.padStart(14)}`);
for (const [label, keyStr] of formats)
  for (const [valueLabel, valueStr] of values) {
    const entryBytes = Buffer.byteLength(keyStr) + Buffer.byteLength(valueStr) + OVERHEAD;
    console.log(label.padEnd(23) + String(Buffer.byteLength(keyStr)).padStart(8) + valueLabel.padStart(20) +
      String(Buffer.byteLength(valueStr)).padStart(7) + String(entryBytes).padStart(7) + String(entryBytes * ENTRIES).padStart(14));
  }

// a new field (branch_code) is inserted in second position — how OLD entries are read
const parsed = JSON.parse(values[0][1]), [, k1, k2, k3] = values[1][1].split("|");
console.log(`\nold entry, read with new order (member|branch_code|branch|role):`);
console.log(`  self-describing -> branch=${parsed.branch} role=${parsed.role} branch_code=${parsed.branch_code}`);
console.log(`  positional      -> branch=${k2} role=${k3} branch_code=${k1}`);

const before = process.memoryUsage().heapUsed;                  // ENVIRONMENT-DEPENDENT
const table = new Map();
for (let i = 1; i <= ENTRIES; i += 1)
  table.set(`session:${i}:${(i * 2654435761) % 4294967296}`, { s: JSON.stringify(session(i)), expiresAt: 1800 });
const after = process.memoryUsage().heapUsed;
const structural = [...table].reduce((t, [key, entry]) => t + Buffer.byteLength(key) + Buffer.byteLength(entry.s) + OVERHEAD, 0);
console.log(`\nstructural count of ${ENTRIES} entries is ${structural} bytes (run-independent); ` +
  `the real heap difference is ${((after - before) / structural).toFixed(1)} times that (ENVIRONMENT-DEPENDENT, varies from run to run)`);
```

```
key format                  key        value format  value  entry      at 20000
session:token:4242:...       29     self-describing     53    138       2760000
session:token:4242:...       29          positional     22    107       2140000
session:4242:...             23     self-describing     53    132       2640000
session:4242:...             23          positional     22    101       2020000
s:...                        12     self-describing     53    121       2420000
s:...                        12          positional     22     90       1800000

old entry, read with new order (member|branch_code|branch|role):
  self-describing -> branch=university role=reader branch_code=undefined
  positional      -> branch=reader role=undefined branch_code=university

structural count of 20000 entries is 2600406 bytes (run-independent); the real heap difference is 2.5 times that (ENVIRONMENT-DEPENDENT, varies from run to run)
```

At 20,000 entries, there are 960,000 bytes between the priciest shape and the cheapest — a
34.8 percent difference from a one-line code change. Shrinking the key from 29 bytes to 12 bytes
saves 340,000 bytes; stripping the value of its field names saves 620,000 bytes.

Both losses are concrete. The short key removes the key namespace's readability: without the
`session:` prefix, the same namespace cannot distinguish which entry is a session and which is a
cache record, and two different jobs that produce the same name collide silently. The loss from
the positional value is harsher. The three middle lines of the output show it: once a
`branch_code` field is added in second position, the self-describing value still resolves old
entries correctly (`branch=university`, `role=reader`) and only finds the new field empty. The
positional value misreads the same entry: `reader` lands in the branch field, `university` lands
in the branch code. The 620,000 bytes gained are paid for with the data's ability to describe
itself.

The last line marks the limit of the measurement. The structural count is 2,600,406 bytes,
identical to the first setup's end-of-day count; the real heap usage, in this run, is 2.5 times
that. This second number is environment-dependent and varies from run to run — it only says that
the structural count is a lower bound; the measure that drives the decision throughout the course
is the first one. The ratio between the two also says something else: the 56-byte overhead takes
up 62.2 percent of the entry in the cheapest shape. As entries shrink, the payoff from shaping
them better shrinks with them; the real lever is not the shape of the entry, it is the **number
of entries** — which is to say, the expiration policy.

#### Summary

- An in-memory store defines three operations: set, get, renew. The value is opaque to the
  store; the only thing that changes is that the record sits in memory instead of on disk, and
  that turns space from a capacity line item into a budget.
- Held bytes are counted as key + value + 56. 20,000 session records hold 2,600,406 bytes — an
  average of 130.0 bytes per entry.
- Expiration is a memory policy: fixed expiration drops the peak from 20,000 entries to 2,193
  and from 2,600,406 bytes to 285,185, at the cost of 16,951 extra re-entries.
- Sliding expiration holds 60,432 bytes more and prevents 5,769 re-entries — 10.5 bytes per
  prevented re-entry. Policy choice is a purchase priced in bytes.
- Key and value shape make a 960,000-byte difference at 20,000 entries; the positional value
  earns that gain by keeping field order outside the store, and it silently misreads old entries
  once the schema changes.
- The structural count is run-independent and is the measure that drives the decision; the real
  heap usage (2.5 times the structural count) is environment-dependent and only shows that the
  structural count is a lower bound.

#### Next Step

Every write in this lesson overwrote the entire value: the session record was read, changed, and
put back. The things the library needs to count do not fit this pattern. How many times a book
has been borrowed, how many loans a branch handed out during the day, how many times a member has
been late — each of these is a single number that goes up by one on every request. What happens
when two requests read and write back the same counter at the same time was never asked in this
lesson. The next lesson measures exactly that: the difference between read-modify-write and the
store's own increment under concurrent requests, the number of lost updates, and how many bytes a
single counter costs across a 200,000-book catalog.

### Strings and Counters

Source: https://academia.sh/en/courses/in-memory-stores/strings-and-counters

What atomic increment buys: updating the same loan counter with read-modify-write and with the store's own single-step increment, measuring the lost updates and round trips this produces at increasing branch concurrency, breaking down a counter's real memory price into key, value, and overhead, and comparing no-counter, partial-counter, and full-counter options in terms of steps prevented per byte.

Every write in the previous lesson overwrote the entire value. Most of what the library needs to
count does not fit that pattern: how many times a book has been borrowed, a branch's daily loan
total, how many times a member has been late — each of these is a single number that goes up by
one on every event. This lesson takes up that number with two questions: what changes depending
on **who** increments the counter, and what a single number actually costs in memory.

The store holds a value as a byte string; a number is also a string. But the store can interpret
that string as a number and increment it in a single step. This capability is called atomic
increment, and at first glance it looks like a convenience. Measured, it turns out to be not a
convenience but accuracy itself.

#### Who Increments the Counter

There are two paths. **Read-modify-write** is three separate steps: the value is read,
incremented in the application, and written back. **Atomic increment** is a single step: the
store reads the value, adds to it, and writes it; nothing else can interleave in between. The
difference only shows up under concurrency.

The measurement uses a popular book's loan counter. **DS1:** the same book is loaned out
concurrently by 1, 2, 4, 8, and 16 branches, and each branch records 500 loans; the expected
counter value is the branch count times 500. **DS2:** concurrency is built with generators,
every `yield` is a suspension point, and the scheduler picks among ready clients with a generator
whose seed is visible; the run is deterministic.

```js
// memory/counter.mjs — the same loan counter incremented two ways. Clients are
// generators, the scheduler is seeded; step order is run-independent.
class InMemoryStore {
  #table = new Map();
  roundTrips = 0;
  get(key) { this.roundTrips += 1; const s = this.#table.get(key); return s === undefined ? 0 : Number(s); }
  set(key, value) { this.roundTrips += 1; this.#table.set(key, String(value)); }
  increment(key, delta) {                      // reads, adds, and writes in a single step
    this.roundTrips += 1;
    const value = (this.#table.has(key) ? Number(this.#table.get(key)) : 0) + delta;
    this.#table.set(key, String(value)); return value; }
}

function* readModifyWrite(store, key, times) {
  for (let i = 0; i < times; i += 1) { const value = store.get(key); yield; store.set(key, value + 1); yield; }
}
function* atomic(store, key, times) {
  for (let i = 0; i < times; i += 1) { store.increment(key, 1); yield; }
}

const SEED = 20240115, TIMES = 500, KEY = "book:100427:loan";
function schedule(generators) {                // seeded selection among ready clients
  let state = SEED;
  const rand = () => (state = (state * 1103515245 + 12345) % 2147483648) / 2147483648;
  const live = [...generators];
  while (live.length > 0) {
    const i = Math.floor(rand() * live.length);
    if (live[i].next().done) live.splice(i, 1);
  }
}

console.log(`seed ${SEED}; each branch records ${TIMES} loans, key ${KEY}`);
console.log(`${"branches".padStart(9)}${"expected".padStart(10)}${"read-modify-write".padStart(19)}` +
  `${"lost".padStart(7)}${"round trips".padStart(13)}${"atomic increment".padStart(18)}${"lost".padStart(7)}${"round trips".padStart(13)}`);
for (const branches of [1, 2, 4, 8, 16]) {
  const expected = branches * TIMES;
  const rmwStore = new InMemoryStore();
  schedule(Array.from({ length: branches }, () => readModifyWrite(rmwStore, KEY, TIMES)));
  const atomicStore = new InMemoryStore();
  schedule(Array.from({ length: branches }, () => atomic(atomicStore, KEY, TIMES)));
  const rmwFinal = rmwStore.get(KEY), atomicFinal = atomicStore.get(KEY);
  console.log(String(branches).padStart(9) + String(expected).padStart(10) + String(rmwFinal).padStart(19) +
    String(expected - rmwFinal).padStart(7) + String(rmwStore.roundTrips - 1).padStart(13) + String(atomicFinal).padStart(18) +
    String(expected - atomicFinal).padStart(7) + String(atomicStore.roundTrips - 1).padStart(13));
}
```

```
seed 20240115; each branch records 500 loans, key book:100427:loan
 branches  expected  read-modify-write   lost  round trips  atomic increment   lost  round trips
        1       500                500      0         1000               500      0          500
        2      1000                620    380         2000              1000      0         1000
        4      2000                747   1253         4000              2000      0         2000
        8      4000                871   3129         8000              4000      0         4000
       16      8000                938   7062        16000              8000      0         8000
```

With a single branch running, both paths give the correct result; the divergence starts at the
second branch. At two branches, 380 of 1,000 loans go missing; at sixteen branches, 7,062 of
8,000. The counter stalls at 938: the library handed out eight thousand loans, and its record
reads nine hundred thirty-eight. A lost update is not a delay or a slowdown — it is a **wrong
number**, and it shows up nowhere as an error.

The cause is the gap between the read and the write. By the time one branch reads 41 and writes
42, other branches have also read 41; all of them write 42, and all four of them together have
made a single increment. Atomic increment has no such gap — the store reads, adds, and writes as
one operation that cannot be split. At sixteen branches, the loss is zero.

The round-trip column shows the second gain: read-modify-write spends two round trips per
increment, while atomic increment finishes in one — 8,000 instead of 16,000. Getting the same
correctness out of read-modify-write means holding a lock in the application, which in turn means
keeping the lock itself in memory and lining the branches up in a queue. Atomic increment removes
both a round trip and the lock's memory — and asks for no extra bytes in return, because the
increment is the store's own job.

#### The Memory Price of a Number

The counter now increments correctly; the real question comes next. How much memory does holding
a loan counter for the entire catalog take up. **DS3:** the catalog carries 200,000 books, each
book's counter value comes from a deterministic function and falls between 1 and 4,096; the loan
ledger is 119,993 records and sits outside the in-memory store — scanning it counts steps, not
bytes. **DS4:** the query load is 10,000 "how many times was this book borrowed" questions,
skewed toward popularity; the skew comes from the same seeded generator.

```js
// memory/budget.mjs — the memory price of one counter and two options that skip it.
// The loan ledger is outside the in-memory store; scanning it counts steps, not bytes.
const OVERHEAD = 56, BOOKS = 200_000, LEDGER = 119_993, QUERIES = 10_000, HOT = 1_000, SEED = 20240115;
let state = SEED;
const rand = () => (state = (state * 1103515245 + 12345) % 2147483648) / 2147483648;

// counter value: daily loan count for book i (deterministic)
const counterValue = (i) => 1 + ((i * 7919) % 4096);
const key = (i) => `book:${100_000 + i}:loan`;

let totalKey = 0, totalValue = 0, totalOverhead = 0;
for (let i = 1; i <= BOOKS; i += 1) {
  totalKey += Buffer.byteLength(key(i));
  totalValue += Buffer.byteLength(String(counterValue(i)));
  totalOverhead += OVERHEAD;
}
const full = totalKey + totalValue + totalOverhead;
console.log(`${BOOKS} books, sample entry "${key(427)}" -> "${counterValue(427)}"`);
console.log(`full counter: key ${totalKey} + value ${totalValue} + overhead ${totalOverhead} = ${full} bytes` +
  ` (per entry ${(full / BOOKS).toFixed(1)}; value's share %${(100 * totalValue / full).toFixed(1)})`);

// query load: "how many times was this book borrowed" — skewed to popularity, seeded
const queries = Array.from({ length: QUERIES }, () => 1 + Math.floor(BOOKS * rand() ** 6));
const hotQueries = queries.filter((i) => i <= HOT).length;
const partial = Array.from({ length: HOT }, (_, j) =>
  Buffer.byteLength(key(j + 1)) + Buffer.byteLength(String(counterValue(j + 1))) + OVERHEAD)
  .reduce((t, x) => t + x, 0);

const baseline = QUERIES * LEDGER;              // steps when no counter is held
const paths = [
  ["no counter, ledger scan", 0, 0, baseline],
  [`partial counter (top ${HOT})`, HOT, partial, hotQueries + (QUERIES - hotQueries) * LEDGER],
  ["full counter", BOOKS, full, QUERIES],
];
console.log(`\n${QUERIES} queries, ${hotQueries} of them to the first ${HOT} books (%${(100 * hotQueries / QUERIES).toFixed(1)})`);
console.log(`${"path".padEnd(27)}${"entries".padStart(8)}${"bytes held".padStart(14)}` +
  `${"steps per query".padStart(19)}${"steps prevented per byte".padStart(27)}`);
for (const [name, n, b, steps] of paths)
  console.log(name.padEnd(27) + String(n).padStart(8) + String(b).padStart(14) +
    (steps / QUERIES).toFixed(1).padStart(19) + (b === 0 ? "-" : ((baseline - steps) / b).toFixed(1)).padStart(27));
```

```
200000 books, sample entry "book:100427:loan" -> "2214"
full counter: key 3200000 + value 745901 + overhead 11200000 = 15145901 bytes (per entry 75.7; value's share %4.9)

10000 queries, 4149 of them to the first 1000 books (%41.5)
path                        entries    bytes held    steps per query   steps prevented per byte
no counter, ledger scan           0             0           119993.0                          -
partial counter (top 1000)     1000         75662            70208.3                     6579.9
full counter                 200000      15145901                1.0                       79.2
```

The first line carries the lesson's headline: 200,000 counters hold 15,145,901 bytes, 75.7 per
entry. Yet the information stored is a four-digit number. The number itself takes up only
4.9 percent of the total; the key name takes 21.1 percent, entry overhead takes 73.9 percent.
Holding a number in an in-memory store costs not four bytes but seventy-six. This ratio holds
for every small value, and the real lever is not the value itself, it is **how many keys are
opened**.

The lower table compares three options under the same load. If the counter is never held, memory
is zero and every query scans the 119,993-record ledger. With the full counter, memory rises to
15.1 MB and steps per query drop to 1: 15.1 MB buys 119,992 steps per query.

The last column is the number that actually decides. Holding a counter only for the first 1,000
books costs 75,662 bytes — one two-hundredth of the full counter — and it drops 41.5 percent of
the queries to a single step. Every byte it holds prevents 6,579.9 steps; the same figure for the
full counter is 79.2. The partial counter is roughly eighty-three times more byte-efficient. But
the raw number is still unforgiving: 70,208 steps per query, because each of the missed
58.5 percent scans the entire ledger. If the memory budget is tight, the partial counter is the
more efficient purchase; if a fixed step count per query is required, there is no alternative to
the 15.1 MB. What decides between the two is not "speed" — it is the product of the miss rate and
the budget.

#### Summary

- A counter has two update paths: read-modify-write is three steps and is open to other clients
  between the read and the write; atomic increment is the store's single, unsplittable step.
- Loss grows with concurrency: at 2 branches, 380 of 1,000 loans go missing; at 16 branches,
  7,062 of 8,000, and the counter stalls at 938. With atomic increment, the loss is zero at
  every level.
- Atomic increment also halves round trips (8,000 instead of 16,000) and removes the need to
  hold a lock in the application for correctness; a lock means both memory and waiting.
- 200,000 loan counters hold 15,145,901 bytes. The number itself is 4.9 percent of that, the key
  name is 21.1 percent, entry overhead is 73.9 percent: a number in an in-memory store costs
  75.7 bytes.
- Three options under the same load: no counter, 0 bytes and 119,993 steps per query; partial
  counter, 75,662 bytes and 70,208 steps; full counter, 15,145,901 bytes and 1 step.
- Steps prevented per byte are 6,579.9 for the partial counter and 79.2 for the full counter.
  The partial counter is far more efficient per unit of budget, but it gives no guarantee of a
  fixed step count per query.

#### Next Step

A counter answers the "how many" question at the scale of a single byte, but it never meets the
library's second question: who is waiting for a popular book, and in what order. A waiting list
is not a number, it is a sequence; the order itself is part of the information, and if the store
is to preserve it, it has to hold every member individually. The next lesson takes up the list
and measures three things: the step count of inserting and removing from both ends, the effect
of the list's growth on memory, and what a bounded list — one trimmed to a fixed length —
cheapens compared to an unbounded one, and what it silently loses.

### Lists

Source: https://academia.sh/en/courses/in-memory-stores/lists

The cost of holding order itself as data: measuring the same waiting-list workload in contiguous-array, linked-node, and ring-buffer implementations by steps and bytes per entry, how many bytes per entry constant-step access from both ends is bought for, rank lookup being a scan in all three structures, and how trimming a recent-activity list lowers bytes held while narrowing the covered time window and the satisfied query rate.

The counter answered the "how many" question with a single number. The library's second
question cannot be answered with a number: who is waiting for a popular book, and in what order.
In a waiting list, the order itself is part of the information; if the store is to preserve it,
it has to hold every member individually, together with their position.

A list is a single structure with two distinct uses. A **queue** appears when items are added at
one end and removed from the other: the waiting list works this way, first come, first served. A
**stack** appears when items are added and removed from the same end: a clerk undoing their last
action works this way. From the store's point of view, the difference is not in the structure, it
is in which end is chosen. The real question is this: what does constant-step access from both
ends cost in memory.

#### The Same Workload, Three Implementations

**DS1:** 20,000 requests are added to the waiting list from the back, a front pickup happens
after every fourth request (5,000 removals), and a priority request is moved to the front every
fortieth request (500 insertions); 25,500 operations total. **DS2:** an array slot is 8 bytes, a
linked node's extra cost is 24 bytes (previous and next pointers plus the node header), overhead
per structure is 56 bytes. **DS3:** the ring buffer starts with 1,024 slots and doubles its
capacity once full; copy steps are counted.

```js
// memory/list.mjs — the same waiting-list workload in three implementations. Step and
// byte counters live inside the structure itself; the workload is deterministic.
const OVERHEAD = 56, SLOT = 8, NODE_OVERHEAD = 24;   // DS2

class ArrayList {                              // contiguous array: a front operation shifts everything
  #items = []; steps = 0;
  pushBack(v) { this.steps += 1; this.#items.push(v); }
  pushFront(v) { this.steps += this.#items.length + 1; this.#items.unshift(v); }
  popFront() { this.steps += this.#items.length; return this.#items.shift(); }
  get length() { return this.#items.length; }
  get bytes() { return OVERHEAD + this.#items.reduce((t, v) => t + Buffer.byteLength(v) + SLOT, 0); }
  rank(v) { let n = 0; for (const x of this.#items) { n += 1; if (x === v) return n; } return -1; }
}

class LinkedList {                             // constant single step from both ends; extra pointer per node
  #head = null; #tail = null; #n = 0; #b = 0; steps = 0;
  #node(v) { this.#n += 1; this.#b += Buffer.byteLength(v) + SLOT + NODE_OVERHEAD; return { v, prev: null, next: null }; }
  pushBack(v) { this.steps += 1; const node = this.#node(v);
    node.prev = this.#tail; if (this.#tail) this.#tail.next = node; else this.#head = node; this.#tail = node; }
  pushFront(v) { this.steps += 1; const node = this.#node(v);
    node.next = this.#head; if (this.#head) this.#head.prev = node; else this.#tail = node; this.#head = node; }
  popFront() { this.steps += 1; const node = this.#head; if (!node) return undefined;
    this.#head = node.next; if (this.#head) this.#head.prev = null; else this.#tail = null;
    this.#n -= 1; this.#b -= Buffer.byteLength(node.v) + SLOT + NODE_OVERHEAD; return node.v; }
  get length() { return this.#n; }
  get bytes() { return OVERHEAD + this.#b; }
  rank(v) { let n = 0; for (let node = this.#head; node; node = node.next) { n += 1; if (node.v === v) return n; } return -1; }
}

class RingBuffer {                             // fixed slots; capacity doubles once full
  #slots; #head = 0; #n = 0; steps = 0; copies = 0; growths = 0;
  constructor(capacity) { this.#slots = new Array(capacity); }
  #grow() { if (this.#n < this.#slots.length) return;
    const grown = new Array(this.#slots.length * 2);
    for (let i = 0; i < this.#n; i += 1) grown[i] = this.#slots[(this.#head + i) % this.#slots.length];
    this.steps += this.#n; this.copies += this.#n; this.growths += 1; this.#slots = grown; this.#head = 0; }
  pushBack(v) { this.#grow(); this.steps += 1; this.#slots[(this.#head + this.#n) % this.#slots.length] = v; this.#n += 1; }
  pushFront(v) { this.#grow(); this.steps += 1;
    this.#head = (this.#head - 1 + this.#slots.length) % this.#slots.length; this.#slots[this.#head] = v; this.#n += 1; }
  popFront() { this.steps += 1; if (this.#n === 0) return undefined;
    const v = this.#slots[this.#head]; this.#slots[this.#head] = undefined;
    this.#head = (this.#head + 1) % this.#slots.length; this.#n -= 1; return v; }
  get length() { return this.#n; }
  get capacity() { return this.#slots.length; }
  get bytes() { let d = 0;
    for (let i = 0; i < this.#n; i += 1) d += Buffer.byteLength(this.#slots[(this.#head + i) % this.#slots.length]);
    return OVERHEAD + this.#slots.length * SLOT + d; }
  rank(v) { for (let i = 0; i < this.#n; i += 1) if (this.#slots[(this.#head + i) % this.#slots.length] === v) return i + 1; return -1; }
}

// --- workload: 20,000 requests to the back, 1 front pop every 4 requests, 1 priority push to the front every 40 requests
const REQUESTS = 20_000;
const value = (i) => `${10_000 + i}:${40_000 + i * 2}`;
const operations = [];
for (let i = 1; i <= REQUESTS; i += 1) {
  operations.push(["back", value(i)]);
  if (i % 4 === 0) operations.push(["pop", null]);
  if (i % 40 === 0) operations.push(["front", `9${value(i)}`]);
}
const run = (list) => { for (const [op, v] of operations) {
  if (op === "back") list.pushBack(v); else if (op === "front") list.pushFront(v); else list.popFront(); } return list; };

const ring = new RingBuffer(1024);
const structures = [["contiguous array", run(new ArrayList())], ["linked node", run(new LinkedList())],
  ["ring buffer", run(ring)]];
console.log(`${operations.length} operations (${REQUESTS} to the back, ${REQUESTS / 4} pops from the front, ${REQUESTS / 40} to the front)`);
console.log(`${"structure".padEnd(15)}${"length".padStart(9)}${"bytes held".padStart(14)}` +
  `${"per entry".padStart(13)}${"total steps".padStart(13)}${"per operation".padStart(14)}`);
for (const [name, list] of structures)
  console.log(name.padEnd(15) + String(list.length).padStart(9) + String(list.bytes).padStart(14) +
    (list.bytes / list.length).toFixed(1).padStart(13) + String(list.steps).padStart(13) +
    (list.steps / operations.length).toFixed(1).padStart(14));
console.log(`ring buffer: ${ring.growths} growths, ${ring.copies} copy steps, capacity ${ring.capacity}, ` +
  `empty slots ${ring.capacity - ring.length}`);

const target = value(19_000);
console.log(`\n"${target}" ranked at: ` + structures.map(([name, list]) => `${name} -> rank ${list.rank(target)}`).join(", "));
```

```
25500 operations (20000 to the back, 5000 pops from the front, 500 to the front)
structure         length    bytes held    per entry  total steps per operation
contiguous array    15500        294557         19.0     42662750        1673.0
linked node        15500        666557         43.0        25500           1.0
ring buffer        15500        301629         19.5        40860           1.6
ring buffer: 4 growths, 15360 copy steps, capacity 16384, empty slots 884

"29000:78000" ranked at: contiguous array -> rank 14500, linked node -> rank 14500, ring buffer -> rank 14500
```

All three structures carry the same 15,500-person waiting list; the bytes they hold and the
steps they spend are far apart. The contiguous array is the cheapest at 19.0 bytes per entry, but
because every front operation shifts every remaining entry, it spends 1,673 steps per operation;
the total for 25,500 operations is 42.7 million steps. Calling one person off the waiting list
means moving everyone else's position.

The linked node reverses this: every operation is exactly 1 step, 25,500 total. Its price is
43.0 bytes per entry — 2.26 times the array's cost. The difference is purely pointer overhead:
every entry carries an extra 24 bytes for two neighbor pointers and the node header. A member
identifier itself is 11 bytes, while binding it into the sequence costs 24 — as much space as the
data itself goes toward describing the data's position.

The ring buffer shows the third path, and it is the best purchase for this workload: 19.5 bytes
per entry — only 0.5 bytes more than the array — and 1.6 steps per operation. Keeping a head
pointer turns removal from the front from a shift into a constant step. Its price is capacity
management: starting with 1,024 slots, it has grown four times, spent 15,360 copy steps, and ends
up holding 884 empty slots. The only thing the linked node buys over the ring buffer is those
15,360 steps, and its price is 364,928 bytes: 23.8 bytes per step. That is a poor purchase for a
waiting list; the decision is set by this ratio, not by the structure's name.

The last line shows a limit common to all three. A given member's rank is 14,500 in all three
structures, and finding it means walking 14,500 entries. The list holds order but does not
provide lookup by order; going from membership to position is a scan in every case.

#### The Price of Trimming

The second use is the recent-activity list: every loan, return, renewal, and reservation is
written to the end of the list, and the clerk panel asks for the "last m events." If this list is
never trimmed, it grows all day. **DS4:** the day is 43,200 seconds and produces 60,000 events; an
entry averages 25.7 bytes. **DS5:** 2,000 queries arrive, the requested m is skewed toward small
values with a visible seed, and a trimmed list is counted at ring-buffer cost.

```js
// memory/trim.mjs — the recent-activity list: holding it unbounded vs. trimming it to
// a fixed length. A trimmed list is counted at ring-buffer cost: 56 + capacity*8 + live value bytes.
const OVERHEAD = 56, SLOT = 8, DAY = 43_200, EVENTS = 60_000, QUERIES = 2_000, SEED = 20240115;
let state = SEED;
const rand = () => (state = (state * 1103515245 + 12345) % 2147483648) / 2147483648;

const KIND = ["loan", "return", "renewal", "reservation"];
const events = Array.from({ length: EVENTS }, (_, i) =>
  `${Math.floor(i * DAY / EVENTS)}|${10_000 + (i * 7919) % 20_000}|${KIND[i % 4]}|${100_000 + (i * 4241) % 200_000}`);
const valueBytes = events.map((e) => Buffer.byteLength(e));
const totalBytes = valueBytes.reduce((t, x) => t + x, 0);

// query: "last m events" — skewed toward small m, seeded
const queries = Array.from({ length: QUERIES }, () => 1 + Math.floor(3_000 * rand() ** 2));

console.log(`${EVENTS} events / ${DAY} s, sample "${events[41]}" (${valueBytes[41]} bytes), ` +
  `average ${(totalBytes / EVENTS).toFixed(1)} bytes`);
console.log(`${QUERIES} queries, largest m ${Math.max(...queries)}, median m ${[...queries].sort((a, b) => a - b)[QUERIES / 2]}`);
console.log(`\n${"limit".padStart(9)}${"bytes held".padStart(14)}${"dropped".padStart(11)}` +
  `${"covered s".padStart(13)}${"queries satisfied".padStart(19)}${"rate".padStart(8)}`);
for (const k of [100, 1_000, 10_000, EVENTS]) {
  const live = events.slice(EVENTS - k);
  const bytes = OVERHEAD + k * SLOT + valueBytes.slice(EVENTS - k).reduce((t, x) => t + x, 0);
  const span = Number(live.at(-1).split("|")[0]) - Number(live[0].split("|")[0]);
  const satisfied = queries.filter((m) => m <= k).length;
  console.log(String(k === EVENTS ? "unbounded" : k).padStart(9) + String(bytes).padStart(14) +
    String(EVENTS - k).padStart(11) + String(span).padStart(13) +
    String(satisfied).padStart(19) + `%${(100 * satisfied / QUERIES).toFixed(1)}`.padStart(8));
}
```

```
60000 events / 43200 s, sample "29|14679|return|273881" (22 bytes), average 25.7 bytes
2000 queries, largest m 2999, median m 752

    limit    bytes held    dropped    covered s  queries satisfied    rate
      100          3456      59900           71                368   %18.4
     1000         34056      59000          719               1167   %58.4
    10000        340056      50000         7199               2000  %100.0
unbounded       2024625          0        43199               2000  %100.0
```

The unbounded list closes out the day at 2,024,625 bytes and keeps growing at the same rate the
next day; a list has no natural stopping point. The 10,000-entry trim satisfies all 2,000 of the
same queries, and it does so with 16.8 percent of the unbounded list's memory: the 1.7 MB saved
costs nothing on the query side.

The **covered s** column tells us where the cost is. The 10,000-entry list only covers the last
7,199 seconds, that is, two hours; the unbounded list covers twelve hours. Because nobody in this
workload asks further back than two hours, the loss is invisible — but invisible does not mean it
does not exist. When the limit drops to 1,000, memory falls to 34,056 bytes and the list covers
only 719 seconds, twelve minutes; 41.6 percent of queries get an incomplete answer. At the 100
limit, coverage drops to 71 seconds and the satisfied rate falls to 18.4 percent.

The real warning here is not in the numbers themselves, it is in the shape of the error. A
trimmed list does not say "I do not have it"; it gives whatever it has. A query asking for 2,400
events gets 1,000 records back from a 1,000-entry list and uses the answer without knowing it is
incomplete. The limit is chosen where the memory budget and the query distribution intersect, and
once the distribution shifts, it silently starts producing wrong answers.

#### Summary

- A queue and a stack are not separate structures; they are which end of the same list items are
  added to and removed from. The waiting list is a queue, the clerk's undo sequence is a stack.
- Across the same 25,500 operations, the contiguous array holds 19.0 bytes per entry but spends
  1,673 steps per operation; the linked node drops to 1 step per operation and demands 43.0 bytes
  per entry (2.26 times).
- The ring buffer closes the gap between the two: 19.5 bytes and 1.6 steps. The 15,360 steps the
  linked node buys over it are priced at 364,928 bytes — 23.8 bytes per step.
- Rank lookup is a scan in all three structures: the target entry ranks 14,500th and finding it
  takes 14,500 steps. The list holds order but does not provide lookup by order.
- The recent-activity list closes out the day at 2,024,625 bytes when it is never trimmed, and it
  has no stopping point. A 10,000-entry limit cuts memory to 16.8 percent and satisfies all 2,000
  queries in this workload.
- The real price of trimming is the covered time window: 10,000 entries cover two hours, 1,000
  entries cover twelve minutes. A trimmed list does not report that its answer is incomplete; it
  gives whatever it has.

#### Next Step

Everything placed in the store up to this point has been a single-piece value: a session record
is a whole, a counter is a number, a list entry is a string. The library's actual record is not
like that. A book has a title, an author, a shelf code, a status, and a loan count; status changes
several times an hour while the title never changes. Holding the whole record as a single value
means writing the whole record to update a single field. The next lesson builds this record in two
more shapes and measures three things: the bytes written on a single-field update, the bytes read
on a single-field read, and the overhead share that managing fields separately asks back.

### Hash Structures

Source: https://academia.sh/en/courses/in-memory-stores/hashes

The cost and the payoff of introducing object fields to the store: building the same book record in holistic-value, dense-hash, and per-field-entry shapes and measuring it under daily loan load by bytes held, bytes written, bytes read, round trips, and steps, per-field overhead roughly doubling record cost, and how the dense representation's memory gain is paid for in scan steps as field count grows, plus a threshold policy's catalog-wide outcome.

Everything placed in the store up to this point has been a single piece: a session record is a
whole, a counter is a number, a list entry is a string. The library's actual record is not like
that. A book has a title, an author, a shelf code, a status, a loan count, a date added, and a
number. These fields also do not change at the same rate: status changes many times a day, the
title never changes.

In a holistic value, the store has no concept of a field; updating a single field means reading
the whole record and writing it back — and the lost-update problem measured in the previous
lesson comes from exactly this. The hash structure is the alternative: the store knows the
record's fields, reads by name, writes by name. This lesson asks the price of that knowledge.

#### The Same Record, Three Shapes

Three shapes are compared. A **holistic value** holds the record as a single serialized string.
A **dense hash** holds the fields consecutively in one block, but the store knows the field
boundaries; a single-field request is served by scanning the block. A **hash structure** holds
every field as its own entry and accesses it by name in a single step.

**DS1:** the catalog carries 200,000 books, each record has seven fields, and field values come
from deterministic functions. **DS2:** overhead per structure is 56 bytes, overhead per field is
40 bytes (hash bucket pointer, pointers for the field name and value, length fields). **DS3:**
`bytesRead` and `bytesWritten` are the bytes the store touches for that operation, `roundTrips` is
the client's call count, `steps` is the number of fields resolved or compared. **DS4:** during the
day, 119,993 loan/return events update two fields (status and loan count), 400,000 status queries
read one field, 60,000 book pages read the whole record.

```js
// memory/hash.mjs — the same book record in three shapes. read/written = bytes the
// store touches for that operation, roundTrips = client call count, steps = field
// lookup/comparison count. The workload is deterministic.
const OVERHEAD = 56, FIELD_OVERHEAD = 40, BOOKS = 200_000;   // DS2
const bl = (x) => Buffer.byteLength(String(x));
const key = (i) => `book:${100_000 + i}`;
const record = (i) => ({
  title: `Notes on Book ${100_000 + i}`, author: `Author ${i % 997}`,
  shelf: `K-${1 + (i % 20)}-${String(i % 400).padStart(3, "0")}`,
  status: i % 3 === 0 ? "on_loan" : "on_shelf", loan_count: String(1 + (i * 7919) % 4096),
  added: `20${10 + (i % 15)}-${String(1 + (i % 12)).padStart(2, "0")}-14`,
  isbn: String(9_780_000_000_000 + i),
});

class HolisticStore {                            // the value is a single piece: the store does not know fields
  #table = new Map(); bytesRead = 0; bytesWritten = 0; roundTrips = 0; steps = 0;
  put(key, record) { this.#table.set(key, JSON.stringify(record)); }
  readAll(key) { this.roundTrips += 1; const s = this.#table.get(key); this.bytesRead += bl(s);
    const record = JSON.parse(s); this.steps += Object.keys(record).length; return record; }   // every field is parsed
  readField(key, field) { return this.readAll(key)[field]; }        // the whole value is read, one field used
  writeField(key, field, value) { const record = this.readAll(key); record[field] = value;      // read-modify-write
    const s = JSON.stringify(record); this.roundTrips += 1; this.bytesWritten += bl(key) + bl(s); this.#table.set(key, s); }
  bytes() { let b = 0; for (const [key, s] of this.#table) b += bl(key) + bl(s) + OVERHEAD; return b; }
}

class DenseHashStore {                          // fields sit consecutively in one blob; the store knows fields
  #table = new Map(); bytesRead = 0; bytesWritten = 0; roundTrips = 0; steps = 0;
  put(key, record) { this.#table.set(key, Object.entries(record).map(([k, v]) => `${k}=${v}`).join(";")); }
  #find(s, field) { for (const part of s.split(";")) { this.steps += 1; if (part.startsWith(`${field}=`)) return part; } return null; }
  readAll(key) { this.roundTrips += 1; const s = this.#table.get(key); this.bytesRead += bl(s);
    return Object.fromEntries(s.split(";").map((part) => part.split("="))); }
  readField(key, field) { this.roundTrips += 1; const s = this.#table.get(key); this.bytesRead += bl(s);
    const part = this.#find(s, field); return part === null ? undefined : part.slice(field.length + 1); }
  writeField(key, field, value) { this.roundTrips += 1; const s = this.#table.get(key); this.bytesRead += bl(s);
    const updated = s.split(";").map((part) => { this.steps += 1; return part.startsWith(`${field}=`) ? `${field}=${value}` : part; }).join(";");
    this.bytesWritten += bl(key) + bl(updated); this.#table.set(key, updated); }                        // the blob is rewritten
  bytes() { let b = 0; for (const [key, s] of this.#table) b += bl(key) + bl(s) + OVERHEAD; return b; }
}

class HashStore {                               // every field is its own entry: accessed by name
  #table = new Map(); bytesRead = 0; bytesWritten = 0; roundTrips = 0; steps = 0;
  put(key, record) { this.#table.set(key, new Map(Object.entries(record).map(([k, v]) => [k, String(v)]))); }
  readAll(key) { this.roundTrips += 1; const fields = this.#table.get(key);
    for (const [k, v] of fields) { this.steps += 1; this.bytesRead += bl(k) + bl(v); } return Object.fromEntries(fields); }
  readField(key, field) { this.roundTrips += 1; this.steps += 1;
    const v = this.#table.get(key).get(field); this.bytesRead += bl(v); return v; }
  writeField(key, field, value) { this.roundTrips += 1; this.steps += 1; this.bytesWritten += bl(key) + bl(field) + bl(value);
    this.#table.get(key).set(field, String(value)); }
  bytes() { let b = 0;
    for (const [key, fields] of this.#table) { b += bl(key) + OVERHEAD;
      for (const [k, v] of fields) b += bl(k) + bl(v) + FIELD_OVERHEAD; }
    return b; }
}

const sample = record(427);
console.log(`sample ${key(427)}: ${Object.keys(sample).length} fields, JSON ${bl(JSON.stringify(sample))} bytes, ` +
  `field names ${Object.keys(sample).reduce((t, k) => t + bl(k), 0)} bytes, ` +
  `field values ${Object.values(sample).reduce((t, v) => t + bl(v), 0)} bytes`);

const stores = [["holistic value", new HolisticStore()], ["dense hash", new DenseHashStore()],
  ["hash structure", new HashStore()]];
for (const [, store] of stores) for (let i = 1; i <= BOOKS; i += 1) store.put(key(i), record(i));

// daily load: 119,993 loan/return events update two fields, 400,000 status queries, 60,000 full reads
const LOANS = 119_993, STATUS_QUERIES = 400_000, PAGE_VIEWS = 60_000;
for (const [, store] of stores) {
  for (let j = 0; j < LOANS; j += 1) { const i = 1 + (j * 4241) % BOOKS;
    store.writeField(key(i), "status", j % 2 === 0 ? "on_loan" : "on_shelf");
    store.writeField(key(i), "loan_count", String(1 + (i * 7919) % 4096)); }
  for (let j = 0; j < STATUS_QUERIES; j += 1) store.readField(key(1 + (j * 7919) % BOOKS), "status");
  for (let j = 0; j < PAGE_VIEWS; j += 1) store.readAll(key(1 + (j * 4241) % BOOKS));
}

console.log(`\n${BOOKS} books; daily load ${2 * LOANS} field writes, ${STATUS_QUERIES} field reads, ${PAGE_VIEWS} full reads`);
console.log(`${"shape".padEnd(16)}${"bytes held".padStart(14)}${"per record".padStart(13)}` +
  `${"written".padStart(10)}${"read".padStart(11)}${"round trips".padStart(13)}${"steps".padStart(9)}`);
for (const [name, store] of stores)
  console.log(name.padEnd(16) + String(store.bytes()).padStart(14) + (store.bytes() / BOOKS).toFixed(1).padStart(13) +
    String(store.bytesWritten).padStart(10) + String(store.bytesRead).padStart(11) + String(store.roundTrips).padStart(13) +
    String(store.steps).padStart(9));
```

```
sample book:100427: 7 fields, JSON 156 bytes, field names 41 bytes, field values 72 bytes

200000 books; daily load 239986 field writes, 400000 field reads, 60000 full reads
shape               bytes held   per record   written       read  round trips    steps
holistic value        44547129        222.7  39998208  109012753       939972  4899902
dense hash            38547129        192.7  32798628   88013173       699986  3279902
hash structure        91947129        459.7   5907195    9786807       699986  1059986
```


The hash structure's price shows up in the first column: the same 200,000 records hold 44.5 MB as
a holistic value and 91.9 MB as a hash structure, 222.7 bytes per record against 459.7. The
difference is a single line item: each of the seven fields is its own entry, and every entry
carries 40 bytes of overhead along with a copy of the field name. Of the 237-byte increase per
record, 280 bytes is field overhead — meaning more space than the record itself goes toward
recognizing the fields separately.

What that buys is in the other four columns. A single-field update writes the entire record in a
holistic value; 40.0 MB is written over the day. In a hash structure, only the key, the field
name, and the new value are written: 5.9 MB, 6.8 times less. The gap on the read side is larger.
Each of the 400,000 status queries reads out the entire 156-byte record in a holistic value; the
total is 109.0 MB against 9.8 MB in a hash structure — 11.1 times. Round trips also fall: because
a field update is read-modify-write in a holistic value, it needs two calls, 939,972 against
699,986. These 239,986 extra round trips are not just a traffic line item; the previous lesson's
lost update arises from exactly this gap. In a hash structure, a field update is a single call
and cannot be split.

The middle row carries the lesson's surprise. Even though the dense hash holds fields in a
single block, it takes up less space than the holistic value (192.7 bytes, because there is no
delimiter and quote overhead) while also preserving the concept of a field: round trips drop to
699,986, and updates become unsplittable. Across a seven-field record, there is no single column
where the holistic value beats the dense hash. What the dense hash pays for is the step column:
finding a single field means scanning the block, 3,279,902 steps. The hash structure finishes the
same job in 1,059,986 steps.

#### Where the Threshold Is

At seven fields, the dense hash is a good choice; the scan is four steps. As field count grows,
this changes. **DS5:** for comparison, the field name is 9 bytes and the field value is 7 bytes,
and record kinds in the catalog come in four different sizes.

```js
// memory/threshold.mjs — as field count grows, dense and hash representations
// diverge, and the catalog-wide cost of a threshold policy. All numbers are structural.
const OVERHEAD = 56, FIELD_OVERHEAD = 40;
const fieldName = (j) => `field_${String(j).padStart(3, "0")}`;
const fieldValue = (j) => String(1_000_000 + j * 7919);
const bl = (x) => Buffer.byteLength(String(x));
const KEY = bl("book:100427");

const denseBytes = (f) => KEY + OVERHEAD +
  bl(Array.from({ length: f }, (_, j) => `${fieldName(j)}=${fieldValue(j)}`).join(";"));
const hashBytes = (f) => KEY + OVERHEAD +
  Array.from({ length: f }, (_, j) => bl(fieldName(j)) + bl(fieldValue(j)) + FIELD_OVERHEAD).reduce((t, x) => t + x, 0);

console.log(`field name ${bl(fieldName(0))} bytes, field value ${bl(fieldValue(0))} bytes, ` +
  `field overhead ${FIELD_OVERHEAD} bytes, key ${KEY} bytes`);
console.log(`\n${"fields".padStart(6)}${"dense bytes".padStart(13)}${"hash bytes".padStart(12)}${"ratio".padStart(7)}` +
  `${"dense access steps".padStart(20)}${"dense field write".padStart(19)}${"hash field write".padStart(18)}`);
for (const f of [4, 7, 16, 64, 256]) {
  const dense = denseBytes(f), hash = hashBytes(f);
  console.log(String(f).padStart(6) + String(dense).padStart(13) + String(hash).padStart(12) +
    (hash / dense).toFixed(2).padStart(7) + `${((f + 1) / 2).toFixed(1)} / ${f}`.padStart(20) +
    String(dense - OVERHEAD).padStart(19) +
    String(KEY + bl(fieldName(0)) + bl(fieldValue(0))).padStart(18));
}

// catalog: four record kinds, three policies
const kinds = [["book record", 200_000, 7], ["member profile", 20_000, 12],
  ["branch daily counter", 5, 24], ["popular book tag counter", 2_000, 256]];
console.log(`\n${"record kind".padEnd(29)}${"count".padStart(8)}${"fields".padStart(7)}` +
  `${"all dense".padStart(13)}${"all hash".padStart(13)}${"threshold 64".padStart(14)}`);
let totalDense = 0, totalHash = 0, totalThreshold = 0;
for (const [name, n, f] of kinds) {
  const dense = n * denseBytes(f), hash = n * hashBytes(f), chosen = f <= 64 ? dense : hash;
  totalDense += dense; totalHash += hash; totalThreshold += chosen;
  console.log(name.padEnd(29) + String(n).padStart(8) + String(f).padStart(7) +
    String(dense).padStart(13) + String(hash).padStart(13) + String(chosen).padStart(14));
}
console.log("total".padEnd(29) + "".padStart(8) + "".padStart(7) +
  String(totalDense).padStart(13) + String(totalHash).padStart(13) + String(totalThreshold).padStart(14));
console.log(`threshold 64: %${(100 * totalThreshold / totalHash).toFixed(1)} of all-hash, %` +
  `${(100 * totalThreshold / totalDense).toFixed(1)} of all-dense; worst-case field access is 64 steps`);
```

```
field name 9 bytes, field value 7 bytes, field overhead 40 bytes, key 11 bytes

fields  dense bytes  hash bytes  ratio  dense access steps  dense field write  hash field write
     4          138         291   2.11             2.5 / 4                 82                27
     7          192         459   2.39             4.0 / 7                136                27
    16          354         963   2.72            8.5 / 16                298                27
    64         1218        3651   3.00           32.5 / 64               1162                27
   256         4674       14403   3.08         128.5 / 256               4618                27

record kind                     count fields    all dense     all hash  threshold 64
book record                    200000      7     38400000     91800000      38400000
member profile                  20000     12      5640000     14780000       5640000
branch daily counter                5     24         2490         7055          2490
popular book tag counter         2000    256      9348000     28806000      28806000
total                                            53390490    135393055      72848490
threshold 64: %53.8 of all-hash, %136.4 of all-dense; worst-case field access is 64 steps
```

The upper table sets two curves side by side. The memory ratio climbs from 2.11 to 3.08 as field
count grows — the dense representation's gain increases further on larger records. But two
columns grow in opposite directions. In the dense representation, finding a field averages
`(f+1)/2` steps, rising to 128.5 steps at 256 fields and 256 steps in the worst case. Sharper
still is the single-field write: the dense representation rewrites the entire block, 4,618 bytes
at 256 fields. The same write in a hash structure is 27 bytes, independent of field count. The
write multiplier from the list lesson returns here, and it grows in direct proportion to field
count.

The lower table gives the decision at catalog scale. Keeping everything dense costs 53.4 MB,
keeping everything as a hash structure costs 135.4 MB. A threshold policy — dense below 64
fields, hash structure above — holds steady at 72.8 MB: 53.8 percent of keeping everything as a
hash structure. The price of the 62.5 MB saved in between is at most a 7-step scan across the
200,000 book records. The same policy, by keeping the 2,000 tag counters as a hash structure,
avoids a 256-step scan and a 4,618-byte write. The threshold policy spends 36.4 percent more
memory than keeping everything dense, and in return it caps the worst-case field access at 64
steps instead of 256. The decision is not choosing one representation over the other, it is
choosing where the line runs.

#### Summary

- In a holistic value, the store has no concept of a field: a single-field update reads and
  writes the entire record and needs two round trips; the lost update arises from exactly this
  gap.
- 200,000 records hold 44.5 MB as a holistic value and 91.9 MB as a hash structure. Most of the
  237-byte increase per record is the 40-byte overhead per field.
- What the hash structure buys is a drop in daily write traffic from 40.0 MB to 5.9 MB, in read
  traffic from 109.0 MB to 9.8 MB, and in round trips from 939,972 to 699,986.
- At seven fields, the dense hash loses to the holistic value in no column: 192.7 bytes, the same
  round-trip count, and an unsplittable field update. The only price it pays is the scan step
  count (3,279,902 against 1,059,986).
- As field count grows, the dense representation's memory gain increases (ratio rising from 2.11
  to 3.08), but access rises to an average of `(f+1)/2` steps, and a single-field write climbs to
  4,618 bytes at 256 fields; the same write is 27 bytes in a hash structure.
- A threshold policy (dense up to 64 fields) holds 72.8 MB catalog-wide: 53.8 percent of keeping
  everything as a hash structure, 136.4 percent of keeping everything dense. What that buys is
  capping the worst-case access at 64 steps.

#### Next Step

The hash structure separated a record's fields, but they all still belonged to a single record.
Some of the library's questions do not belong to a single record: "is this book among the ones
currently on loan," "is this member on the fine list," "which twenty books are borrowed the
most." The first is a membership question and its answer is yes or no; the last is a rank
question and its answer is a sorted list. The next lesson takes up these two structures and
measures three things: the step count of a membership check, the step count of a rank query in a
sorted set, and the bytes-per-entry cost of both.

### Sets and Sorted Sets

Source: https://academia.sh/en/courses/in-memory-stores/sets-and-sorted-sets

The memory price of membership and rank questions: building the same on-loan book set as a list, sorted array, and hash-based set and measuring it in query steps, insertion steps, and bytes per entry, building the popular-book ranking on a member-score hash with three separate rank indexes, how much the skip list's pointer cost buys down the rank query, and how the sorted array buys nothing under update load.

The hash structure separated a record's fields, but every field still belonged to a single
record. Some of the library's questions do not belong to a single record. "Is this book currently
among the ones on loan" is a membership question, and its answer is yes or no. "Where does this
book rank among the most-borrowed" is a rank question, and its answer is a number, but one read
off a sorted whole. Both questions require the same thing: holding in memory which members are
present.

The complexity analysis of these structures has already been done elsewhere; what is measured
here is bytes held and store semantics.

#### Membership: Yes or No

**DS1:** 38,000 books are on loan during the day, the member identifier is 6 bytes; half of the
10,000 membership queries ask about a book that is in the set, half about one that is not.
**DS2:** an array slot is 8 bytes, structure overhead is 56 bytes, overhead per set entry is 48
bytes (bucket pointer, entry structure, hash value, alignment).

```js
// memory/membership.mjs — "is this book currently on loan" in three structures. Steps
// are counted inside the implementation; the workload is deterministic.
const OVERHEAD = 56, SLOT = 8, SET_OVERHEAD = 48;          // DS2
const bl = (x) => Buffer.byteLength(String(x));
const ON_LOAN = 38_000, QUERIES = 10_000, BOOKS = 200_000;
const member = (i) => String(100_000 + (i * 4241) % BOOKS);
const members = [...new Set(Array.from({ length: ON_LOAN }, (_, i) => member(i)))];

class ListMembership {                          // order is preserved, membership is a scan
  #items = []; steps = 0; insertSteps = 0;
  insert(v) { this.insertSteps += 1; this.#items.push(v); }
  has(v) { for (const x of this.#items) { this.steps += 1; if (x === v) return true; } return false; }
  bytes() { return OVERHEAD + this.#items.reduce((t, v) => t + bl(v) + SLOT, 0); }
  get size() { return this.#items.length; }
}
class SortedArrayMembership {                   // binary search; insertion needs shifting
  #items = []; steps = 0; insertSteps = 0;
  #locate(v) { let lo = 0, hi = this.#items.length;
    while (lo < hi) { this.steps += 1; const mid = (lo + hi) >> 1; if (this.#items[mid] < v) lo = mid + 1; else hi = mid; }
    return lo; }
  insert(v) { const i = this.#locate(v); this.insertSteps += this.#items.length - i + 1; this.#items.splice(i, 0, v); }
  has(v) { const i = this.#locate(v); return this.#items[i] === v; }
  bytes() { return OVERHEAD + this.#items.reduce((t, x) => t + bl(x) + SLOT, 0); }
  get size() { return this.#items.length; }
}
class SetMembership {                           // hash: a single step, extra overhead per entry
  #set = new Set(); steps = 0; insertSteps = 0;
  insert(v) { this.insertSteps += 1; this.#set.add(v); }
  has(v) { this.steps += 1; return this.#set.has(v); }
  bytes() { let b = OVERHEAD; for (const v of this.#set) b += bl(v) + SET_OVERHEAD; return b; }
  get size() { return this.#set.size; }
}

const structures = [["list", new ListMembership()], ["sorted array", new SortedArrayMembership()],
  ["set", new SetMembership()]];
for (const [, impl] of structures) for (const v of members) impl.insert(v);

// half the queries ask about a book that is in the set, half about one that is not
const queries = Array.from({ length: QUERIES }, (_, j) =>
  j % 2 === 0 ? members[(j * 7919) % members.length] : String(100_000 + BOOKS + j));
let found = 0;
for (const [, impl] of structures) { let f = 0; for (const q of queries) if (impl.has(q)) f += 1; found = f; }

console.log(`${members.length} books on loan (member ${bl(members[0])} bytes), ${QUERIES} membership queries, ` +
  `${found} found`);
console.log(`${"structure".padEnd(13)}${"bytes held".padStart(14)}${"per entry".padStart(13)}` +
  `${"query steps".padStart(13)}${"per query".padStart(14)}${"insert steps".padStart(14)}`);
for (const [name, impl] of structures)
  console.log(name.padEnd(13) + String(impl.bytes()).padStart(14) + (impl.bytes() / impl.size).toFixed(1).padStart(13) +
    String(impl.steps).padStart(13) + (impl.steps / QUERIES).toFixed(1).padStart(14) +
    String(impl.insertSteps).padStart(14));
```

```
38000 books on loan (member 6 bytes), 10000 membership queries, 5000 found
structure        bytes held    per entry  query steps     per query  insert steps
list                 532056         14.0    284642000       28464.2         38000
sorted array         532056         14.0       676550          67.7     360742025
set                 2052056         54.0        10000           1.0         38000
```

The list and the sorted array hold the same 532,056 bytes, 14.0 per entry. The set demands
2,052,056 bytes for the same 38,000 members — 54.0 per entry, that is, 3.86 times. The difference
is a single line item: 40 bytes per member for the bucket pointer and the entry structure.
Holding a six-byte book identifier in a set costs nine times the identifier's own size.

The payoff is in the query column: the list answers each question in an average of 28,464.2
steps, the set in 1. The sorted array looks like an interesting middle ground — 67.7 steps at the
list's memory cost — but the insert column finishes it off. In a sorted array, every insertion
shifts the remaining entries: 38,000 insertions cost 360,742,025 steps, 9,493 per insertion. The
set of books on loan is not static; during the day, every loan is an insertion and every return
is a removal. In this workload, the sorted array gives back what it gains on queries a hundred
times over on insertion.

The 1,520,000 extra bytes the set holds bring both the query and the insertion down to a single
step. What it loses does not show up in any column: the set does not preserve order. The list
carried the 38,000 books in insertion order; the set carries the same books, but there is no
information about which was loaned out first. Order is abandoned in exchange for the membership
answer.

#### Rank: Where in the Order

A leaderboard asks two questions at once: what is a book's score, and where does that score rank.
A member-score hash is enough for the first question and is common to all three approaches; the
second needs a **rank index**. **DS3:** 50,000 books have a popularity score, 10,000 loans
increment a score, 2,000 rank queries and 200 "top 20" queries arrive. **DS4:** the skip list's
node levels come from a generator with a visible seed; the node header is 24 bytes, the pointer
pair per level is 16 bytes. The ranks given by all three approaches are checked against brute
force.

```js
// memory/ranking.mjs — popular-book ranking: three rank indexes over a member->score
// hash. Skip list levels come from a seeded generator; ranks are checked by brute force.
const OVERHEAD = 56, SLOT = 8, SET_OVERHEAD = 48, NODE = 24, POINTER = 16;   // DS2
const BOOKS = 50_000, UPDATES = 10_000, QUERIES = 2_000, TOP = 20, SEED = 20240115;
const bl = (x) => Buffer.byteLength(String(x));
let c = SEED;
const rand = () => (c = (c * 1103515245 + 12345) % 2147483648) / 2147483648;

const member = (i) => String(100_000 + i);
const score0 = (i) => 1 + (i * 7919) % 4096;
const before = (score1, member1, score2, member2) => score1 > score2 || (score1 === score2 && member1 < member2);   // score descending, member ascending

class SkipList {
  #head; #level = 1; #n = 0; #maxLevel; steps = 0; pointers = 0;
  constructor(maxLevel = 16) { this.#maxLevel = maxLevel;
    this.#head = { member: "", score: 0, forward: new Array(maxLevel).fill(null), span: new Array(maxLevel).fill(0) }; }
  #pickLevel() { let lvl = 1; while (rand() < 0.5 && lvl < this.#maxLevel) lvl += 1; return lvl; }
  #path(score, member) {
    const update = new Array(this.#maxLevel), rankSum = new Array(this.#maxLevel).fill(0);
    let node = this.#head;
    for (let i = this.#level - 1; i >= 0; i -= 1) {
      rankSum[i] = i === this.#level - 1 ? 0 : rankSum[i + 1];
      while (node.forward[i] && before(node.forward[i].score, node.forward[i].member, score, member)) {
        this.steps += 1; rankSum[i] += node.span[i]; node = node.forward[i]; }
      update[i] = node;
    }
    return [update, rankSum, node.forward[0]];
  }
  insert(member, score) {
    const [update, rankSum] = this.#path(score, member);
    const lvl = this.#pickLevel();
    if (lvl > this.#level) { for (let i = this.#level; i < lvl; i += 1) {
      rankSum[i] = 0; update[i] = this.#head; this.#head.span[i] = this.#n; } this.#level = lvl; }
    const newNode = { member, score, forward: new Array(lvl), span: new Array(lvl) };
    this.pointers += lvl;
    for (let i = 0; i < lvl; i += 1) {
      newNode.forward[i] = update[i].forward[i]; update[i].forward[i] = newNode;
      newNode.span[i] = update[i].span[i] - (rankSum[0] - rankSum[i]); update[i].span[i] = (rankSum[0] - rankSum[i]) + 1;
    }
    for (let i = lvl; i < this.#level; i += 1) update[i].span[i] += 1;
    this.#n += 1;
  }
  remove(member, score) {
    const [update, , node] = this.#path(score, member);
    if (!node || node.member !== member || node.score !== score) return false;
    for (let i = 0; i < this.#level; i += 1) {
      if (update[i].forward[i] === node) { update[i].span[i] += node.span[i] - 1; update[i].forward[i] = node.forward[i]; }
      else update[i].span[i] -= 1;
    }
    this.pointers -= node.forward.length;
    while (this.#level > 1 && this.#head.forward[this.#level - 1] === null) this.#level -= 1;
    this.#n -= 1; return true;
  }
  rank(member, score) { const [, rankSum, node] = this.#path(score, member); return node && node.member === member ? rankSum[0] + 1 : -1; }
  top(k) { const r = []; let node = this.#head.forward[0];
    while (node && r.length < k) { this.steps += 1; r.push(node.member); node = node.forward[0]; } return r; }
  bytes() { let b = OVERHEAD; let node = this.#head.forward[0];
    while (node) { b += bl(node.member) + NODE + node.forward.length * POINTER; node = node.forward[0]; } return b; }
  get level() { return this.#level; }
}

const scores = new Map();                        // member -> score; common to all three approaches
for (let i = 1; i <= BOOKS; i += 1) scores.set(member(i), score0(i));
const scoresBytes = [...scores].reduce((t, [m]) => t + bl(m) + SLOT + SET_OVERHEAD, OVERHEAD);

const sorted = [...scores].map(([m, s]) => [s, m]).sort((a, b) => (before(a[0], a[1], b[0], b[1]) ? -1 : 1));
const skip = new SkipList();
for (const [m, s] of scores) skip.insert(m, s);
let sortedSteps = 0, noIndexSteps = 0;
const locate = (score, m) => { let lo = 0, hi = sorted.length;
  while (lo < hi) { sortedSteps += 1; const mid = (lo + hi) >> 1;
    if (before(sorted[mid][0], sorted[mid][1], score, m)) lo = mid + 1; else hi = mid; } return lo; };

// daily load: score increments, rank queries, top K
const events = Array.from({ length: UPDATES }, (_, j) => member(1 + (j * 4241) % BOOKS));
for (const m of events) {
  const oldScore = scores.get(m), newScore = oldScore + 1;
  const oldPos = locate(oldScore, m); sortedSteps += sorted.length - oldPos; sorted.splice(oldPos, 1);        // shift
  const newPos = locate(newScore, m); sortedSteps += sorted.length - newPos; sorted.splice(newPos, 0, [newScore, m]);
  skip.remove(m, oldScore); skip.insert(m, newScore); scores.set(m, newScore);
}
const queries = Array.from({ length: QUERIES }, (_, j) => member(1 + (j * 7919) % BOOKS));
let matches = 0;
for (const m of queries) {
  const score = scores.get(m);
  let rankNoIndex = 1; for (const [otherMember, otherScore] of scores) { noIndexSteps += 1; if (before(otherScore, otherMember, score, m)) rankNoIndex += 1; }
  const rankSorted = locate(score, m) + 1, rankSkip = skip.rank(m, score);
  if (rankNoIndex === rankSorted && rankSorted === rankSkip) matches += 1;
}
for (let j = 0; j < 200; j += 1) {
  [...scores].sort((a, b) => { noIndexSteps += 1; return before(a[1], a[0], b[1], b[0]) ? -1 : 1; }).slice(0, TOP);
  sortedSteps += TOP; sorted.slice(0, TOP); skip.top(TOP);
}

console.log(`${BOOKS} books, ${UPDATES} score increments, ${QUERIES} rank queries, 200 "top ${TOP}"`);
console.log(`${matches}/${QUERIES} queries: all three approaches gave the same rank; skip list level ${skip.level}, ` +
  `pointers ${skip.pointers} (${(skip.pointers / BOOKS).toFixed(2)} per node), seed ${SEED}`);
const paths = [
  ["no rank index", scoresBytes, noIndexSteps],
  ["sorted array", scoresBytes + OVERHEAD + sorted.reduce((t, [, m]) => t + bl(m) + SLOT + SLOT, 0), sortedSteps],
  ["skip list", scoresBytes + skip.bytes(), skip.steps],
];
console.log(`\n${"rank index".padEnd(17)}${"bytes held".padStart(14)}${"index share".padStart(12)}` +
  `${"per entry".padStart(13)}${"total steps".padStart(13)}`);
for (const [name, b, steps] of paths)
  console.log(name.padEnd(17) + String(b).padStart(14) + String(b - scoresBytes).padStart(12) +
    (b / BOOKS).toFixed(1).padStart(13) + String(steps).padStart(13));
```

```
50000 books, 10000 score increments, 2000 rank queries, 200 "top 20"
2000/2000 queries: all three approaches gave the same rank; skip list level 13, pointers 102228 (2.04 per node), seed 20240115

rank index           bytes held index share    per entry  total steps
no rank index           3100056           0         62.0    212473400
sorted array            4200112     1100056         84.0    500247667
skip list               6235760     3135704        124.7      1336074
```

All three approaches give the same rank in all 2,000 queries; the difference is only in price.
If no rank index is held, the hash is 3,100,056 bytes, and every rank query walks every entry
while every "top 20" query sorts the entire table: 212,473,400 steps.

The sorted array buys nothing in this workload. It holds 1,100,056 extra bytes and pushes the
total step count to 500,247,667 — worse than the no-index path. The reason is the same as in the
previous section: while a rank query is cheap with binary search, every score increment shifts an
average of fifty thousand entries to move the entry out of its old position and into its
new one. A sorted array is good for a static table and the wrong choice for a leaderboard that
keeps changing.

The skip list holds 3,135,704 extra bytes — 124.7 bytes per entry, more than double the
membership set — and brings the total step count down to 1,336,074. Every extra byte it holds
prevents 67.3 steps. More than half of those bytes are neither score nor identifier: 102,228
pointer pairs, 2.04 levels per node, 1,635,648 bytes. The rank index's cost is not the data
itself, it is the way of reaching the data in order. In exchange, both the rank query and the
"top 20" query become independent of table size, and so does the score update.

#### Summary

- The membership question is answered correctly by all three structures: the list in 28,464.2
  steps, the sorted array in 67.7, the set in 1.0.
- The set holds the same 38,000 members in 2,052,056 bytes; the list and the sorted array in
  532,056 bytes. The difference is 40 bytes per member of bucket and entry overhead — nine times
  the six-byte identifier.
- The sorted array is deceptive for membership: the query is cheap, but 38,000 insertions cost
  360,742,025 steps (9,493 per insertion). It is the worst choice for a membership set that keeps
  changing.
- What the set loses is order: there is no information in the set about which book was loaned
  out first.
- Holding no rank index for the leaderboard means 3,100,056 bytes and 212,473,400 steps; the
  sorted array holds 1,100,056 extra bytes and pushes the step count up to 500,247,667, because
  every score increment requires a shift.
- The skip list holds 3,135,704 extra bytes and brings the step count down to 1,336,074:
  67.3 steps per byte. 1,635,648 of those bytes are pure pointers — 2.04 levels per node.

#### Next Step

Both structures in this lesson gave the correct answer, and both did the same thing: they held
exactly what they counted. The set carried the identity of 38,000 books one by one; the sorted
set carried the identity, the score, and the rank position of 50,000 books. The same held true in
earlier lessons — the hash structure held every field, the list held every entry, the counter
held every key, each one individually.

Yet part of the library's questions do not want the members themselves. "How many distinct
members logged in today," "has anyone ever borrowed this book before," "on which days was
anything loaned out" — the answer to these is a number or a yes-no, not a list of members. While
a number is enough for the answer, the cost of holding every member in memory has not been asked
in any of these lessons. The next lesson opens that question.

### Bitmaps and Probabilistic Structures

Source: https://academia.sh/en/courses/in-memory-stores/bitmaps-and-probabilistic-structures

The cost of keeping a member list when the number alone would do: the same distinct-reader question solved with an exact set, a bitmap, a probabilistic membership structure, and a cardinality estimate, the bytes each of the four structures holds read from its own buffer, the false positive rate measured, and how many bytes giving up certainty buys counted out.

The structures covered so far keep what they count **exactly**. For a set to know its member count,
every member number sits in memory; for a hash structure to know its field count, every field name
sits in memory. This is unavoidable when a member has to be handed back. But not every question asks
for the member back: the library's end-of-day report asks "how many distinct readers took a loan
today," and the answer is a single number — holding hundreds of thousands of reader numbers in
memory just to produce it has never been questioned as a decision.

This lesson questions that decision. Two questions are separated: **cardinality estimation** ("how
many distinct readers") and **membership** ("did this reader take a loan today"). Each can be
answered with a different structure, a different byte budget, and a different degree of certainty.

#### Two Questions, Four Structures

The library's loan record accumulates in the in-memory store over the course of the day. At day's
end the report asks for the distinct reader count; during the day the loan screen asks "has this
reader already made a transaction today."

| Code | Assumption | Value | Rationale |
|---|---|---|---|
| DS10 | registered reader | 2,000,000 | member number is a dense range starting from 1 |
| DS11 | daily loan event | 900,000 | sum across all branches |
| DS12 | distribution of events | 70% from 15% of readers | a core of frequent borrowers exists |
| DS13 | deviation accepted for the report | 2% | the end-of-day report does not need an exact count |

DS10 is this lesson's deciding assumption: because the member number is a **dense** range, the
reader number can be used directly as a bit position. A sparse range would give the table's second
row a completely different number; the lesson measures this at the end.

#### Two Exact Structures

The first setup fills two exact structures with the same data. The exact set is an open-addressed
hash table and the bytes it holds are the size of its buffer; the bitmap uses the reader number
directly as a bit position.

```js
// distinct-count.mjs — the day's distinct readers who took a loan: exact set vs. bitmap.
// Byte counts are read straight from the structure's own buffer, no estimate; the seed is visible.
const READERS = 2_000_000, EVENTS = 900_000, SEED = 20260731;
let d = SEED;                                         // 32-bit integer generator, no overflow
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 reader = () => rand() < 0.7                     // 70% of events, from 15% of readers
  ? 1 + Math.floor(rand() * READERS * 0.15) : 1 + Math.floor(rand() * READERS);

class ExactSet {                                      // open addressing, empty slot is 0
  constructor() { this.k = 1024; this.t = new Int32Array(this.k); this.n = 0; }
  slot(t, k, x) { let i = (Math.imul(x, 2654435761) >>> 0) & (k - 1);
    while (t[i] !== 0 && t[i] !== x + 1) i = (i + 1) & (k - 1); return i; }
  add(x) { if ((this.n + 1) * 2 > this.k) this.grow();
    const i = this.slot(this.t, this.k, x);
    if (this.t[i] === 0) { this.t[i] = x + 1; this.n += 1; } }
  contains(x) { return this.t[this.slot(this.t, this.k, x)] !== 0; }
  grow() { const y = new Int32Array(this.k * 2);
    for (const v of this.t) if (v !== 0) y[this.slot(y, this.k * 2, v - 1)] = v;
    this.t = y; this.k *= 2; }
  bytes() { return this.t.byteLength; }
}
class Bitmap {                                        // the reader number is directly the bit position
  constructor(n) { this.b = new Uint8Array(Math.ceil(n / 8)); }
  add(x) { this.b[x >> 3] |= 1 << (x & 7); }
  contains(x) { return (this.b[x >> 3] & (1 << (x & 7))) !== 0; }
  count() { let s = 0; for (let v of this.b) while (v) { s += v & 1; v >>= 1; } return s; }
  bytes() { return this.b.byteLength; }
}

const set = new ExactSet(), bitmap = new Bitmap(READERS + 1);
for (let i = 0; i < EVENTS; i += 1) { const o = reader(); set.add(o); bitmap.add(o); }
const n = set.n, fmt = (x) => x.toLocaleString("en-US");
console.log(`model: ${fmt(READERS)} registered readers, ${fmt(EVENTS)} loan events, seed ${SEED}`);
console.log(`distinct readers: ${fmt(n)} (bitmap gives the same count: ${fmt(bitmap.count())})\n`);
console.log("structure".padEnd(14) + "bytes held".padStart(14) + "per distinct".padStart(14) +
  "membership".padStart(12) + "count".padStart(12));
for (const [label, y] of [["exact set", set], ["bitmap", bitmap]])
  console.log(label.padEnd(14) + fmt(y.bytes()).padStart(14) + (y.bytes() / n).toFixed(2).padStart(14) +
    "exact".padStart(12) + "exact".padStart(12));

let mismatches = 0;                                   // do both structures give the same answer
for (let x = 1; x <= READERS; x += 7) if (set.contains(x) !== bitmap.contains(x)) mismatches += 1;
console.log(`\n${fmt(Math.ceil(READERS / 7))} membership queries where the two structures differ: ${mismatches}`);
const SPARSE = 1_000_000_000;                         // if a 9-digit member number were used
console.log(`the bitmap depends on the number range: 1..${fmt(READERS)} takes ${fmt(bitmap.bytes())} bytes, ` +
  `1..${fmt(SPARSE)} takes ${fmt(Math.ceil(SPARSE / 8))} bytes (exact set unchanged: ${fmt(set.bytes())})`);
```

```
model: 2,000,000 registered readers, 900,000 loan events, seed 20260731
distinct readers: 482,085 (bitmap gives the same count: 482,085)

structure         bytes held  per distinct  membership       count
exact set          4,194,304          8.70       exact       exact
bitmap               250,001          0.52       exact       exact

285,715 membership queries where the two structures differ: 0
the bitmap depends on the number range: 1..2,000,000 takes 250,001 bytes, 1..1,000,000,000 takes 125,000,000 bytes (exact set unchanged: 4,194,304)
```

These numbers are in the **measurement** class: the distinct reader count depends on the seed, the
bytes held do not — that number is the buffer's size and stays constant across runs with the same
entry count.

The bitmap holds **sixteen times** less space than the exact set (250,001 against 4,194,304 bytes)
and loses nothing: the distinct count is the same, and the two structures never differ across the
285,715 membership queries. The reason: the bitmap uses the reader number **as an address, not
as data**. The exact set has to store the number, because which number occupies a slot is only
known if it was written; in a bitmap, where the number sits is already the number, so nothing is
left to store, and cost per entry drops from 8.70 bytes to 0.52.

What is lost shows up in the last line: the bitmap's cost depends **not on how many readers took a
loan, but on how wide the number range is**. If the library switched to a nine-digit member number,
the same 482,085 readers would need 125,000,000 bytes instead of 250,001; the exact set's cost would
not change. A bitmap is free where the range is dense and unaffordable where it is sparse.

#### Giving Up Certainty

Both exact structures represent every reader individually. The next step is to abandon that
representation: answering the same question with structures that **leave uncertain** which reader
is inside.

```js
// probabilistic-count.mjs — the same day, the same two questions, this time giving up certainty.
// Membership structure and cardinality estimate; a full list is also kept for the accuracy measure.
const READERS = 2_000_000, EVENTS = 900_000, SEED = 20260731, QUERIES = 300_000;
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 reader = () => rand() < 0.7
  ? 1 + Math.floor(rand() * READERS * 0.15) : 1 + Math.floor(rand() * READERS);
const hash = (x, seed) => { let h = Math.imul(x ^ seed, 0x85ebca6b) >>> 0;
  h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35) >>> 0; return (h ^ (h >>> 16)) >>> 0; };

const truth = new Uint8Array(READERS + 1);            // accuracy measure only, not the measured structure
for (let i = 0; i < EVENTS; i += 1) truth[reader()] = 1;
const members = [], outsiders = [];
for (let x = 1; x <= READERS; x += 1) (truth[x] ? members : outsiders).push(x);
const queries = outsiders.slice(0, QUERIES), n = members.length;
const fmt = (x) => x.toLocaleString("en-US");
console.log(`model: ${fmt(n)} distinct readers, ${fmt(QUERIES)} negative membership queries, seed ${SEED}\n`);

console.log("probabilistic membership structure (k = 3 hashes)");
console.log("bits".padEnd(10) + "bytes held".padStart(14) + "per distinct".padStart(14) +
  "false positive".padStart(15) + "expected".padStart(10) + "missed".padStart(11));
for (const p of [17, 18, 19, 20, 21, 22]) {
  const m = 1 << p, K = 3, b = new Uint8Array(m / 8);
  const positions = (x) => Array.from({ length: K }, (_, i) => hash(x, 0x1000 + i * 0x9e3779b1) % m);
  for (const x of members) for (const i of positions(x)) b[i >> 3] |= 1 << (i & 7);
  const contains = (x) => positions(x).every((i) => (b[i >> 3] & (1 << (i & 7))) !== 0);
  let fp = 0, missed = 0;
  for (const x of queries) if (contains(x)) fp += 1;
  for (const x of members) if (!contains(x)) missed += 1;
  const expected = (1 - Math.exp((-K * n) / m)) ** K;
  console.log(`2^${p}`.padEnd(10) + fmt(b.byteLength).padStart(14) +
    (b.byteLength / n).toFixed(2).padStart(14) +
    `%${((fp / QUERIES) * 100).toFixed(2)}`.padStart(15) +
    `%${(expected * 100).toFixed(2)}`.padStart(10) + `${missed}`.padStart(11));
}

console.log("\ncardinality estimate (count only; does not answer membership)");
console.log("register".padEnd(10) + "bytes held".padStart(14) + "per distinct".padStart(14) +
  "estimate".padStart(12) + "deviation".padStart(11) + "theoretical".padStart(13));
for (const p of [8, 10, 12, 14]) {
  const m = 1 << p, R = new Uint8Array(m);
  for (const x of members) { const h = hash(x, 0x9e37), i = h >>> (32 - p), w = (h << p) >>> 0;
    const r = w === 0 ? 33 - p : Math.clz32(w) + 1; if (r > R[i]) R[i] = r; }
  let z = 0, zeros = 0;
  for (const v of R) { z += 2 ** -v; if (v === 0) zeros += 1; }
  let e = (0.7213 / (1 + 1.079 / m)) * m * m / z;
  if (e <= 2.5 * m && zeros > 0) e = m * Math.log(m / zeros);   // linear counting correction
  console.log(`2^${p}`.padEnd(10) + fmt(R.byteLength).padStart(14) +
    (R.byteLength / n).toFixed(4).padStart(14) + fmt(Math.round(e)).padStart(12) +
    `%${(((e - n) / n) * 100).toFixed(2)}`.padStart(11) +
    `%${((1.04 / Math.sqrt(m)) * 100).toFixed(2)}`.padStart(13));
}
```

```
model: 482,085 distinct readers, 300,000 negative membership queries, seed 20260731

probabilistic membership structure (k = 3 hashes)
bits          bytes held  per distinct false positive  expected     missed
2^17              16,384          0.03         %99.99   %100.00          0
2^18              32,768          0.07         %98.83    %98.80          0
2^19              65,536          0.14         %82.26    %82.16          0
2^20             131,072          0.27         %42.09    %41.89          0
2^21             262,144          0.54         %12.41    %12.37          0
2^22             524,288          1.09          %2.53     %2.48          0

cardinality estimate (count only; does not answer membership)
register      bytes held  per distinct    estimate  deviation  theoretical
2^8                  256        0.0005     476,215     %-1.22        %6.50
2^10               1,024        0.0021     481,111     %-0.20        %3.25
2^12               4,096        0.0085     474,849     %-1.50        %1.63
2^14              16,384        0.0340     478,877     %-0.67        %0.81
```

In the upper table, the **missed** column reads zero on every row: the probabilistic membership
structure never answers "no" for a member, because the bits of every added member are set and never
cleared. The error runs in one direction only, and its name is **false positive**: even for a
reader that was never added, the structure can find all three bits set. The measured ratio tracks
the theoretical ratio row by row (12.41% against 12.37% at 2^21), so this ratio is not a surprise —
it is a quantity **chosen** by the structure's size.

The same pair of words also appears in software testing; there, a **false alarm** is a fixable
defect of a test that flags correct code as broken, while the **false positive rate** measured here
is written into the structure's definition — lowered by adding bytes, never zeroed.

The lower table is the sharpest form of the trade-off. A register array holding 4,096 bytes counts
482,085 distinct readers with a 1.50% deviation: it holds **one thousand and twenty-four times** less
space than the exact set, sixty-one times less than the bitmap. The size to pick is read from the
last column, not from the measured deviation. The 256-byte array came out at 1.22% deviation on this
run, but its theoretical margin of error is 6.50%; the first size that safely meets DS13's 2%
tolerance is the 4,096-byte one, whose theoretical margin is 1.63%. What it gives up in return is
absolute: this structure cannot answer "did this reader take a loan" **at any degree of certainty**,
because the reader itself is never represented — only how many leading zeros its hash has is kept.

#### Which Structure Buys What

The four structures hold 4,194,304, 250,001, 524,288, and 4,096 bytes for the same day,
respectively. The ranking breaks in a surprising place: **in a dense number range, the probabilistic
membership structure loses to the bitmap.** The structure running at 2.53% false positive wants
524,288 bytes, the exact bitmap wants 250,001. So under these conditions, giving up certainty does
not save memory — it costs more.

The probabilistic membership structure wins where the range is **sparse**. The library's second
question is this: which book ID changed hands at least once today? A book ID is not a dense number
but a thirteen-digit string, and a bitmap has no address to use. There the exact set has to hold the
string itself, while the probabilistic structure uses the string's hash and its size stays tied to
the member count. The rule for choosing is not in the structure's name — it is in the **density of
the key space**.

There is also merging. The weekly distinct-reader question requires combining seven days of results.
Bitmaps merge with a bitwise OR and stay exact; cardinality estimates merge by taking the maximum
per register and the deviation does not grow; exact sets, when merged, touch every member again and
the cost multiplies by the number of days. A daily 4,096-byte register array, kept for a year, holds
1,495,040 bytes — still smaller than a single day's exact set, and it can still answer separately for
any sub-range within the year.

#### Summary

- Cardinality and membership are two separate questions answered by separate structures: cardinality
  estimation gives a count but cannot answer membership at any certainty, since it does not
  represent the member.
- In a dense number range, a bitmap holds sixteen times less space than an exact set (250,001
  against 4,194,304 bytes) and loses nothing; its cost depends on the width of the range, not the
  entry count, and rises to 125,000,000 bytes for a nine-digit number.
- The probabilistic membership structure's error runs in one direction only: missed members are
  zero, and the false positive rate is chosen by the structure's size — 42.09% at 131,072 bytes,
  2.53% at 524,288 bytes, and the measured ratio tracks the theoretical ratio.
- Giving up certainty does not save memory in a dense range: the structure with 2.53% error wants
  524,288 bytes, the exact bitmap wants 250,001. The probabilistic structure only wins once the key
  space is sparse.
- Cardinality estimation gives a 1.50% deviation at 4,096 bytes; it holds one thousand and
  twenty-four times less space than the exact set, and in return it irreversibly gives up which
  reader was counted.

#### Next Step

The structures measured in this lesson never cared about time. The bitmap answers "did they take a
loan today" but does not know **when**; cardinality estimation produces a number at day's end but
cannot say in what order it was collected. One more question has to be read within the same day: the
library's shelving team, its overdue-notice job, and its search-index update each have to see events
**in the order they happened, each from its own last position.** None of the structures so far
provide this — a set does not keep order, a counter does not keep history, and a list does not help
each reading side remember its own position separately. The next lesson takes up the structure that
stores events in order and keeps every reader's position inside the structure itself.

### Streams

Source: https://academia.sh/en/courses/in-memory-stores/streams

Treating the ordered event log as a data structure: the entry id built from time and sequence, the bytes per entry counted field by field, length and duration limits compared on a day's activity stream, and the unread share shown to determine the bytes held.

None of the previous structures kept order and read position together. A set holds the member but
forgets the order entries were added in; a counter holds the total but leaves no history; a list
keeps order but leaves it to each reader to remember where it left off. The library's three separate
jobs — shelving, overdue notices, and search-index updates — each have to see the same event
sequence **in the order it happened** and **each from its own last position**.

A **stream** combines this into a single structure: entries sit in the order they were added, every
entry has a sortable id, and each **consumer group** remembers where it left off by holding only a
boundary id. This lesson treats the stream not as a message channel but as a **data structure that
holds memory**: how many bytes an entry takes, what the id is for, and when entries are discarded.

#### Entry ID

The id is built from two parts: the clock time when the entry was added and the sequence within that
time. When the clock does not advance, the sequence advances, so two entries never receive the same
id without needing a separate counter structure.

```js
// entry-id.mjs — a stream entry's id and its bytes. The id is generated in <time>-<seq>
// form: when the clock does not advance, the sequence advances, so no two entries collide.
const generator = () => { let lastMs = -1, seq = 0;
  return (ms) => { if (ms === lastMs) seq += 1; else { lastMs = ms; seq = 0; } return `${ms}-${seq}`; }; };
const generate = generator();
const ids = [999, 1000, 1000, 1000, 1001, 1001].map(generate);
console.log("ids generated in one burst: " + ids.join("  "));

const parse = (k) => k.split("-").map(Number);
const isLess = (a, b) => { const [x, y] = parse(a), [p, q] = parse(b);
  return x !== p ? x < p : y < q; };
console.log(`numeric comparison  999-0 < 1000-0: ${isLess("999-0", "1000-0")}`);
console.log(`string comparison  999-0 < 1000-0: ${"999-0" < "1000-0"} (the id does not sort as a string)`);
const boundary = "1000-1";                    // the last id a consumer group has acknowledged
console.log(`entries to read after ${boundary}: ` + ids.filter((k) => isLess(boundary, k)).join(" "));

const METADATA = 24, POINTER = 8;             // id 16 + chain pointer 8; 8 per field
const entry = { type: "loan", reader: "418302", book: "9780000041173", branch: "branch-3" };
let total = METADATA;
console.log("\nfield".padEnd(8) + "name".padStart(4) + "value".padStart(7) + "pointer".padStart(10) +
  "bytes".padStart(6));
for (const [f, v] of Object.entries(entry)) {
  const b = Buffer.byteLength(f) + Buffer.byteLength(v) + POINTER; total += b;
  console.log(f.padEnd(8) + String(Buffer.byteLength(f)).padStart(4) +
    String(Buffer.byteLength(v)).padStart(7) + String(POINTER).padStart(10) + String(b).padStart(6));
}
console.log(`metadata`.padEnd(8) + "".padStart(4) + "".padStart(7) + "".padStart(10) +
  String(METADATA).padStart(6));
console.log(`entry total: ${total} bytes; without field names the same data would take ` +
  `${total - Object.keys(entry).reduce((s, f) => s + Buffer.byteLength(f), 0)} bytes`);
```

```
ids generated in one burst: 999-0  1000-0  1000-1  1000-2  1001-0  1001-1
numeric comparison  999-0 < 1000-0: true
string comparison  999-0 < 1000-0: false (the id does not sort as a string)
entries to read after 1000-1: 1000-2 1001-0 1001-1

field  name  value   pointer bytes
type       4      4         8    16
reader     6      6         8    20
book       4     13         8    25
branch     6      8         8    22
metadata                         24
entry total: 107 bytes; without field names the same data would take 87 bytes
```

The id does three jobs. The first is **ordering**: entries sort by id, and that order is the
insertion order. The second is **boundary**: the only thing a consumer group holds is the last id it
has acknowledged; the phrase "after 1000-1" reduces the group's read position to sixteen bytes. The
third is **collision avoidance**: three entries falling in the same millisecond become 1000-0,
1000-1, and 1000-2.

The line where the comparison is numeric is not a detail to skip. If the id were compared as a
string, 999-0 and 1000-0 would sort in reverse order, because their digit counts differ; the
ordering guarantee comes not from the id's shape but from **comparing it parsed**.

The byte table makes a design decision visible. Of the entry's 107 bytes, 20 are field names, and
these names are rewritten on every entry. For 600,000 entries a day, that comes to 11.44 MiB just
from repeating four words. Short field names or a position-based layout would cut this share; in
exchange, the side reading the stream would have to know the field order, meaning the log's
self-description is lost.

#### A Day's Stream

The stream's memory cost is the product of the bytes per entry and **how many entries are held**,
and the policy that trims determines the second factor.

| Code | Assumption | Value | Rationale |
|---|---|---|---|
| DS14 | daily activity entry | 600,000 | loan, return, hold, and shelf movement across all branches |
| DS15 | entry metadata | 24 bytes | id 16 bytes, chain pointer 8 bytes |
| DS16 | consumer group | 3 groups, 1500 / 1000 / 700 entries/min | the three jobs process at different speeds |
| DS17 | hourly density curve | peak multiplier 2.8 | the library is open by day, nearly idle at night |

DS16 is this lesson's deciding assumption: if the three groups' speed were equal, the trimming
policy alone would determine the memory; since it is not equal, the **slowest group** determines the
memory.

```js
// daily-stream.mjs — a day's activity stream: trimming policies and the memory effect of
// the unread share. The day is split into 1440 one-minute steps, arrivals are generated
// from hourly multipliers, the seed is visible.
const EVENTS = 600_000, SEED = 20260731, MIN = 1440, METADATA = 24, POINTER = 8;
const MULT = [0.05, 0.03, 0.02, 0.02, 0.03, 0.08, 0.2, 0.6, 1.6, 2.2, 2.6, 2.4,
              1.8, 2.0, 2.6, 2.8, 2.5, 2.0, 1.4, 0.9, 0.5, 0.3, 0.15, 0.08];
const GROUPS = [["shelving", 1500], ["overdue notice", 1000], ["search index", 700]];
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 TYPES = ["loan", "return", "hold", "reshelving"];
const entry = (i) => ({ type: TYPES[Math.floor(rand() * 4)], reader: String(100000 + (i % 900000)),
  book: String(9780000000000 + (i % 120000)), branch: `branch-${1 + Math.floor(rand() * 6)}` });
const entryBytes = (e) => METADATA + Object.entries(e)
  .reduce((s, [f, v]) => s + Buffer.byteLength(f) + Buffer.byteLength(v) + POINTER, 0);

const scale = MULT.reduce((s, c) => s + c, 0) * 60;
const eb = new Uint16Array(EVENTS * 2), et = new Uint16Array(EVENTS * 2);
let n = 0;
for (let t = 0; t < MIN; t += 1) {
  const k = Math.round((EVENTS * MULT[Math.floor(t / 60)]) / scale);
  for (let i = 0; i < k; i += 1) { eb[n] = entryBytes(entry(n)); et[n] = t; n += 1; }
}
const cum = new Float64Array(n + 1);                          // cumulative bytes
for (let i = 0; i < n; i += 1) cum[i + 1] = cum[i] + eb[i];
const fmt = (x) => x.toLocaleString("en-US", { maximumFractionDigits: 2 });
const hourMin = (t) => `${Math.floor(t / 60)}:${String(t % 60).padStart(2, "0")}`;
let min = eb[0], max = eb[0];
for (let j = 1; j < n; j += 1) { if (eb[j] < min) min = eb[j]; if (eb[j] > max) max = eb[j]; }
console.log(`model: ${fmt(n)} entries/day, ${MIN} one-minute steps, seed ${SEED}`);
console.log(`entry bytes: average ${fmt(cum[n] / n)}, smallest ${min}, largest ${max}; ` +
  `the whole day ${fmt(Math.round(cum[n] / 1024 ** 2))} MiB\n`);

function run(label, trim, pause = null) {
  const cursor = GROUPS.map(() => 0);
  let head = 0, tail = 0, i = 0, peak = 0, peakBytes = 0, peakMin = 0, lost = 0;
  for (let t = 0; t < MIN; t += 1) {
    while (i < n && et[i] === t) { i += 1; tail += 1; }
    for (let g = 0; g < GROUPS.length; g += 1) {
      const stalled = pause && g === pause[0] && t >= pause[1] && t < pause[2];
      if (!stalled) cursor[g] = Math.min(tail, cursor[g] + GROUPS[g][1]);
    }
    head = Math.max(head, trim(tail, t, cursor));
    for (let g = 0; g < GROUPS.length; g += 1)
      if (cursor[g] < head) { lost += head - cursor[g]; cursor[g] = head; }
    if (tail - head > peak) { peak = tail - head; peakBytes = cum[tail] - cum[head]; peakMin = t; }
  }
  console.log(label.padEnd(26) + fmt(tail - head).padStart(11) + fmt(peak).padStart(14) +
    fmt(Math.round(peakBytes / 1024)).padStart(11) + hourMin(peakMin).padStart(9) +
    fmt(lost).padStart(11));
}
const firstIndex = (t, T) => { let l = 0, r = n; while (l < r) {
  const m = (l + r) >> 1; if (et[m] < t - T) l = m + 1; else r = m; } return l; };
console.log("policy".padEnd(26) + "day end".padStart(11) + "peak entries".padStart(14) +
  "peak KiB".padStart(11) + "peak min".padStart(9) + "missed".padStart(11));
run("no trimming", () => 0);
run("length 50,000", (tail) => tail - 50_000);
run("duration 60 min", (tail, t) => firstIndex(t, 60));
run("safe (slowest group)", (tail, t, im) => Math.min(...im));
run("safe + 30 min pause", (tail, t, im) => Math.min(...im), [2, 1020, 1050]);

console.log("\ngroup".padEnd(26) + "rate/min".padStart(9) + "peak unread".padStart(16) +
  "peak KiB".padStart(11) + "peak min".padStart(9) + "day end".padStart(10));
const cursor = GROUPS.map(() => 0), top = GROUPS.map(() => [0, 0, 0]);
let tail = 0, i = 0;
for (let t = 0; t < MIN; t += 1) {
  while (i < n && et[i] === t) { i += 1; tail += 1; }
  for (let g = 0; g < GROUPS.length; g += 1) {
    cursor[g] = Math.min(tail, cursor[g] + GROUPS[g][1]);
    if (tail - cursor[g] > top[g][0]) top[g] = [tail - cursor[g], cum[tail] - cum[cursor[g]], t];
  }
}
for (let g = 0; g < GROUPS.length; g += 1)
  console.log(GROUPS[g][0].padEnd(26) + fmt(GROUPS[g][1]).padStart(9) + fmt(top[g][0]).padStart(16) +
    fmt(Math.round(top[g][1] / 1024)).padStart(11) + hourMin(top[g][2]).padStart(9) +
    fmt(tail - cursor[g]).padStart(10));
```

```
model: 600,000 entries/day, 1440 one-minute steps, seed 20260731
entry bytes: average 109, smallest 107, largest 113; the whole day 62 MiB

policy                        day end  peak entries   peak KiB peak min     missed
no trimming                   600,000       600,000     63,865    23:59          0
length 50,000                  50,000        50,000      5,321     8:45     38,920
duration 60 min                 1,856        63,488      6,757    15:59     46,220
safe (slowest group)                0        88,920      9,465    17:59          0
safe + 30 min pause                 0       109,920     11,701    17:59          0

group                     rate/min     peak unread   peak KiB peak min   day end
shelving                      1,500               0          0     0:00         0
overdue notice                1,000           2,520        268    15:59         0
search index                    700          88,920      9,465    17:59         0
```

These numbers are in the **measurement** class; the distribution of entries depends on the hourly
curve and the seed, the difference between the policies does not.

#### The Unread Share

The upper table compares four policies over the same day. **No trimming** brings the stream to
63,865 KiB and misses no entry: history stays queryable, at the cost of the whole day sitting in
memory. **Length 50,000** fixes memory at 5,321 KiB — twelve times less — but the search index loses
38,920 entries without ever seeing them. **Duration 60 min** looks more measured at first glance,
holding only 1,856 entries at day's end; yet it peaks at 63,488 entries and misses 46,220. The
difference is what each policy fixes: a length limit fixes **memory**, a duration limit fixes
**age** and leaves memory to the arrival rate. If density tripled, the duration-limited stream would
hold three times the space.

**Safe trimming** is the fourth option: an entry is not deleted until every group has read it.
Missed entries drop to zero, bytes held peak at 9,465 KiB and reset to zero at day's end. But the
ceiling is no longer in the operator's hands. The lower table shows this: shelving never falls
behind, overdue notice accumulates a peak of 2,520 entries, search index 88,920. The entire 9,465
KiB the stream holds is the share the slowest group has not read — not because the stream is a
queue, but because it **cannot discard anything before the furthest-behind boundary id**.

The last row measures exactly how sharp this is. If the search index pauses for thirty minutes at
peak hour, the stream climbs to 109,920 entries: exactly 21,000 entries and 2,236 KiB more — the
pause duration times the group's rate. The memory cost here belongs not to the stream but to the
decision of **the side that is not reading it**.

The reverse is equally clear: the number of readers does not increase memory. Three groups hold
three boundary ids, sixteen bytes each for forty-eight bytes total; adding a fourth, fifth, or tenth
group to the stream adds sixteen bytes to a 62 MiB body. This is what separates a stream from a
list: a list is consumed as it is read and a second reader needs a second copy, while a stream is
held once and the position stays on the reader's side.

#### Summary

- The entry id is built from time and sequence and does three jobs: it gives the insertion order,
  separates entries within the same millisecond, and reduces a consumer group's read position to
  sixteen bytes; the comparison must be numeric — a string comparison sorts 999-0 and 1000-0 in
  reverse.
- A share of the entry byte count is field names repeated on every entry: 20 of 107 bytes, 11.44 MiB
  a day. Shortening them costs the stream its self-description.
- A length limit fixes memory (5,321 KiB), a duration limit fixes age and leaves memory to the
  arrival rate (peaking at 63,488 entries); both miss 38,920 and 46,220 entries respectively because
  neither knows the slowest group.
- Under safe trimming, missed entries are zero but the ceiling is not the operator's: the entire
  9,465 KiB held is the slowest group's unread share, and if that group pauses for thirty minutes
  the stream grows by 21,000 entries (2,236 KiB).
- The number of readers does not increase memory, the number of non-readers does: the three groups'
  total ledger cost is forty-eight bytes.

#### Next Step

Because the stream's id is sortable, the question "everything after now" was a position lookup, not
a search. The library has one more question, and it has no sortable key: a reader wants the
collection point nearest to where they stand. Every way of sorting a two-dimensional position along
a single axis pushes some neighbors far apart, so neither a sorted set nor a stream answers this
question directly. The next lesson builds the same proximity query with an in-memory structure and
compares it against how many candidates a flat scan has to examine.

### Spatial Indexes

Source: https://academia.sh/en/courses/in-memory-stores/spatial-indexes

Building a proximity query with an in-memory structure: the same query solved with a flat scan, a cell bucket, and a spatial-keyed sorted set, the extra bytes each of the three structures holds and the candidates scanned per query measured, and what being able to choose the grain at query time costs counted out.

Because the stream's id is sortable, the question "everything after now" was a position lookup, not
a search. The library has one more question, and it has no natural ordering: a reader wants the loan
and return points within one kilometer of where they stand. Every way of sorting a two-dimensional
position along a single axis pushes some neighbors far apart; neither a sorted set nor a stream
answers this question directly.

The solution is to split the plane into cells and write a **spatial key** for every point. How the
cell size is chosen is not this lesson's subject; that decision was measured and given in the Case
Studies course, in the Data-Intensive Systems topic, in the Location-Based Service case. Here the
grain is an **input**, and the question asked is different: which structure builds the same
proximity query in an in-memory store, how many bytes extra does that structure hold, and how many
candidates does it save from being eliminated in return.

#### Spatial Key

The key is produced by interleaving the bits of two coordinates: each axis is quantized to sixteen
bits, the bits are placed alternately, and the result is a single thirty-two-bit number. This layout
has one useful property: every point in a cell shares the key's **leading bits**, so the cell is a
**contiguous range** in a sorted array. How many bits are shared determines the grain — the first
twelve bits give a 1 km cell, the first sixteen bits give a 0.25 km cell.

| Code | Assumption | Value | Rationale |
|---|---|---|---|
| DS18 | loan and return point | 120,000 | branch, return box, school, and station points |
| DS19 | region | 64 × 64 km | a single metropolitan area; the plane approach is a model simplification |
| DS20 | base record | 20 bytes | position pair 16 bytes, point id 4 bytes |
| DS21 | query radius | 1 km and 0.25 km | walking distance and a building's perimeter |

DS21 is this lesson's deciding assumption: with a single radius, two of the three structures would
be indistinguishable.

#### Three Structures

The setup places the same 120,000 points into three separate structures and asks the same 300
queries of all of them. Byte counts are read from the buffers' own size; the hash tables' cost is
read from a formula visible in the code.

```js
// spatial.mjs — the same proximity query with three in-memory structures: flat scan, cell
// bucket, and a spatial-keyed sorted set. Byte counts are read from buffers and an open formula.
// The region is modeled as a 64x64 km plane; points are clustered, the seed is visible.
const N = 120_000, SIDE = 64, QUERIES = 300, SEED = 20260731, CLUSTERS = 24;
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 centers = Array.from({ length: CLUSTERS }, () => [rand() * SIDE, rand() * SIDE]);
const point = () => {                                  // 35% uniform, 65% around a cluster
  if (rand() < 0.35) return [rand() * SIDE, rand() * SIDE];
  const [mx, my] = centers[Math.floor(rand() * CLUSTERS)];
  const a = rand() * 2 * Math.PI, r = -Math.log(1 - rand()) * 2.5;
  return [Math.min(SIDE - 1e-9, Math.max(0, mx + r * Math.cos(a))),
          Math.min(SIDE - 1e-9, Math.max(0, my + r * Math.sin(a)))];
};

const px = new Float64Array(N), py = new Float64Array(N), pid = new Int32Array(N);
for (let i = 0; i < N; i += 1) { const [x, y] = point(); px[i] = x; py[i] = y; pid[i] = 100000 + i; }
const BASE = px.byteLength + py.byteLength + pid.byteLength;

const spread = (v) => { v = (v | (v << 8)) & 0x00ff00ff; v = (v | (v << 4)) & 0x0f0f0f0f;
  v = (v | (v << 2)) & 0x33333333; v = (v | (v << 1)) & 0x55555555; return v >>> 0; };
const quantize = (u) => Math.min(65535, Math.floor((u / SIDE) * 65536));
const key = (x, y) => ((spread(quantize(y)) << 1) | spread(quantize(x))) >>> 0;   // 32-bit spatial key
const p2 = (n) => { let k = 1; while (k < n) k *= 2; return k; };

const rawKey = new Uint32Array(N), order = new Int32Array(N);   // sorted set: key + member id
for (let i = 0; i < N; i += 1) { rawKey[i] = key(px[i], py[i]); order[i] = i; }
order.sort((a, b) => rawKey[a] - rawKey[b]);
const sortedKey = new Uint32Array(N);
for (let i = 0; i < N; i += 1) sortedKey[i] = rawKey[order[i]];
const SCORE_TABLE = p2(2 * N) * 8;                       // member -> score hash table, slot 8 bytes
const SORTED = sortedKey.byteLength + order.byteLength + SCORE_TABLE;

const BIT = 6, SHIFT = 2 * (16 - BIT);                   // 2^6 = 64 cells/axis, cell 1 km
const buckets = new Map();
for (let i = 0; i < N; i += 1) {
  const h = Math.floor(rawKey[i] / 2 ** SHIFT);
  if (buckets.has(h)) buckets.get(h).push(i); else buckets.set(h, [i]);
}
const filled = buckets.size;
const BUCKET = p2(2 * filled) * 12 + filled * 8 + N * 4; // slot 12 + array metadata 8 + member 4

const queries = Array.from({ length: QUERIES }, point);
const fmt = (x) => x.toLocaleString("en-US", { maximumFractionDigits: 2 });
const near = (q, i, R) => (px[i] - q[0]) ** 2 + (py[i] - q[1]) ** 2 <= R * R;
const lowerBound = (v) => { let l = 0, r = N; while (l < r) {
  const m = (l + r) >> 1; if (sortedKey[m] < v) l = m + 1; else r = m; } return l; };

function run(R, bit) {                                   // cells covering the query box
  const c = SIDE / 2 ** bit, k = Math.ceil(R / c), shift = 2 * (16 - bit);
  let flatCand = 0, flatMatch = 0, bucketCand = 0, bucketMatch = 0, ssCand = 0, ssMatch = 0, ranges = 0;
  for (const q of queries) {
    for (let i = 0; i < N; i += 1) { flatCand += 1; if (near(q, i, R)) flatMatch += 1; }
    const cx = Math.floor(q[0] / c), cy = Math.floor(q[1] / c);
    for (let dy = -k; dy <= k; dy += 1) for (let dx = -k; dx <= k; dx += 1) {
      const x = cx + dx, y = cy + dy;
      if (x < 0 || y < 0 || x >= 2 ** bit || y >= 2 ** bit) continue;
      const h = ((spread(y) << 1) | spread(x)) >>> 0;
      if (bit === BIT) {                                 // cell bucket only knows its own grain
        for (const i of buckets.get(h) ?? []) { bucketCand += 1; if (near(q, i, R)) bucketMatch += 1; }
      }
      const lo = h * 2 ** shift, hi = lo + 2 ** shift;    // contiguous range in the sorted set
      ranges += 1;
      for (let j = lowerBound(lo); j < N && sortedKey[j] < hi; j += 1) {
        ssCand += 1; if (near(q, order[j], R)) ssMatch += 1;
      }
    }
  }
  return { flatCand: flatCand / QUERIES, flatMatch: flatMatch / QUERIES, bucketCand: bucketCand / QUERIES,
    bucketMatch: bucketMatch / QUERIES, ssCand: ssCand / QUERIES, ssMatch: ssMatch / QUERIES,
    ranges: ranges / QUERIES, c };
}

console.log(`model: ${fmt(N)} points, ${SIDE}x${SIDE} km, ${CLUSTERS} clusters, ${QUERIES} queries, seed ${SEED}`);
console.log(`density ${fmt(N / SIDE ** 2)} points/km2, filled cells ${fmt(filled)} (at 1 km granularity)\n`);
console.log("structure".padEnd(30) + "base bytes".padStart(12) + "index bytes".padStart(12) +
  "per member".padStart(15) + "total".padStart(12));
for (const [label, ext] of [["flat scan (array)", 0], ["cell bucket, 1 km cell", BUCKET],
                        ["spatial-keyed sorted set", SORTED]])
  console.log(label.padEnd(30) + fmt(BASE).padStart(12) + fmt(ext).padStart(12) +
    (ext / N).toFixed(2).padStart(15) + fmt(BASE + ext).padStart(12));

console.log("\ncandidates scanned per query (same 300 queries, same radius)");
console.log("radius".padEnd(10) + "cell".padStart(9) + "cells".padStart(7) + "flat".padStart(10) +
  "bucket".padStart(9) + "s.set".padStart(9) + "matches (flat/bucket/s.set)".padStart(29));
for (const [R, bit] of [[1, BIT], [0.25, BIT], [0.25, 8]]) {
  const s = run(R, bit), k = bit === BIT;
  console.log(`${R} km`.padEnd(10) + `${SIDE / 2 ** bit} km`.padStart(9) +
    s.ranges.toFixed(0).padStart(7) + fmt(s.flatCand).padStart(10) +
    (k ? fmt(s.bucketCand) : "-").padStart(9) + fmt(s.ssCand).padStart(9) +
    `${s.flatMatch.toFixed(2)} / ${k ? s.bucketMatch.toFixed(2) : "-"} / ${s.ssMatch.toFixed(2)}`.padStart(29));
}
```

```
model: 120,000 points, 64x64 km, 24 clusters, 300 queries, seed 20260731
density 29.3 points/km2, filled cells 4,096 (at 1 km granularity)

structure                       base bytes index bytes     per member       total
flat scan (array)                2,400,000           0           0.00   2,400,000
cell bucket, 1 km cell           2,400,000     611,072           5.09   3,011,072
spatial-keyed sorted set         2,400,000   3,057,152          25.48   5,457,152

candidates scanned per query (same 300 queries, same radius)
radius         cell  cells      flat   bucket    s.set  matches (flat/bucket/s.set)
1 km           1 km      9   120,000   821.22   821.22     372.25 / 372.25 / 372.25
0.25 km        1 km      9   120,000   821.22   821.22        40.90 / 40.90 / 40.90
0.25 km     0.25 km      9   120,000        -    97.39            40.90 / - / 40.90
```

These numbers are in the **measurement** class; the candidate and match counts depend on the seed
and the clustering parameters, the byte counts do not — they are buffer sizes.

The last column says the same thing on every row: all three structures find the same matches —
372.25 at 1 km, 40.90 at 0.25 km. The index is not an approximation, it is a **pre-filter**; the
exact distance is computed separately for every candidate, and no structure ever misses a result.

#### What the Extra Bytes Buy

All three structures carry the same 2,400,000-byte base record; the difference is in the **index
bytes** column. The cell bucket adds 5.09 bytes per member and cuts the candidates scanned per query
from 120,000 to 821.22 — a hundred and forty-six times fewer. This is the cheapest gain in the
lesson: for 611,072 bytes, 99.3 percent of the query cost disappears.

The sorted set gains nothing on the same query. At 25.48 bytes per member — five times the cell
bucket's cost — it scans the exact same 821.22 candidates on the 1 km query. Read on its own, this
row makes the sorted set a poor choice.

Where the extra bytes go can also be counted: of the 3,057,152 bytes, 2,097,152 — 68.6 percent — is
the hash table from member id to key. This table is held not for querying but for **updating**. When
a mobile library stop moves, the member's old position in the sorted array has to be found; without
the table, that means scanning the index. If points never moved, the sorted set's overhead would
drop from 25.48 bytes per member to 8 — in that case only three bytes of the cell bucket would be
the expensive one.

#### If the Grain Is Chosen at Query Time

The second and third rows show the difference. When a reader asks about a building's perimeter —
0.25 km — the cell bucket still scans 821.22 candidates: the buckets were built at insertion time
for a 1 km grain, and asking a finer question still returns the same nine-square-kilometer area.
Asking for a finer grain means rebuilding every bucket.

In the sorted set, because the key carries the full thirty-two bits, **every prefix length is a
valid range**. Asking the same 0.25 km query using the first sixteen bits drops the scanned
candidates to 97.39: eight times less work for the same 40.90 matches. What the sorted set buys is
not savings on candidates — it is the ability to **choose the grain at query time rather than at
insertion time**.

This settles the three structures into a decision table. A flat scan holds zero extra bytes and
touches a hundred twenty thousand records on every query; it is the right choice if the point count
stays under a few thousand. A cell bucket is the cheapest index when the query radius is fixed,
eliminating 99.3 percent of candidates for 5.09 bytes. A spatial-keyed sorted set earns its 25.48
bytes when the radius changes from query to query or points relocate; on a fixed-radius, static set,
those bytes go to waste.

#### Summary

- The spatial key produces a single sortable number by interleaving two coordinates' bits; a cell
  thereby becomes a contiguous range in the sorted array, and the prefix length determines the
  grain.
- All three structures return the same result: 372.25 matches at 1 km, 40.90 at 0.25 km. The index
  is a pre-filter, not an approximation, and the exact distance is computed separately for every
  candidate.
- The cell bucket is the lesson's cheapest gain: for 5.09 bytes per member, candidates scanned drop
  from 120,000 to 821.22, meaning 99.3 percent of the query cost disappears.
- The sorted set wants 25.48 bytes per member and gains nothing on a query at its own grain; 68.6
  percent of those bytes is a hash table held not for querying but for updating a member's position.
- What the extra bytes buy shows up once the radius changes: on a 0.25 km query the cell bucket
  scans 821.22 candidates, the sorted set 97.39, because the grain is chosen at query time rather
  than insertion time.

#### Next Step

The proximity query returns a list, and the work does not end there. Once a reader picks a point
from the list, the library has to do all of the following: read the copy count at that point, check
the reader's open-loan limit, decrement the copy count by one, add the record to the loan set, and
write an entry to the activity stream. Every structure so far can make **one** of these steps
atomic; nothing has made all five atomic at once. When the steps are sent one at a time, another
reader can slip in and take the same copy in between. The next lesson builds this multi-step work in
two forms and counts the difference as the number of round trips, the race window, and blocked time.

### Server-Side Scripting

Source: https://academia.sh/en/courses/in-memory-stores/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 never gave out more than the copies
allow: with more clients than copies exactly three loans went out, and with two clients, two.
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, no concurrency level overpays — with three
  copies at most three loans go out — 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.

## Persistence and Memory Management

Durability and the memory limit turned into settings: snapshot persistence through periodic disk writes, the command log and its rewrite cost, the persistence choice between durability and performance, the eviction policies that decide which key gets evicted once the memory limit is reached, and expiration management's balance between lazy and active cleanup.

### Snapshot Persistence

Source: https://academia.sh/en/courses/in-memory-stores/snapshot-persistence

The persistence form that writes the entire store to disk at intervals: how the interval affects the write count inside the data loss window, the disk cost of three intervals, the difference between the bytes held in memory and the size of the snapshot file, and the choice between pausing and copying while taking a snapshot.

The previous topic built the loan system entirely in memory: catalog entries, session records,
loan counters, waiting lists, leaderboards. For each structure, the bytes held were counted and
what they bought in return was measured. All of those measurements shared one quiet assumption:
the process is running. When the process stops, none of it survives. Durability was called a
setting, but what that setting does, what it costs, and exactly what is lost while it is off were
never asked.

This lesson takes up the first form of that setting: writing the entire store to disk at fixed
intervals. It raises two questions. How many writes are lost when the process stops, and how much
does taking the snapshot itself cost the store while it keeps running? Both answers depend on the
same dial — the interval — and pull in opposite directions.

#### Setting Up the Store and the Measurement

For the measurement, the loan store is written directly. The store is a key-value mapping; on
every write it also updates the byte count it holds, so the memory cost is not estimated — it is
counted.

**PM1.** An entry's memory cost is the key's bytes, the value's bytes, and a fixed 48 bytes of
overhead per entry. Those 48 bytes represent the hash table slot, the two length fields, and the
entry pointer; a real runtime would move the number slightly, but not the direction of the
measurement.

```js
// store.mjs — in-memory loan store and snapshot taker
export const OVERHEAD = 48;          // PM1: fixed overhead per entry (slot + pointer + length)

export class Store {
  constructor() { this.entries = new Map(); this.bytes = 0; }
  #cost(k, v) { return Buffer.byteLength(k) + Buffer.byteLength(v) + OVERHEAD; }
  write(k, v) {
    const previous = this.entries.get(k);
    if (previous !== undefined) this.bytes -= this.#cost(k, previous);
    this.entries.set(k, v);
    this.bytes += this.#cost(k, v);
  }
  get count() { return this.entries.size; }
  snapshot() {                     // snapshot: all entries converted to text in a single pass
    let s = '';
    for (const [k, v] of this.entries) s += k + '\t' + v + '\n';
    return s;
  }
}

export function generator(seed) {     // deterministic pseudorandom generator
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

export function generateLoad(count, seed, memberCount = 20000) {
  const r = generator(seed), list = [];
  for (let i = 0; i < count; i++) {
    const m = Math.floor(r() * memberCount);
    list.push([
      'loan:m' + String(m).padStart(5, '0'),
      `branch=${1 + (m % 8)};day=${[7, 14, 28][i % 3]};renewal=${i % 4}`,
    ]);
  }
  return list;
}
```

The load is the library's loan traffic: sixty thousand loan writes spread across twenty thousand
members. The generator is hand-written and its seed is visible, so every number below can be
reproduced.

#### Interval, Data Loss Window, and Disk

In snapshot persistence, durability is set with a single dial: how many writes pass between
snapshots. The run below replays the same sixty thousand writes across three intervals. After
every write, the answer to "how many writes would be lost if the process stopped right now" is
accumulated; at the end, its average and its worst case are printed.

```js
// interval.mjs — data loss window and disk cost of three snapshot intervals
import { writeFileSync, statSync } from 'node:fs';
import { Store, generateLoad } from './store.mjs';

const LOAD = generateLoad(60000, 20260731);
console.log('load: 60000 writes, 20000 members, seed 20260731');
console.log('interval  snapshots  disk(MB)  average loss  worst loss');

for (const interval of [1000, 5000, 20000]) {
  const store = new Store();
  let snapshots = 0, diskBytes = 0, lossTotal = 0, worst = 0, lastSnapshot = 0;
  for (let i = 0; i < LOAD.length; i++) {
    store.write(LOAD[i][0], LOAD[i][1]);
    const loss = (i + 1) - lastSnapshot;        // writes that would be lost if the process stopped now
    lossTotal += loss;
    if (loss > worst) worst = loss;
    if ((i + 1) % interval === 0) {
      diskBytes += Buffer.byteLength(store.snapshot());
      snapshots++;
      lastSnapshot = i + 1;
    }
  }
  console.log(String(interval).padStart(6), String(snapshots).padStart(8),
    String(Math.round(diskBytes / 1048576)).padStart(10),
    String(Math.round(lossTotal / LOAD.length)).padStart(15), String(worst).padStart(14));
}

const final = new Store();
for (const [k, v] of LOAD) final.write(k, v);
writeFileSync('snapshot.txt', final.snapshot());
console.log('store:', final.count, 'entries,', final.bytes, 'bytes in memory |',
  'snapshot file', statSync('snapshot.txt').size, 'bytes');
```

```
load: 60000 writes, 20000 members, seed 20260731
interval  snapshots  disk(MB)  average loss  worst loss
  1000       60         30             501           1000
  5000       12          6            2501           5000
 20000        3          2           10001          20000
store: 19040 entries, 1593100 bytes in memory | snapshot file 717260 bytes
```

The data loss window is a direct function of the interval: its average is half the interval, its
worst case is the interval itself. Compared to the twenty-thousand interval, the thousand-write
interval saves an average of 9,500 writes. That is what is bought, and its cost sits in the same
row: 30 MB of disk writes instead of 2 MB, a factor of fifteen. The cost grows this way because a
snapshot writes the entire store every single time. Even for a change of a thousand writes, the
whole store — around five hundred kilobytes — goes back to disk.

The last line gives a second distinction. Of the sixty thousand writes, 19,040 entries remain; the
rest overwrote records of the same members. A snapshot's size grows with the number of keys, not
the number of writes — this is the fundamental difference from the log format in the next lesson.
The same data holds 1,593,100 bytes in memory and 717,260 bytes in the file: the file is less than
half of memory. The difference is overhead. Hash table slots, pointers, and length fields do not
go to disk. Looking at the small size of the file on disk and concluding that the store will
comfortably fit in memory accepts, from the start, an error of roughly double per entry.

#### The Cost of Taking the Snapshot Itself

The snapshot has to be consistent: recovery cannot work from a file that is half old and half new.
There are two ways to guarantee consistency, and neither is free.

**Pausing.** Writes are held while the snapshot is taken. The memory cost is zero; clients pay the
price in wait time.

**Copying.** Writes continue; when an entry that belongs in the snapshot is about to be changed,
its old value is copied first, and the snapshot reads the copy. No one waits; the cost is paid in
memory: the copies are held until the snapshot finishes.

**PM2.** Taking a snapshot is not instantaneous; every thousand entries scanned takes forty
writes' worth of time. Duration is counted in a unit tied to the entry count, not to the runtime
environment.

```js
// copy.mjs — two options while taking a snapshot: pausing or copying
import { Store, OVERHEAD, generateLoad } from './store.mjs';

const LOAD = generateLoad(60000, 20260731);
const SCAN = 40;                    // PM2: 40 writes of scan time per 1,000 entries
console.log('interval  snapshots  scan  pausing: waiting  copying: entries  peak bytes');

for (const interval of [1000, 5000, 20000]) {
  const store = new Store();
  let snapshots = 0, duration = 0, waiting = 0, copyCount = 0, peak = 0, remaining = 0, copy = null;
  for (let i = 0; i < LOAD.length; i++) {
    const [k, v] = LOAD[i];
    if (remaining > 0) {                  // snapshot in progress: the changing entry is copied first
      const previous = store.entries.get(k);
      if (previous !== undefined && !copy.has(k)) { copy.set(k, previous); copyCount++; }
      if (--remaining === 0) {
        let b = 0;
        for (const [ck, cv] of copy) b += Buffer.byteLength(ck) + Buffer.byteLength(cv) + OVERHEAD;
        if (b > peak) peak = b;
        copy = null;
      }
    }
    store.write(k, v);
    if ((i + 1) % interval === 0) {
      duration = Math.ceil((store.count / 1000) * SCAN);
      snapshots++; waiting += duration; remaining = duration; copy = new Map();
    }
  }
  console.log(String(interval).padStart(6), String(snapshots).padStart(8), String(duration).padStart(7),
    String(waiting).padStart(21), String(copyCount).padStart(18), String(peak).padStart(10));
}
```

```
interval  snapshots  scan  pausing: waiting  copying: entries  peak bytes
  1000       60     762                 33285              24985      59565
  5000       12     762                  6946               4710      58498
 20000        3     762                  1965                913      49266
```

The scan duration is the same in all three rows: 762 writes. Taking a snapshot depends on the
entry count, not the interval, so shortening the interval does not make the scan faster — it only
makes it more frequent.

At the thousand-write interval, the pausing option holds up 33,285 of the sixty thousand writes —
more than half the load. At the twenty-thousand interval, that number drops to 1,965. The copying
option holds up no writes at all; in exchange, it holds at most 59,565 extra bytes while the
snapshot runs. That is roughly 3.7 percent of the store's 1,593,100 bytes. The peak value coming
out close across all three intervals has the same cause: what determines the peak is not the
interval but how many **distinct** keys were touched during the scan duration. 762 writes could
copy at most 762 distinct entries; because members repeat, the count stays around four hundred per
snapshot. If writes had piled onto a few hot keys, the copy count would have been even lower; if
every write had gone to a distinct key, the peak would have come close to double.

The total number of copied entries, though, grows as the interval shrinks: 24,985 against 913.
Copying is cheap in peak memory, but as snapshots become more frequent it accumulates as work
performed.

A hidden limit also becomes visible at the thousand-write interval. If the scan takes 762 writes,
the store is taking a snapshot during 762 of every thousand writes. Once the interval is pushed
below the scan duration, snapshots start overlapping; the new request is either skipped or races
the previous one. Either way, the interval that actually occurs is larger than the one configured,
and the data loss window written in the configuration no longer describes reality.

#### The Interval Decision

The three measurements come together in a single table. The numbers belong to the
sixty-thousand-write run.

| Interval | Average loss | Worst loss | Disk writes | Copied entries |
|---|---|---|---|---|
| 1,000 | 501 writes | 1,000 writes | 30 MB | 24,985 |
| 5,000 | 2,501 writes | 5,000 writes | 6 MB | 4,710 |
| 20,000 | 10,001 writes | 20,000 writes | 2 MB | 913 |

The decision is made by looking at what the lost write actually is. For the catalog cache, even
the twenty-thousand interval is excessive protection: the entries that would be lost can be
regenerated from their source anyway. For loan counters and the waiting list, an average loss of
ten thousand writes can amount to a full day of the library's transactions, and recovering it is
done by hand.

This is where snapshot persistence hits its structural limit: no matter how short the interval
gets, the data loss window never reaches zero, because every write after the last snapshot is
unprotected. Pushing the interval below the scan duration does not help either. Actually closing
the data loss window requires a format that records each write itself, not the entire store.

#### Summary

- Snapshot persistence writes the entire store to disk at intervals; the data loss window averages
  half the interval, with the worst case equal to the interval itself.
- In the measurement, the thousand-write interval brought average loss down from 10,001 to 501,
  and in exchange raised disk writes from 2 MB to 30 MB; a snapshot writes the entire store every
  time.
- A snapshot's size grows with the key count, not the write count: 60,000 writes left 19,040
  entries, and the file came out to 717,260 bytes.
- The same data holds 1,593,100 bytes in memory; the difference is overhead that never reaches
  disk, so memory requirements cannot be inferred from file size.
- While taking a snapshot, pausing held up 33,285 writes; copying held up none, but held 59,565
  extra bytes at its peak; the peak is set by the number of distinct keys touched during the scan,
  not the interval.

#### Next Step

A snapshot writes the store's state at one instant and records nothing that happens between two
instants. The only way to close the data loss window is to record the writes that lead to a state,
rather than the state itself. The next lesson takes this up: every write is appended to the end of
a file as a record, and recovery replays those records. What gets measured is how many bytes a
record costs per write, how much space the rewrite that trims an unboundedly growing file
recovers, and how the choice of how often records are synced to disk sets the data loss window
against the write cost.

### Append Log Persistence

Source: https://academia.sh/en/courses/in-memory-stores/append-log-persistence

The persistence form that records the write, not the state: how many bytes the command log holds per write, how the log grows relative to the snapshot, how much space the rewrite recovers, and the effect on the data loss window and write cost of syncing on every command, once per second, or leaving it to the operating system.

A snapshot writes the store's state at one instant. Nothing that happens between two instants is
ever recorded, which is why the data loss window never reaches zero no matter how short the
interval gets. For the library's loan counters, in the worst case that means thousands of loan
records have to be collected back by hand.

This lesson takes up the second form: recording the **write**, not the state. Every mutating
command is appended to the end of a file in the order it was applied. Recovery replays these
commands over an empty store. Because nothing happens beyond appending to the end of the file, the
write path is cheap; its cost shows up in two other places: the file grows without bound, and the
gap between "appended" and "on disk" becomes a setting.

#### Recording the Write Itself

The record format carries the store's command as-is: command letter, key, value. The load is the
same as the previous lesson — sixty thousand loan writes spread across twenty thousand members,
with the same seed — so the numbers from the two formats can be compared directly.

```js
// log.mjs — command log record and the same loan load
export const record = (k, v) => `W\t${k}\t${v}\n`;   // W: write command

export function generator(seed) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

export function generateLoad(count, seed, memberCount = 20000) {
  const r = generator(seed), list = [];
  for (let i = 0; i < count; i++) {
    const m = Math.floor(r() * memberCount);
    list.push([
      'loan:m' + String(m).padStart(5, '0'),
      `branch=${1 + (m % 8)};day=${[7, 14, 28][i % 3]};renewal=${i % 4}`,
    ]);
  }
  return list;
}
```

#### The Log's Growth and the Rewrite

The run below writes the load to disk for real and, every twenty thousand writes, stops to place
two sizes side by side: the log file up to that point, and a snapshot of the same state.

```js
// growth.mjs — the log's growth and the space the rewrite recovers
import { appendFileSync, writeFileSync, statSync, rmSync } from 'node:fs';
import { record, generateLoad } from './log.mjs';

const LOAD = generateLoad(60000, 20260731);
rmSync('loan.log', { force: true });
const state = new Map();
let buffer = '', logBytes = 0;
console.log('write   log bytes  keys  snapshot bytes  log/snapshot');
for (let i = 0; i < LOAD.length; i++) {
  const [k, v] = LOAD[i];
  buffer += record(k, v);                       // every write is appended to the end of the log
  state.set(k, v);
  if ((i + 1) % 20000 === 0) {
    appendFileSync('loan.log', buffer);
    logBytes += Buffer.byteLength(buffer);
    buffer = '';
    let g = 0;
    for (const [sk, sv] of state) g += Buffer.byteLength(sk) + Buffer.byteLength(sv) + 2;
    console.log(String(i + 1).padStart(5), String(logBytes).padStart(12),
      String(state.size).padStart(8), String(g).padStart(13),
      (logBytes / g).toFixed(2).padStart(15));
  }
}
console.log('file:', statSync('loan.log').size, 'bytes,', LOAD.length, 'records,',
  (statSync('loan.log').size / LOAD.length).toFixed(1), 'bytes/write');

let rewritten = '';                                  // rewrite: only the surviving keys
for (const [k, v] of state) rewritten += record(k, v);
writeFileSync('loan-rewritten.log', rewritten);
const before = statSync('loan.log').size, after = statSync('loan-rewritten.log').size;
console.log('rewrite:', state.size, 'records,', after, 'bytes |',
  'saved', before - after, 'bytes (%' + (((before - after) / before) * 100).toFixed(1) + ')');
```

```
write   log bytes  keys  snapshot bytes  log/snapshot
20000       793333    12717        479015            1.66
40000      1586666    17330        652842            2.43
60000      2380000    19040        717260            3.32
file: 2380000 bytes, 60000 records, 39.7 bytes/write
rewrite: 19040 records, 755340 bytes | saved 1624660 bytes (%68.3)
```

The cost per write is constant: 39.7 bytes. The log file grows linearly **with the write count**
and does not care what it is writing; the tenth update to the same member's loan record still adds
a full record to the file. A snapshot, by contrast, grows with the key count and carries no
history of the values it overwrote. The last column gives the gap between the two: at twenty
thousand writes, the log is 1.66 times the snapshot; at sixty thousand, 3.32 times. As long as the
library keeps operating, the ratio keeps growing, because the numerator never stops while the
denominator saturates at twenty thousand members.

This growth is the direct consequence of an append log, and it has exactly one remedy: the
**rewrite**. The store's current state is scanned and a single record is produced for each
surviving key; the old file is replaced with this one. In the measurement, 60,000 records shrink to
19,040, the file drops from 2,380,000 bytes to 755,340 bytes, a saving of 68.3 percent.

Two details are worth reading here. First, the rewritten log (755,340 bytes) is exactly 38,080
bytes larger than the snapshot of the same state (717,260 bytes); that is the two-byte command
prefix attached to each of the 19,040 records. Apart from that formatting difference, a rewritten
log is a snapshot. Second, and more important: the rewrite also has to scan every entry. In other
words, the snapshot cost from the previous lesson — pausing or copying — exists on this path too,
just less often. An append log does not eliminate taking a snapshot; it ties the question of when
to the file's size.

#### The Fsync Policy

A record can be considered "written" at three separate points. It first lands in the
**application's own buffer**. Once that buffer is flushed, it moves into the **operating system's
buffer**; the process can crash past this point and the record still survives, because the
operating system now holds it. Guaranteeing that the record has reached **persistent storage** is
a separate request: only records past that step survive when the machine loses power.

The policy chooses among these three stops. The run below writes the same fifteen hundred records
in three modes and prints quantities independent of the run: write calls, sync count, file size.
Duration is also measured, but not printed as a number on its own — only as a ranking, since
duration depends on the type of disk and the machine's load.

**PM3.** At the library's busiest hour, the store receives 3,000 writes per second.
**PM4.** The application buffer is 65,536 bytes, and the operating system flushes its own buffer
to persistent storage every five seconds.

```js
// sync.mjs — cost and data loss window of three fsync policies
import { openSync, writeSync, fsyncSync, closeSync, statSync, rmSync } from 'node:fs';
import { record, generateLoad } from './log.mjs';

const N = 1500, RATE = 3000, BUFFER = 65536, WRITEBACK = 5;   // PM3, PM4
const LOAD = generateLoad(N, 20260731);

function run(path, period, buffered) {
  rmSync(path, { force: true });
  const fd = openSync(path, 'w');
  let calls = 0, syncs = 0, buf = '', bufBytes = 0;
  const start = process.hrtime.bigint();
  for (let i = 0; i < N; i++) {
    const line = record(LOAD[i][0], LOAD[i][1]);
    if (buffered) {                              // written once the application buffer fills, no syncing
      buf += line; bufBytes += Buffer.byteLength(line);
      if (bufBytes >= BUFFER) { writeSync(fd, buf); calls++; buf = ''; bufBytes = 0; }
    } else {
      writeSync(fd, line); calls++;
      if ((i + 1) % period === 0) { fsyncSync(fd); syncs++; }
    }
  }
  if (buf) { writeSync(fd, buf); calls++; }
  if (!buffered && N % period !== 0) { fsyncSync(fd); syncs++; }
  const duration = Number(process.hrtime.bigint() - start);
  closeSync(fd);
  return { calls, syncs, bytes: statSync(path).size, duration };
}

const modes = [
  ['every write', run('a.log', 1, false)],
  ['once per second', run('b.log', RATE, false)],
  ['leave to the OS', run('c.log', 0, true)],
];
console.log(`measurement: ${N} records, same content, three modes`);
console.log('mode                     write calls  syncs  file bytes');
for (const [label, s] of modes)
  console.log(label.padEnd(24), String(s.calls).padStart(13), String(s.syncs).padStart(12),
    String(s.bytes).padStart(12));
console.log('duration ranking (slowest to fastest):',
  [...modes].sort((x, y) => y[1].duration - x[1].duration).map(([a]) => a).join(', '));

const bytesPerRecord = modes[0][1].bytes / N;              // bytes per record
const TOTAL = 60000;
console.log(`\n${TOTAL} writes of load, ${RATE} writes per second, a ${BUFFER}-byte buffer,`,
  `${WRITEBACK}-second writeback`);
console.log('mode                     syncs  worst loss  average loss');
const row = (label, syncs, worst) => console.log(label.padEnd(24), String(syncs).padStart(11),
  String(worst).padStart(14), String(Math.round(worst / 2)).padStart(15));
row('every write', TOTAL, 0);
row('once per second', Math.ceil(TOTAL / RATE), RATE);
row('leave to the OS', 0, Math.floor(BUFFER / bytesPerRecord) + WRITEBACK * RATE);
```

```
measurement: 1500 records, same content, three modes
mode                     write calls  syncs  file bytes
every write                       1500         1500        59500
once per second                   1500            1        59500
leave to the OS                      1            0        59500
duration ranking (slowest to fastest): every write, once per second, leave to the OS

60000 writes of load, 3000 writes per second, a 65536-byte buffer, 5-second writeback
mode                     syncs  worst loss  average loss
every write                    60000              0               0
once per second                   20           3000            1500
leave to the OS                    0          16652            8326
```

All three modes produce the same file: 59,500 bytes, identical content. What differs is only when
those bytes take their place, and under what guarantee.

**Every write** means sixty thousand separate syncs over a sixty-thousand-write load, and it
drives the data loss window to zero. On the machine this measurement ran on, each sync cost
roughly 3.8 milliseconds; the number changes with the disk and the load, which is why a ranking
was printed instead of a duration. What stays fixed is the sync count, and the table shows a gap
of four orders of magnitude among the three modes. A zero data loss window has one more condition:
the sync must finish **before** the response reaches the client. If a "loan accepted" reply goes
out before the record reaches disk, the window reopens.

**Once per second** brings the sync count for the same load down to twenty — three thousand times
less waiting on the write path. In exchange, the worst case loses one second's worth of work, that
is, 3,000 loan writes. The average loss is half of that.

**Leaving it to the operating system** removes syncing entirely and also lowers the number of
write calls: fifteen hundred records were written with a single call, because all of them fit in
the 65,536-byte buffer. This is the cheapest mode, and it also carries the worst loss: 16,652
writes. Where that number comes from matters, because two separate delays add up — the 1,652
records still sitting in the application buffer, and up to five seconds' worth of work already
handed to the operating system but not yet on persistent storage.

#### What the Policy Buys

The three policies are three answers to the same question: is the cost of losing one loan write
larger than the cost of pushing that write to disk? For the library's session records it is not;
a librarian who loses a session just reopens it. For loan counters it is on the margin. For
late-fee records it is large, because a lost record maps to money and cannot be recovered.

The append log's real advantage over a snapshot is that it makes the data loss window adjustable
**per write**. Its cost is the file's size: 3.32 times the space for the same state, and the
regular rewrite needed to reclaim that space. Where the two formats cover each other's gaps is
visible from here, but which one to choose and when has not yet been brought into a single table.

#### Summary

- An append log records the command, not the state; in the measurement it held 39.7 bytes per
  write, and the file grew linearly with the write count.
- The log grows with the write count, the snapshot with the key count: for the same data, the
  ratio was 1.66 at 20,000 writes and 3.32 at 60,000.
- The rewrite brought 60,000 records down to 19,040 and shrank the file by 68.3 percent; the
  resulting file is a snapshot apart from its two-byte command prefix, and carries the same full
  scan cost.
- The fsync policy sets the data loss window: every write gives 0 lost writes and 60,000 syncs;
  once per second gives 3,000 lost writes and 20 syncs; leaving it to the operating system gives
  16,652 lost writes and no syncing at all.
- All three modes produced the same 59,500-byte file; the difference between them is not in the
  content but in the guarantee about which stop the record has reached.

#### Next Step

Both persistence forms have now been measured, and each has shown its own gap: a snapshot cannot
close the data loss window, an append log cannot hold its size down. The next lesson brings the
choice together into a single table. Three measures are compared: the recovery step from the
process restarting to being ready to serve, the data loss window, and the steady-state cost. A
scheme that uses **both** paths together also enters the same table — a snapshot base with the
records since the last snapshot layered on top — and the numbers show why that scheme shortens
recovery while also keeping the data loss window narrow.

### Persistence Selection

Source: https://academia.sh/en/courses/in-memory-stores/persistence-selection

Comparing four persistence schemes in the same crash scenario: how many steps recovery takes and which scheme builds an incomplete state, the data loss window, steady-state cost as bytes written to disk and sync count, and how the snapshot's job shifts from durability to recovery time once the two paths are used together.

Both persistence forms have now been measured, and each fell short on its own. A snapshot cannot
close the data loss window: whatever the interval, the writes after the last snapshot are
unprotected. An append log, meanwhile, sets the data loss window per write, but its file takes up
more than three times the space for the same state, and recovery is forced to replay the entire
history.

This lesson makes the choice. The choice is made on three measures, and all three are counted in
the same run:

**Recovery steps.** How many records are applied for the process to become ready to serve after
restarting. What is counted is steps, not duration; a step is independent of the environment.

**Data loss window.** How many writes are lost at the moment of the crash.

**Steady-state cost.** The bytes persistence writes to disk while the store is running normally,
the syncs it performs, and the number of full passes that scan every entry.

#### Four Schemes, One Crash

**PM5.** The crash happens right after write 55,000. The snapshot is taken every 20,000 writes,
so the last completed snapshot belongs to write 40,000. The log is synced once per second and is
rewritten once over the course of the run. Every completed snapshot performs one sync.

The run actually builds all four schemes: files are written to disk, recovery reads from those
files, and the built state is compared entry by entry against the correct state at write 55,000.

```js
// selection.mjs — recovery, data loss window, and steady-state cost of four persistence schemes
import { writeFileSync, readFileSync, statSync } from 'node:fs';

function generator(seed) {
  let a = seed >>> 0;
  return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
}
const r = generator(20260731), LOAD = [];
for (let i = 0; i < 60000; i++) {
  const m = Math.floor(r() * 20000);
  LOAD.push(['loan:m' + String(m).padStart(5, '0'),
    `branch=${1 + (m % 8)};day=${[7, 14, 28][i % 3]};renewal=${i % 4}`]);
}
const stateAt = (n) => { const m = new Map();
  for (let i = 0; i < n; i++) m.set(LOAD[i][0], LOAD[i][1]); return m; };
const serialize = (m) => { let s = '';
  for (const [k, v] of m) s += k + '\t' + v + '\n'; return s; };

const SNAPSHOT = 20000, CRASH = 55000;            // snapshot interval and crash instant
writeFileSync('snap40.txt', serialize(stateAt(40000)));    // last completed snapshot
writeFileSync('full.log', LOAD.slice(0, CRASH).map(([k, v]) => `W\t${k}\t${v}\n`).join(''));
writeFileSync('queue.log', LOAD.slice(40000, CRASH).map(([k, v]) => `W\t${k}\t${v}\n`).join(''));
const correct = stateAt(CRASH);

function recover(files) {                      // replays files in order and rebuilds the state
  const m = new Map();
  let steps = 0, bytes = 0;
  for (const f of files) {
    bytes += statSync(f).size;
    for (const s of readFileSync(f, 'utf8').split('\n')) {
      if (s === '') continue;
      const p = s.split('\t');
      m.set(p[p.length - 2], p[p.length - 1]);
      steps++;
    }
  }
  let mismatches = 0;
  for (const [k, v] of correct) if (m.get(k) !== v) mismatches++;
  return { bytes, steps, entries: m.size, mismatches };
}

const schemes = [                                  // name, source, snapshot, log, rewrite, loss
  ['persistence off', [], 0, 0, 0, 60000],
  ['snapshot only', ['snap40.txt'], 1, 0, 0, SNAPSHOT],
  ['log only', ['full.log'], 0, 1, 1, 3000],
  ['snapshot + log', ['snap40.txt', 'queue.log'], 1, 1, 0, 3000],
];
console.log('crash instant: write 55000 | snapshot interval: 20000 writes | sync: once per second');
console.log('scheme             bytes read  recovery steps  entries built  missing entries');
for (const [name, files] of schemes) {
  const k = recover(files);
  console.log(name.padEnd(18), String(k.bytes).padStart(11), String(k.steps).padStart(15),
    String(k.entries).padStart(14), String(k.mismatches).padStart(12));
}

let snapshotBytes = 0;                             // steady-state cost of the 60000-write run
for (let n = SNAPSHOT; n <= 60000; n += SNAPSHOT) snapshotBytes += Buffer.byteLength(serialize(stateAt(n)));
const logBytes = Buffer.byteLength(LOAD.map(([k, v]) => `W\t${k}\t${v}\n`).join(''));
const rewriteBytes = Buffer.byteLength(serialize(stateAt(60000)));
console.log('\nsteady-state cost of the 60000-write run');
console.log('scheme             bytes written  syncs  full scans  worst loss');
for (const [name, , snap, log, rewrite, loss] of schemes) {
  const bytes = snap * snapshotBytes + log * logBytes + rewrite * rewriteBytes;
  console.log(name.padEnd(18), String(bytes).padStart(13),
    String(snap * 3 + log * 20).padStart(12),
    String(snap * 3 + rewrite).padStart(11), String(loss).padStart(14));
}

console.log('\neffect of the snapshot interval on recovery when used together (same crash instant)');
console.log('snapshot interval  snapshot steps  log steps  total steps  snapshot disk');
for (const interval of [5000, 20000, 60000]) {
  const last = Math.floor(CRASH / interval) * interval;
  let disk = 0;
  for (let n = interval; n <= 60000; n += interval) disk += Buffer.byteLength(serialize(stateAt(n)));
  const g = last ? stateAt(last).size : 0;
  console.log(String(interval).padStart(17), String(g).padStart(15),
    String(CRASH - last).padStart(11), String(g + CRASH - last).padStart(12),
    String(disk).padStart(14));
}
```

```
crash instant: write 55000 | snapshot interval: 20000 writes | sync: once per second
scheme             bytes read  recovery steps  entries built  missing entries
persistence off              0               0              0        18763
snapshot only           652842           17330          17330         9816
log only               2181666           55000          18763            0
snapshot + log         1247842           32330          18763            0

steady-state cost of the 60000-write run
scheme             bytes written  syncs  full scans  worst loss
persistence off                0            0           0          60000
snapshot only            1849117            3           3          20000
log only                 3097260           20           1           3000
snapshot + log           4229117           23           3           3000

effect of the snapshot interval on recovery when used together (same crash instant)
snapshot interval  snapshot steps  log steps  total steps  snapshot disk
             5000           18763           0        18763        6535929
            20000           17330       15000        32330        1849117
            60000               0       55000        55000         717260
```

#### Reading the Recovery

The most telling column in the first table is the last one. The correct state has 18,763 entries;
**missing entries** counts how many of them, once recovery finishes, are either absent altogether
or hold the wrong value.

The scheme with persistence off recovers in zero steps and builds all 18,763 entries missing.
Zero recovery steps is not a success; it is just another way of writing that there was nothing to
recover.

The real warning is in the second row. Recovering with the snapshot alone builds 17,330 entries —
92 percent of the correct count, and on the surface the store looks full. And yet 9,816 of the
entries, more than half, are either missing or hold a stale value. From the library's point of
view, this means opening with a loan table where half the rows belong to last week: loan counts do
not add up, return dates show the past, and there is no way from outside to tell which record is
wrong. Opening with an incomplete state is harder to notice than not opening at all.

The log alone and the combination give the same result: zero missing entries. The difference
between them is in cost. The log alone applies 55,000 steps and reads 2,181,666 bytes; the
combination builds the same state in 32,330 steps, reading 1,247,842 bytes. Recovery work drops by
41 percent, because instead of replaying all of the first 40,000 writes one by one, only their
**result** is read.

#### Steady-State Cost

The second table gives the price the same schemes pay while running. The snapshot alone writes
1,849,117 bytes and performs three syncs; it is the cheapest protected scheme and loses up to
20,000 writes in the worst case. The log alone writes 3,097,260 bytes, performs twenty syncs, and
brings the loss down to 3,000 writes.

The combination is the most expensive row: 4,229,117 bytes, twenty-three syncs. That number would
be expected to be the sum of the log and the snapshot, but it is not the exact sum — in the
combination, the log no longer needs its own rewrite, because it can be trimmed after every
completed snapshot. The snapshot becomes the log's pruner.

The full-scan column counts a silent cost. The scan cost measured in the previous lesson —
pausing or copying — is paid again on every full pass. The log-only scheme makes this pass once
over the course of the run; the schemes with a snapshot make it three times.

#### The Snapshot's Job When Used Together

The third table shows the real decision behind using both together. The data loss window is no
longer the snapshot's job; the log holds it, and it stays at 3,000 writes across all three rows.
The only thing that changes is the recovery step count.

When the snapshot interval is 60,000 writes — that is, when no snapshot completes before the
crash — recovery climbs to 55,000 steps and the snapshot disk usage drops to 717,260 bytes. At an
interval of 20,000, recovery drops to 32,330 steps; at 5,000, it drops to 18,763 steps — the
lowest possible value, since it cannot go below the entry count. The cost is the snapshot disk
usage: it climbs from 717,260 bytes to 6,535,929 bytes, a factor of nine.

This is the lesson's decision. **When a snapshot and an append log are used together, the
snapshot's job is recovery time, not durability.** The interval is set not by the question "how
much data loss am I willing to accept" but by "how long am I willing to let a restart take."

#### The Decision

The library's data does not call for a single scheme; each kind of data carries its own price.

| Data | Scheme | Rationale |
|---|---|---|
| Catalog cache | persistence off | Regenerable from its source; zero recovery steps, loss is inconsequential |
| Session records | snapshot only | Reopening a session is cheap; a 20,000-write window is acceptable, lowest steady-state cost |
| Loan counters, waiting list | snapshot + log | Opening with an incomplete state is not acceptable; recovery is 32,330 steps, window is 3,000 writes |
| Late-fee records | snapshot + log, sync on every write | A lost record maps to money; the window drops to zero, the cost is a sync per write |

The one scheme missing from the table is "log only," and the reason is visible in the
measurement: while giving the same durability, its recovery takes 1.7 times as many steps as the
combination, and stopping the log's growth still requires the same full scan. Using the log alone
does not escape the snapshot's cost; it only postpones it.

#### Summary

- Recovery is evaluated on three measures: the number of records applied, the data loss window,
  and the steady-state cost while running.
- Recovering with the snapshot alone built 92 percent of the correct entries but left 9,816
  missing or stale; opening with an incomplete state is harder to notice than not opening at all.
- The log alone built the same correct state in 55,000 steps, the snapshot and log together in
  32,330; bytes read dropped from 2,181,666 to 1,247,842.
- The combination carries the most expensive steady-state cost (4,229,117 bytes, 23 syncs), but
  because the snapshot trims the log, it needs no separate rewrite.
- In the combination, the snapshot interval sets the recovery step count, not the data loss
  window: the 5,000 interval brought recovery down to 18,763 steps and raised the snapshot disk
  usage ninefold.

#### Next Step

Once the persistence decision is made, the store has learned how to recover after a crash. A
limit that arrives **before** a crash has not been addressed yet: memory runs out. Every
measurement in this topic let the store keep growing; 19,040 entries held 1,593,100 bytes and
nothing stopped it. The next lesson takes up the question of which key gets evicted once the
memory limit is reached. Four policies — least recently used, least frequently used, random, and
time-based — are compared on the same loan access trace along the dimensions of hit rate and
bytes held; the overhead a policy adds to each entry is counted as well.

### Memory Limit and Eviction Policies

Source: https://academia.sh/en/courses/in-memory-stores/memory-limit-and-eviction-policies

Comparing the four policies that decide which key gets evicted once the memory limit is reached, on the same loan access trace: the share of the budget taken by the overhead a policy adds to entries, the number of entries that fit in the same budget, the hit rates measured across three budgets, and why time-based eviction fails to protect memory.

The persistence decision settles what happens after a crash. A limit that arrives before a crash
has not been addressed at all in this topic: memory runs out. In every measurement so far, the
store grew freely; 19,040 loan entries held 1,593,100 bytes and nothing stopped it. In a real
setup, the store is given an upper limit, and once that limit is reached, every new entry takes
the place of an old one.

This lesson's question is which old entry goes. The definitions of eviction policies and the
arithmetic of hit rate were established in previous courses; they are not repeated here. What gets
measured is what four policies buy with the same byte budget on the same loan access trace.

#### The Budget Is Bytes, Not Entry Count

If the limit is given as an entry count, the policies' cost becomes invisible. Every policy adds
something to an entry so it can make its own decision, and that addition eats into the limit.

**PM6.** Policy overhead: least recently used keeps two links to hold its place in access order
(16 bytes); least frequently used keeps a counter and a link in a frequency bucket (24 bytes);
random keeps only its place in the sampling array (8 bytes); time-based keeps an expiry timestamp
and ordering information (16 bytes). The entry body's cost is the same as in the previous lessons:
key, value, and 48 bytes.

**PM7.** The access trace is the library's loan pattern: across four periods, 60 percent of
requests go to that period's two-hundred-book reading list, 25 percent go to a fixed
thousand-book popular segment that never changes, and 15 percent go to the rest of the
twenty-thousand-book catalog. The reading list is entirely replaced whenever the period changes.

**PM8.** Catalog entries are placed with a three-thousand-access freshness window; this window
concerns only the time-based policy.

```js
// policy.mjs — four eviction policies at the same byte budget
export const BASE = 48, LIFETIME = 3000;            // PM1: entry overhead, PM8: freshness window

export function generator(seed) {
  let a = seed >>> 0;
  return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
}

// Loan trace: four periods; each period changes a 200-book reading list.
export function generateTrace(length, seed, catalogSize = 20000) {
  const r = generator(seed), trace = [], period = length / 4;
  for (let i = 0; i < length; i++) {
    const d = Math.floor(i / period), p = r();
    let b;
    if (p < 0.60) b = d * 200 + Math.floor(r() * 200);        // this period's reading list
    else if (p < 0.85) b = 1000 + Math.floor(r() * 1000);     // permanently popular books
    else b = Math.floor(r() * catalogSize);                       // the rest of the catalog
    trace.push('book:' + String(b).padStart(5, '0'));
  }
  return trace;
}
export const value = (k) => {
  const n = Number(k.slice(5));
  return `branch=${1 + (n % 8)};shelf=${String.fromCharCode(65 + (n % 26))}${String(n % 100).padStart(2, '0')}`;
};

class Cache {
  constructor(budget, overhead) {
    this.budget = budget; this.overhead = overhead; this.entries = new Map();
    this.bytes = 0; this.hits = 0; this.misses = 0; this.dropped = 0; this.clock = 0;
  }
  cost(k) { return Buffer.byteLength(k) + Buffer.byteLength(value(k)) + BASE + this.overhead; }
  access(k) {
    this.clock++;
    if (this.entries.has(k) && this.valid(k)) { this.hits++; this.touch(k); return; }
    if (this.entries.has(k)) { this.dropped++; this.remove(k); }
    this.misses++;
    while (this.bytes + this.cost(k) > this.budget) this.remove(this.victim());
    this.entries.set(k, value(k)); this.bytes += this.cost(k); this.inserted(k);
  }
  remove(k) { this.bytes -= this.cost(k); this.entries.delete(k); this.removed(k); }
  valid() { return true; }
  touch() {} inserted() {} removed() {}
  get overheadBytes() { return this.entries.size * this.overhead; }
}

export class LeastRecentlyUsed extends Cache {      // least recently used: access-order link, 16 bytes
  constructor(budget) { super(budget, 16); }
  touch(k) { const v = this.entries.get(k); this.entries.delete(k); this.entries.set(k, v); }
  victim() { return this.entries.keys().next().value; }
}

export class LeastFrequentlyUsed extends Cache {             // least frequently used: counter + bucket link, 24 bytes
  constructor(budget) { super(budget, 24); this.counter = new Map(); this.buckets = new Map(); this.minFreq = 1; }
  #move(k, from, to) {
    if (from) { const s = this.buckets.get(from); s.delete(k); if (s.size === 0 && this.minFreq === from) this.minFreq = to; }
    if (to) { if (!this.buckets.has(to)) this.buckets.set(to, new Set()); this.buckets.get(to).add(k); }
  }
  touch(k) { const c = this.counter.get(k); this.counter.set(k, c + 1); this.#move(k, c, c + 1); }
  inserted(k) { this.counter.set(k, 1); this.#move(k, 0, 1); this.minFreq = 1; }
  removed(k) { this.#move(k, this.counter.get(k), 0); this.counter.delete(k); }
  victim() {
    while (!this.buckets.get(this.minFreq) || this.buckets.get(this.minFreq).size === 0) this.minFreq++;
    return this.buckets.get(this.minFreq).values().next().value;
  }
}

export class Random extends Cache {          // random: only the key array, 8 bytes
  constructor(budget, seed) { super(budget, 8); this.list = []; this.position = new Map(); this.r = generator(seed); }
  inserted(k) { this.position.set(k, this.list.length); this.list.push(k); }
  removed(k) {
    const i = this.position.get(k), last = this.list.pop();
    if (i < this.list.length) { this.list[i] = last; this.position.set(last, i); }
    this.position.delete(k);
  }
  victim() { return this.list[Math.floor(this.r() * this.list.length)]; }
}

export class TimeBased extends Cache {        // time-based: expiry timestamp + order, 16 bytes
  constructor(budget) { super(budget, 16); this.expiry = new Map(); }
  valid(k) { return this.expiry.get(k) > this.clock; }
  inserted(k) { this.expiry.set(k, this.clock + LIFETIME); }
  removed(k) { this.expiry.delete(k); }
  victim() { return this.entries.keys().next().value; }   // fixed lifetime: closest to expiry = oldest entry
  get expired() {                                    // entries whose lifetime is up but are still held
    let count = 0, bytes = 0;
    for (const k of this.entries.keys()) if (this.expiry.get(k) <= this.clock) { count++; bytes += this.cost(k); }
    return { count, bytes };
  }
}
```

The time-based policy's victim selection carries a simplification that has to be stated plainly:
because every entry is placed with the same lifetime, "the one closest to expiry" points to the
same entry as "the one inserted earliest." The victim is therefore the head of the insertion
order, a position that access never changes.

#### Four Policies on the Same Trace

```js
// measurement.mjs — entry count and hit rate of four policies on the same budget
import { LeastRecentlyUsed, LeastFrequentlyUsed, Random, TimeBased, generateTrace, value, BASE } from './policy.mjs';

const TRACE = generateTrace(200000, 20260731);
const factories = { 'least recently used': (b) => new LeastRecentlyUsed(b), 'least frequently used': (b) => new LeastFrequentlyUsed(b),
  'random': (b) => new Random(b, 4242), 'time-based': (b) => new TimeBased(b) };
const BUDGETS = [200000, 400000, 800000];
const sample = 'book:01234';
console.log('trace: 200000 accesses, 20000 books, four periods, seed 20260731');
console.log('entry body:', Buffer.byteLength(sample) + Buffer.byteLength(value(sample)) + BASE, 'bytes');
console.log('policy                 overhead  bytes/entry  entries at 400000 bytes  overhead share');
for (const [name, f] of Object.entries(factories)) {
  const o = f(400000);
  for (const k of TRACE) o.access(k);
  console.log(name.padEnd(22), String(o.overhead).padStart(8), String(o.cost(sample)).padStart(12),
    String(o.entries.size).padStart(24),
    ('%' + ((o.overheadBytes / o.bytes) * 100).toFixed(1)).padStart(15));
}

console.log('\nhit rate (same trace, three budgets)');
console.log('policy                 200000 bytes  400000 bytes  800000 bytes  dropped entries');
for (const [name, f] of Object.entries(factories)) {
  const row = [], dropped = [];
  for (const b of BUDGETS) {
    const o = f(b);
    for (const k of TRACE) o.access(k);
    row.push(('%' + ((o.hits / TRACE.length) * 100).toFixed(1)).padStart(12));
    dropped.push(o.dropped);
  }
  console.log(name.padEnd(22), row.join(' '), String(dropped[1]).padStart(15));
}

console.log('\ntime-based: what changed as the budget grew');
console.log('budget   hits  entries held  expired  dead bytes');
for (const b of BUDGETS) {
  const o = new TimeBased(b);
  for (const k of TRACE) o.access(k);
  const d = o.expired;
  console.log(String(b).padStart(6), String(o.hits).padStart(6), String(o.entries.size).padStart(14),
    String(d.count).padStart(9), String(d.bytes).padStart(11));
}
```

```
trace: 200000 accesses, 20000 books, four periods, seed 20260731
entry body: 76 bytes
policy                 overhead  bytes/entry  entries at 400000 bytes  overhead share
least recently used          16           92                     4347           %17.4
least frequently used        24          100                     4000           %24.0
random                        8           84                     4761            %9.5
time-based                   16           92                     4347           %17.4

hit rate (same trace, three budgets)
policy                 200000 bytes  400000 bytes  800000 bytes  dropped entries
least recently used           %82.6        %87.3        %89.9               0
least frequently used         %77.0        %87.1        %89.5               0
random                        %78.0        %84.7        %89.7               0
time-based                    %65.5        %65.5        %65.5           43525

time-based: what changed as the budget grew
budget   hits  entries held  expired  dead bytes
200000 130965           2173      1154      106168
400000 130965           4347      3328      306176
800000 130965           8695      7676      706192
```

#### The Share Taken by Overhead

The first table shows that choosing a policy is a memory decision. The entry body is 76 bytes; the
gap between 8 and 24 bytes that a policy adds swings the number of entries that fit in the same
400,000-byte budget between 4,000 and 4,761. Least frequently used's counters and bucket links
take up 24.0 percent of the budget, roughly a quarter; for random, that share is 9.5 percent. With
the same budget, random holds 19 percent more books than least frequently used.

This shows why comparing policies by entry count alone is misleading: a comparison measured at a
fixed entry count leaves a cheap-overhead policy uncharged for the very place it wins.

#### Reading the Hit Rate

Least recently used leads at all three budgets. This is the expected result, because the trace's
main source is the period's reading list, and access to that list clusters in the recent past.

Least frequently used falls clearly behind at the narrow budget: 77.0 percent against 82.6
percent. The reason is in the trace's structure. When a period changes, the old reading list's
entries keep sitting with high counters; the new list's books, meanwhile, enter with a counter of
one and land first in line for the next eviction. The policy keeps treating what was correct in
the past as correct today. At an 800,000-byte budget the gap between the two policies narrows to
0.4 points, because the budget is wide enough to carry both the old list and the new one at the
same time.

Random's result is more interesting. At 400,000 bytes it trails least recently used by 2.6
points; at 800,000 bytes the gap drops to 0.2 points. Random selection picks what to discard
without any thought at all, but it carries two advantages: its overhead is the cheapest, so it
holds the most entries for the same budget, and once the budget comfortably covers the working
set, which entry gets discarded stops changing the outcome. At the narrow budget the policy is off
by 4.6 points; at the wide budget the gap approaches measurement noise.

The decision follows from this: the policy debate only matters while the budget is **smaller**
than the working set. Once the budget is comfortable, the effort spent changing the eviction
policy buys less than the same effort spent lowering memory overhead.

#### Time-Based Eviction Does Not Protect Memory

The last table shows that one of the four policies is actually doing a different job. The
time-based policy gives the same hit rate at all three budgets: 65.5 percent. This is not a
rounding artifact — the hit count is exactly 130,965 in all three runs. Quadrupling the budget
changed the hit rate not at all.

What changes is only dead weight. At a 200,000-byte budget the store holds 2,173 entries, and
1,154 of them are already expired; at 800,000 bytes it holds 8,695 entries, of which 7,676 have
expired and take up 706,192 bytes. More than three-quarters of the budget, in other words, goes to
entries that will never produce another hit.

The cause is the policy's own logic. An expired entry, even while it sits in memory, counts as a
miss and gets dropped the moment it is accessed: 43,525 entries dropped this way over the course
of the four-hundred-thousand-byte run. Eviction, meanwhile, always discards the oldest entry, and
under a fixed lifetime the oldest entry is already the expired one. As a result, eviction never
discards a live entry; the extra memory only serves to hold more dead entries in waiting.

Expiration is a freshness tool, not a memory tool. Refreshing the library's catalog entries every
three thousand accesses is a correctness decision, and it belongs where it is; but meeting the
memory limit still requires a separate eviction policy. Neither substitutes for the other.

#### Summary

- The memory limit is given in bytes, not entry count, because the overhead a policy adds to each
  entry eats into the budget: the share ranged from 9.5 percent to 24.0 percent.
- At the same 400,000 bytes, random held 4,761 entries and least frequently used held 4,000;
  cheap overhead partly compensates for a weaker decision.
- Least recently used leads at all three budgets; least frequently used fell 5.6 points behind at
  the narrow budget, because when the period changed, the old reading list's high counters kept
  their place.
- As the budget widens, the gap between policies closes: at 800,000 bytes only 0.2 points
  separated least recently used from random.
- Time-based eviction gave the same 130,965 hits at all three budgets; the extra memory only grew
  the dead entries (from 106,168 bytes to 706,192 bytes). Expiration protects freshness, not
  memory.

#### Next Step

The last measurement left a question open: 7,676 expired entries sat in memory because nothing
touched them. Without a mechanism that removes an entry the instant its lifetime is up, the
freshness window only fixes what a read returns, not the bytes it holds. The next lesson takes up
that gap: the lazy method that only cleans an entry when it is accessed, against the active method
that samples the key space in the background. What gets measured is how much memory expired
entries hold, how late that memory is freed under lazy cleanup, and what a round of active
sampling costs.

### Expiration Management

Source: https://academia.sh/en/courses/in-memory-stores/expiration-management

When expired keys actually drop out of memory: the dead bytes and delay left behind by the lazy method that cleans only on access, the per-round cost of the active method that samples the key space in the background, and the diminishing returns between dead memory and sampling work across three sample sizes.

The previous lesson's last measurement left a gap. In a store carrying expiry timestamps, at the
end of the eight-hundred-thousand-byte budget, 7,676 of the 8,695 entries held were expired and
were still sitting in memory. Accessing an expired entry produced the correct behavior — the entry
was disregarded and dropped — but as long as nothing accessed it, the entry kept its place. The
freshness window fixed what a read returned; it did not fix the bytes held.

This lesson takes up that gap. There are two paths, and both can run together in the same store.
**Lazy cleanup** checks an entry only when it is accessed and deletes it if its lifetime is up; its
cost is close to zero, because it only adds a comparison on top of a lookup that would happen
anyway. **Active cleanup** samples the key space in the background, deletes the expired ones, and
repeats the round if the sample turns up too many expired entries; its cost is work that no client
asked for.

#### Mechanism

**PM9.** The library's traffic over one hour: 20 new keys are placed per second and 60 accesses
are made. Of the keys, 30 percent are 60-second loan locks, 50 percent are 300-second catalog
entries, and 20 percent are 1,800-second session records. Of the accesses, 70 percent go to the
last two thousand keys and 30 percent go to the entire key space. The value body is 24 bytes;
entry overhead is 48 bytes, the expiry timestamp is 16 bytes.

**PM10.** Active cleanup starts one round every second. In a round, a set number of keys is
sampled; the expired ones are deleted. If more than a quarter of the sampled keys are expired, the
round repeats, up to sixteen times.

```js
// expiry.mjs — store with expiry timestamps, lazy and active cleanup
export const BASE = 48, STAMP = 16;             // PM1: entry overhead, PM6: expiry timestamp

export function generator(t0) {
  let a = t0 >>> 0;
  return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
}

export class Store {
  constructor(seed) {
    this.entries = new Map(); this.expiry = new Map(); this.list = []; this.position = new Map();
    this.bytes = 0; this.clock = 0; this.r = generator(seed);
    this.lazyDeleted = 0; this.activeDeleted = 0; this.sampled = 0; this.rounds = 0;
    this.delayTotal = 0; this.delayWorst = 0; this.peakDead = 0;
  }
  cost(k) { return Buffer.byteLength(k) + 24 + BASE + STAMP; }
  put(k, lifetime) {
    if (!this.entries.has(k)) {
      this.bytes += this.cost(k);
      this.position.set(k, this.list.length); this.list.push(k);
    }
    this.entries.set(k, 1); this.expiry.set(k, this.clock + lifetime);
  }
  remove(k) {
    this.bytes -= this.cost(k); this.entries.delete(k); this.expiry.delete(k);
    const i = this.position.get(k), last = this.list.pop();
    if (i < this.list.length) { this.list[i] = last; this.position.set(last, i); }
    this.position.delete(k);
  }
  access(k) {                                      // lazy cleanup: an expired entry drops on access
    if (!this.entries.has(k)) return 'missing';
    if (this.expiry.get(k) > this.clock) return 'hit';
    const delay = this.clock - this.expiry.get(k);
    this.delayTotal += delay;
    if (delay > this.delayWorst) this.delayWorst = delay;
    this.lazyDeleted++; this.remove(k);
    return 'expired';
  }
  active(sampleSize, threshold = 0.25, maxRounds = 16) {      // active cleanup: rounds that sample and delete
    for (let t = 0; t < maxRounds; t++) {
      let deletedCount = 0, checked = 0;
      for (let i = 0; i < sampleSize && this.list.length > 0; i++) {
        const k = this.list[Math.floor(this.r() * this.list.length)];
        checked++;
        if (this.expiry.get(k) <= this.clock) { this.remove(k); deletedCount++; }
      }
      this.sampled += checked; this.activeDeleted += deletedCount; this.rounds++;
      if (checked === 0 || deletedCount / checked < threshold) return;
    }
  }
  dead() {
    let count = 0, bytes = 0;
    for (const k of this.entries.keys()) if (this.expiry.get(k) <= this.clock) { count++; bytes += this.cost(k); }
    return { count, bytes };
  }
}

// One hour of library traffic: 20 new keys per second, 60 accesses per second.
export function run(sampleSize, seed = 20260731, duration = 3600) {
  const store = new Store(seed + 7), r = generator(seed);
  const categories = [['lock', 60], ['catalog', 300], ['session', 1800]];
  const keys = [];
  for (let t = 1; t <= duration; t++) {
    store.clock = t;
    for (let i = 0; i < 20; i++) {
      const p = r(), [name, lifetime] = p < 0.30 ? categories[0] : p < 0.80 ? categories[1] : categories[2];
      const k = name + ':' + String(keys.length).padStart(6, '0');
      keys.push(k); store.put(k, lifetime);
    }
    for (let i = 0; i < 60; i++) {                // 70% from the last 2000 keys, 30% from the entire space
      const n = keys.length;
      const j = r() < 0.70 ? n - 1 - Math.floor(r() * Math.min(2000, n))
        : Math.floor(r() * n);
      store.access(keys[j]);
    }
    if (sampleSize > 0) store.active(sampleSize);
    if (t % 60 === 0) {                           // peak measured once per minute
      const d = store.dead();
      if (d.bytes > store.peakDead) store.peakDead = d.bytes;
    }
  }
  return store;
}
```

#### Measurement

```js
// measurement.mjs — lazy cleanup alone and active cleanup at three sample sizes
import { run } from './expiry.mjs';

console.log('one hour of traffic: 72000 keys placed, 216000 accesses made, seed 20260731');
console.log('method            entries held  dead entries  dead bytes  dead share  peak dead bytes');
const runs = [['lazy only', 0], ['active, 5 samples', 5], ['active, 20 samples', 20],
  ['active, 100 samples', 100]];
const results = runs.map(([name, n]) => [name, n, run(n)]);
for (const [name, , store] of results) {
  const d = store.dead();
  console.log(name.padEnd(20), String(store.entries.size).padStart(13), String(d.count).padStart(10),
    String(d.bytes).padStart(9), ('%' + ((d.bytes / store.bytes) * 100).toFixed(1)).padStart(8),
    String(store.peakDead).padStart(14));
}

console.log('\ncleanup cost and delay');
console.log('method            lazy deleted  average delay  worst  active deleted  sampled');
for (const [name, , store] of results) {
  const avg = store.lazyDeleted ? (store.delayTotal / store.lazyDeleted).toFixed(1) : '0.0';
  console.log(name.padEnd(20), String(store.lazyDeleted).padStart(14), (avg + ' s').padStart(17),
    (store.delayWorst + ' s').padStart(8), String(store.activeDeleted).padStart(14),
    String(store.sampled).padStart(11));
}

const t = results[0][2], y = results[2][2];
console.log('\nextra work per access: lazy 0 (limited to the accessed entry) |',
  'active, 20 samples:', (y.sampled / 216000).toFixed(3), 'entries,',
  y.rounds, 'rounds /', 3600, 'seconds');
console.log('memory with lazy only:', t.bytes, 'bytes | with active, 20 samples:', y.bytes, 'bytes |',
  'difference', t.bytes - y.bytes, 'bytes (%' + (((t.bytes - y.bytes) / t.bytes) * 100).toFixed(1) + ')');
```

```
one hour of traffic: 72000 keys placed, 216000 accesses made, seed 20260731
method            entries held  dead entries  dead bytes  dead share  peak dead bytes
lazy only                    39714      29205   2964300    %73.5        2964300
active, 5 samples            20891      10382   1053954    %49.6        1113648
active, 20 samples           14460       3951    401040    %27.2         414303
active, 100 samples          12539       2030    205941    %16.1         212913

cleanup cost and delay
method            lazy deleted  average delay  worst  active deleted  sampled
lazy only                     32286           421.4 s   3500 s              0           0
active, 5 samples             23572           206.5 s   3009 s          27537       62375
active, 20 samples            16649            68.1 s   1563 s          40891      167200
active, 100 samples           12772            30.7 s    781 s          46689      362500

extra work per access: lazy 0 (limited to the accessed entry) | active, 20 samples: 0.774 entries, 8360 rounds / 3600 seconds
memory with lazy only: 4035180 bytes | with active, 20 samples: 1471920 bytes | difference 2563260 bytes (%63.5)
```

#### The Limit of Lazy Cleanup

The first row does not mean lazy cleanup fails to work. It works exactly as intended: over the
course of the run, 32,286 entries were caught and deleted during access. The problem is that
cleanup only touches the entry that gets accessed. At the end of one hour the store holds 39,714
entries, and 29,205 of them are expired entries; the 2,964,300 bytes they occupy amount to 73.5
percent of the memory the store holds. These entries were never requested again after their
lifetime ran out, so they were never checked at all.

The second table gives the delay. Entries deleted through the lazy path dropped out of memory an
average of 421.4 seconds after their lifetime expired; in the worst case, 3,500 seconds, which is
nearly the entire run. In the library's terms: a sixty-second loan lock is still sitting in memory
roughly seven minutes after its job is done.

This confirms that expiration alone is not a memory tool. The freshness window honors its
contract — an expired entry is never read — but the memory side of that contract goes unattended.
Over one hour of traffic, three-quarters of the store goes to data that no one will ever ask for
again.

#### The Price of Active Cleanup

Active cleanup closes this gap and pays for it with work that no client asked for. The scheme that
samples twenty keys per round brings the dead share down from 73.5 percent to 27.2 percent and
pulls the store's total memory down from 4,035,180 bytes to 1,471,920 bytes, a drop of 63.5
percent. The average delay falls from 421.4 seconds to 68.1 seconds.

The cost is measured by the number of entries sampled: 167,200 entries. Since the number of
accesses made in the same hour is 216,000, cleanup adds 0.774 entry touches for every access. The
number of rounds is 8,360 — an average of 2.3 rounds per second. The threshold rule shows up here:
while expired entries are dense, the round repeats and cleanup speeds up; once the space thins
out, it finishes in a single round. The sampling round is taken out of the time the store sets
aside for serving accesses, which is why both the sample count per round and the cap on round
count are, in effect, response-time decisions.

The effect of the sample size is not linear. Going from five to twenty brought the dead memory
down from 1,053,954 bytes to 401,040 bytes in exchange for 104,825 extra samples: 6.2 bytes per
sample. Going from twenty to a hundred brought it down from 401,040 bytes to 205,941 bytes in
exchange for 195,300 extra samples: 1.0 byte per sample. The marginal return drops to a sixth.
The reason is direct: as the space gets cleaned, the probability that a randomly chosen key turns
out to be expired falls, and sampling starts coming back empty.

The dead share never reaching zero in any scheme has the same cause. Even at a hundred samples per
round, 2,030 entries remain expired; random sampling eventually finds any given entry, but
"eventually" is a stretch of time, and that stretch sits in memory as bytes.

#### The Choice

The two methods do not substitute for each other; they run together and guarantee two different
things. Lazy cleanup guarantees **correctness**: an expired entry cannot be read, even if active
cleanup has not found it yet. Active cleanup guarantees **memory**: it reclaims the space of
entries that will never be accessed again.

The sample size, in turn, is a budget setting. For short-lived, heavily produced keys like the
library's loan locks, the space fills quickly, so a large sample pays off. For long-lived, sparse
keys like session records, the same sampling mostly comes back empty, and the work spent on it is
paid in response time. In this run, the twenty-sample round reclaimed 86 percent of the dead
memory at a cost of 0.774 entry touches per access; the hundred-sample round reclaimed half of the
remaining share and doubled the cost.

#### Summary

- Lazy cleanup only touches the accessed entry: at the end of the run, 29,205 of 39,714 entries
  were expired and held 73.5 percent of memory.
- Entries dropped through the lazy path left memory an average of 421.4 seconds after their
  lifetime expired, and up to 3,500 seconds in the worst case.
- Active cleanup sampling twenty keys per round brought the dead share down to 27.2 percent and
  total memory down from 4,035,180 bytes to 1,471,920 bytes; its cost is 0.774 entry touches per
  access.
- The return on a larger sample size diminishes: going from five to twenty gained 6.2 bytes per
  sample, going from twenty to a hundred gained 1.0 byte per sample.
- Lazy cleanup guarantees correctness, active cleanup guarantees memory; expiration alone meets
  neither on its own.

#### Next Step

This topic built the ways a store looks after itself: writing its state to disk, recording its
writes, deciding between the two by looking at recovery and the data loss window, knowing which
key to evict once the memory limit is reached, and not leaving an expired one in place. Every
number measured was read from inside a single process.

All of these decisions share one assumption: the node is running. A snapshot was written to disk,
but that disk belongs to that one machine; the data loss window was calculated, but it assumed the
process could restart; the eviction policy protected the memory limit, but that memory belongs to
a single machine. When the machine itself stops — or becomes unreachable — the recovery files are
still there, but the clients asking for the store are somewhere else. The next topic opens this
question: how a second copy of the data is kept, how far behind that copy falls, and what happens
to a write that was accepted but not yet propagated at the moment of failover.

## Cluster and Usage Patterns

Moving beyond a single node and the store's everyday work: replication for read scaling and redundancy, automatic failover through sentinel processes, clustering by partitioning the key space, optimistic locking through watch-based collision control, publish-subscribe for lightweight messaging, catalog cache, session, rate limit, and leaderboard scenarios, performance diagnosis through slow command analysis, and authentication, network isolation, and encryption.

### Replication

Source: https://academia.sh/en/courses/in-memory-stores/replication

The in-memory store moving beyond a single node: replication is not a setting but the forced consequence of how the store acknowledges writes, replica count and propagation delay run against the same workload to measure work per node, data held, stale reads, and their rate, the staleness window reduces to delay times write rate, the primary node's output buffer counts as memory, and a replica that cannot keep up overflows its buffer into full resync and offline rounds.

The previous topic framed persistence and eviction as decisions made by a single node: which
moment gets written to disk, which entry gets dropped when the memory limit fills, when an
expired key gets cleaned up. All of these were made inside one process, and all of them assumed
that process stayed up. What happens when the process itself stops was never asked.

This topic covers setups where the store spreads across more than one node. The first question
is the simplest one: keep the same data on a second node too. The library catalog and loan
system is again the example domain here; the work being measured is reading the catalog cache
and writing loan records.

#### The Forced Consequence of the Acknowledgment Model

The mechanics of replication are not this course's subject. Log streaming, replication lag, and
the diminishing return of adding replicas were measured in the Scaling the Data Layer course;
how a cluster decides for itself when it loses a member was measured in the Non-Relational Data
Models course. Neither is repeated here.

This lesson's question is the in-memory store's own constraint. When the store accepts a write,
it writes it to memory and acknowledges it **immediately**; tying that acknowledgment to a
replica's apply would cancel the store's reason for existing. This is why replication in an
in-memory store is **asynchronous**, and this is not a setting — it is a consequence of the
acknowledgment model. It carries three measurable costs, and all three are paid out of memory:

1. **Every replica holds the entire data set.** Adding a replica does not divide the bytes
   stored, it multiplies them. On disk this is a capacity line item; in memory it is a direct
   purchase.
2. **The primary node holds an output buffer for every replica.** Writes that have not yet
   reached a replica wait in the primary's memory. The buffer is memory too.
3. **A replica's lag is not a span of time, it is a count of writes.** Any key read from a
   replica returns **stale** if it was written within that window.

#### Mechanism

The mechanism below is a **model**: no real store, network, or replica is set up. A round is an
abstract step; propagation delay and apply capacity are parameters counted in rounds, not
measured time. The model's only realistic assumption is the asynchrony above.

**CU1 — propagation delay defaults to 2 rounds, and the replica keeps up with the primary's
write rate.** Rationale: a healthy replica does not carry a backlog, it only arrives with a
fixed lag. **CU2 — a catalog entry is 240 bytes, a write record in the replication stream is
96 bytes.** Rationale: the record carries only the change, not the whole entry. **CU3 — 70
percent of accesses go to 10 percent of keys.** Rationale: loan and lookup traffic concentrates
on popular books. CU1 and CU2 have a linear effect: if the delay doubles, the staleness window
doubles too.

```js
// replication/model.mjs — the in-memory store's replication is an IN-PROCESS MODEL. A round is an
// abstract step; propagation delay and apply capacity are parameters counted in rounds, not
// measured time. The model's only realistic assumption is the asynchrony described above.
export const ENTRY_BYTES = 240;   // per catalog entry: key + value + metadata (my own accounting)
export const RECORD_BYTES = 96;   // bytes of one write record in the replication stream

export function generator(seed) {  // linear congruential generator; seed is visible
  let s = seed >>> 0;
  return () => { s = (Math.imul(s, 1103515245) + 12345) >>> 0; return s / 4294967296; };
}

export function run({ replicas, delay, capacity = null, bufferLimit = Infinity, resyncRounds = 5,
                      rounds = 200, writes = 40, reads = 400, keys = 2000, seed = 20260731 }) {
  const rnd = generator(seed);
  const hot = Math.floor(keys * 0.10);                        // popular books: 10% of keys
  const pick = () => (rnd() < 0.70 ? Math.floor(rnd() * hot)   // 70% of accesses go to them
                                    : hot + Math.floor(rnd() * (keys - hot)));
  const seq = new Int32Array(keys);                           // sequence number of key's last write
  const applied = Array(replicas).fill(0);                    // number of writes the replica applied
  const offline = Array(replicas).fill(0);                    // rounds offline due to resync
  const peak = Array(replicas).fill(0);                       // largest output buffer per replica
  let accepted = 0, stale = 0, replicaReads = 0, fullResyncs = 0, firstOverflow = null, offlineTotal = 0;

  for (let t = 1; t <= rounds; t += 1) {
    for (let w = 0; w < writes; w += 1) { accepted += 1; seq[pick()] = accepted; }
    for (let i = 0; i < replicas; i += 1) {
      const target = Math.max(0, (t - delay[i]) * writes);    // arrives as far behind as the delay
      if (offline[i] > 0) {                                   // full resync in progress: reads unmet
        offline[i] -= 1; offlineTotal += 1;
        if (offline[i] === 0) applied[i] = target;            // resync brings the replica to normal lag
        continue;
      }
      applied[i] = Math.min(target, applied[i] + (capacity === null ? writes : capacity[i]));
      const buffer = (accepted - applied[i]) * RECORD_BYTES;  // output buffer at the primary: MEMORY
      if (buffer > peak[i]) peak[i] = buffer;
      if (buffer > bufferLimit) {                             // buffer overflowed: replica is dropped
        fullResyncs += 1; offline[i] = resyncRounds;
        if (firstOverflow === null) firstOverflow = t;
      }
    }
    const open = [0, ...[...Array(replicas).keys()].map((i) => i + 1).filter((n) => offline[n - 1] === 0)];
    for (let r = 0; r < reads; r += 1) {
      const n = open[r % open.length];                        // reads round-robin over open nodes
      const k = pick();
      if (n === 0) continue;                                  // the primary is always current
      replicaReads += 1;
      if (seq[k] > applied[n - 1]) stale += 1;                // replica has not yet applied this key
    }
  }
  const nodes = replicas + 1, buffer = peak.reduce((a, b) => a + b, 0);
  return { accepted, stale, replicaReads, fullResyncs, firstOverflow, buffer, offlineTotal,
           data: nodes * keys * ENTRY_BYTES,                  // each node holds the ENTIRE data set
           mostLoaded: Math.round(reads / nodes) + writes };   // primary: read share + all writes
}
```

```js
// replication/measure.mjs — same workload across three settings: replica count, propagation delay, slow replica
import { run, ENTRY_BYTES, RECORD_BYTES } from "./model.mjs";

const s = (x, n) => String(x).padStart(n);
const kib = (b, n) => s((b / 1024).toFixed(1) + " KiB", n);
const pct = (r, n = 10) => s(((r.stale / r.replicaReads) * 100).toFixed(2) + "%", n);

console.log("200 rounds; each round writes 40 loans/renewals, reads 400 catalog entries. 2,000 keys,");
console.log("240 bytes per entry, replication record 96 bytes. Seed 20260731. Delay 2 rounds.\n");
console.log("replicas |  data held | busiest node | reads/replica |  stale | stale rate");
console.log("---------|------------|--------------|---------------|--------|-----------");
for (let k = 0; k <= 4; k += 1) {
  const r = run({ replicas: k, delay: Array(k).fill(2) });
  console.log(`${s(k, 8)} | ${kib(r.data, 10)} | ${s(r.mostLoaded + " wr/rd", 12)} | ${s(r.replicaReads, 13)} |` +
    ` ${s(r.stale, 6)} | ${k === 0 ? s("n/a", 10) : pct(r)}`);
}

console.log("\n2 replicas fixed; propagation delay varies:");
console.log("delay | staleness window | reads/replica |  stale | stale rate | output buffer");
console.log("------|------------------|---------------|--------|------------|---------------");
for (const g of [1, 2, 4, 8]) {
  const r = run({ replicas: 2, delay: [g, g] });
  console.log(`${s(g, 5)} | ${s(g * 40 + " writes", 16)} | ${s(r.replicaReads, 13)} | ${s(r.stale, 6)} |` +
    ` ${pct(r)} | ${kib(r.buffer, 13)}`);
}

console.log("\n2 replicas; second one has low apply capacity, buffer limit 64 KiB, resync 5 rounds:");
console.log("capacity | buffer growth | first overflow | full resync | offline | peak buffer |  stale");
console.log("---------|---------------|-----------------|-------------|---------|-------------|-------");
for (const c of [40, 36, 32, 24]) {
  const r = run({ replicas: 2, delay: [2, 2], capacity: [40, c], bufferLimit: 64 * 1024 });
  console.log(`${s(c, 8)} | ${s((40 - c) * RECORD_BYTES + " b/rd", 13)} | ` +
    `${s(r.firstOverflow === null ? "n/a" : r.firstOverflow, 15)} | ${s(r.fullResyncs, 11)} | ` +
    `${s(r.offlineTotal + " rd", 7)} | ${kib(r.buffer, 11)} | ${s(r.stale, 6)}`);
}

const N = [0, 1, 2, 3, 4];
console.log("\nquantities independent of the run:");
console.log("  staleness window = delay x write rate (writes)");
console.log("  buffer growth rate = (write rate - apply capacity) x " + RECORD_BYTES + " bytes/round");
console.log("  replica count  : " + N.map((n) => s(n, 8)).join(""));
console.log("  reads/node     : " + N.map((n) => s((400 / (n + 1)).toFixed(1), 8)).join(""));
console.log("  total data KiB : " + N.map((n) => s(((n + 1) * 2000 * ENTRY_BYTES / 1024).toFixed(1), 8)).join(""));
```

```
200 rounds; each round writes 40 loans/renewals, reads 400 catalog entries. 2,000 keys,
240 bytes per entry, replication record 96 bytes. Seed 20260731. Delay 2 rounds.

replicas |  data held | busiest node | reads/replica |  stale | stale rate
---------|------------|--------------|---------------|--------|-----------
       0 |  468.8 KiB |    440 wr/rd |             0 |      0 |        n/a
       1 |  937.5 KiB |    240 wr/rd |         40000 |   7132 |     17.83%
       2 | 1406.3 KiB |    173 wr/rd |         53200 |   9494 |     17.85%
       3 | 1875.0 KiB |    140 wr/rd |         60000 |  10740 |     17.90%
       4 | 2343.8 KiB |    120 wr/rd |         64000 |  11420 |     17.84%

2 replicas fixed; propagation delay varies:
delay | staleness window | reads/replica |  stale | stale rate | output buffer
------|------------------|---------------|--------|------------|---------------
    1 |        40 writes |         53200 |   4992 |      9.38% |       7.5 KiB
    2 |        80 writes |         53200 |   9494 |     17.85% |      15.0 KiB
    4 |       160 writes |         53200 |  16344 |     30.72% |      30.0 KiB
    8 |       320 writes |         53200 |  25517 |     47.96% |      60.0 KiB

2 replicas; second one has low apply capacity, buffer limit 64 KiB, resync 5 rounds:
capacity | buffer growth | first overflow | full resync | offline | peak buffer |  stale
---------|---------------|-----------------|-------------|---------|-------------|-------
      40 |        0 b/rd |             n/a |           0 |    0 rd |    15.0 KiB |   9494
      36 |      384 b/rd |             153 |           1 |    5 rd |    71.6 KiB |  16660
      32 |      768 b/rd |              78 |           2 |   10 rd |    72.0 KiB |  16769
      24 |     1536 b/rd |              40 |           4 |   20 rd |    72.0 KiB |  16480

quantities independent of the run:
  staleness window = delay x write rate (writes)
  buffer growth rate = (write rate - apply capacity) x 96 bytes/round
  replica count  :        0       1       2       3       4
  reads/node     :    400.0   200.0   133.3   100.0    80.0
  total data KiB :    468.8   937.5  1406.3  1875.0  2343.8
```

#### The Balance Sheet of Adding a Replica

**What gets bought is the splitting of reads, not the splitting of stored data.** The first
table puts two columns side by side: at four replicas, reads per node drop from 400 to 80, while
data held climbs from 468.8 KiB to 2,343.8 KiB. This is replication, not sharding — the name
itself says so. On disk the same multiplier is a capacity line item; in memory it is a directly
purchased one, and the cost per replica is fixed: the entire data set, 468.8 KiB.

**Adding a replica does not make the data staler; it grows the share of stale reads.** The stale
rate is 17.83 percent at one replica, 17.84 percent at four — unchanged. What changes is the
number of reads sent to a replica: from 40,000 to 64,000. The stale-read count climbs with it,
from 7,132 to 11,420. The rate depends on delay, not on replica count; the count is the product
of both.

**Staleness is not a duration, it is a count of writes.** The second table shows this directly:
the window is `delay × write rate`, giving 40, 80, 160, and 320 writes, and the stale rate
climbs from 9.38 percent to 47.96 percent. At a delay of eight rounds, nearly half of the reads
made against a replica return an out-of-date catalog record. This does not mean the replica is
broken; it means the loan counter returns the value from 320 writes ago, not the value currently
on the primary.

**Delay also carries a memory price.** The same table's last column measures the output buffer:
from 7.5 KiB to 60 KiB. This byte count sits on the primary, not the replicas, and is directly
proportional to the delay. A replica falling behind is not only the reader's problem; the cost
of that lag is drawn from the primary's memory budget.

#### The Replica That Cannot Keep Up

The third table measures the case where the replica stops arriving with a fixed lag. The moment
apply capacity drops below the write rate, the buffer grows by `(40 − capacity) × 96` bytes
every round, and that growth never stops: 384 bytes at capacity 36, 1,536 bytes at capacity 24.
When the buffer hits its limit falls out of the division — at the 64 KiB limit, the first
overflow lands at round 153, 78, and 40 respectively.

The consequence of overflow is that the replica gets dropped and undergoes a **full resync**:
since it can no longer keep up with the stream, it reloads the entire data set. The
memory-budget consequence of that shows up at the worst possible moment — the primary, already
overflowing its buffer, now also has to produce a snapshot of the whole data set, a 468.8 KiB
line item. In the table, the full-resync count climbs to 1, 2, and 4 as capacity drops, and the
offline round count climbs from 5 to 20.

The last column carries a number that looks backward at first glance: stale reads for the
lagging replica climb from 9,494 to 16,660, but when capacity drops further they stay at 16,769,
and even fall to 16,480 at capacity 24. The reason is the neighboring column. During offline
rounds, that replica serves no reads at all; reads shift to the primary and the healthy replica,
and return a current answer from there. A drop in stale reads is not an improvement: reads per
node have risen again because the replica was removed. A slow replica, as long as it is counted
as read capacity, distorts the measurement twice — once while it answers stale, once more while
it is not in a position to answer at all.

#### Summary

- Replication in an in-memory store is not a setting but the consequence of acknowledging writes
  from memory; tying the acknowledgment to a replica would cancel the store's own rationale.
- Adding a replica splits reads but multiplies stored data: reads per node dropped from 400 to
  80 while data held climbed from 468.8 KiB to 2,343.8 KiB, and the cost per replica is fixed.
- The stale-read rate did not change with replica count (17.83 percent versus 17.84 percent);
  what changed was the number of reads sent to a replica, and with it the stale-read count
  (7,132 versus 11,420).
- The staleness window is `delay × write rate`: across windows of 40, 80, 160, and 320 writes,
  the stale rate climbed from 9.38 percent to 47.96 percent.
- The primary node's output buffer is a memory line item directly proportional to delay: from
  7.5 KiB to 60 KiB; the bill for the replica's lag is charged to the primary.
- When apply capacity drops below the write rate, the buffer grows by `(40 − capacity) × 96`
  bytes/round, crosses the 64 KiB limit at round 153, 78, and 40, and each crossing triggers a
  full resync and offline rounds.

#### Next Step

This lesson measured replication under the assumption that the primary node stays up: a replica
can fall behind, overflow its buffer, even undergo a full resync — but the node accepting writes
is always the same one. When the primary stops, two questions open at once. The first is who
decides: ruling that a node has truly stopped is not a decision an observer who cannot reach it
can make alone. The second, and the one specific to this course, is that a replica is by
definition behind the primary. The writes it does not have at the moment it takes over are
writes that were reported as successful loan transactions. The next lesson counts the sentinel
processes' majority decision, the number of accepted writes lost in the failover window, and the
false failovers produced by shortening the detection threshold.

### Automatic Failover

Source: https://academia.sh/en/courses/in-memory-stores/automatic-failover

The in-memory store's availability: testing the sentinel processes' majority decision when their own views diverge, measuring outage duration and false-failover count across eight sentinel settings, counting how shortening the detection threshold shortens the outage while growing false failovers and split writes, showing that asynchronous replication guarantees a per-failover loss of delay times write rate, and calculating what the full copy a hot backup holds buys against outage and loss.

The previous lesson measured replication under the assumption that the primary node stayed up:
a replica fell behind, overflowed its buffer, underwent a resync, but the node accepting writes
always stayed the same. This lesson covers the moment that node stops, and asks two questions
together: who decides it has stopped, and what remains once the decision is made.

#### Who Decides

Being unable to reach a node does not show that the node has stopped; the side that cannot
reach it may itself be broken. This is why the decision is not left to a single observer:
**sentinel** processes watch the primary independently, and failover begins only once a sentinel
**majority** votes the same way. The majority is `floor(g/2)+1` votes; the mechanics of this
pattern, built up in the leader-election and recovery courses, are not repeated here.

This lesson's question is the in-memory store's own constraint. The store acknowledges a write
from memory and does not wait for a replica's apply; a backup is by definition behind the
primary. The writes it does not have at the moment it takes over are loan records that were
accepted and reported successful. Loss on failover is not a possibility, it is a **guarantee**;
only its size remains to be measured.

#### Mechanism

The mechanism below is a **model**: no real sentinel process, network, or node is set up. A
round is an abstract step; detection threshold, election, and load time are parameters counted
in rounds, not measured time.

**CU4 — the primary's temporary pauses (2–7 rounds) and each sentinel's own unreachability
windows (3–6 rounds) come from separate generators and are independent of the setting.**
Rationale: the schedule is generated ahead of time, with a visible seed, so the same outage
sequence can run across every setting; also, a sentinel's own fault is indistinguishable from a
pause in the node it is watching. **CU5 — after a false failover, the old primary keeps
accepting writes for 3 rounds.** Rationale: learning that it has been displaced is not
instantaneous; the records written during those rounds are **split writes**, discarded once the
node is demoted to backup.

```js
// failover/model.mjs — sentinel-driven failover is an IN-PROCESS MODEL. A round is an abstract
// step; detection threshold, election, and load time are parameters counted in rounds, not
// measured time. No real sentinel process, network, or node is set up. The previous lesson's
// constraint still holds: replication is asynchronous, so writes the backup has not applied are
// writes that were ACCEPTED but never arrived.
export const ENTRY_BYTES = 240, RECORD_BYTES = 96, KEYS = 2000;

export function generator(seed) {  // linear congruential generator; seed is visible
  let s = seed >>> 0;
  return () => { s = (Math.imul(s, 1103515245) + 12345) >>> 0; return s / 4294967296; };
}

export function run({ sentinels = 3, downSentinels = 0, detection = 3, election = 2, delay = 2,
                      cold = null, rounds = 300, writes = 40, outage = 200, splitRounds = 3,
                      pauseProb = 0.02, selfProb = 0.03, seed = 20260731 }) {
  const rnd = generator(seed);
  const schedule = (prob, minLen, variety) => {             // schedules are generated independent of setting
    const d = new Uint8Array(rounds + 2);
    for (let t = 1; t <= rounds; t += 1) {
      if (d[t] || rnd() >= prob) continue;
      for (let x = t, b = minLen + Math.floor(rnd() * variety); x < t + b && x <= rounds; x += 1) d[x] = 1;
    }
    return d;
  };
  const pause = schedule(pauseProb, 2, 6);                  // the primary's temporary pauses
  const own = [...Array(7)].map(() => schedule(selfProb, 3, 4));  // a sentinel's OWN unreachability
  const majority = Math.floor(sentinels / 2) + 1, voters = sentinels - downSentinels;
  const wait = election + (cold ? cold.load : 0), counter = Array(7).fill(0);
  let accepted = 0, applied = 0, outageActive = true, remaining = 0, cause = null, splitRemaining = 0;
  let writelessRounds = 0, realFailovers = 0, falseFailovers = 0, lostWrites = 0, splitWrites = 0,
      outageLoss = 0, outageDuration = null;

  for (let t = 1; t <= rounds; t += 1) {
    if (splitRemaining > 0) { splitRemaining -= 1; splitWrites += writes; }  // old primary still writing
    const unreachable = (outageActive && t >= outage) || pause[t] === 1;
    if (remaining > 0) {                                    // failover window: writes not accepted
      remaining -= 1; writelessRounds += 1;
      if (remaining > 0) continue;
      const floor = cold                                    // cold backup starts from the last snapshot
        ? Math.floor(accepted / (cold.interval * writes)) * cold.interval * writes : applied;
      lostWrites += accepted - floor;
      if (cause === "real") { realFailovers += 1; outageActive = false; outageDuration = t - outage + 1; outageLoss = accepted - floor; }
      else { falseFailovers += 1; splitRemaining = splitRounds; }
      accepted = floor; applied = floor; counter.fill(0);
      for (let x = t; x <= rounds && pause[x] === 1; x += 1) pause[x] = 0;  // new primary is not in that pause
      continue;
    }
    if (unreachable) writelessRounds += 1;
    else { accepted += writes; applied = Math.max(applied, accepted - delay * writes); }
    let votes = 0;                                          // each sentinel votes by its OWN view
    for (let j = 0; j < voters; j += 1) {
      counter[j] = unreachable || own[j][t] === 1 ? counter[j] + 1 : 0;
      if (counter[j] >= detection) votes += 1;
    }
    if (votes >= majority) { remaining = wait; cause = outageActive && t >= outage ? "real" : "false"; }
  }
  return { majority, voters, realFailovers, falseFailovers, lostWrites, outageLoss, splitWrites,
           writelessRounds, outageDuration,
           data: (cold ? 1 : 2) * KEYS * ENTRY_BYTES };      // hot backup keeps the data set in memory
}
```

```js
// failover/measure.mjs — same outage across three settings: sentinel majority, detection threshold, backup kind
import { run, ENTRY_BYTES, RECORD_BYTES, KEYS } from "./model.mjs";

const s = (x, n) => String(x).padStart(n);
const e = (x, n) => String(x).padEnd(n);
const kib = (b) => (b / 1024).toFixed(1) + " KiB";
const row = (cells, widths) => cells.map((c, i) => s(c, widths[i])).join(" | ");

console.log("300 rounds; each round writes 40 loan records. Primary stops permanently at round 200. Seed");
console.log("20260731: temporary pauses of 2-7 rounds at the primary, each sentinel gets its own");
console.log("unreachability windows of 3-6 rounds. Delay 2 rounds, detection 3, election 2 rounds.\n");
const W1 = [9, 4, 8, 6, 16, 16, 9];
console.log(row(["sentinels", "down", "majority", "voters", "outage duration", "false failovers", "writeless"], W1));
console.log(W1.map((w) => "-".repeat(w)).join("-|-"));
for (const [g, d] of [[1, 0], [2, 0], [3, 0], [3, 1], [3, 2], [5, 0], [5, 2], [5, 3]]) {
  const r = run({ sentinels: g, downSentinels: d });
  console.log(row([g, d, r.majority, r.voters, r.outageDuration === null ? "n/a" : r.outageDuration + " rd",
    r.falseFailovers, r.writelessRounds + " rd"], W1));
}

console.log("\n3 sentinels, all up; detection threshold varies:");
const W2 = [9, 16, 12, 16, 9, 10];
console.log(row(["threshold", "false failovers", "split writes", "outage duration", "writeless", "total lost"], W2));
console.log(W2.map((w) => "-".repeat(w)).join("-|-"));
for (const a of [1, 2, 3, 4, 5, 6]) {
  const r = run({ detection: a });
  console.log(row([a, r.falseFailovers, r.splitWrites, r.outageDuration + " rd", r.writelessRounds + " rd",
    r.lostWrites], W2));
}

console.log("\nBackup kind. Temporary pauses off: a single-outage failover. Cold backup's data is not");
console.log("in memory; the last snapshot is 30 rounds old, loading it takes 12 rounds:");
const W3 = [13, 9, 16, 11, 10];
console.log(e("backup", W3[0]) + " | " + row(["data held", "outage duration", "lost writes", "lost bytes"], W3.slice(1)));
console.log(W3.map((w) => "-".repeat(w)).join("-|-"));
for (const g of [1, 2, 4, 8]) {
  const r = run({ delay: g, pauseProb: 0, selfProb: 0 });
  console.log(e("hot, delay " + g, W3[0]) + " | " +
    row([kib(r.data), r.outageDuration + " rd", r.outageLoss, r.outageLoss * RECORD_BYTES + " B"], W3.slice(1)));
}
const c = run({ pauseProb: 0, selfProb: 0, cold: { load: 12, interval: 30 } });
console.log(e("cold backup", W3[0]) + " | " +
  row([kib(c.data), c.outageDuration + " rd", c.outageLoss, c.outageLoss * RECORD_BYTES + " B"], W3.slice(1)));

console.log("\nquantities independent of the run:");
console.log("  sentinel majority   = floor(g/2)+1; sentinel loss tolerated = g - majority");
console.log("  g        : " + [1, 2, 3, 4, 5, 6, 7].map((n) => s(n, 4)).join(""));
console.log("  majority : " + [1, 2, 3, 4, 5, 6, 7].map((n) => s(Math.floor(n / 2) + 1, 4)).join(""));
console.log("  tolerated: " + [1, 2, 3, 4, 5, 6, 7].map((n) => s(n - (Math.floor(n / 2) + 1), 4)).join(""));
console.log("  lost per failover = delay x write rate (hot backup)");
console.log("  delay    : " + [1, 2, 4, 8].map((n) => s(n, 6)).join(""));
console.log("  lost     : " + [1, 2, 4, 8].map((n) => s(n * 40, 6)).join(""));
console.log("  outage duration      = detection threshold + election (+ load time for cold backup) rounds");
console.log("  worst-case loss for cold backup = snapshot interval x write rate = 30 x 40 = 1200");
console.log("  hot backup's cost = " + (KEYS * ENTRY_BYTES / 1024).toFixed(1) +
  " KiB, i.e. a second full copy of the data set (" + KEYS + " x " + ENTRY_BYTES + " bytes)");
```

```
300 rounds; each round writes 40 loan records. Primary stops permanently at round 200. Seed
20260731: temporary pauses of 2-7 rounds at the primary, each sentinel gets its own
unreachability windows of 3-6 rounds. Delay 2 rounds, detection 3, election 2 rounds.

sentinels | down | majority | voters |  outage duration |  false failovers | writeless
----------|------|----------|--------|------------------|------------------|----------
        1 |    0 |        1 |      1 |             6 rd |               12 |     40 rd
        2 |    0 |        2 |      2 |             5 rd |                6 |     29 rd
        3 |    0 |        2 |      3 |             5 rd |                9 |     35 rd
        3 |    1 |        2 |      2 |             5 rd |                6 |     29 rd
        3 |    2 |        2 |      1 |              n/a |                0 |    115 rd
        5 |    0 |        3 |      5 |             5 rd |                4 |     25 rd
        5 |    2 |        3 |      3 |             5 rd |                4 |     25 rd
        5 |    3 |        3 |      2 |              n/a |                0 |    115 rd

3 sentinels, all up; detection threshold varies:
threshold |  false failovers | split writes |  outage duration | writeless | total lost
----------|------------------|--------------|------------------|-----------|-----------
        1 |               13 |         1560 |             3 rd |     33 rd |        960
        2 |               11 |         1320 |             5 rd |     34 rd |        880
        3 |                9 |         1080 |             5 rd |     35 rd |        800
        4 |                4 |          480 |             6 rd |     28 rd |        400
        5 |                2 |          240 |             7 rd |     26 rd |        240
        6 |                2 |          240 |             8 rd |     28 rd |        240

Backup kind. Temporary pauses off: a single-outage failover. Cold backup's data is not
in memory; the last snapshot is 30 rounds old, loading it takes 12 rounds:
backup        | data held |  outage duration | lost writes | lost bytes
--------------|-----------|------------------|-------------|-----------
hot, delay 1  | 937.5 KiB |             5 rd |          40 |     3840 B
hot, delay 2  | 937.5 KiB |             5 rd |          80 |     7680 B
hot, delay 4  | 937.5 KiB |             5 rd |         160 |    15360 B
hot, delay 8  | 937.5 KiB |             5 rd |         320 |    30720 B
cold backup   | 468.8 KiB |            17 rd |         760 |    72960 B

quantities independent of the run:
  sentinel majority   = floor(g/2)+1; sentinel loss tolerated = g - majority
  g        :    1   2   3   4   5   6   7
  majority :    1   2   2   3   3   4   4
  tolerated:    0   0   1   1   2   2   3
  lost per failover = delay x write rate (hot backup)
  delay    :      1     2     4     8
  lost     :     40    80   160   320
  outage duration      = detection threshold + election (+ load time for cold backup) rounds
  worst-case loss for cold backup = snapshot interval x write rate = 30 x 40 = 1200
  hot backup's cost = 468.8 KiB, i.e. a second full copy of the data set (2000 x 240 bytes)
```

#### Two Faces of the Majority

**When the majority cannot be reached, failover never starts.** When two of three sentinels or
three of five go down, the surviving sentinels correctly see that the primary has stopped; the
vote count still falls short of the majority. The result is in the last column: 115 writeless
rounds. This is the rule itself: a sentinel cannot decide until the others confirm what it sees.

**The majority mainly guards against a sentinel's own misjudgment.** In the single-sentinel row,
the majority is 1, and the sentinel's own unreachability windows turn directly into decisions:
12 false failovers. At five sentinels, the majority rises to 3, and with the same schedule false
failovers drop to 4. The primary's outage schedule is identical across every row; what changes
is whether one wrong sentinel is enough on its own.

The three-sentinel row looks backward at first glance: the majority is still 2, yet false
failovers climb from 6 to 9. The reason is the voter count — at two sentinels, both must be
wrong at once; at three, any two of them being wrong is enough, three times as many pairs. The
confirmation is in the same table: when one of the three sentinels goes down, the row produces
exactly the same numbers as the two-sentinel row.

#### The Price of the Threshold

The second table measures the detection threshold in both directions at once. At threshold 1,
the real outage's duration drops to 3 rounds; the same row shows 13 false failovers and 1,560
split-write records. At threshold 6, false failovers drop to 2 and split writes to 240; in
exchange, the outage duration climbs to 8 rounds.

**Split writes are the most expensive line item in this trade-off.** In a false failover, the
old primary is healthy and keeps accepting loan records for three rounds; these records never
reach the new primary, and are discarded once the old node is demoted to backup. The difference
between threshold 1 and threshold 6 in this line item is 1,320 records.

The total-lost column moves in the same direction (960 versus 240): **every failover, right or
wrong, produces its own loss.** The writeless-round column is not one-directional (33, 34, 35,
28, 26, 28) — raising the threshold extends the outage on the real failure but closes the
windows opened by false failovers.

#### The Backup's Memory Cost

The third table turns off temporary pauses and isolates a single real outage. In the hot-backup
rows, the loss is exactly `delay × write rate`: 40, 80, 160, and 320 loan records. The outage
duration is 5 rounds across all four rows; delay grows the loss, not the outage.

The cold-backup row shows this course's rule directly. When the backup's data is not held in
memory, data held drops from 937.5 KiB to 468.8 KiB. In exchange, the outage duration climbs
from 5 rounds to 17, and the loss climbs from 80 records to 760 (72,960 bytes; up to 1,200
records in the worst case). The hot backup's 468.8 KiB cost buys exactly this: a third of the
outage and a tenth of the loss.

#### Summary

- The sentinel majority is `floor(g/2)+1`; when two of three sentinels or three of five went
  down, failover never started, and not a single loan record was accepted in the 115 rounds
  after the outage.
- The majority rule mainly guards against a sentinel's own misjudgment: with the same outage
  schedule, 12 false failovers were measured at one sentinel, 4 at five.
- Adding sentinels without raising the majority ratio works in reverse: two sentinels (majority
  2) gave 6 false failovers, three sentinels (majority still 2) gave 9.
- The detection threshold cuts both ways: at threshold 1, the outage is 3 rounds, false
  failovers 13, split writes 1,560 records; at threshold 6, the outage is 8 rounds, false
  failovers 2, split writes 240.
- Asynchronous replication guarantees a per-failover loss, and its size is `delay × write rate`:
  40, 80, 160, 320 loan records — all of them records that were reported successful.
- A hot backup holds a second full copy of the data set, 468.8 KiB; in exchange it cuts the
  outage from 17 rounds to 5 and the loss from 760 records to 80.

#### Next Step

The two lessons so far carried the same implicit assumption: the entire data set fits on a
single node. Replication produced copies of that set, failover promoted one of the copies into
service; neither one split the set. When catalog and loan records outgrow a single node's memory
budget, only one path remains: splitting the key space. Its cost shows up immediately — if two
keys touched by the same transaction land on different nodes, that transaction cannot run on a
single node. The next lesson counts how a key is mapped to a partition, how many multi-key
transactions run into the cluster's cross-node constraint, and how much tagging keys into the
same partition unbalances the bytes held per node.

### Clustering

Source: https://academia.sh/en/courses/in-memory-stores/clustering

Partitioning the key space: mapping a key to a slot by hash and a slot to a node, shown with a hand-written hash function, measuring the multi-key transaction's cross-node constraint across four node counts and comparing the single-node landing rate against the formula one over n to the power of k minus one, tagging keys into the same partition raising transactions to one hundred percent while narrowing the usable slot count to six, and counting how the tag's granularity trades multiplied bytes held against imbalance in load per node.

The previous two lessons assumed that the entire data set fits on a single node. Replication
produced copies of that set, failover promoted one of the copies into service; both multiplied
the bytes stored, neither split it. When catalog and loan records outgrow a single node's memory
budget, only one path remains: splitting the key space and giving each node only a share of it.

This lesson covers that split. Sharding and shard-key selection were measured in the Scaling the
Data Layer and Non-Relational Data Models courses; they are not repeated here. This lesson's
question is different: how does the split decision constrain the in-memory store's transaction
semantics, and what does removing that constraint cost in the memory budget.

#### From Key to Node

Partitioning is two steps. A key passes through a **hash** function and lands on one of a fixed
number of **slots**; slot ranges are distributed across nodes. The slot layer in between means
that adding a node only moves slot ranges — keys do not need to be rehashed. The mechanism below
has 16,384 slots, and the hash function is hand-written.

The direct consequence is this: which node two keys land on is independent of each other. If a
loan transaction touches the catalog entry, the loan counter, and the waiting queue together,
these three keys being on the same node is not a design decision, it is a coincidence. When they
are not on the same node, the transaction cannot run on a single node — this is the **multi-key
transaction's cross-node constraint**.

#### Mechanism

The mechanism is a **model**: no real cluster, network, or node is set up; a node is a number,
and which node a key falls on is computed from the hash.

**CU6 — branch shares are not equal: six branches carry 34, 24, 16, 12, 9, and 5 percent of
loans.** Rationale: library branches are not equally sized, and the cost of the tagging decision
shows up exactly in this inequality. **CU7 — two transaction shapes are measured: T1 touches the
catalog entry, the loan counter, and the waiting queue; T2 adds the branch's concurrent-loan
counter to that.** Rationale: an unbounded loan and one that checks a branch limit touch a
different number of keys, and the cross-node constraint depends exponentially on key count.

```js
// clustering/model.mjs — key-space partitioning is an IN-PROCESS MODEL. No real cluster, network, or
// node is set up; a node is a number and which node a key falls on is computed from the hash.
// The hash function (FNV-1a, 32 bit) is hand-written here.
export const SLOTS = 16384, CATALOG_BYTES = 240, LOAN_BYTES = 96, QUEUE_BYTES = 128, COUNTER_BYTES = 64;

export function hash(s) {
  let h = 0x811c9dc5;
  for (let i = 0; i < s.length; i += 1) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; }
  return h >>> 0;
}
export function slotOf(key) {              // if the key carries a tag, ONLY the tag is hashed
  const a = key.indexOf("{"), b = key.indexOf("}");
  return hash(a >= 0 && b > a + 1 ? key.slice(a + 1, b) : key) % SLOTS;
}
export const nodeOf = (slot, n) => Math.floor(slot * n / SLOTS);   // equal slot ranges

export function generator(seed) {          // linear congruential generator; seed is visible
  let s = seed >>> 0;
  return () => { s = (Math.imul(s, 1103515245) + 12345) >>> 0; return s / 4294967296; };
}

// Branch shares are not equal: the largest branch carries a third of all loans.
export const SHARE = [0.34, 0.24, 0.16, 0.12, 0.09, 0.05];

export function data({ books = 3000, loans = 12000, queueShare = 0.25, seed = 20260731 }) {
  const rnd = generator(seed), records = [], queue = new Set();
  const threshold = SHARE.map((p, i) => SHARE.slice(0, i + 1).reduce((a, b) => a + b, 0));
  for (let i = 0; i < loans; i += 1) {
    const x = rnd(), s = threshold.findIndex((t) => x < t);
    records.push([s < 0 ? SHARE.length - 1 : s, Math.floor(rnd() * books)]);
  }
  for (let k = 0; k < books; k += 1) if (rnd() < queueShare) queue.add(k);
  return { books, records, queue };
}

// Three tagging schemes. The tag determines which part of the key gets hashed.
export const SCHEME = {
  none: { catalog: (k) => `catalog:${k}`, loan: (s, k) => `loan:${s}:${k}`,
          queue: (k) => `queue:${k}`, counter: (s) => `branch:${s}:openLoans` },
  branch: { catalog: (k, s) => `{branch:${s}}catalog:${k}`, loan: (s, k) => `{branch:${s}}loan:${k}`,
            queue: (k, s) => `{branch:${s}}queue:${k}`, counter: (s) => `{branch:${s}}openLoans` },
  book: { catalog: (k) => `{book:${k}}catalog:${k}`, loan: (s, k) => `{book:${k}}loan:${s}`,
          queue: (k) => `{book:${k}}queue:${k}`, counter: (s) => `branch:${s}:openLoans` },
};

export function run({ scheme, nodes, d }) {
  const S = SCHEME[scheme], bytes = new Map(), slots = new Set();
  const add = (key, b) => { bytes.set(key, b); slots.add(slotOf(key)); };
  let t1 = 0, t2 = 0;
  for (const [s, k] of d.records) {
    add(S.catalog(k, s), CATALOG_BYTES);                 // if the tag is a branch, the catalog entry MULTIPLIES
    add(S.loan(s, k), LOAN_BYTES);
    if (d.queue.has(k)) add(S.queue(k, s), QUEUE_BYTES);
    add(S.counter(s), COUNTER_BYTES);
    const g = [S.catalog(k, s), S.loan(s, k), S.queue(k, s)].map((a) => nodeOf(slotOf(a), nodes));
    if (g.every((x) => x === g[0])) t1 += 1;             // T1: catalog + loan + queue
    if (g.every((x) => x === g[0]) && nodeOf(slotOf(S.counter(s)), nodes) === g[0]) t2 += 1;
  }
  const load = Array(nodes).fill(0), count = Array(nodes).fill(0);
  for (const [key, b] of bytes) {
    const n = nodeOf(slotOf(key), nodes);
    load[n] += b; count[n] += 1;
  }
  const total = load.reduce((a, b) => a + b, 0);
  return { slots: slots.size, keys: bytes.size, total, load, count,
           mostLoaded: Math.max(...load), imbalance: Math.max(...load) / (total / nodes),
           t1: t1 / d.records.length, t2: t2 / d.records.length };
}
```

```js
// clustering/measure.mjs — same data set: first node count, then three tagging schemes
import { data, run, slotOf, nodeOf, SLOTS } from "./model.mjs";

const s = (x, n) => String(x).padStart(n);
const e = (x, n) => String(x).padEnd(n);
const kib = (b, n) => s((b / 1024).toFixed(1) + " KiB", n);
const pct = (x, n) => s((x * 100).toFixed(2) + "%", n);
const row = (cells, widths) => cells.map((c, i) => s(c, widths[i])).join(" | ");
const d = data({});

console.log("3,000 books, 6 branches, 12,000 loan records (seed 20260731). Catalog entry 240,");
console.log("loan 96, queue 128, branch counter 64 bytes. " + SLOTS + " slots, equal ranges per node.");
console.log("T1 = catalog + loan + queue; T2 = T1 + branch counter. No tag:\n");
const WA = [5, 7, 12, 15, 12, 15, 7];
console.log(row(["nodes", "keys", "total bytes", "busiest node", "imbalance", "T1 on 1 node", "T2"], WA));
console.log(WA.map((w) => "-".repeat(w)).join("-|-"));
for (const n of [3, 4, 6, 8]) {
  const r = run({ scheme: "none", nodes: n, d });
  console.log(row([n, r.keys, kib(r.total, 10), kib(r.mostLoaded, 10), r.imbalance.toFixed(3),
    pct(r.t1, 13), pct(r.t2, 6)], WA));
}

console.log("\n6 nodes fixed; tagging scheme varies:");
const WB = [6, 15, 7, 12, 10, 11, 7, 7];
console.log(row(["scheme", "slots used", "keys", "total bytes", "busiest", "imbalance", "T1", "T2"], WB));
console.log(WB.map((w) => "-".repeat(w)).join("-|-"));
for (const scheme of ["none", "branch", "book"]) {
  const r = run({ scheme, nodes: 6, d });
  console.log(row([scheme, r.slots, r.keys, kib(r.total, 10), kib(r.mostLoaded, 6), r.imbalance.toFixed(3),
    pct(r.t1, 6), pct(r.t2, 6)], WB));
}

console.log("\n6 nodes, tag = branch. Load per node (branch shares 34/24/16/12/9/5):");
const b = run({ scheme: "branch", nodes: 6, d });
console.log("node    : " + b.load.map((_, i) => s("n" + i, 9)).join(""));
console.log("keys    : " + b.count.map((x) => s(x, 9)).join(""));
console.log("KiB     : " + b.load.map((x) => s((x / 1024).toFixed(1), 9)).join(""));

console.log("\nquantities independent of the run:");
console.log("  probability that a K-key transaction lands on 1 node = (1/n)^(K-1), untagged");
console.log("  n       : " + [3, 4, 6, 8].map((n) => s(n, 8)).join(""));
console.log("  K=3     : " + [3, 4, 6, 8].map((n) => s((100 / n ** 2).toFixed(2) + "%", 8)).join(""));
console.log("  K=4     : " + [3, 4, 6, 8].map((n) => s((100 / n ** 3).toFixed(2) + "%", 8)).join(""));
console.log("  tag = branch -> usable slot count = branch count = 6");
console.log("  tag = book   -> usable slot count = book count   = 3000");
console.log("  sample slot no: branch:0 -> " + slotOf("{branch:0}catalog:7") +
  ", book:7 -> " + slotOf("{book:7}catalog:7") + ", untagged catalog:7 -> " + slotOf("catalog:7"));
console.log("  nodes for those slots at 6 nodes: " + [
  nodeOf(slotOf("{branch:0}catalog:7"), 6), nodeOf(slotOf("{book:7}catalog:7"), 6),
  nodeOf(slotOf("catalog:7"), 6)].join(", "));
```

```
3,000 books, 6 branches, 12,000 loan records (seed 20260731). Catalog entry 240,
loan 96, queue 128, branch counter 64 bytes. 16384 slots, equal ranges per node.
T1 = catalog + loan + queue; T2 = T1 + branch counter. No tag:

nodes |    keys |  total bytes |    busiest node |    imbalance |    T1 on 1 node |      T2
------|---------|--------------|-----------------|--------------|-----------------|--------
    3 |   11758 |   1539.6 KiB |       543.8 KiB |        1.060 |           7.55% |   2.90%
    4 |   11758 |   1539.6 KiB |       410.1 KiB |        1.066 |           3.79% |   1.18%
    6 |   11758 |   1539.6 KiB |       278.3 KiB |        1.085 |           1.15% |   0.27%
    8 |   11758 |   1539.6 KiB |       209.4 KiB |        1.088 |           0.08% |   0.02%

6 nodes fixed; tagging scheme varies:
scheme |      slots used |    keys |  total bytes |    busiest |   imbalance |      T1 |      T2
-------|-----------------|---------|--------------|------------|-------------|---------|--------
  none |            8437 |   11758 |   1539.6 KiB |  278.3 KiB |       1.085 |   1.15% |   0.27%
branch |               6 |   18151 |   2899.0 KiB | 2373.9 KiB |       4.913 | 100.00% | 100.00%
  book |            2706 |   11758 |   1539.6 KiB |  306.5 KiB |       1.195 | 100.00% |  14.90%

6 nodes, tag = branch. Load per node (branch shares 34/24/16/12/9/5):
node    :        n0       n1       n2       n3       n4       n5
keys    :     14865        0        0        0        0     3286
KiB     :    2373.9      0.0      0.0      0.0      0.0    525.2

quantities independent of the run:
  probability that a K-key transaction lands on 1 node = (1/n)^(K-1), untagged
  n       :        3       4       6       8
  K=3     :   11.11%   6.25%   2.78%   1.56%
  K=4     :    3.70%   1.56%   0.46%   0.20%
  tag = branch -> usable slot count = branch count = 6
  tag = book   -> usable slot count = book count   = 3000
  sample slot no: branch:0 -> 487, book:7 -> 11715, untagged catalog:7 -> 6819
  nodes for those slots at 6 nodes: 0, 4, 2
```

#### The Size of the Cross-Node Constraint

**Splitting is balanced; balance is not what constrains.** The first table's imbalance column
sits between 1.060 and 1.088 across all four node counts: the busiest node is at most nine
percent above average. The hash distribution does its job and the bytes held really do get
split — the busiest node holds 543.8 KiB at three nodes, 209.4 KiB at eight. This is what
replication could not do.

**What gets lost is the transaction.** The same table's last two columns count this: in a
three-node cluster, 7.55 percent of T1 transactions and 2.90 percent of T2 transactions land on
a single node. At eight nodes, the rates drop to 0.08 percent and 0.02 percent. The expected
value, in the run-independent rows, is `(1/n)^(K-1)`. The measured numbers sit well below that
expectation — loan records are not independent, the same book–branch pair recurs many times, so
a particular set of hot pairs can land off the theoretical curve in either direction — but the
trend is the same: **adding a node drops a transaction's chance of staying on one node
exponentially**, and the number of keys touched is the exponent. T2 adds a single key to T1 and
lowers the rate fivefold at eight nodes.

This is the real cost of moving to a cluster. If a loan transaction that checks a branch limit
can run on a single node only about once in six thousand attempts, that transaction has to be
carried out by the application in a cluster deployment, and it keeps no atomicity.

#### The Tag's Granularity

The second table compares three ways of lifting the constraint. A **tag** is hashing only a
specific part of the key; every key carrying the same tag lands on the same slot, and therefore
the same node.

When the tag is branch, the cross-node constraint disappears completely, and so does the
partitioning. T1 and T2 climb to 100 percent. In exchange, the slot count in use drops from
8,437 to **6**: six branches, six slots. The third block's result shows this plainly — four of
the six nodes are completely empty, all the data is concentrated on two nodes, and the imbalance
is 4.913. Which node will hit the memory limit is obvious in advance.

There is a second line item, and it comes directly out of the memory budget: total bytes held
climb from 1,539.6 KiB to **2,899.0 KiB**, and the key count from 11,758 to 18,151. The reason is
that the catalog entry is now written with the branch tag — the same book's record stands
separately at every branch it was lent from. Tagging determines not only placement, but **copy
count**.

When the tag is book, the picture is mixed rather than a clean reversal. The slot count in use
climbs to 2,706, and total bytes never grow, because the catalog entry does not get multiplied.
But the imbalance is slightly higher than the untagged distribution (1.195 against 1.085):
spreading 3,000 tags across six nodes carries more sampling noise than spreading tens of
thousands of independently hashed keys does. T1 is 100 percent: a book's catalog entry, loan
record, and waiting-queue entry always land on the same node. T2 stays at 14.90 percent, because
the branch's concurrent-loan counter does not carry the book tag and lands on one of six nodes
at random.

The rule that comes out of this is not about the tag itself but about the transaction: **a tag
does not fit a transaction onto one node, it defines the transaction's scope.** Pulling the
branch counter onto the same node too requires either binding the counter to the book tag — at
which point there is no longer a single counter per branch — or moving the limit check outside
the transaction. In a cluster deployment, key naming decides which transactions stay atomic.

#### Summary

- A key is first mapped by hash to one of 16,384 slots, and the slot is then mapped to a node;
  two keys landing on the same node is not a design decision, it is a coincidence.
- Untagged partitioning is balanced (imbalance 1.060–1.088) and really does split the bytes
  held: the busiest node holds 543.8 KiB at three nodes, 209.4 KiB at eight.
- The multi-key transaction's single-node landing rate follows the shape of `(1/n)^(K-1)`: for
  T1 it drops from 7.55 percent to 0.08 percent, for T2 from 2.90 percent to 0.02 percent.
- When the tag is branch, T1 and T2 climb to 100 percent; the cost is the slot count in use
  dropping to 6, four of the six nodes staying empty, and the imbalance reaching 4.913.
- Because the branch tag multiplies the catalog entry per branch, total bytes held climbed from
  1,539.6 KiB to 2,899.0 KiB, and the key count from 11,758 to 18,151.
- The book tag never grows the bytes held but pushes the imbalance to 1.195 and makes only T1
  reach 100 percent; T2, which includes the branch counter, stays at 14.90 percent — the tag
  defines the transaction's scope.

#### Next Step

This lesson showed that all of a transaction's keys can be gathered onto the same node, but it
never asked what happens once they are. A loan transaction reads the branch's concurrent-loan
counter, checks whether it is over the limit, and only then increments it; in the time between
the read and the increment, another branch clerk may have changed the same counter. In an
in-memory store, closing that gap is not done by taking a lock — it is done by **watching** the
key and, if it changed, dropping the transaction and retrying it. The next lesson measures this
watch-based check under concurrency: the collision rate, retries per transaction, the metadata a
watched key holds, and the rounds during which every client waits while a transaction runs.

### Transactions and Optimistic Locking

Source: https://academia.sh/en/courses/in-memory-stores/transactions-and-optimistic-locking

Measuring watch-based collision control in an in-memory store: collisions and retries per job growing exponentially as concurrency doubles, comparing the same job across three schemes where the unconditional atomic step exceeds the limit eighty-six times without a single collision, watch overhead growing with client count until it exceeds the counter memory it protects, and counting how long a single-threaded store keeps every client waiting while a transaction block runs, as a function of block length.

The previous lesson showed that all of a transaction's keys can be gathered onto the same node,
but it did not ask what happens once they are. A loan transaction that checks a branch limit
does this: it reads the branch's concurrent-loan counter, checks whether it is over the limit,
and only then increments it. There is a gap between the read and the increment, and in that gap
another clerk may have changed the same counter.

The optimistic locking pattern — compare versions, retry on conflict — was built up and measured
in the Data Access Layer and Business Logic course; it is not repeated here. This lesson's
subject is how the pattern plays out inside an in-memory store: the store is single-threaded, it
processes commands in order, and a transaction block runs without splitting. These two
properties determine both how a collision is detected and who pays its cost.

#### Watch, Think, Send

The client first puts the counter under **watch**, then reads it. Comparing the value it read
against the limit happens on the client's side, not the store's; the store serves other clients
meanwhile. When the client sends the transaction, the store checks exactly one question: has the
watched key changed since the watch was set. If it has, the transaction does not run at all, and
the client starts over.

The collision window is exactly this **think** interval, and its length is in the client's
hands. What the store controls is something else: while a transaction block runs, no other
command can interleave. This explains where atomicity comes from, and it also writes down its
cost — the longer the block, the longer every client waits.

#### Mechanism

The mechanism is a **model**: no real store, network, or client is set up. A tick is an abstract
step, and the store processes exactly one command per tick.

**CU8 — the think interval is 3 ticks, the transaction block defaults to 4 commands.**
Rationale: the arithmetic between reading and sending and the block's length are two independent
settings, and each is measured separately. **CU9 — 55 percent of jobs are loans (+1), 45 percent
are returns (−1), and the job list is generated independent of the scheme.** Rationale: the
counter should hover near the limit so the check actually binds, and the same job list needs to
run under all three schemes. **CU10 — a watched key holds 48 bytes of overhead, a counter 64, a
lock key 64.** Rationale: a watch record holds the key binding and the version; the counter and
the lock are each a single entry.

```js
// transaction/model.mjs — the SINGLE-THREADED in-memory store is an IN-PROCESS MODEL. A tick is an
// abstract step: the store processes exactly one command per tick, and a transaction block runs
// without splitting, holding the block for that many ticks. No real store, network, or client is
// set up. Think time is the client's own arithmetic between reading and sending; the store serves
// others meanwhile, and the collision window is exactly that gap.
export const COUNTER_BYTES = 64, WATCH_BYTES = 48, LOCK_BYTES = 64;

export function generator(seed) {  // linear congruential generator; seed is visible
  let s = seed >>> 0;
  return () => { s = (Math.imul(s, 1103515245) + 12345) >>> 0; return s / 4294967296; };
}

export function run({ scheme, clients, branches = 6, limit = 20, tasks = 30, think = 3,
                      block = 4, seed = 20260731 }) {
  const rnd = generator(seed);
  // Job list is generated independent of the scheme: each job is either a loan (+1) or a return (-1).
  const jobs = [...Array(clients)].map(() => [...Array(tasks)].map(() => rnd() < 0.55));
  const counter = Array(branches).fill(10), version = Array(branches).fill(0), lock = Array(branches).fill(-1);
  const c = [...Array(clients)].map((_, i) => ({ id: i, br: i % branches, i: 0, phase: 0, wait: 0, seen: 0 }));
  let tick = 0, busy = 0, turn = 0, collisions = 0, overLimit = 0, blocked = 0, blockWait = 0, done = 0;

  const apply = (x) => {                                    // job's effect on the counter; limit IS enforced
    const grow = jobs[x.id][x.i];
    if (grow && counter[x.br] < limit) counter[x.br] += 1;
    else if (!grow && counter[x.br] > 0) counter[x.br] -= 1;
    version[x.br] += 1; x.i += 1; done += 1; x.phase = 0;
  };

  while (done < clients * tasks && tick < 400000) {
    tick += 1;
    for (const x of c) if (x.wait > 0) x.wait -= 1;
    const ready = c.filter((x) => (x.i < tasks || x.phase > 0) && x.wait === 0);
    if (busy > 0) { busy -= 1; blocked += ready.length; blockWait += ready.length; continue; }
    if (ready.length === 0) continue;
    const x = ready[turn % ready.length]; turn += 1;        // the store serves one command
    blocked += ready.length - 1;
    if (scheme === "atomic") {                              // one atomic step, NO condition
      const grow = jobs[x.id][x.i];
      if (grow) { if (counter[x.br] >= limit) overLimit += 1; counter[x.br] += 1; }
      else if (counter[x.br] > 0) counter[x.br] -= 1;
      version[x.br] += 1; x.i += 1; done += 1;
    } else if (scheme === "watch") {
      if (x.phase === 0) { x.seen = version[x.br]; x.phase = 1; }   // watch
      else if (x.phase === 1) { x.wait = think; x.phase = 2; }       // read, then the client computes
      else if (version[x.br] !== x.seen) { collisions += 1; x.phase = 0; }  // watched key changed
      else { busy = block - 1; apply(x); }                    // transaction block runs WITHOUT splitting
    } else {                                                  // coarse lock: held across the think gap
      if (x.phase === 0) { if (lock[x.br] === -1) { lock[x.br] = x.id; x.phase = 1; } }
      else if (x.phase === 1) { x.wait = think; x.phase = 2; }
      else if (x.phase === 2) { apply(x); x.phase = 3; }
      else { lock[x.br] = -1; x.phase = 0; }
    }
  }
  const overhead = scheme === "watch" ? clients * WATCH_BYTES : scheme === "lock" ? branches * LOCK_BYTES : 0;
  return { tick, collisions, overLimit, blocked, blockWait, done, counter,
           retries: collisions / (clients * tasks), memory: branches * COUNTER_BYTES + overhead, overhead };
}
```

```js
// transaction/measure.mjs — same job: first concurrency, then three schemes, then transaction block length
import { run, COUNTER_BYTES, WATCH_BYTES } from "./model.mjs";

const s = (x, n) => String(x).padStart(n);
const row = (cells, widths) => cells.map((c, i) => s(c, widths[i])).join(" | ");
const pct = (x, n) => s((x * 100).toFixed(2) + "%", n);

console.log("6 branches, concurrent-loan limit per branch 20. Each client does 30 jobs (55% loan,");
console.log("45% return; seed 20260731). Think 3 ticks, transaction block 4 commands. Watch scheme:\n");
const W1 = [7, 5, 10, 16, 11, 21];
console.log(row(["clients", "jobs", "collisions", "retries per job", "total ticks", "blocked client-ticks"], W1));
console.log(W1.map((w) => "-".repeat(w)).join("-|-"));
for (const n of [2, 4, 8, 16, 32]) {
  const r = run({ scheme: "watch", clients: n });
  console.log(row([n, n * 30, r.collisions, r.retries.toFixed(3), r.tick, r.blocked], W1));
}

console.log("\n16 clients fixed; three schemes:");
const W2 = [7, 11, 10, 10, 8, 14, 10];
console.log(row(["scheme", "total ticks", "collisions", "over limit", "blocked", "overhead bytes", "held bytes"], W2));
console.log(W2.map((w) => "-".repeat(w)).join("-|-"));
for (const scheme of ["atomic", "watch", "lock"]) {
  const r = run({ scheme, clients: 16 });
  console.log(row([scheme, r.tick, r.collisions, r.overLimit, r.blocked, r.overhead, r.memory], W2));
}

console.log("\n16 clients, watch scheme; transaction block length varies:");
const W3 = [5, 11, 10, 21, 10, 12];
console.log(row(["block", "total ticks", "collisions", "waiting due to block", "blocked", "wait share"], W3));
console.log(W3.map((w) => "-".repeat(w)).join("-|-"));
for (const b of [1, 2, 4, 8, 16]) {
  const r = run({ scheme: "watch", clients: 16, block: b });
  console.log(row([b, r.tick, r.collisions, r.blockWait, r.blocked, pct(r.blockWait / r.blocked, 11)], W3));
}

console.log("\nquantities independent of the run:");
console.log("  watch overhead = client count x " + WATCH_BYTES + " bytes (per watched key)");
console.log("  clients : " + [2, 4, 8, 16, 32].map((n) => s(n, 7)).join(""));
console.log("  bytes   : " + [2, 4, 8, 16, 32].map((n) => s(n * WATCH_BYTES, 7)).join(""));
console.log("  counter memory = 6 x " + COUNTER_BYTES + " = " + 6 * COUNTER_BYTES + " bytes (independent of scheme)");
console.log("  job waiting while a transaction block runs <= (block - 1) x ready-client count");
console.log("  block   : " + [1, 2, 4, 8, 16].map((n) => s(n, 7)).join(""));
console.log("  upper bound (15 ready clients): " + [1, 2, 4, 8, 16].map((n) => s((n - 1) * 15, 7)).join(""));
```

```
6 branches, concurrent-loan limit per branch 20. Each client does 30 jobs (55% loan,
45% return; seed 20260731). Think 3 ticks, transaction block 4 commands. Watch scheme:

clients |  jobs | collisions |  retries per job | total ticks |  blocked client-ticks
--------|-------|------------|------------------|-------------|----------------------
      2 |    60 |          0 |            0.000 |         387 |                   470
      4 |   120 |          0 |            0.000 |         717 |                  2244
      8 |   240 |         87 |            0.362 |        1704 |                 10070
     16 |   480 |        475 |            0.990 |        4314 |                 54181
     32 |   960 |       2201 |            2.293 |       12372 |                350407

16 clients fixed; three schemes:
 scheme | total ticks | collisions | over limit |  blocked | overhead bytes | held bytes
--------|-------------|------------|------------|----------|----------------|-----------
 atomic |         480 |          0 |         86 |     7080 |              0 |        384
  watch |        4314 |        475 |          0 |    54181 |            768 |       1152
   lock |        4353 |          0 |          0 |    58883 |            384 |        768

16 clients, watch scheme; transaction block length varies:
block | total ticks | collisions |  waiting due to block |    blocked |   wait share
------|-------------|------------|-----------------------|------------|-------------
    1 |        2965 |        506 |                     0 |      37476 |        0.00%
    2 |        3356 |        475 |                  6268 |      41303 |       15.18%
    4 |        4314 |        475 |                 19146 |      54181 |       35.34%
    8 |        6230 |        475 |                 44902 |      79937 |       56.17%
   16 |       10062 |        475 |                 96414 |     131449 |       73.35%

quantities independent of the run:
  watch overhead = client count x 48 bytes (per watched key)
  clients :       2      4      8     16     32
  bytes   :      96    192    384    768   1536
  counter memory = 6 x 64 = 384 bytes (independent of scheme)
  job waiting while a transaction block runs <= (block - 1) x ready-client count
  block   :       1      2      4      8     16
  upper bound (15 ready clients):       0     15     45    105    225
```

#### Collisions Are Not Linear

The first table doubles concurrency and counts collisions. At two and four clients there is not
a single collision: clerks spread across six branches do not touch the same counter at the same
time. At eight clients, 87 collisions are measured; at sixteen, 475; at thirty-two, 2,201. While
job count doubles, collisions grow four to five times over, because a collision is proportional
not to client count but to the number of client **pairs** touching the same counter in the same
window.

The retries-per-job column makes this directly readable: 0.362, then 0.990, then 2.293. At
thirty-two clients, a loan transaction is sent about three times on average, and two of those go
to waste. Total ticks climb from 1,704 to 12,372 — as the job count quadruples, the ticks spent
grow sevenfold.

The blocked-client-ticks column grows from 10,070 to 350,407, thirty-five times over. This
number is the sum of ticks spent by clients that are ready but not yet served, and it is the
real measure of a single-threaded store: even when no work is happening, someone is waiting.

#### The Same Job Under Three Schemes

The second table puts three schemes side by side at sixteen clients. **The unconditional atomic
step is the fastest, and it is wrong.** 480 jobs finish in 480 ticks — one tick per command, not
a single retry. The over-limit column writes down what that buys: the branch limit was exceeded
86 times. An atomic increment resolves a race condition, but it cannot enforce a **check**;
reading the limit and acting on it takes two steps, and something else gets in between them.

**The watch scheme is correct and spends nine times the ticks.** 4,314 ticks, 475 collisions,
zero over-limit violations. The difference is the price of correctness, and its size is exactly
what is measured here.

**A coarse lock gives the same correctness and is not cheaper.** At 4,353 ticks it sits slightly
above the watch scheme, and at 58,883 blocked client-ticks it stays clearly above it. The reason
is that the lock is held across the client's think interval too: no collisions, but plenty of
waiting. Optimistic control's advantage here is that it keeps no one waiting when there is no
collision.

The memory columns recall this course's rule. Watch overhead grows with client count: 768 bytes
at sixteen clients, 1,536 at thirty-two. The protected data itself is nothing more than six
counters, 384 bytes. **Past eight clients, the collision-control ledger outweighs the data it
protects.** The lock's overhead, in contrast, is per branch and independent of client count: 384
bytes. The memory difference between the two approaches comes down to what the overhead is bound
to.

#### The Length of the Transaction Block

The third table isolates the cost specific to a single-threaded store. At block length 1, the
wait caused by the block is zero. At block length 16, 96,414 client-ticks accumulate purely
while the block runs, making up 73.35 percent of total waiting. The collision count is nearly
constant across these rows (475) — block length does not change collisions, because the
collision window sits in the think interval.

What changes is that no one else can be served while the transaction runs. Total ticks climb
from 2,965 to 10,062. The run-independent upper bound is in the last row: at block length 16,
with 15 ready clients, that is 225 client-ticks for a single transaction. Adding a command to a
transaction block charges that command to every client's bill.

#### Summary

- Collisions do not grow linearly with concurrency: there were no collisions at two and four
  clients; 87, 475, and 2,201 collisions were measured at 8, 16, and 32; retries per job climbed
  from 0.362 to 2.293.
- The unconditional atomic step finished 480 jobs in 480 ticks with zero collisions, but
  exceeded the branch limit 86 times: an atomic increment resolves the race, it does not enforce
  the check.
- The watch scheme spent 4,314 ticks with zero over-limit violations; the price of correctness
  is that ninefold factor.
- A coarse lock gave the same correctness in 4,353 ticks but blocked 58,883 client-ticks (54,181
  for watch), because the lock is held across the client's think interval too.
- Watch overhead is per client (768 bytes at 16 clients) and exceeds the six counters' 384
  bytes; lock overhead is per branch and does not grow with client count.
- As the transaction block grew from 1 to 16, total ticks climbed from 2,965 to 10,062,
  block-caused waiting from 0 to 96,414 client-ticks, reaching 73.35 percent of total waiting;
  the collision count did not change.

#### Next Step

This lesson measured under what conditions a write gets accepted, but it never asked who finds
out that it was accepted. Notifying a waitlisted reader once the loan counter hits its limit,
refreshing the shelf display once a book is returned, a branch board showing the latest activity
— all of these ask the same question: how does a change reach the parties waiting on it. This
work does not require reading a key; the store itself can distribute a message to its
subscribers. The next lesson measures what that distribution is, and more importantly what it is
not: there is no delivery guarantee, a message to a channel with no subscriber is lost, and a
slow subscriber grows a buffer in the store's memory.

### Publish-Subscribe

Source: https://academia.sh/en/courses/in-memory-stores/publish-subscribe

The in-memory store's lightweight messaging path and its lack of a delivery guarantee: counting the loss of a message published with no subscriber present, the bytes a slow subscriber's output buffer holds and the threshold at which it gets dropped, and the memory the same event stream pays when built with a persistent structure instead.

The previous lesson ran collision control on a single key: whether the value a client read had
been changed by someone else was seen through the watch, and a colliding transaction was
retried. There, communication was established indirectly, between two clients touching the same
key. A client saying something **directly** to another client — the store used as a transmission
channel — was never covered.

In-memory stores carry a separate path for this: a client **publishes** to a **channel**, and
clients subscribed to that channel receive the message. Nothing gets written to the key space.
What this lesson measures is not that path's speed, but **what it does not guarantee**.

#### The Store's Publish Path

In the library system, loan events land on branch boards instantly. The mechanism below sets up
three branch channels, one board per branch, and an audit subscriber that joins at step three
hundred. Board connectivity drops on a deterministic schedule; instead of a real network outage,
a rule based on the step number is used, so the numbers come out the same on every run (**CU10**).

```js
// publish-loss.mjs — a message published to a channel with no subscriber is lost; the loss is counted
class Store {                                       // publish-subscribe: no record, no rewind
  constructor() { this.channels = new Map(); }      // channel -> Set(subscriber)
  subscribe(channel, a) {
    if (!this.channels.has(channel)) this.channels.set(channel, new Set());
    this.channels.get(channel).add(a);
  }
  unsubscribe(channel, a) {
    this.channels.get(channel)?.delete(a);
  }
  publish(channel, message) {                        // return value: number of subscribers reached
    const set = this.channels.get(channel);
    if (!set || set.size === 0) return 0;             // no subscriber: message is written nowhere
    for (const a of set) a.receive(message);
    return set.size;
  }
}

const events = (count) => {                          // deterministic workload, seed visible
  let seed = 20250731;
  const rand = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648);
  return Array.from({ length: count }, (_, i) => ({
    step: i + 1, branch: Math.floor(rand() * 3) + 1, bookId: Math.floor(rand() * 400) + 1,
  }));
};

const store = new Store();
const channel = (s) => `branch:${s}:loans`;
const boards = [1, 2, 3].map((s) => ({ name: `board${s}`, branch: s, received: 0, receive() { this.received += 1; } }));
const audit = { name: "audit", received: 0, receive() { this.received += 1; } };
const counters = [1, 2, 3].map(() => ({ published: 0, delivered: 0, unsubscribed: 0 }));

// Board connectivity: each board disconnects periodically (deterministic schedule, no randomness).
const boardOpen = (s, step) => (step + s * 37) % 100 >= 25;   // connected in 75% of steps
for (const e of events(600)) {
  for (const b of boards) {                          // connection state translates to subscription
    const open = boardOpen(b.branch, e.step);
    if (open) store.subscribe(channel(b.branch), b); else store.unsubscribe(channel(b.branch), b);
  }
  if (e.step === 301) for (const s of [1, 2, 3]) store.subscribe(channel(s), audit);  // late-joining subscriber

  const c = counters[e.branch - 1];
  const reached = store.publish(channel(e.branch), e);
  c.published += 1; c.delivered += reached;
  if (reached === 0) c.unsubscribed += 1;
}

console.log(["channel", "published", "delivered", "unsubscribed publish", "missed by board"]
  .map((h, i) => (i === 0 ? h.padEnd(16) : h.padStart(22))).join(""));
let total = [0, 0, 0, 0];
for (const [i, c] of counters.entries()) {
  const missed = c.published - boards[i].received;
  total = total.map((t, j) => t + [c.published, c.delivered, c.unsubscribed, missed][j]);
  console.log(channel(i + 1).padEnd(16) +
    [c.published, c.delivered, c.unsubscribed, missed].map((n) => String(n).padStart(22)).join(""));
}
console.log("total".padEnd(16) + total.map((n) => String(n).padStart(22)).join(""));
console.log(`received by the late subscriber: ${audit.received}  (published before it joined: 300)`);
```

```
channel                      published             delivered  unsubscribed publish       missed by board
branch:1:loans                     199                   266                    21                    46
branch:2:loans                     195                   227                    31                    55
branch:3:loans                     206                   260                    22                    46
total                              600                   753                    74                   147
received by the late subscriber: 300  (published before it joined: 300)
```

Three numbers need reading. The **delivered** column is larger than the published count
(753 > 600), because a message gets copied once for every subscriber present, and the publisher
does not know that count in advance. The **unsubscribed publish** column says that 74 messages
were written nowhere at all: no one was on the channel at that moment, and the message vanished
at the step it was produced. The **missed by board** column is larger still (147), because when
one board is disconnected but another subscriber is connected, the message is not lost — it just
never reaches that board.

The last line is the sharpest one. The audit subscriber joined at step three hundred and
received exactly 300 messages; it has no way to see the 300 that came before it. No trace of
those messages remains anywhere in the store's key space. In the publish-subscribe path,
delivery reduces to whether the moment a message is published and the moment a subscriber is
connected happen to overlap.

#### The Slow Subscriber's Buffer

Loss does not come only from a broken connection. When a subscriber is connected but messages
are produced faster than it reads them, the unread messages pile up in an **output buffer** the
store holds. That buffer eats memory, and it is invisible in the publisher's accounting. The run
below drives the subscriber at different read speeds and counts the bytes the buffer holds. Once
the buffer exceeds 32,768 bytes, the store drops the subscriber; for a dropped subscriber, every
publish after that is a loss.

```js
// slow-subscriber.mjs — a slow subscriber's output buffer: bytes held and the step it hits the limit
const MESSAGE = (step, branch, bookId) =>          // body carried verbatim; its bytes are measured
  JSON.stringify({ event: "loan", step, branch, bookId });

function run(readInterval, byteLimit, count = 2000) {
  let seed = 20250731;
  const rand = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648);
  const buffer = [];                               // messages not yet written to the subscriber
  let bytes = 0, peak = 0, dropStep = 0, afterDrop = 0, read = 0;
  for (let step = 1; step <= count; step += 1) {
    const g = MESSAGE(step, Math.floor(rand() * 3) + 1, Math.floor(rand() * 400) + 1);
    if (dropStep) { afterDrop += 1; continue; }      // dropped subscriber: publish is a loss
    buffer.push(g); bytes += Buffer.byteLength(g);   // the store writes to the buffer first
    peak = Math.max(peak, bytes);
    if (bytes > byteLimit) { dropStep = step; continue; }   // limit exceeded: subscriber is dropped
    if (step % readInterval === 0) {                 // subscriber reads once every `readInterval` steps
      const c = buffer.shift();
      if (c !== undefined) { bytes -= Buffer.byteLength(c); read += 1; }
    }
  }
  return { readInterval, read, peak, pending: buffer.length, dropStep, afterDrop };
}

console.log(`message body: ${MESSAGE(1, 1, 1)}  (${Buffer.byteLength(MESSAGE(1, 1, 1))} bytes)`);
console.log(["read interval", "read", "peak bytes", "pending in buffer", "drop step", "after drop"]
  .map((h, i) => (i === 0 ? h.padEnd(15) : h.padStart(19))).join(""));
for (const interval of [1, 2, 4, 10]) {
  const r = run(interval, 32768);
  console.log(`every ${String(interval).padStart(2)} steps`.padEnd(15) +
    [r.read, r.peak, r.pending, r.dropStep, r.afterDrop]
      .map((n) => String(n).padStart(19)).join(""));
}
```

```
message body: {"event":"loan","step":1,"branch":1,"bookId":1}  (47 bytes)
read interval                 read         peak bytes  pending in buffer          drop step         after drop
every  1 steps                2000                 52                  0                  0                  0
every  2 steps                 639              32800                641               1280                720
every  4 steps                 214              32777                646                860               1140
every 10 steps                  71              32801                647                718               1282
```

A subscriber that keeps up adds 52 bytes to memory: the buffer never holds more than one
message. When the read interval grows to two, the same subscriber reaches 32,800 bytes and gets
dropped at step 1,280; the remaining 720 publishes do not exist for it. When the interval grows
to ten, the limit is hit at step 718, and the loss climbs to 1,282 messages.

The budget relationship here runs in reverse. A subscriber's **slowness** is paid out of the
store's **memory**, and that cost is per subscriber: a hundred slow subscribers with the same
limit means a hundred separate buffers, up to 3.2 MB in the worst case. The limit is the store's
way of protecting itself; its cost is that a subscriber that hits the limit gets dropped
silently. The publisher sees none of this, because the publish call does not wait for the
message to be read (**CU11**).

#### Persistent Versus Not

The same event stream can also be built with the store's **stream** structure. The difference is
a single point: a stream stores every entry with an id, so a subscriber that arrives late or
reconnects after dropping can read from where it left off. The price of that is memory. The run
below passes 2,000 events through three paths; the subscriber drops at step 800 and returns at
step 1,500. A 16-byte pointer/length overhead per entry is assumed (**CU12**).

```js
// stream-comparison.mjs — same event stream: bytes for a non-persistent publish vs. a trimmed stream
const OVERHEAD = 16;                                // per-entry id/length overhead (my own accounting)
const MESSAGE = (step, branch, bookId) => JSON.stringify({ event: "loan", step, branch, bookId });
const DROP = 800, RETURN = 1500, COUNT = 2000;      // subscriber drops at 800, returns at 1500

function events(count) {
  let seed = 20250731;
  const rand = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648);
  return Array.from({ length: count }, (_, i) =>
    MESSAGE(i + 1, Math.floor(rand() * 3) + 1, Math.floor(rand() * 400) + 1));
}

function pubsubPath() {                             // non-persistent: nothing but the channel record
  let bytes = 0;
  for (const name of ["branch:activity", "board"]) bytes += Buffer.byteLength(name) + OVERHEAD;
  return { name: "publish-subscribe", returnBytes: bytes, endBytes: bytes, entries: 0, recovered: 0 };
}

function streamPath(bodies, trim) {                  // persistent: entry id and body sit in memory
  const stream = [];
  let bytes = 0, returnBytes = 0, recovered = 0;
  const size = (g) => Buffer.byteLength(g.id) + Buffer.byteLength(g.body) + OVERHEAD;
  bodies.forEach((body, i) => {
    const entry = { id: `${i + 1}-0`, body };
    stream.push(entry); bytes += size(entry);
    while (trim && stream.length > trim) bytes -= size(stream.shift());   // oldest entry drops
    if (i + 1 === RETURN) {                          // subscriber returns at exactly this step
      returnBytes = bytes;
      recovered = stream.filter((g) => {              // only entries still in the window are recoverable
        const step = Number(g.id.split("-")[0]);
        return step >= DROP && step < RETURN;
      }).length;
    }
  });
  return { name: trim ? `stream (trim=${trim})` : "stream (no trim)",
           returnBytes, endBytes: bytes, entries: stream.length, recovered };
}

const bodies = events(COUNT);
const gap = RETURN - DROP;                           // events the subscriber was disconnected for
console.log(`events=${COUNT}  gap=${gap} events (steps ${DROP}-${RETURN - 1})`);
console.log(["path", "bytes@1500", "bytes@2000", "entries", "recovered", "lost"]
  .map((h, i) => (i === 0 ? h.padEnd(20) : h.padStart(13))).join(""));
for (const r of [pubsubPath(), streamPath(bodies, 500), streamPath(bodies, 0)]) {
  console.log(r.name.padEnd(20) +
    [r.returnBytes, r.endBytes, r.entries, r.recovered, gap - r.recovered]
      .map((n) => String(n).padStart(13)).join(""));
}
```

```
events=2000  gap=700 events (steps 800-1499)
path                   bytes@1500   bytes@2000      entries    recovered         lost
publish-subscribe              52           52            0            0          700
stream (trim=500)           36865        36862          500          499          201
stream (no trim)           108381       145243         2000          700            0
```

The three rows show this course's rule exactly as it is. The publish-subscribe path holds 52
bytes; that is nothing more than the channel name and the subscription record, and it never
grows with event count. In exchange, all 700 of the dropped subscriber's events are lost.

The trimmed stream holds 36,865 bytes and returns 499 of the gap; the remaining 201 events are
lost because they were trimmed away before the subscriber returned. The trim length is directly
a **recovery window** here: 500 entries means 500 events of lookback.

The untrimmed stream loses nothing, but holds 108,381 bytes at step 1,500 and 145,243 at step
2,000. The growth between those two steps is the real warning: an untrimmed stream's memory
grows without bound as events accumulate, and once a memory limit kicks in, that growth forces
the eviction of other data.

#### Where Publishing Fits

These measurements do not make the publish-subscribe path worthless; they set its place. Work
where loss is acceptable is work where missing a message is **correctable**: a board's live
counter, a cache-invalidation notice, an announcement of a configuration change. In every one of
these, a subscriber makes up what it missed on the next full read.

For work where loss is not acceptable, the measured cost gets paid: a loan record posting to
accounting, a late fee being processed, the record of a book returning to the shelf. This work
either gets written to a persistent stream or handed off outside the store, to a structure that
gives a delivery guarantee. The distinction is not made by asking "which is faster" but by
asking "can a missed message be recovered."

#### Summary

- The publish-subscribe path does not write the message to the key space: 74 of 600 publishes
  vanished without reaching anywhere because no subscriber was on the channel at that moment,
  and boards missed 147 messages besides.
- The delivered count is independent of the published count; 600 publishes produced 753
  delivered copies, and the publisher does not know that number in advance.
- A late-joining subscriber cannot see any of the 300 messages published before it joined; the
  history sits nowhere in the store.
- The cost of a slow subscriber is the store's memory: when the read interval grew to two, the
  output buffer reached 32,800 bytes, the subscriber was dropped at step 1,280, and the next 720
  publishes were lost for it.
- When the same stream is built with a persistent structure, it holds 36,865 bytes (trim 500) or
  145,243 bytes (no trim) instead of 52; what that buys is 499 and 700 recoverable events
  respectively.

#### Next Step

The lessons so far measured the store's abilities one at a time: replication, failover,
partitioning, collision control, and now publishing. Each was defined by its own cost, but none
of them answered end to end the question "which structure does this job get built with in this
store, and how many bytes does it hold." The next lesson takes on four typical jobs from the
library system — the catalog cache, session records, a rate limit, and a popular-book
leaderboard — one at a time; it fits each one to a structure and gathers each one's memory
accounting into the same table.

### Typical Use Cases

Source: https://academia.sh/en/courses/in-memory-stores/typical-use-cases

Fitting the catalog cache, session records, a rate limit, and a leaderboard onto the in-memory store's structures: which structure each scenario is built with, how many bytes it holds per entry, and what access that byte count buys.

The previous lesson measured the publish-subscribe path and ended on a limit: because that path
saves nothing, it held 52 bytes, and in exchange the 700 events of a dropped subscriber never
came back. That was a single instance of this course's recurring question. The same question
gets asked of every one of the store's everyday jobs.

This lesson takes four typical jobs from the library system — the catalog cache, session
records, a rate limit, a popular-book leaderboard — and reduces each one to a single question:
**which structure is it built with, and how many bytes does it hold.** What the caching strategy
should be, which algorithm the rate limit runs on, are not discussed here; those choices were
measured in other courses. What gets measured here is the structure itself.

#### Shared Cost Model

All four accounts use the same counting rule (**CU13**): 16 bytes of pointer/length overhead per
entry, 8 bytes of overhead per field in a hash structure, 24 bytes of index-node overhead per
member in a sorted set, 8 bytes per numeric value. Keys and string values are read from their
real length. This model is not a product's internal layout, it is this lesson's own accounting
contract; what matters is that the two options are counted by the same rule.

#### Catalog Cache and Session Records

The first two scenarios pick the same structure, a hash structure, for two different reasons. In
the catalog cache, the question is whether the record is held whole or field by field. In
session records, the question is how long the records are held for: session data is set up
together with an expiration, and memory scales not with the number of sessions opened but with
the number **live at the same time**.

```js
// catalog-session.mjs — catalog cache and session records: which structure, how many bytes
const ENTRY_OVERHEAD = 16, FIELD_OVERHEAD = 8, NUMBER_BYTES = 8;   // my own cost model (entry/field/number overhead)
const b = (s) => Buffer.byteLength(String(s));
const fieldBytes = (name, value) =>                // cost of one field in a hash structure
  b(name) + (typeof value === "number" ? NUMBER_BYTES : b(value)) + FIELD_OVERHEAD;

function books(count) {
  let seed = 20250731;
  const rand = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648);
  const tags = ["fiction", "history", "children", "science"];
  return Array.from({ length: count }, (_, i) => ({
    bookId: i + 1, title: `Book ${i + 1}`,
    author: `Author ${Math.floor(rand() * 120) + 1}`,
    branch: Math.floor(rand() * 3) + 1, shelf: Math.floor(rand() * 5),
    tag: tags[Math.floor(rand() * 4)],
  }));
}

// --- Scenario 1: catalog cache. The same record held in two structures.
const records = books(400);
let stringBytes = 0, hashBytes = 0, stringWrites = 0, hashWrites = 0;
for (const k of records) {
  const key = `book:${k.bookId}`;
  const body = JSON.stringify(k);
  stringBytes += b(key) + b(body) + ENTRY_OVERHEAD;   // single string: body held whole
  hashBytes += b(key) + ENTRY_OVERHEAD +               // hash structure: field by field
    Object.entries(k).reduce((t, [name, d]) => t + fieldBytes(name, d), 0);
}
for (let i = 0; i < 1000; i += 1) {                  // 1000 shelf updates: only `shelf` changes
  const k = records[i % records.length];
  stringWrites += b(JSON.stringify(k));               // string: the whole body is rewritten
  hashWrites += NUMBER_BYTES;                          // hash: only that field's value
}
console.log("catalog cache (400 books)");
console.log(`  string : held=${stringBytes} bytes  per entry=${Math.round(stringBytes / 400)}` +
  `  written over 1000 updates=${stringWrites} bytes`);
console.log(`  hash   : held=${hashBytes} bytes  per entry=${Math.round(hashBytes / 400)}` +
  `  written over 1000 updates=${hashWrites} bytes`);

// --- Scenario 2: session records. Hash structure + expiration; expiration bounds live sessions.
const MINUTES = 720, ENTRIES_PER_MIN = 25, LIFETIME = 30;   // 12 hours, 25 entries/minute, 30 min lifetime
const sessionFields = { userId: 1, branch: 1, role: "member", lastAccess: 1 };
const sessionBytes = b("session:") + 32 + ENTRY_OVERHEAD +  // key: prefix + 32-char id
  Object.entries(sessionFields).reduce((t, [name, d]) => t + fieldBytes(name, d), 0);
const live = new Map();                              // id -> expiry (minute)
let peakLive = 0, opened = 0;
for (let m = 1; m <= MINUTES; m += 1) {
  for (const [id, exp] of live) if (exp <= m) live.delete(id);   // expiration cleans up
  for (let i = 0; i < ENTRIES_PER_MIN; i += 1) live.set(`s${opened++}`, m + LIFETIME);
  peakLive = Math.max(peakLive, live.size);
}
console.log("session records (12 hours)");
console.log(`  per entry=${sessionBytes} bytes  sessions opened=${opened}  peak live=${peakLive}`);
console.log(`  with expiration    : held=${peakLive * sessionBytes} bytes`);
console.log(`  without expiration : held=${opened * sessionBytes} bytes` +
  `  (${(opened / peakLive).toFixed(1)}x)`);
```

```
catalog cache (400 books)
  string : held=45825 bytes  per entry=115  written over 1000 updates=90728 bytes
  hash   : held=60333 bytes  per entry=151  written over 1000 updates=8000 bytes
session records (12 hours)
  per entry=144 bytes  sessions opened=18000  peak live=750
  with expiration    : held=108000 bytes
  without expiration : held=2592000 bytes  (24.0x)
```

The catalog rows' trade-off is clear-cut. The hash structure holds 14,508 bytes more (151 bytes
per entry instead of 115), because every field's name and every field's overhead get paid
separately. What that buys is in the second column: over a thousand shelf updates, the string
form rewrites 90,728 bytes while the hash structure writes 8,000. When a record is held whole,
changing a single number means rewriting the entire body; the body also has to be parsed and
rebuilt.

The choice depends on how the record gets used. If the record is always read whole and rarely
updated, the string form holds fewer bytes and finishes in a single access. If fields are read
or updated individually, the hash structure's extra 36 bytes are paid back by the roughly 83
bytes saved on writes with every update.

The session rows' trade-off sits on a different axis. At 144 bytes per entry, holding all 18,000
sessions opened over twelve hours would take 2,592,000 bytes. Expiration keeps the sessions live
at any one moment at 750 and cuts the cost to 108,000 bytes: twenty-four times over. Here,
memory scales not with user count but with **session lifetime**. What gets lost is just as
clear: an expired session's data cannot be recovered, and the user authenticates again
(**CU14**).

#### Rate Limit and Leaderboard

The last two scenarios are two ends of the same question: is a number by itself enough, or are
the individual entries that make up that number needed too. In the rate limit, the counter holds
only the window's total; a sorted set holds each request's moment as a separate member. In the
leaderboard, the hash structure holds only the loan counts; a sorted set carries a sorted index
on top of them.

```js
// rate-leaderboard.mjs — rate limit and leaderboard: which structure, how many bytes, how many entries touched
const ENTRY_OVERHEAD = 16, FIELD_OVERHEAD = 8, INDEX_OVERHEAD = 24, NUMBER_BYTES = 8;   // same cost model
const b = (s) => Buffer.byteLength(String(s));

// --- Scenario 3: rate limit. Counter holds the window's total, sorted set holds each moment.
const USERS = 2000, LIMIT = 60;                      // 60 requests per minute
let counterBytes = 0, setBytes = 0;
for (let u = 1; u <= USERS; u += 1) {
  counterBytes += b(`rate:${u}:2025073114`) + NUMBER_BYTES + ENTRY_OVERHEAD;   // window is in the key
  let member = b(`rate:${u}`) + ENTRY_OVERHEAD;
  for (let i = 0; i < LIMIT; i += 1)                 // one member per request: timestamp + sequence
    member += b(`1753963200000-${i}`) + NUMBER_BYTES + INDEX_OVERHEAD;
  setBytes += member;
}
console.log(`rate limit (${USERS} users, ${LIMIT} requests/minute)`);
console.log(`  counter    : held=${counterBytes} bytes  per user=${(counterBytes / USERS).toFixed(1)}` +
  `  entries=${USERS}`);
console.log(`  sorted set : held=${setBytes} bytes  per user=${(setBytes / USERS).toFixed(1)}` +
  `  entries=${USERS * LIMIT}  (${(setBytes / counterBytes).toFixed(1)}x)`);

// --- Scenario 4: leaderboard. Same counts; the difference is whether a sorted index is held.
function loanCounts(count) {
  let seed = 20250731;
  const rand = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648);
  return Array.from({ length: count }, (_, i) => [`Book ${i + 1}`, Math.floor(rand() * 900) + 1]);
}
const counts = loanCounts(400);
let hashBytes = b("popular") + ENTRY_OVERHEAD, sortedSetBytes = b("popular") + ENTRY_OVERHEAD;
for (const [member] of counts) {
  hashBytes += b(member) + NUMBER_BYTES + FIELD_OVERHEAD;      // just name and count
  sortedSetBytes += b(member) + NUMBER_BYTES + INDEX_OVERHEAD; // plus a sorted-index node on top
}

const sorted = [...counts].sort((x, y) => y[1] - x[1] || (x[0] < y[0] ? -1 : 1));
let hashTouched = 0, setTouched = 0;
const topTen = sorted.slice(0, 10);                  // sorted set: first ten entries from the index
setTouched += topTen.length;
hashTouched += counts.length;                        // hash: everything gets scanned and sorted

const targetScore = counts[136][1];                  // where "Book 137" ranks
let hashHigher = 0;                                  // hash answers by a full scan
for (const [, s] of counts) if (s > targetScore) hashHigher += 1;
hashTouched += counts.length;
let lo = 0, hi = sorted.length - 1, steps = 0;        // binary search in the sorted set
while (lo <= hi) {
  const mid = (lo + hi) >> 1; steps += 1;
  if (sorted[mid][1] > targetScore) lo = mid + 1; else hi = mid - 1;
}
setTouched += steps;
console.log(`leaderboard (${counts.length} books)`);
console.log(`  hash        : held=${hashBytes} bytes  touched for top-ten + rank=${hashTouched}`);
console.log(`  sorted set  : held=${sortedSetBytes} bytes  touched for top-ten + rank=${setTouched}` +
  `  (${(sortedSetBytes / hashBytes).toFixed(2)}x bytes)`);
console.log(`  top three: ${topTen.slice(0, 3).map(([a, s]) => `${a}=${s}`).join("  ")}`);
console.log(`  "Book 137" score=${targetScore}; books with a higher score: hash=${hashHigher}` +
  ` (400 entries) set=${lo} (${steps} entries)`);
```

```
rate limit (2000 users, 60 requests/minute)
  counter    : held=86893 bytes  per user=43.4  entries=2000
  sorted set : held=5788893 bytes  per user=2894.4  entries=120000  (66.6x)
leaderboard (400 books)
  hash        : held=9515 bytes  touched for top-ten + rank=800
  sorted set  : held=15915 bytes  touched for top-ten + rank=19  (1.67x bytes)
  top three: Book 141=900  Book 211=900  Book 125=897
  "Book 137" score=533; books with a higher score: hash=146 (400 entries) set=146 (9 entries)
```

The rate limit's difference is sixty-seven times over, and the reason fits in one sentence: the
counter holds one entry per user, the sorted set holds sixty. 2,894.4 bytes get paid per user
instead of 43.4. What that buys is knowing the moment of every request inside the window; the
counter does not know this, it only knows how many there were. Whether that knowledge is needed
depends on how the limit is meant to work, and that decision is outside this lesson; what gets
measured here is the price.

In the leaderboard, the ratio is far smaller: 1.67 times, that is, 6,400 bytes. What that buys
shows up in the last two rows. Finding the top ten books and one book's rank requires touching
800 entries in the hash structure (two full scans), while 19 entries are enough in the sorted
set (ten entries read from the front, the rank found by a nine-step binary search). Both paths
give the same answer: there are 146 books that were loaned more often than "Book 137".

A sorted set's index overhead is paid once but earns itself back on every query. When the
catalog holds 400,000 books instead of 400, the hash structure's full scan grows to 800,000
entries while the sorted set's work stays at 10 + 19 entries; the extra bytes paid grow directly
in proportion to entry count.

#### Four Scenarios in One Table

| Scenario | Structure | Entries | Bytes held | What it buys | What it loses |
|---|---|---|---|---|---|
| Catalog cache | hash structure | 400 | 60,333 | an 8-byte write per field (91 bytes for the string form) | 14,508 bytes versus the string form |
| Session records | hash structure + expiration | 750 live | 108,000 | bounding memory to session lifetime | the data of an expired session |
| Rate limit | counter | 2,000 | 86,893 | a single entry per user | the individual moments of requests |
| Leaderboard | sorted set | 400 | 15,915 | 19 entries for the top-ten-and-rank query | 6,400 bytes versus the hash structure |

The table looks like four separate jobs, but it repeats a single kind of decision. In every row,
the structure is the answer to the question of **which information has to be held** for that
job. The more detail is held, the cheaper access gets and the more expensive memory gets; the
hash structure cuts writes because it separates fields, the sorted set cuts the rank query
because it keeps the order, the counter is cheap because it throws the detail away — and the
detail it throws away does not come back.

#### Summary

- The same record's string form held 45,825 bytes, its hash form 60,333; the hash structure's
  14,508-byte excess was paid back by writing 8,000 bytes instead of 90,728 over a thousand
  updates.
- In session records, memory scales with session lifetime, not user count: expiration cut 18,000
  sessions to 750 live ones, 108,000 bytes instead of 2,592,000.
- In the rate limit, the counter holds 43.4 bytes per user, the sorted set 2,894.4; the
  sixty-seven-fold difference is the price of storing each request's individual moment.
- In the leaderboard, the sorted set's index overhead is 6,400 bytes, and it cuts the
  top-ten-and-rank query from 800 entries to 19; both paths give the same answer.
- In all four scenarios, the structure choice comes down to a single question: which information
  gets held. The more detail is held, the cheaper access gets and the more expensive memory
  gets.

#### Next Step

Every accounting in this lesson assumed the store behaves as expected: every access happens the
moment its turn comes, no request waits on another. Most in-memory stores process commands in
order on a single thread, and that assumption breaks in a particular way there: a single command
scanning 400,000 entries delays every request waiting behind it by its own duration. The next
lesson measures that delay, counts the number of waiting requests, and shows how sweeping the
slow command log's threshold makes the problem command visible.

### Metrics and Slow Command Analysis

Source: https://academia.sh/en/courses/in-memory-stores/metrics-and-slow-command-analysis

Performance diagnosis for a single-threaded in-memory store: counting the number of requests waiting behind one long-running command and the total delay, what splitting a scan by cursor gains and what it misses, and sweeping the slow command log's threshold.

All four of the previous lesson's memory accountings leaned on a silent assumption: every access
happens the moment its turn comes, no request waits on another. In most in-memory stores, that
assumption breaks in a particular way, because commands are processed in order on a single
thread. That order is what makes atomicity free: two commands never interleave, no lock is
needed.

The same order also brings a trap. However long a command takes, every request behind it waits
that long. In a disk-based database, a slow query delays its own client; here it delays **every**
client. This lesson counts that delay.

#### What Waits Behind a Long Command

The mechanism is single-queued and single-threaded. Requests arrive once every two work units, an
ordinary command takes one unit; the load runs at half capacity (**CU15**). At one point, a
maintenance command that scans the entire catalog cache interrupts, bringing 4,000 units of work
in total. The same work is given once as a single command, then as commands split into parts.
Total work is identical in every row.

```js
// single-thread.mjs — single-threaded store: requests waiting behind a long command are counted
const ARRIVAL = 2, REQUESTS = 3000, SCAN_START = 2000;  // one request every 2 units, command cost 1 unit

function run(totalWork, parts) {                    // scan work is split into `parts` commands
  const partCost = parts ? totalWork / parts : 0;
  const queue = [];                                 // single queue, single thread, in order
  let arrived = 0, remainingParts = parts, scanArrival = parts ? SCAN_START : -1;
  let running = null, finishAt = 0;
  let waiting = 0, longest = 0, totalWaiting = 0, scanEnd = 0, processed = 0;

  for (let t = 0; processed < REQUESTS + parts && t < 200000; t += 1) {
    if (running && finishAt === t) {                // 1) the running command finished
      if (running.scan) { scanEnd = t; if (remainingParts > 0) scanArrival = t; }
      running = null; processed += 1;
    }
    while (arrived < REQUESTS && (arrived + 1) * ARRIVAL === t) {          // 2) normal request arrival
      queue.push({ arrival: t, cost: 1, scan: false }); arrived += 1;
    }
    if (remainingParts > 0 && scanArrival === t) {   // scan part: the first by schedule, the rest
      queue.push({ arrival: t, cost: partCost, scan: true });              // when the previous one finishes
      remainingParts -= 1; scanArrival = -1;
    }
    if (running === null && queue.length > 0) {      // 3) the first waiting command is taken
      running = queue.shift();
      const w = t - running.arrival;
      if (w > 0) { waiting += 1; totalWaiting += w; longest = Math.max(longest, w); }
      finishAt = t + running.cost;
    }
  }
  return { partCost, waiting, longest, totalWaiting, scanEnd };
}

console.log(`workload: ${REQUESTS} requests, one arrival every ${ARRIVAL} units, command cost 1 unit`);
console.log(["scan", "part cost", "waiting requests", "longest wait", "total waiting",
  "scan end"].map((h, i) => (i === 0 ? h.padEnd(18) : h.padStart(18))).join(""));
for (const [name, total, parts] of [
  ["no scan", 0, 0], ["single command (4000)", 4000, 1],
  ["10 parts", 4000, 10], ["40 parts", 4000, 40], ["200 parts", 4000, 200],
]) {
  const r = run(total, parts);
  console.log(name.padEnd(18) + [r.partCost, r.waiting, r.longest, r.totalWaiting, r.scanEnd]
    .map((n) => String(n).padStart(18)).join(""));
}
```

```
workload: 3000 requests, one arrival every 2 units, command cost 1 unit
scan                       part cost  waiting requests      longest wait     total waiting          scan end
no scan                            0                 0                 0                 0                 0
single command (4000)              4000              2001              3999           5999001              6001
10 parts                         400              2007               786           1045801              8001
40 parts                         100              2022               198            289501              8001
200 parts                         20              2102                38             58681              8001
```

The first row is the measurement's zero point: with no scan, no request waits, because the
arrival rate is half the service rate. In the second row, a single command runs for 4,000 units,
and 2,001 requests pile up behind it. The longest wait is 3,999 units; that is, a request that
enters the queue right after the scan waits nearly four thousand units even though its own work
takes one unit. Total waiting is 5,999,001 units.

In the rows below, that same 4,000-unit job gets split into parts. When part cost drops from
4,000 to 400, the longest wait drops from 3,999 to 786, total waiting from 5,999,001 to
1,045,801. At 200 parts, the longest wait is 38 units, total waiting 58,681: less than one
percent. Not a single unit of work went away; it only became **splittable**.

The last column shows the cost. In the single command, the scan finishes at unit 6,001; split,
at unit 8,001. A split scan gives up its own finish time to buy down everyone else's waiting. In
a single-threaded store, this trade-off gets made anew for every long job: when the scan
finishes usually concerns no one, the three thousand requests waiting in between concern
everyone.

#### What a Cursor Scan Misses

Splitting is not free, and its cost is not only a delayed finish. A single command runs the
entire scan while holding the thread, so it sees an unchanging snapshot of the key space. A
cursor-driven scan gives up the thread between parts, and the key space changes in those gaps.
The run below measures this on a 200,000-entry key space; after every part, two books get loaned
out and their cache entries deleted.

```js
// cursor-scan.mjs — same scan: as a single block, or cursor by cursor
function keySpace(count) {                          // key list that preserves insertion order
  const store = new Map();
  const keys = [];
  for (let i = 1; i <= count; i += 1) {
    const a = `book:${i}`;
    store.set(a, { shelf: i % 5, branch: (i % 3) + 1 });
    keys.push(a);
  }
  return { store, keys };
}

const COUNT = 200000, PART = 1000, DELETIONS = 2;    // 2 deletions per part (books loaned out)

function singleBlock() {                             // the command holds the thread start to finish
  const { store, keys } = keySpace(COUNT);
  let seen = 0, matched = 0;
  for (const a of keys) { seen += 1; if (store.get(a).shelf === 0) matched += 1; }
  return { name: "single block", longestBlock: seen, seen, matched, commands: 1, missed: 0 };
}

function cursorScan() {                              // each part is its own command; others run in between
  const { store, keys } = keySpace(COUNT);
  const visited = new Set();
  let p = 0, seen = 0, matched = 0, commands = 0, deleted = 0;
  while (p < keys.length) {
    const end = Math.min(p + PART, keys.length);
    for (let i = p; i < end; i += 1) {                // this part's work
      const a = keys[i];
      visited.add(a); seen += 1;
      if (store.get(a).shelf === 0) matched += 1;
    }
    p = end; commands += 1;
    for (let d = 0; d < DELETIONS && p < keys.length; d += 1) {   // deletions happen between parts
      const pos = (deleted * 977) % p;                 // a key is deleted just ahead of the cursor
      store.delete(keys[pos]);
      keys.splice(pos, 1);                              // the list shifts: the cursor's entry gets skipped
      deleted += 1;
    }
  }
  const missed = keys.filter((a) => !visited.has(a)).length;   // survived to the end but never seen
  return { name: `cursor (${PART})`, longestBlock: PART, seen, matched, commands, missed };
}

console.log(`key space=${COUNT}  part=${PART}  deletions per part=${DELETIONS}`);
console.log(["path", "commands", "longest block", "entries seen", "matched", "missed"]
  .map((h, i) => (i === 0 ? h.padEnd(16) : h.padStart(15))).join(""));
for (const r of [singleBlock(), cursorScan()]) {
  console.log(r.name.padEnd(16) + [r.commands, r.longestBlock, r.seen, r.matched, r.missed]
    .map((n) => String(n).padStart(15)).join(""));
}
```

```
key space=200000  part=1000  deletions per part=2
path                   commands  longest block   entries seen        matched         missed
single block                  1         200000         200000          40000              0
cursor (1000)               200           1000         199602          39921            398
```

The single block walks 200,000 entries without interruption and gives the exact count: 40,000
matches, zero missed entries. The cursor scan cuts the longest block to 1,000 entries — the same
change that dropped waiting by two orders of magnitude in the previous measurement — but 398
entries never get seen. These entries survived for the whole scan; they were skipped because the
list shifted whenever a key got deleted just ahead of the cursor.

This is the real trade-off in a single-threaded store's diagnostic toolkit. An uninterrupted scan
gives an **exact** answer and makes everyone wait; a cursor scan makes no one wait and gives an
**approximate** answer. For a maintenance job, an approximate answer is usually enough; for a
count report, it is not.

#### The Slow Command Log's Threshold

The measurements above were possible because they could see inside the mechanism. In a running
store, which command took long is not directly visible; a **slow command log** is kept for this:
commands whose duration stays above a threshold get written to a ring buffer holding the last N
records. The log itself eats memory too, so the threshold decides two things at once: what gets
seen, and how many bytes get held. The run below sweeps five thresholds against a 128-record
ring (**CU16**).

```js
// slow-command-log.mjs — threshold sweep: which threshold reveals the culprit, how many bytes the log holds
const RING = 128, ENTRY_OVERHEAD = 16;               // the log is a ring buffer: last 128 records

function commands(count) {                           // deterministic mix, seed visible
  let seed = 20250731;
  const rand = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648);
  return Array.from({ length: count }, (_, i) => {
    if (i === 1500) return { name: "scan", cost: 4000 };            // looking for the culprit
    const r = rand();
    if (r < 0.95) return { name: "read", cost: 1 };
    if (r < 0.99) return { name: "set-member", cost: 5 + Math.floor(rand() * 36) };
    return { name: "sorted-range", cost: 200 };
  });
}

const stream = commands(3000);
const entryBytes = (k, seq) =>                       // log record: command name, cost, sequence
  Buffer.byteLength(JSON.stringify({ name: k.name, cost: k.cost, seq })) + ENTRY_OVERHEAD;

console.log(`commands=${stream.length}  ring=${RING} entries  total work=` +
  `${stream.reduce((t, k) => t + k.cost, 0)} units`);
console.log(["threshold", "logged", "left in ring", "bytes held", "scan in ring"]
  .map((h, i) => (i === 0 ? h.padEnd(8) : h.padStart(18))).join(""));
for (const threshold of [1, 5, 20, 100, 1000]) {
  const ring = [];
  let logged = 0;
  stream.forEach((k, i) => {
    if (k.cost < threshold) return;                   // below the threshold: not logged
    logged += 1;
    ring.push({ ...k, seq: i, bytes: entryBytes(k, i) });
    if (ring.length > RING) ring.shift();              // the oldest record drops
  });
  const bytes = ring.reduce((t, k) => t + k.bytes, 0);
  const present = ring.some((k) => k.cost === 4000) ? "yes" : "no";
  console.log(String(threshold).padEnd(8) +
    [logged, ring.length, bytes, present].map((n) => String(n).padStart(18)).join(""));
}
```

```
commands=3000  ring=128 entries  total work=17267 units
threshold            logged      left in ring        bytes held      scan in ring
1                     3000               128              6593                no
5                      159               128              7482               yes
20                     108               108              6340               yes
100                     40                40              2420               yes
1000                     1                 1                54               yes
```

The last column shows why threshold choice is a measurement decision. At threshold 1, the log
records every command: 3,000 records get produced, but because the ring can hold only the last
128, the 4,000-unit scan has fallen out of the log. A log that records every command is a log
that does not record the command being searched for.

At threshold 5, the record count drops to 159, and the scan stays in the ring. At threshold 20,
the record count falls below the ring's size (108), and no record gets lost anymore; the log
holds 6,340 bytes. At threshold 1,000, a single record remains: the culprit is visible, but the
sorted-range commands at 200 units each become invisible — even though they account for a
nearly half of the total 17,267 units of work.

A usable threshold is the range that keeps the record count under the ring's size while leaving
ordinary commands out. That range is read from the workload; it cannot be known in advance: the
threshold gets set wide at first and narrowed if the record count exceeds the ring's size. The
log's budget is in the same table — 128 records held at most 7,482 bytes — and that budget is
what buys the problem becoming visible.

#### Summary

- In a single-threaded store, a long command does not delay only its own client: a single
  4,000-unit command left 2,001 requests waiting behind it, the longest wait was 3,999 units,
  total waiting 5,999,001 units.
- When the same job was split into 200 parts, the longest wait dropped to 38 units, total
  waiting to 58,681; the work done did not change, it only became splittable.
- Splitting costs two things: the scan finished at unit 8,001 instead of 6,001, and 398 entries
  were never seen because of keys deleted just ahead of the cursor (0 in the single block).
- In the slow command log, the threshold decides both what gets seen and how many bytes get
  held: at threshold 1, 3,000 records were produced, and the command being searched for fell out
  of the 128-record ring.
- A usable threshold is one that keeps the record count under the ring's size; at threshold 20,
  108 records, 6,340 bytes, and the culprit command stayed in the log.

#### Next Step

Every measurement in this course so far assumed that the store talks only to its own clients:
the publisher was a recognized application, the scanning command was a maintenance job, the
delay measured was the delay of its own workload. But every cost counted so far was paid without
ever asking who sent the command. No barrier was ever raised against a command that deletes the
entire key space, a sequence of writes that fills all of memory, or a connection that subscribes
slowly and inflates the buffer. The next lesson takes on that gap: it measures how much surface an
open-by-default setup exposes to a stranger, what command restriction shuts off, and what cost
encryption comes with.

### Security

Source: https://academia.sh/en/courses/in-memory-stores/security

The store's configuration is a security decision: measuring the keys, bytes, and secret bytes opened to a stranger in a default setup with authentication off, showing that command restriction fully reopens the destructive surface it closed when server-side scripting is left on, network isolation moving the stranger outside the surface, and counting encryption's cost as an extra step per connection, extra bytes per frame, and a buffer per connection, working out that the buffer overtakes the data itself after ninety concurrent connections.

Every measurement in this course assumed that the store talks only to its own clients; every
cost counted so far was paid without asking who sent the command. This lesson closes that gap
and covers not the mechanics of identity but the **store's configuration**: who can connect,
which command classes a connection can run, and what these decisions cost in the memory budget.

#### Configuration Is a Surface

The default setup is kept simple: a process with authentication off, listening on every network
interface, accepting every command class. This setup has a measurable surface, and its size is a
key count and a byte count.

The three decisions that determine the surface do not substitute for one another.
**Authentication** decides who can connect, **command restriction** decides what a connection can
do, and **network isolation** decides whether a connection attempt reaches the store at all. The
fourth decision, **encryption**, does not narrow the surface; it makes data in transit
unreadable, and it is the only cost line item of the four.

#### Mechanism

The mechanism is a **model**: no real store, network, or connection is set up, access is a
number. Command **names** are not counted, command **classes** are; a step is an abstract
passage, not measured time.

**CU17 — the key space consists of the catalog (3,000 × 240 bytes), session (8,000 × 120), loan
(12,000 × 96), queue (800 × 128), and branch counters (6 × 64); session, loan, and queue are
secret.** Rationale: these three regions carry data tied to a reader's identity, the catalog and
counters do not. **CU18 — the encryption buffer is 32,768 bytes per connection, the frame
overhead is 29 bytes, the extra steps per connection are 3 (handshake 2 + auth 1).** Rationale:
the three line items depend on three separate scales — the buffer is allocated per connection,
the extra bytes repeat on every frame, the handshake is paid only when a connection is
established. **CU19 — while server-side scripting is on, every step the caller can reach can
also be run from inside a script.** Rationale: a script is not a separate authority domain.

```js
// security.mjs — open surface and configuration cost is an IN-PROCESS MODEL. No real store, network, or
// connection is set up; access is a number. Command NAMES are not counted, command CLASSES are. A step
// is an abstract passage, not measured time.
const SPACE = [                      // [name, entries, bytes per entry, secret?]
  ["catalog", 3000, 240, false], ["session", 8000, 120, true], ["loan", 12000, 96, true],
  ["queue", 800, 128, true], ["counter", 6, 64, false]];
const CLASS = [                      // [name, destructive?, script?]
  ["read", false, false], ["write", false, false], ["delete", true, false],
  ["flush", true, false], ["config", true, false], ["script", true, true]];
const BUFFER = 32768, FRAME = 29, HANDSHAKE = 2, AUTH_STEP = 1;
const ROLES = 4, ROLE_BYTES = 128, PERMISSION_BYTES = 24;

const keys = SPACE.reduce((a, [, n]) => a + n, 0);
const bytes = SPACE.reduce((a, [, n, b]) => a + n * b, 0);
const secret = SPACE.reduce((a, [, n, b, g]) => a + (g ? n * b : 0), 0);

// configuration: [auth, destructive classes open, script open, network isolation, encryption]
const CONFIG = [
  ["default", false, true, true, false, false], ["auth", true, true, true, false, false],
  ["restrict+script", true, false, true, false, false], ["restrict", true, false, false, false, false],
  ["isolation", true, false, false, true, false], ["isolation+encryption", true, false, false, true, true]];

function surface([, auth, destructive, script, isolation]) {
  const open = CLASS.filter(([, d, s]) => (d ? destructive : true) && (s ? script : true));
  // Stranger: an unauthenticated network call. If isolation is on, it never even arrives.
  const strangerOpen = isolation || auth ? 0 : 1;
  // Application: authenticated, a restricted role. Classes closed off get reopened while scripting is on.
  const appDestructive = destructive || script ? bytes : 0;
  return { open: open.length, strangerKeys: strangerOpen * keys, strangerBytes: strangerOpen * bytes,
           strangerSecret: strangerOpen * secret, appDestructive };
}

const s = (x, n) => String(x).padStart(n);
const e = (x, n) => String(x).padEnd(n);
const pct = (x, n) => s((x * 100).toFixed(1) + "%", n);
const row = (cells, widths) => cells.map((c, i) => s(c, widths[i])).join(" | ");

console.log(`Key space: ${keys} keys, ${bytes} bytes; ${secret} of those bytes are secret`);
console.log("(session, loan, queue). Six command classes: read, write, delete, flush,");
console.log("config, script. Stranger = an unauthenticated network call.\n");
const W1 = [21, 12, 15, 14, 12, 18];
console.log(e("configuration", W1[0]) + " | " +
  row(["open classes", "stranger keys", "stranger bytes", "secret bytes", "app can destroy"], W1.slice(1)));
console.log(W1.map((w) => "-".repeat(w)).join("-|-"));
for (const a of CONFIG) {
  const r = surface(a);
  console.log(e(a[0], W1[0]) + " | " +
    row([r.open + "/6", r.strangerKeys, r.strangerBytes, r.strangerSecret, r.appDestructive + " bytes"], W1.slice(1)));
}

const REQUESTS = 200000;
console.log(`\nStep cost (${REQUESTS} requests; handshake ${HANDSHAKE} + auth ${AUTH_STEP} = 3 steps/connection):`);
const W2 = [24, 11, 11, 24, 19];
console.log(row(["requests/connection", "connections", "extra steps", "extra steps/request", "frame overhead"], W2));
console.log(W2.map((w) => "-".repeat(w)).join("-|-"));
for (const k of [1, 10, 100, 1000]) {
  const c = Math.ceil(REQUESTS / k), extra = c * (HANDSHAKE + AUTH_STEP);
  console.log(row([k, c, extra, (extra / REQUESTS).toFixed(3), REQUESTS * 2 * FRAME + " B"], W2));
}

console.log("\nEncryption's MEMORY cost (16 KiB read + 16 KiB write buffer per connection):");
const W3 = [23, 12, 10, 13, 20];
console.log(row(["concurrent connections", "buffer bytes", "data bytes", "buffer/data", "role+permission table"], W3));
console.log(W3.map((w) => "-".repeat(w)).join("-|-"));
for (const n of [8, 32, 128, 512]) {
  console.log(row([n, n * BUFFER, bytes, ((n * BUFFER / bytes) * 100).toFixed(1) + "%",
    ROLES * ROLE_BYTES + ROLES * CLASS.length * PERMISSION_BYTES + " B"], W3));
}

const ledger = ROLES * ROLE_BYTES + ROLES * CLASS.length * PERMISSION_BYTES;
console.log("\nquantities independent of the run:");
console.log("  frame overhead    = requests x 2 x " + FRAME + " bytes; independent of connection count");
console.log("  steps per request = 3 / (requests per connection)");
console.log("  encryption buffer = concurrent connections x " + BUFFER + " bytes");
console.log("  role+permission ledger = " + ROLES + "x" + ROLE_BYTES + " + " + ROLES + "x" + CLASS.length + "x" +
  PERMISSION_BYTES + " = " + ledger + " bytes; independent of connections");
console.log("  buffer overtakes data: ceil(" + bytes + " / " + BUFFER + ") = " + Math.ceil(bytes / BUFFER) +
  " concurrent connections");
```

```
Key space: 23806 keys, 2934784 bytes; 2214400 of those bytes are secret
(session, loan, queue). Six command classes: read, write, delete, flush,
config, script. Stranger = an unauthenticated network call.

configuration         | open classes |   stranger keys | stranger bytes | secret bytes |    app can destroy
----------------------|--------------|-----------------|----------------|--------------|-------------------
default               |          6/6 |           23806 |        2934784 |      2214400 |      2934784 bytes
auth                  |          6/6 |               0 |              0 |            0 |      2934784 bytes
restrict+script       |          2/6 |               0 |              0 |            0 |      2934784 bytes
restrict              |          2/6 |               0 |              0 |            0 |            0 bytes
isolation             |          2/6 |               0 |              0 |            0 |            0 bytes
isolation+encryption  |          2/6 |               0 |              0 |            0 |            0 bytes

Step cost (200000 requests; handshake 2 + auth 1 = 3 steps/connection):
     requests/connection | connections | extra steps |      extra steps/request |      frame overhead
-------------------------|-------------|-------------|--------------------------|--------------------
                       1 |      200000 |      600000 |                    3.000 |          11600000 B
                      10 |       20000 |       60000 |                    0.300 |          11600000 B
                     100 |        2000 |        6000 |                    0.030 |          11600000 B
                    1000 |         200 |         600 |                    0.003 |          11600000 B

Encryption's MEMORY cost (16 KiB read + 16 KiB write buffer per connection):
 concurrent connections | buffer bytes | data bytes |   buffer/data | role+permission table
------------------------|--------------|------------|---------------|---------------------
                      8 |       262144 |    2934784 |          8.9% |               1088 B
                     32 |      1048576 |    2934784 |         35.7% |               1088 B
                    128 |      4194304 |    2934784 |        142.9% |               1088 B
                    512 |     16777216 |    2934784 |        571.7% |               1088 B

quantities independent of the run:
  frame overhead    = requests x 2 x 29 bytes; independent of connection count
  steps per request = 3 / (requests per connection)
  encryption buffer = concurrent connections x 32768 bytes
  role+permission ledger = 4x128 + 4x6x24 = 1088 bytes; independent of connections
  buffer overtakes data: ceil(2934784 / 32768) = 90 concurrent connections
```

#### Reading the Open Surface

**In the default setup, the surface is the entire data set.** The first row shows 23,806 keys
and 2,934,784 bytes; 2,214,400 bytes of that is information about which reader borrowed what.
All six of the six command classes are open: the same call does not just read these bytes, it
can flush the key space.

**Authentication removes the stranger, it does not restrict the application.** In the second
row, the stranger columns drop to zero, but the last column does not change: 2,934,784 bytes can
still be destroyed. Authentication answers the question "who," not the question "what can they
do." If the batch job that updates the catalog and the application that writes loan records
connect with the same identity, a bug in one takes down the entire key space.

**Command restriction closes nothing while scripting is on.** The third row is this lesson's
central finding: open classes drop from 6 to 2, destructive classes are closed, but the bytes
the application can destroy stay at 2,934,784. Server-side scripting runs the caller's steps; the
door is shut, the window is left open. In the fourth row, once scripting is restricted too, the
destructive surface zeroes out.

**Network isolation is the next layer, not a substitute for one.** The fifth row does not
produce a different number from the fourth; even so, isolation prevents a fallback to the first
row if authentication gets disabled. The sixth row shows that encryption does not narrow the
surface at all.

#### Identity Is Cheap, Encryption Is Expensive

The second and third tables write out the bill line by line.

**The role and permission ledger is practically free: 1,088 bytes**, and it is independent of
connection count besides. The decision that takes the surface from 2,934,784 bytes to zero
occupies a thousandth of the memory budget.

**The handshake is per connection, so its cost erodes with reuse.** When a single request lands
per connection, 200,000 connections and 600,000 extra steps get paid — 3 steps per request, more
expensive than the work itself. At a thousand requests per connection, the same line item drops
to 600 steps, 0.003 per request. Encryption's step cost is not an encryption problem, it is a
connection-management problem.

**Frame overhead is unaffected by reuse:** 11,600,000 bytes for 200,000 requests, the same
across all four rows.

**The real memory line item is the connection buffer.** The buffer is 8.9 percent of the data at
8 concurrent connections, 142.9 percent at 128, 571.7 percent at 512. The run-independent row
gives the turning point — **past 90 concurrent connections, encryption buffers take up more room
than the data being stored.** In an in-memory store, this is a number that changes when the
eviction policy kicks in.

#### Summary

- A default setup's surface is the entire data set: 23,806 keys, 2,934,784 bytes (2,214,400 of
  them secret), and all six of the six command classes are open.
- Authentication zeroes the bytes a stranger can reach but leaves the 2,934,784 bytes an
  authenticated call can destroy; the two decisions are separate.
- Command restriction did not shrink the destructive surface at all while scripting was on: even
  with open classes dropping from 6 to 2, destructible bytes stayed at 2,934,784, and only
  zeroed out once scripting was restricted too.
- The role and permission ledger is 1,088 bytes and independent of connection count: the
  decision that zeroes the surface takes up no memory to speak of.
- Encryption's step cost erodes with reuse (3.000 → 0.003 per request), but frame overhead is
  fixed: 11,600,000 bytes for 200,000 requests.
- Encryption's memory cost is per connection; past 90 connections, buffers exceed the data
  stored, and at 512 connections the ratio is 571.7 percent.

#### Course Wrap-Up

| Lesson | Bytes Held | What It Bought | What It Lost |
|---|---|---|---|
| Key-Value Basics | 20,000 entries, 2,600,406 bytes (130 bytes/entry) | expiration cut the peak to 285,185 bytes | 16,951 extra re-entries |
| Strings and Counters | 200,000 counters, 15,145,901 bytes (75.7 bytes/number) | query 119,993 → 1 step; zero loss on atomic increment | 79.2 steps per byte (6,580 for a partial counter) |
| Lists | linked node 43.0 bytes/entry (array 19.0) | operation 1,673 → 1 step | a rank query is still a scan (14,500 steps); trimming narrows the window to two hours |
| Hash Structures | 200,000 records 91.9 MB (monolithic 44.5 MB) | daily writes 40.0 → 5.9 MB, reads 109.0 → 9.8 MB | 237 bytes of overhead per record |
| Sets and Sorted Sets | 38,000 members 2,052,056 bytes (list 532,056) | membership 28,464.2 → 1.0 step | order information; 3,135,704 bytes more in the skip list |
| Bitmaps and Probabilistic Structures | bitmap 250,001, estimate 4,096 bytes | 16 and 1,024 times less space than the full set | 2.53% false positive rate; which reader got counted |
| Streams | 5,321 KiB at the length limit, 9,465 KiB at the safe trim | read position 16 bytes; three groups' ledger 48 bytes | 38,920 and 46,220 entries missed under bounded trimming |
| Spatial Indexes | cell bucket 5.09, sorted set 25.48 bytes/member | candidates scanned 120,000 → 821.22 | in a cell bucket, granularity is fixed at insertion time |
| Server-Side Scripting | script cache 673, lock table peaks at 87,200 bytes | never more loans than the copies allow (31.93 on the client) | a long script left 8,000 commands waiting in the queue |
| Snapshot Persistence | file 717,260 bytes (1,593,100 in memory) | average loss 10,001 → 501 writes | disk writes 2 → 30 MB; peak of 59,565 bytes during the copy |
| Append Log Persistence | 39.7 bytes per write, the file grows linearly with writes | zero loss on fsync at every write | 60,000 fsyncs; a file 3.32 times the snapshot |
| Persistence Selection | combined use 4,229,117 bytes, 23 fsyncs | recovery 55,000 → 32,330 steps, 1,247,842 bytes read | 9,816 entries missing or stale with the snapshot alone |
| Memory Limit and Eviction Policies | a 400,000-byte budget, overhead share 9.5%-24.0% | random 4,761, least-frequently-used 4,000 entries | 5.6-point hit rate under a tight budget; 706,192 dead-entry bytes |
| Expiration Management | 4,035,180 bytes under lazy, 1,471,920 under active cleanup | dead share 73.5% → 27.2% | 0.774 entry touches per access |
| Replication | 2,343.8 KiB at four replicas (single node 468.8) + buffer 7.5-60 KiB | reads per node 400 → 80 | 11,420 stale reads; a 47.96% rate at delay 8 |
| Automatic Failover | hot backup 937.5 KiB (cold 468.8) | outage 17 → 5 rounds, loss 760 → 80 records | 40/80/160/320 accepted writes lost per failover |
| Clustering | 1,539.6 untagged, 2,899.0 KiB with the branch tag | multi-key transaction 1.15% → 100% | slots 8,437 → 6, imbalance 1.085 → 4.913 |
| Transactions and Optimistic Locking | watch overhead 768 bytes (counters 384) | over-limit 86 → 0 | 480 → 4,314 ticks; 96,414 client-ticks waiting at block 16 |
| Publish-Subscribe | 52 bytes per channel (36,865-145,243 in a persistent structure) | 600 publishes, 753 delivered copies, no ledger kept | 74 publishes vanished unsubscribed; 720 publishes lost for a slow subscriber |
| Typical Use Cases | hash form 60,333 bytes (string 45,825) | writes over a thousand updates 90,728 → 8,000 bytes | 14,508 bytes more; sorted set 2,894.4 bytes/user |
| Metrics and Slow Command Analysis | slow command log 108 records at threshold 20, 6,340 bytes | longest wait 3,999 → 38, total 58,681 units | scan finished at 8,001 instead of 6,001 units, 398 entries never seen |
| Security | role+permission ledger 1,088 bytes; buffer 4,194,304 at 128 connections | 2,934,784 bytes open to a stranger, and the destructive surface zeroed out | 3 steps per connection, 11,600,000 bytes of frame overhead |

All twenty-two lessons are an application of the same rule: **memory is a budget; every structure
buys something and costs something.** The table's second column has no zeros at all — even a
structure that estimates asks for 4,096 bytes. Nor does the third; even the cheapest gain gives
back a certainty, an order, a window, or a guarantee. The store being fast was not this course's
subject, it was its assumption; the subject was what that speed costs.

Every access so far carried one shared assumption: the key was known. Even in the most complex
measurement, the first step was a known key; the spatial index only appeared to stretch this,
and there too, a coordinate got converted into a key. Yet this is the most common question asked
of a library catalog: a reader does not know the book's identity, only a word that appears in
its title. Going from a word inside a text to a document was never covered in this course, and
it cannot be covered with key-value access. **M17/K07 Search Engines and Text Retrieval** opens
with exactly this question.

