feat(log): fact-log format v2 codec — record envelope, type registry, genesis, sector seals; fault-injection shim
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.
This commit is contained in:
parent
6595309765
commit
3484107462
4 changed files with 2360 additions and 0 deletions
1220
src/db/factLogFormat.ts
Normal file
1220
src/db/factLogFormat.ts
Normal file
File diff suppressed because it is too large
Load diff
164
src/db/faultInjectionStorage.ts
Normal file
164
src/db/faultInjectionStorage.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* @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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue