LSM Trees: Writing in Layers
in reviewThis lesson is complete and being validated before release - details may still change.
in reviewThis lesson is complete and being validated before release - details may still change.
lsm-tree.ts
import type { Tracer } from "../trace";/** Entries the sorted memory table holds before it is flushed to disk. */export const MAX_MEMTABLE = 6;/** Runs a level may hold before they merge into the level below. */export const MAX_FANOUT = 4;/** Entries one tree ever accepts, so a workload stays a stage's size. */export const MAX_LSM_KEYS = 64;/** Levels of runs; the last level merges into itself. */export const MAX_LEVELS = 4;/** Bits in a run's Bloom filter. */export const BLOOM_BITS = 16;/** One key and its value; a null value is a tombstone, the record of a delete. */export interface LsmEntry { key: number; value: number | null;}/** A sorted table on disk. Once written it is never changed, only read and eventually merged away. */export interface Run { id: number; level: number; entries: readonly LsmEntry[];}export interface LsmOptions { /** Entries the memory table holds before it flushes. */ memtableLimit: number; /** Runs a level holds before they merge into one run a level down. */ fanout: number; /** Whether each run carries a Bloom filter a lookup consults before reading it. */ bloom: boolean;}export interface LsmReport { writes: number; flushes: number; compactions: number; runs: number; levels: number; lookups: number; probes: number; found: number; missing: number;}/** * A Bloom filter over integer keys: a row of bits, two hash functions, * and one honest answer, "no", plus one hedged answer, "maybe". A key * that was added always comes back maybe; a key that was not usually * comes back no, but two keys can share both bits, and then the filter * lets a needless read through. */export class LsmBloomFilter { private readonly bits: boolean[] = new Array<boolean>(BLOOM_BITS).fill(false); add(key: number): void { this.bits[LsmBloomFilter.first(key)] = true; this.bits[LsmBloomFilter.second(key)] = true; } /** False only when the key was certainly never added. */ maybe(key: number): boolean { return (this.bits[LsmBloomFilter.first(key)] ?? false) && (this.bits[LsmBloomFilter.second(key)] ?? false); } /** The top four bits of a 32-bit multiplicative hash, so neighbouring keys land apart. */ private static first(key: number): number { return LsmBloomFilter.topBits(key * 2654435761); } private static second(key: number): number { return LsmBloomFilter.topBits(key * 2246822519); } private static topBits(product: number): number { return Math.floor((product % 4294967296) / (4294967296 / BLOOM_BITS)); }}/** * A log-structured merge tree never overwrites a record where it lies. * Every write goes into a small sorted table in memory; when that table * is full it is written to disk whole, as an immutable sorted run, and * a fresh table starts. Runs pile up in levels: when a level holds * `fanout` runs they are merged, newest entry winning for each key, into * one run a level down. A delete is a write of a tombstone, which hides * older values until the merge that reaches the last level drops it. A * read checks the places newest first, the memory table and then each * run, and stops at the first that holds the key; a Bloom filter per * run lets most lookups skip runs that cannot hold it. Writes are * sequential appends, and reads pay for that in probes. * * Time: O(limit) per write, O(entries) per compaction, O(runs) per lookup. * Space: O(MAX_LSM_KEYS) across the table and the runs. */export class LsmTree { private table: LsmEntry[] = []; private readonly levels: Run[][] = Array.from({ length: MAX_LEVELS }, () => []); private readonly filters = new Map<number, LsmBloomFilter>(); private nextRun = 1; private writes = 0; private flushes = 0; private compactions = 0; private lookups = 0; private probes = 0; private found = 0; private missing = 0; constructor( readonly options: LsmOptions, private readonly tracer?: Tracer, ) { const { memtableLimit, fanout } = options; if (!Number.isInteger(memtableLimit) || memtableLimit < 2 || memtableLimit > MAX_MEMTABLE) throw new RangeError(`memtableLimit must be in [2, ${MAX_MEMTABLE}]`); if (!Number.isInteger(fanout) || fanout < 2 || fanout > MAX_FANOUT) throw new RangeError(`fanout must be in [2, ${MAX_FANOUT}]`); } /** The memory table's entries in key order; a copy. */ memtable(): readonly LsmEntry[] { return this.table.map((entry) => ({ ...entry })); } /** Every run on disk, level 0 first and within a level oldest first; copies. */ runs(): readonly Run[] { return this.levels.flatMap((level) => level.map((run) => ({ ...run, entries: run.entries.map((entry) => ({ ...entry })) }))); } put(key: number, value: number): void { if (!Number.isInteger(value)) throw new RangeError(`value ${value} must be an integer`); this.write(key, value); } /** A delete is a write: a tombstone that hides every older value of the key. */ remove(key: number): void { this.write(key, null); } /** Reads newest first: the memory table, then every run from the top level down. */ get(key: number): number | null { LsmTree.checkKey(key); this.lookups++; let probes = 0; const places: { place: string; level: number; entries: readonly LsmEntry[]; filter: LsmBloomFilter | null }[] = [{ place: "memtable", level: -1, entries: this.table, filter: null }]; for (const [level, runs] of this.levels.entries()) { for (const run of [...runs].reverse()) places.push({ place: `run ${run.id}`, level, entries: run.entries, filter: this.options.bloom ? (this.filters.get(run.id) ?? null) : null }); } let answer: LsmEntry | undefined; let where = ""; for (const { place, level, entries, filter } of places) { const skipped = filter !== null && !filter.maybe(key); const entry = skipped ? undefined : entries.find((candidate) => candidate.key === key); if (!skipped) probes++; // ▸ check one place for the key, the memory table first and then every run from the newest, unless its filter says the key is absent this.tracer?.emit("probe", { key, place, level, hit: entry !== undefined, skipped, needless: filter !== null && !skipped && entry === undefined }); if (entry !== undefined) { answer = entry; where = place; break; } } this.probes += probes; if (answer !== undefined && answer.value !== null) { this.found++; // ▸ the first place that holds the key answers; nothing older is consulted this.tracer?.emit("found", { key, value: answer.value, place: where, probes }); return answer.value; } // Either no place holds the key, or the newest record of it is a tombstone. this.missing++; // ▸ no place holds a live value for the key, or the newest record of it is a tombstone this.tracer?.emit("missing", { key, probes, tombstone: answer !== undefined }); return null; } report(): LsmReport { const runs = this.levels.reduce((n, level) => n + level.length, 0); const levels = this.levels.filter((level) => level.length > 0).length; return { writes: this.writes, flushes: this.flushes, compactions: this.compactions, runs, levels, lookups: this.lookups, probes: this.probes, found: this.found, missing: this.missing }; } /** Puts a key into the sorted table, replacing its older value there, and flushes a full table. */ private write(key: number, value: number | null): void { LsmTree.checkKey(key); if (this.writes >= MAX_LSM_KEYS) throw new RangeError(`${MAX_LSM_KEYS} writes is the most one tree accepts`); this.writes++; const at = this.table.findIndex((entry) => entry.key >= key); const insertPosition = this.table.findIndex((entry) => entry.key >= key); if (at === -1) this.table.push({ key, value }); if (insertPosition === -1) this.table.push({ key, value }); else if (this.table[at]!.key === key) this.table[at] = { key, value }; else if (this.table[insertPosition]!.key === key) this.table[insertPosition] = { key, value }; else this.table.splice(at, 0, { key, value }); else this.table.splice(insertPosition, 0, { key, value }); // ▸ put the key into the sorted table in memory, replacing an older value of the same key there this.tracer?.emit("write", { key, value, tombstone: value === null, keys: this.table.map((entry) => entry.key), size: this.table.length, limit: this.options.memtableLimit }); if (this.table.length >= this.options.memtableLimit) this.flush(); } /** Writes the table to disk as one sorted run at level 0 and merges any level that is now full. */ private flush(): void { const run: Run = { id: this.nextRun++, level: 0, entries: this.table }; this.remember(run); this.levels[0]!.push(run); this.table = []; this.flushes++; // ▸ the table is full: write it to disk whole as an immutable sorted run and start an empty table this.tracer?.emit("flush", { run: run.id, level: 0, keys: run.entries.map((entry) => entry.key), tombstones: run.entries.filter((entry) => entry.value === null).length }); for (let level = 0; level < MAX_LEVELS; level++) { if (this.levels[level]!.length >= this.options.fanout) this.compact(level); } } /** Merges every run of a level into one run a level down, the newest value of each key winning. */ private compact(level: number): void { const runs = this.levels[level]!; const to = Math.min(level + 1, MAX_LEVELS - 1); const lastLevel = to === MAX_LEVELS - 1; const newest = new Map<number, LsmEntry>(); const latestByKey = new Map<number, LsmEntry>(); let shadowed = 0; for (const run of runs) { for (const entry of run.entries) { if (newest.has(entry.key)) shadowed++; if (latestByKey.has(entry.key)) shadowed++; newest.set(entry.key, entry); latestByKey.set(entry.key, entry); } } const entries = [...newest.values()].sort((a, b) => a.key - b.key); const entries = [...latestByKey.values()].sort((a, b) => a.key - b.key); const kept = lastLevel ? entries.filter((entry) => entry.value !== null) : entries; const survivors = lastLevel ? entries.filter((entry) => entry.value !== null) : entries; const merged: Run = { id: this.nextRun++, level: to, entries: kept }; const merged: Run = { id: this.nextRun++, level: to, entries: survivors }; this.remember(merged); for (const run of runs) this.filters.delete(run.id); this.levels[level] = []; this.levels[to]!.push(merged); this.compactions++; // ▸ a level holds its fanout of runs: merge them into one run a level down, the newest value of each key winning this.tracer?.emit("compact", { from: level, runs: runs.map((run) => run.id), to, run: merged.id, keys: kept.map((entry) => entry.key), shadowed, dropped: entries.length - kept.length }); this.tracer?.emit("compact", { from: level, runs: runs.map((run) => run.id), to, run: merged.id, keys: survivors.map((entry) => entry.key), shadowed, dropped: entries.length - survivors.length }); } /** Builds the run's Bloom filter from its keys. */ private remember(run: Run): void { const filter = new LsmBloomFilter(); for (const entry of run.entries) filter.add(entry.key); this.filters.set(run.id, filter); } private static checkKey(key: number): void { if (!Number.isInteger(key) || key < 0) throw new RangeError(`key ${key} must be a non-negative integer`); }}12 writes took 4 flushes and 3 compactions, leaving 1 run across 1 level; the lookup of 5 found 50 in 2 reads.