The two-implementation contract surface as one pure module (no I/O):
segment header v2 (formatVersion 2 + sealSize in the reserved bytes),
per-record [type u8, version u8] envelope killing the unknown-kind
misclassification trap, the 12-type registry (after-images with minted
ints, tombstones, batch.meta, embed.pending/landed, blob.manifest,
projection.note, bootstrap.baseline, log.genesis with id-space width and
TYPED width-mismatch refusal), vectorLeg inline|{sameAsGeneration} with
writer-enforced single-hop, sector-sealed groups with pad frames, torn-tail
discipline, and GOLDEN BYTE VECTORS pinned so a second (native) reader
implementation can conform byte-for-byte. 50 format pins + a
fault-injecting storage wrapper (tear/drop-sync/fail-append) with 13
self-tests. v1 segments remain readable; nothing writes v2 yet — the
live-format cutover is its own commit.
164 lines
5.8 KiB
TypeScript
164 lines
5.8 KiB
TypeScript
/**
|
|
* @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<void> {
|
|
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<void> {
|
|
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<Uint8Array | null> {
|
|
return this.inner.readRawBytes(path)
|
|
}
|
|
|
|
/** Passthrough. */
|
|
async writeRawBytes(path: string, bytes: Uint8Array): Promise<void> {
|
|
return this.inner.writeRawBytes(path, bytes)
|
|
}
|
|
|
|
/** Passthrough. */
|
|
async rawByteSize(path: string): Promise<number | null> {
|
|
return this.inner.rawByteSize(path)
|
|
}
|
|
|
|
/** Passthrough. */
|
|
async readRawObject(path: string): Promise<any | null> {
|
|
return this.inner.readRawObject(path)
|
|
}
|
|
|
|
/** Passthrough. */
|
|
async writeRawObject(path: string, data: any): Promise<void> {
|
|
return this.inner.writeRawObject(path, data)
|
|
}
|
|
|
|
/** Passthrough. */
|
|
async deleteRawObject(path: string): Promise<void> {
|
|
return this.inner.deleteRawObject(path)
|
|
}
|
|
}
|