Lesson 19 / 22
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.
Contents
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).
// 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.
// 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).
// 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.