/** * @module db/faultInjectionStorage * @description Deterministic fault injection at the fact log's raw-byte * storage surface — the test harness half of the durability protocol. Wraps * any adapter exposing the {@link FactLogStorage} primitives (the exact * surface the fact log appends and syncs through) and injects the three * crash shapes durability tests must prove against: * * - **torn write** ({@link FaultInjectionStorage.tearWriteAtByte}): the next * append persists only its first N bytes, then reports success — the shape * of power loss after a partially-flushed page. The caller-side "crash" is * simulated by abandoning in-memory state and reopening from storage. * - **dropped sync** ({@link FaultInjectionStorage.dropNextSync}): the next * sync becomes a silent no-op — an fsync the device acknowledged into a * volatile cache and lost. * - **failed append** ({@link FaultInjectionStorage.failNextAppend}): the next * append throws {@link FaultInjectedError} without writing a byte — EIO or * a full disk, surfaced to the writer. * * Every injected fault is journaled on {@link FaultInjectionStorage.injectedFaults} * so tests can assert not just the outcome but that the fault actually fired. * Knobs are one-shot (they disarm on firing) and re-arming overwrites the * pending shot. All other operations pass through untouched. */ import type { FactLogStorage } from './factLog.js' /** The error a {@link FaultInjectionStorage.failNextAppend} shot throws. */ export class FaultInjectedError extends Error { /** The operation the fault fired on. */ public readonly operation: 'append' /** The storage path the operation targeted. */ public readonly path: string constructor(operation: 'append', path: string) { super(`fault injection: ${operation} to ${path} failed by test design`) this.name = 'FaultInjectedError' this.operation = operation this.path = path } } /** One journaled fault event — proof the injected fault actually fired. */ export interface InjectedFault { kind: 'torn-write' | 'dropped-sync' | 'failed-append' /** The target path (torn-write / failed-append). */ path?: string /** The paths a dropped sync was asked to make durable. */ paths?: string[] /** Bytes the caller asked to append (torn-write). */ requestedBytes?: number /** Bytes actually persisted (torn-write). */ writtenBytes?: number } /** * A {@link FactLogStorage} wrapper that injects deterministic storage faults. * Construct it around any conforming adapter and hand it wherever a * FactLogStorage is accepted — unarmed, it is a transparent passthrough. */ export class FaultInjectionStorage implements FactLogStorage { private readonly inner: FactLogStorage /** Pending torn-write byte count, or null when unarmed. */ private tearAtByte: number | null = null /** Pending dropped-sync shot. */ private dropSyncArmed = false /** Pending failed-append shot. */ private failAppendArmed = false /** Journal of every fault that fired, in firing order. */ public readonly injectedFaults: InjectedFault[] = [] constructor(inner: FactLogStorage) { this.inner = inner } /** * Arm a torn write: the NEXT {@link appendRawBytes} persists only the first * `n` bytes of its buffer (all of it when `n` exceeds the buffer) and then * reports success. One-shot. */ tearWriteAtByte(n: number): void { if (!Number.isInteger(n) || n < 0) { throw new Error(`fault injection: tearWriteAtByte needs a non-negative integer; got ${n}`) } this.tearAtByte = n } /** Arm a dropped sync: the NEXT {@link syncRawObjects} silently does nothing. One-shot. */ dropNextSync(): void { this.dropSyncArmed = true } /** * Arm a failed append: the NEXT {@link appendRawBytes} throws * {@link FaultInjectedError} without writing. One-shot; wins over a * simultaneously-armed torn write (nothing is written at all). */ failNextAppend(): void { this.failAppendArmed = true } /** Append bytes — the injection point for torn writes and failed appends. */ async appendRawBytes(path: string, bytes: Uint8Array): Promise { if (this.failAppendArmed) { this.failAppendArmed = false this.injectedFaults.push({ kind: 'failed-append', path }) throw new FaultInjectedError('append', path) } if (this.tearAtByte !== null) { const writtenBytes = Math.min(this.tearAtByte, bytes.length) this.tearAtByte = null this.injectedFaults.push({ kind: 'torn-write', path, requestedBytes: bytes.length, writtenBytes }) if (writtenBytes > 0) { await this.inner.appendRawBytes(path, bytes.subarray(0, writtenBytes)) } return } return this.inner.appendRawBytes(path, bytes) } /** Make paths durable — the injection point for dropped syncs. */ async syncRawObjects(paths: string[]): Promise { if (this.dropSyncArmed) { this.dropSyncArmed = false this.injectedFaults.push({ kind: 'dropped-sync', paths: [...paths] }) return } return this.inner.syncRawObjects(paths) } /** Passthrough. */ async readRawBytes(path: string): Promise { return this.inner.readRawBytes(path) } /** Passthrough. */ async writeRawBytes(path: string, bytes: Uint8Array): Promise { return this.inner.writeRawBytes(path, bytes) } /** Passthrough. */ async rawByteSize(path: string): Promise { return this.inner.rawByteSize(path) } /** Passthrough. */ async readRawObject(path: string): Promise { return this.inner.readRawObject(path) } /** Passthrough. */ async writeRawObject(path: string, data: any): Promise { return this.inner.writeRawObject(path, data) } /** Passthrough. */ async deleteRawObject(path: string): Promise { return this.inner.deleteRawObject(path) } }