Lesson 22 / 23
Snapshot and Restore
A backup decision specific to the index: the bytes an incremental snapshot built on segment immutability copies, the background merge breaking that incrementality, the store holding more than the live index, and comparing restoring from a snapshot against reindexing from the source by copied bytes, documents processed, the set, and the order.
Contents
Rollover reduced getting rid of old data to dropping a file. The same structure changes backups too: segments that never change once written turn what needs backing up from “a state constantly in flux” into a set of immutable files.
An in-memory store’s snapshot was measured earlier: the relationship between the interval and the loss window, the cost taking a snapshot places on the running store. That measurement is not repeated here. The question here is specific to the index. If segments are immutable, a snapshot can be incremental; what does a background merge do to that, and when the index has to come back, is moving it from the store cheaper, or reindexing from the source.
Segment-Level Snapshot
CO18. A segment file is immutable along with its name: a file with a given name in the store always carries the same content. An incremental snapshot is built on this — if that name already exists in the store, the file is not copied again.
CO19. The snapshot’s cost is copied bytes; what the store holds is the sum of its distinct segment files.
CO20. Reindexing assumes the source records are on hand, and that the same daily batches and the same merge policy are applied. Time depends on the run environment and so is not printed; instead, two run-independent quantities are counted: copied bytes, and documents and tokens processed.
The corpus is 8,000 book records, seed 20260731.
// backup.mjs — catalog corpus, analyzers, segmented index, and segment-level snapshot store import { writeFileSync, existsSync, mkdirSync, statSync } from 'node:fs'; export function rng(seed) { // deterministic pseudo-random 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; }; } const ADJ = 'silent distant broken lost white black thin deep yellow long'.split(' '); const NOUN = 'door sea road house city garden island notebook river well'.split(' '); const TOPIC = ('juvenile fiction|short story|novel|essay|poetry|history|geography|philosophy|psychology|' + 'economics|architecture|music|cinema|travel|biography|memoir|science fiction|mystery|folklore').split('|'); const ROOT = ('book author narrative story child city memory journey generation war migration family ' + 'sea winter morning letter photograph train street school library wind silence hope island rain ' + 'forest village mountain river border century woman soldier teacher doctor painter song verse cover').split(' '); const FILLER = 'and with a or'.split(' '); function form(root, i) { // surface form: eight suffix variants const SUF = ['', 's', "'s", 'less', 'like', 'ish', 'ward', 'ful']; return root + SUF[i]; } export function corpusBuild(count, seed) { const r = rng(seed), list = []; for (let i = 0; i < count; i++) { const summary = []; for (let j = 0; j < 16; j++) summary.push(j % 4 === 3 ? FILLER[Math.floor(r() * 4)] : form(ROOT[Math.floor(r() ** 2 * ROOT.length)], Math.floor(r() ** 2 * 8))); list.push({ id: 'k' + String(i).padStart(5, '0'), title: `${ADJ[Math.floor(r() * 10)]} ${NOUN[Math.floor(r() * 10)]}`, summary: summary.join(' '), topic: TOPIC[Math.floor(r() * 19)] }); } return list; } export const text = (b) => `${b.title} ${b.summary} ${b.topic}`; const STOP = new Set(FILLER); const SUFFIX = ['less', 'like', 'ward', 'ish', 'ful', "'s", 's']; // longest suffix first const stem = (t) => { // strips the longest matching known ending for (const e of SUFFIX) if (t.length > e.length + 2 && t.endsWith(e)) return t.slice(0, -e.length); return t; }; export const basic = (s) => // lowercase + split on non-letter/non-number s.toLocaleLowerCase('en').split(/[^\p{L}\p{N}]+/u).filter((t) => t.length > 1); export const advanced = (s) => // + stop-word removal + stemming basic(s).filter((t) => !STOP.has(t)).map(stem); let counter = 0; export class Segment { // an immutable segment; its name never changes once written constructor(docs = [], analyze = basic) { this.name = 'b' + String(++counter).padStart(3, '0'); this.dict = new Map(); this.docs = new Set(); for (const b of docs) { this.docs.add(b.id); const count = new Map(); for (const t of analyze(text(b))) count.set(t, (count.get(t) || 0) + 1); for (const [t, tf] of count) { if (!this.dict.has(t)) this.dict.set(t, []); this.dict.get(t).push([b.id, tf]); } } } serialize() { let s = ''; for (const [t, g] of this.dict) s += t + '\t' + g.map(([i, f]) => i + ':' + f).join(' ') + '\n'; return s; } get bytes() { return Buffer.byteLength(this.serialize()); } } export function tiers(factor = 4) { // tiered merge heap const tier = []; let merges = 0; const place = (b, k) => { while (tier.length <= k) tier.push([]); tier[k].push(b); if (tier[k].length < factor) return; merges++; place(merge(tier[k].splice(0, factor)), k + 1); }; return { add(docs, analyze) { merges = 0; place(new Segment(docs, analyze), 0); }, get segments() { return tier.flat(); }, get merges() { return merges; }, }; } export function merge(group) { const y = new Segment(); for (const sg of group) { for (const id of sg.docs) y.docs.add(id); for (const [t, g] of sg.dict) { if (!y.dict.has(t)) y.dict.set(t, []); for (const p of g) y.dict.get(t).push(p); } } return y; } export function search(segments, terms, k = 10) { const scores = new Map(); for (const sg of segments) { const N = sg.docs.size; for (const t of terms) { const g = sg.dict.get(t); if (!g) continue; const idf = Math.log(1 + N / g.length); for (const [id, tf] of g) scores.set(id, (scores.get(id) || 0) + tf * idf); } } const order = [...scores].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)); return { top: order.slice(0, k).map(([id]) => id), matched: order.length }; } // snapshot: a segment file not yet in the store is written; a non-incremental snapshot rewrites all of them export function snapshot(segments, store, incremental) { mkdirSync(store, { recursive: true }); let copied = 0, files = 0; for (const b of segments) { const path = `${store}/${b.name}`; if (incremental && existsSync(path)) continue; writeFileSync(path, b.serialize()); copied += statSync(path).size; files++; } return { copied, files, list: segments.map((b) => b.name) }; }
Incremental Snapshots and What Merging Breaks
The catalog takes in 500 records a day for sixteen days, and a snapshot is taken at the end of each day. Each day is measured twice: a full snapshot that copies the whole index, and an incremental snapshot that copies only the segment files not yet in the store.
// snapshot.mjs — bytes copied by full and incremental snapshots over a sixteen-day index import { corpusBuild, tiers, snapshot } from './backup.mjs'; const corpus = corpusBuild(8000, 20260731); const y = tiers(4); console.log('corpus: 8000 documents, seed 20260731 | one 500-document batch a day, factor 4'); console.log('day segments merges full snapshot bytes incremental bytes incr. files store total'); let fullTotal = 0, storeBytes = 0; for (let day = 1; day <= 16; day++) { y.add(corpus.slice((day - 1) * 500, day * 500)); const full = snapshot(y.segments, `full/d${day}`, false); const incr = snapshot(y.segments, 'store', true); fullTotal += full.copied; storeBytes += incr.copied; console.log(String(day).padStart(3), String(y.segments.length).padStart(8), String(y.merges).padStart(8), String(full.copied).padStart(19), String(incr.copied).padStart(18), String(incr.files).padStart(12), String(storeBytes).padStart(12)); } console.log('total: full snapshots', fullTotal, 'bytes, incremental snapshots', storeBytes, 'bytes');
corpus: 8000 documents, seed 20260731 | one 500-document batch a day, factor 4 day segments merges full snapshot bytes incremental bytes incr. files store total 1 1 0 76788 76788 1 76788 2 2 0 153495 76707 1 153495 3 3 0 230031 76536 1 230031 4 1 1 297765 297765 1 527796 5 2 0 374913 77148 1 604944 6 3 0 451624 76711 1 681655 7 4 0 528250 76626 1 758281 8 2 1 596322 298557 1 1056838 9 3 0 673056 76734 1 1133572 10 4 0 749772 76716 1 1210288 11 5 0 826992 77220 1 1287508 12 3 1 894582 298260 1 1585768 13 4 0 971235 76653 1 1662421 14 5 0 1048455 77220 1 1739641 15 6 0 1125135 76680 1 1816321 16 1 2 1184013 1184013 1 3000334 total: full snapshots 10182428 bytes, incremental snapshots 3000334 bytes
On days without a merge, the incremental snapshot pays a fixed cost: roughly 76,800 bytes, the single segment added that day. The full snapshot, by contrast, copies the whole index every day, and by day fifteen a single backup reaches 1,125,135 bytes. Across the sixteen days the gap is more than threefold: 10,182,428 bytes against 3,000,334 bytes.
The critical rows are days 4, 8, 12, and 16. On a day when a merge runs, the incremental snapshot does as much work as the full snapshot, because the segment a merge produces is a new file: even though its content already sits entirely in the store, it is the file itself that gets copied, not its inputs. Day sixteen is the extreme case — two merges reduce the index to a single segment, and the incremental snapshot rewrites all 1,184,013 bytes. Incrementality is a gain that comes from segment immutability, and merging resets it at regular intervals precisely because it uses that same immutability to produce a new file.
The last column gives a third result. The store holds 3,000,334 bytes, while the live index is 1,184,013 bytes. The store holds about two and a half times the live index; the excess is segment files invalidated by merging but not deletable, because old snapshots still reference them. That space does not come back until the old snapshots are dropped. Lowering the merge factor — a small win on the query side in the previous lesson — directly grows both incremental snapshot traffic and store size here.
Restore or Reindex
When an index is lost, there are two paths. The files from the store’s last snapshot can be moved into place, or the 8,000 records at the catalog source can be reindexed. The second, at first glance, makes a backup unnecessary at all. The measurement separates what each path pays and what it delivers: in the third row, reindexing runs on a system whose analyzer chain has since changed.
// recovery.mjs — comparing restore from snapshot against reindexing from source import { mkdirSync, readFileSync, writeFileSync, statSync } from 'node:fs'; import { corpusBuild, tiers, snapshot, search, text, basic, advanced } from './backup.mjs'; const corpus = corpusBuild(8000, 20260731); const QUERIES = [['juvenile', 'fiction', 'story'], ['lost', 'doors'], ['library', 'memory'], ['seaward', 'island'], ['mystery', 'city']]; function build(analyze) { // same daily batches, same merge policy const y = tiers(4); let tokens = 0; for (let d = 0; d < 16; d++) { const batch = corpus.slice(d * 500, (d + 1) * 500); for (const b of batch) tokens += analyze(text(b)).length; y.add(batch, analyze); } return { segments: y.segments, tokens }; } const original = build(basic); // original index and the content of its last snapshot const snap = snapshot(original.segments, 'store', true); mkdirSync('index', { recursive: true }); // restore: files from the store move into place let restoredBytes = 0; for (const name of snap.list) { writeFileSync(`index/${name}`, readFileSync(`store/${name}`)); restoredBytes += statSync(`index/${name}`).size; } const measure = (segments, analyze) => { let bytes = 0, dict = 0, matched = 0; const top = []; for (const b of segments) { bytes += b.bytes; dict += b.dict.size; } for (const q of QUERIES) { const c = search(segments, analyze(q.join(' '))); matched += c.matched; top.push(c.top); } return { bytes, dict, matched, top }; }; const originalMeasure = measure(original.segments, basic); const diff = (o) => { // top ten change against the original index let dropped = 0, shifted = 0; for (let q = 0; q < QUERIES.length; q++) { for (const id of originalMeasure.top[q]) if (!o.top[q].includes(id)) dropped++; for (let i = 0; i < 10; i++) if (o.top[q][i] !== originalMeasure.top[q][i]) shifted++; } return [dropped, shifted]; }; const reindexed = build(advanced); const reindexedMeasure = measure(reindexed.segments, advanced); console.log('corpus: 8000 documents, seed 20260731 | 5 queries, top ten: 50 ranks'); console.log('path copied docs processed tokens index bytes dict matched dropped shifted'); const row = (name, copied, docs, tokens, o) => console.log(name.padEnd(32), String(copied).padStart(9), String(docs).padStart(14), String(tokens).padStart(9), String(o.bytes).padStart(11), String(o.dict).padStart(6), String(o.matched).padStart(8), String(diff(o)[0]).padStart(6), String(diff(o)[1]).padStart(8)); row('restore', restoredBytes, 0, 0, originalMeasure); row('reindex (same analyzer)', 0, 8000, original.tokens, originalMeasure); row('reindex (new analyzer)', 0, 8000, reindexed.tokens, reindexedMeasure);
corpus: 8000 documents, seed 20260731 | 5 queries, top ten: 50 ranks path copied docs processed tokens index bytes dict matched dropped shifted restore 1184013 0 0 1184013 319 9286 0 0 reindex (same analyzer) 0 8000 145239 1184013 319 9286 0 0 reindex (new analyzer) 0 8000 121252 913738 76 16517 40 50
The first two rows produce the identical index: 1,184,013 bytes, 319 terms, 9,286 matched documents across five queries, and not one rank different in the top ten. What the two paths to that same result pay, though, is entirely different. Restoring moves 1,184,013 bytes and touches no document; reindexing moves not one byte but reads 8,000 documents and produces 145,239 tokens. Time depends on the run environment and is not printed here; the decision is made by how these two quantities scale. Restoring’s cost grows with index size, reindexing’s with source document count and tokens per document. Reindexing also carries a silent condition: the source records have to still be complete. If the index also holds a field no longer present in the catalog, reindexing cannot bring it back.
The third row shows why reindexing does not stand in for a backup. Once the analyzer chain is extended with stop-word removal and stemming, the same 8,000 records produce a different index: 121,252 tokens instead of 145,239 (the 23,987-token gap is the discarded filler words), 76 terms instead of 319, 913,738 bytes instead of 1,184,013. The effect shows up in the response. The five queries’ total matched documents rises from 9,286 to 16,517, because “doors” now collapses onto the same term as “door” and inflected forms pool into one posting list — recall goes up. The order is rebuilt too: all fifty ranks across the top ten results change, and 40 documents drop out of the top ten entirely. This is not a malfunction; it is the decision itself, but it cannot be counted as the same operation as restoring. Restoring brings the index back as it was; reindexing rebuilds it under that day’s configuration.
Summary
- Because segments are immutable, a snapshot can be taken incrementally: on days without a merge, copied bytes are that day’s single segment (roughly 76,800), while a full snapshot copies the whole index.
- Across the sixteen days, full snapshots copied 10,182,428 bytes, incremental snapshots 3,000,334 bytes.
- Merging breaks incrementality: on a merge day the incremental snapshot does as much work as the full snapshot, because the merged segment is a new file; on day sixteen all 1,184,013 bytes were recopied.
- The store holds 3,000,334 bytes, the live index 1,184,013 bytes: the gap is invalidated segment files that cannot be deleted because old snapshots still reference them.
- Restoring moves 1,184,013 bytes and processes zero documents; reindexing moves zero bytes and processes 8,000 documents and 145,239 tokens; both produce the identical index.
- If the analyzer chain has changed, reindexing does not produce the same index: matched documents rose from 9,286 to 16,517, all fifty ranks of the top ten results changed, and 40 documents dropped out of the top ten.
Next Step
This lesson covered copying, moving, and rebuilding an index as a whole. All of it shared one assumption: that everyone with access to the cluster is authorized to see the entire index — the one taking the snapshot, the one restoring it, and the one querying it all saw the same documents. In a library catalog this is not true — personal loan records, acquisition notes, and donation correspondence can sit in the same index without being open to every user. The next lesson measures this distinction: how the returned set narrows when the same query runs under two different roles, what document- and field-level filtering does to the order, and what that filtering adds to query cost.
To keep your progress and take notes, Log in
My notes
Log in to take notes.