WAL: Crash Recovery
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.
write-ahead-log.ts
import type { Tracer } from "../trace";/** Pages on disk by default: a value each, numbered from 0. */export const PAGE_COUNT = 6;export const MAX_PAGES = 8;export const MAX_LOG_RECORDS = 64;export const MAX_WAL_ACTIONS = 48;/** One line of the log, numbered by its log sequence number from 1. */export type LogRecord = | { lsn: number; kind: "update"; tx: string; page: number; before: number; after: number } | { lsn: number; kind: "commit"; tx: string } | { lsn: number; kind: "abort"; tx: string } | { lsn: number; kind: "checkpoint"; dirtyPages: readonly number[]; activeTx: readonly string[] };export type LogRecordKind = LogRecord["kind"];/** One step of a script the store runs. */export type WalAction = | { kind: "update"; tx: string; page: number; value: number } | { kind: "commit"; tx: string } | { kind: "abort"; tx: string } | { kind: "writeBack"; page: number } | { kind: "checkpoint" } | { kind: "crash" } | { kind: "recover" };export interface WalOptions { /** Pages of storage, 1 to MAX_PAGES (default PAGE_COUNT). */ pages?: number; /** False writes every update straight to disk with no log at all, the comparison the lesson draws. */ logging?: boolean;}export type TransactionState = "active" | "committed" | "aborted";export interface RecoveryReport { /** Update records replayed onto the disk. */ redone: number; /** Update records reversed on the disk. */ undone: number; /** Transactions whose commit record survived the crash. */ winners: readonly string[]; /** Transactions with updates but no commit record: their work is undone. */ losers: readonly string[]; /** The disk after recovery, page by page. */ disk: readonly number[]; /** What the disk should hold: every committed write and nothing else. */ expected: readonly number[]; consistent: boolean;}/** * A database promises that a committed write survives a crash and an * uncommitted one leaves no trace, while its pages live in a buffer pool * that is lost the moment the power goes. The write-ahead log keeps the * promise: every update is appended to the log, with the value before * and after, before the page it changes may reach the disk, and a commit * forces the log to stable storage before it is acknowledged. After a * crash, recovery reads the log from the last checkpoint: updates of * transactions that committed are redone onto the disk, updates of * transactions that did not are undone in reverse, and the disk is what * the committed transactions said. With logging off, every update goes * straight to the disk and a crash leaves whatever was there. * * Time: O(log records) per recovery, O(1) per update. Space: O(pages + log records). */export class WriteAheadLog { readonly pages: number; readonly logging: boolean; private readonly disk: number[]; /** Dirty pages: the value in memory that the disk does not have yet. */ private readonly buffer = new Map<number, number>(); private readonly log: LogRecord[] = []; /** Records at or below this sequence number are on stable storage. */ private flushedLsn = 0; private readonly states = new Map<string, TransactionState>(); /** Each active transaction's writes so far, for the commit payload and the oracle. */ private readonly pending = new Map<string, Map<number, number>>(); /** What a perfect store would hold: committed writes only, applied at commit. */ private readonly committedImage: number[]; private crashed = false; constructor(options: WalOptions = {}, private readonly tracer?: Tracer) { const pages = options.pages ?? PAGE_COUNT; if (!Number.isInteger(pages) || pages < 1 || pages > MAX_PAGES) throw new RangeError(`pages must be in [1, ${MAX_PAGES}]`); this.pages = pages; this.logging = options.logging ?? true; this.disk = new Array<number>(pages).fill(0); this.committedImage = new Array<number>(pages).fill(0); } /** The disk's pages; a copy. */ diskPages(): readonly number[] { return [...this.disk]; } /** The dirty pages in the buffer pool, by page; a copy. */ bufferPages(): ReadonlyMap<number, number> { return new Map(this.buffer); } /** Every record still in the log; a copy. */ records(): readonly LogRecord[] { return this.log.map((record) => ({ ...record })); } flushed(): number { return this.flushedLsn; } state(tx: string): TransactionState | null { return this.states.get(tx) ?? null; } /** A transaction writes a page: logged first, then the buffer; never the disk. */ update(tx: string, page: number, value: number): void { this.checkPage(page); this.checkActive(tx); if (!Number.isInteger(value) || value < 0 || value > 99) throw new RangeError(`value ${value} is outside [0, 99]`); if (!this.states.has(tx)) { this.states.set(tx, "active"); this.pending.set(tx, new Map()); } this.pending.get(tx)!.set(page, value); const before = this.buffer.get(page) ?? this.disk[page]!; if (this.logging) { this.append({ lsn: 0, kind: "update", tx, page, before, after: value }); this.buffer.set(page, value); } else { // No log: the page goes straight to disk, committed or not. this.writePage(page, value, 0, tx); } } /** The commit record is appended and the log forced before the commit is acknowledged. */ commit(tx: string): void { this.checkActive(tx); const writes = this.pending.get(tx) ?? new Map<number, number>(); const record = this.append({ lsn: 0, kind: "commit", tx }); this.force(record?.lsn ?? 0); for (const [page, value] of writes) this.committedImage[page] = value; this.states.set(tx, "committed"); this.pending.delete(tx); // ▸ only once its commit record is on disk is the transaction acknowledged; its pages may still be only in the buffer this.tracer?.emit("commit", { tx, lsn: record?.lsn ?? 0, pages: [...writes.keys()].sort((a, b) => a - b), logged: this.logging }); } /** The transaction gives up: its writes are reversed from its own log records. */ abort(tx: string): void { this.checkActive(tx); this.append({ lsn: 0, kind: "abort", tx }); const mine = this.log.filter((record): record is Extract<LogRecord, { kind: "update" }> => record.kind === "update" && record.tx === tx); const ownUpdates = this.log.filter((record): record is Extract<LogRecord, { kind: "update" }> => record.kind === "update" && record.tx === tx); for (const record of [...mine].reverse()) this.undo(record, "buffer"); for (const record of [...ownUpdates].reverse()) this.undo(record, "buffer"); this.states.set(tx, "aborted"); this.pending.delete(tx); } /** The buffer pool steals a frame: a dirty page is written to disk, committed or not, once its log records are. Nothing to do with no log, where every page is already on disk. */ writeBack(page: number): void { this.checkPage(page); this.checkRunning(); if (!this.logging) return; const value = this.buffer.get(page); if (value === undefined) throw new RangeError(`page ${page} is not dirty`); const latest = this.log.filter((record) => record.kind === "update" && record.page === page).at(-1); const writer = latest?.kind === "update" ? latest.tx : ""; this.writePage(page, value, latest?.lsn ?? 0, writer); this.buffer.delete(page); } /** Every dirty page to disk and a record of who was active, so recovery need not read the log from its start. */ checkpoint(): void { this.checkRunning(); const dirtyPages = [...this.buffer.keys()].sort((a, b) => a - b); const activeTx = [...this.states.entries()].filter(([, state]) => state === "active").map(([tx]) => tx); const record = this.append({ lsn: 0, kind: "checkpoint", dirtyPages, activeTx }); this.force(record?.lsn ?? 0); for (const page of dirtyPages) { this.disk[page] = this.buffer.get(page)!; this.buffer.delete(page); } // ▸ every dirty page is written and the record names who was active, so recovery can start here this.tracer?.emit("checkpoint", { lsn: record?.lsn ?? 0, pages: dirtyPages, activeTx }); } /** The power goes: the buffer pool and every unforced log record are gone; the disk keeps what reached it. */ crash(): void { this.checkRunning(); const lostRecords = this.log.length - this.flushedLsn; const lostPages = [...this.buffer.keys()].sort((a, b) => a - b); this.log.length = this.flushedLsn; this.buffer.clear(); this.crashed = true; // ▸ the buffer pool and every record not yet forced are gone; the disk is all that is left this.tracer?.emit("crash", { flushedLsn: this.flushedLsn, lostRecords, lostPages }); } /** Repeat history from the last checkpoint for the winners, then reverse the losers. */ recover(): RecoveryReport { if (!this.crashed) throw new RangeError("nothing to recover from: the store has not crashed"); let checkpointAt = -1; this.log.forEach((record, i) => { if (record.kind === "checkpoint") checkpointAt = i; }); const since = this.log.slice(checkpointAt + 1); const recordsSinceCheckpoint = this.log.slice(checkpointAt + 1); const winners = new Set(this.log.flatMap((record) => (record.kind === "commit" ? [record.tx] : []))); const touched = new Set(this.log.flatMap((record) => (record.kind === "update" ? [record.tx] : []))); const losers = [...touched].filter((tx) => !winners.has(tx)); let redone = 0; for (const record of since) { for (const record of recordsSinceCheckpoint) { if (record.kind !== "update" || !winners.has(record.tx)) continue; this.disk[record.page] = record.after; redone++; // ▸ the transaction committed, so its write is replayed onto the disk whether or not it got there before this.tracer?.emit("redo", { lsn: record.lsn, tx: record.tx, page: record.page, value: record.after }); } let undone = 0; for (const record of [...this.log].reverse()) { if (record.kind !== "update" || !losers.includes(record.tx)) continue; this.undo(record, "disk"); undone++; } for (const tx of losers) this.states.set(tx, "aborted"); for (const tx of touched) this.pending.delete(tx); this.crashed = false; return this.report(redone, undone, [...winners], losers); } /** The disk against the committed image, with the counts a recovery produced. */ report(redone = 0, undone = 0, winners: readonly string[] = [], losers: readonly string[] = []): RecoveryReport { const disk = this.diskPages(); const expected = [...this.committedImage]; return { redone, undone, winners, losers, disk, expected, consistent: disk.every((value, page) => value === expected[page]) }; } /** A script of actions in order; the last recovery's report, or the plain report when nothing crashed. */ run(script: readonly WalAction[]): RecoveryReport { if (script.length > MAX_WAL_ACTIONS) throw new RangeError(`${script.length} actions exceeds ${MAX_WAL_ACTIONS}`); let report: RecoveryReport | null = null; for (const action of script) { switch (action.kind) { case "update": this.update(action.tx, action.page, action.value); break; case "commit": this.commit(action.tx); break; case "abort": this.abort(action.tx); break; case "writeBack": this.writeBack(action.page); break; case "checkpoint": this.checkpoint(); break; case "crash": this.crash(); break; case "recover": report = this.recover(); break; } } return report ?? this.report(); } /** The one place a record enters the log; null with logging off, where there is no log. */ private append(record: LogRecord): LogRecord | null { if (!this.logging) return null; if (this.log.length >= MAX_LOG_RECORDS) throw new RangeError(`the log holds at most ${MAX_LOG_RECORDS} records`); const entry: LogRecord = { ...record, lsn: this.log.length + 1 }; this.log.push(entry); // ▸ a record of the change, old value and new, goes into the log in memory before the page is touched this.tracer?.emit("append", { lsn: entry.lsn, kind: entry.kind, tx: "tx" in entry ? entry.tx : "", page: entry.kind === "update" ? entry.page : null, before: entry.kind === "update" ? entry.before : null, after: entry.kind === "update" ? entry.after : null, flushedLsn: this.flushedLsn }); return entry; } /** Forces the log to stable storage up to a record; a commit and a steal both need it. */ private force(upTo: number): void { private force(throughLsn: number): void { if (upTo <= this.flushedLsn) return; if (throughLsn <= this.flushedLsn) return; const forced = upTo - this.flushedLsn; const forced = throughLsn - this.flushedLsn; this.flushedLsn = upTo; this.flushedLsn = throughLsn; // ▸ the log is forced to stable storage up to this record: from here, nothing before it can be lost this.tracer?.emit("flush", { lsn: upTo, forced }); this.tracer?.emit("flush", { lsn: throughLsn, forced }); } /** The one place a value reaches the disk outside recovery: the log is forced up to the page's latest record first. */ private writePage(page: number, value: number, lsn: number, tx: string): void { const forced = lsn > this.flushedLsn; this.force(lsn); this.disk[page] = value; // ▸ a dirty page goes to disk, committed or not, but never before the log records that describe it this.tracer?.emit("evict", { page, value, lsn, tx, forced, logged: this.logging }); } /** Reverses one update record where the page lives: the buffer during an abort, the disk during recovery. */ private undo(record: Extract<LogRecord, { kind: "update" }>, where: "buffer" | "disk"): void { if (where === "buffer") this.buffer.set(record.page, record.before); else this.disk[record.page] = record.before; // ▸ the transaction did not commit, so its write is reversed to the value before, newest record first this.tracer?.emit("undo", { lsn: record.lsn, tx: record.tx, page: record.page, before: record.before, where }); } private checkPage(page: number): void { if (!Number.isInteger(page) || page < 0 || page >= this.pages) throw new RangeError(`page ${page} is outside [0, ${this.pages})`); } private checkRunning(): void { if (this.crashed) throw new RangeError("the store has crashed: recover first"); } private checkActive(tx: string): void { this.checkRunning(); const state = this.states.get(tx); if (state !== undefined && state !== "active") throw new RangeError(`${tx} has already ${state === "committed" ? "committed" : "aborted"}`); }}Recovery redid 2 updates and undid 0 updates; winners T1, losers none. The disk now reads 0 5 0 8 0 0 against the committed state 0 5 0 8 0 0: they match.