THE DEFAULT FLIP (ruled on proven evidence — at-ack survived 301/301 acked-writes-through-power-cut in block-layer fault injection; deferred tree authority demonstrably loses flush-covered acks): a brain with NO stored authority artifact now ADOPTS LOG AUTHORITY AT OPEN. The oracle gates the flip exactly as the guarded adoption path always did — curable divergences baseline-backfilled, the flip lands ONLY on a green verdict — and a brain that cannot verify STAYS tree-authoritative loudly, with the refusal recorded on the switch artifact so subsequent opens are cheap. config logAuthority: 'defer' is the explicit documented opt-out (no automatic adoption; declared flush-window loss; adoptLogAuthority() flips later). A stored artifact always wins. RELEASES.md carries the posture. Two standing .fails debt pins FLIP TO HOLDING under the default: the at-ack crash-survival gap and the ack-at-log durability target — both now permanent asserted truths, not aspirations. POWER-CUT THROW SITES (fault-injection findings, brainy-alone config): - A manifest-listed-but-unloadable column segment QUARANTINES at discovery (loud once, counted always, quarantinedSegments() exposed for the heal) and the field serves its remaining segments DEGRADED — never a raw throw killing every query on the field. Real storage faults still propagate untouched. - Torn generation artifacts (NaN/garbage in manifest or counter) DISCARD with narration at the store's open and recovery re-derives — plus a defensive finite-integer guard at the init consumer. Never a RangeError killing an open. THE LOUD TORN-RECORD CONTRACT: an existing-but-unparseable stored record now surfaces as a typed, counted TornRecordError on every entity-read surface (including fifteen previously-blind per-item batch catches); ENOENT stays clean-absent; artifact readers with designed absent-recovery keep null-tolerance behind the loud floor. Disk corruption can no longer read as silent data invisibility. Suite migration: the default's pins inverted deliberately, generation baselines made relative, quarantine-contract pins rewritten to the ruled behavior. Gates: tsc 0 · unit 2065/2065 (159 files) · integration 826 (93 files) · conformance 31/31 · kill-matrix 15/15 · torn-open guards 2/2.
251 lines
10 KiB
TypeScript
251 lines
10 KiB
TypeScript
/**
|
||
* @module tests/unit/storage/torn-record-loud
|
||
* @description Torn records must be LOUD, never silent. A file that EXISTS but
|
||
* cannot be decoded (truncated/garbled JSON, undecodable gzip) is disk
|
||
* corruption, not absence — the old behavior logged "gracefully skipping" and
|
||
* returned `null`, so a consumer could not distinguish "never existed" from
|
||
* "exists but torn" and nothing ever healed it. Pins the cured contract:
|
||
*
|
||
* - ENTITY read paths (get/getBatch/pagination) throw a typed
|
||
* `TornRecordError` ({path, cause}, code `TORN_RECORD`) — NEVER a silent null.
|
||
* - Genuine absence (ENOENT) still reads as clean `null` — no error, no gauge.
|
||
* - EVERY torn encounter increments the per-process torn-record gauge and
|
||
* records the path, whatever the caller surface decides.
|
||
* - SYSTEM-ARTIFACT reads (`readRawObject`: manifests/markers with recovery
|
||
* paths) map torn → `null` BY DESIGN — but only after the encounter was
|
||
* logged and counted (loud degrade, not a quiet loss).
|
||
* - Legacy dual-format recovery: a torn `.gz` with a decodable uncompressed
|
||
* fallback returns the recovered object AND still counts the torn `.gz`.
|
||
*/
|
||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||
import * as fs from 'node:fs'
|
||
import * as os from 'node:os'
|
||
import * as path from 'node:path'
|
||
import { FileSystemStorage } from '../../../src/storage/adapters/fileSystemStorage.js'
|
||
import {
|
||
TornRecordError,
|
||
isTornRecordError,
|
||
getTornRecordGauge,
|
||
resetTornRecordGauge
|
||
} from '../../../src/storage/tornRecordError.js'
|
||
import type { NounMetadata } from '../../../src/coreTypes.js'
|
||
|
||
const VEC = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]
|
||
|
||
/** Release a raw FileSystemStorage so a subsequent open of the dir is unblocked. */
|
||
async function teardown(s: any): Promise<void> {
|
||
try { await s.flush?.() } catch { /* best effort */ }
|
||
try { s.stopFlushRequestWatcher?.() } catch { /* best effort */ }
|
||
try { await s.releaseWriterLock?.() } catch { /* best effort */ }
|
||
}
|
||
|
||
/** Save one noun (metadata + vector) through the adapter's real write path. */
|
||
async function seedOne(s: any, id: string): Promise<void> {
|
||
await s.saveNounMetadata(id, {
|
||
noun: 'thing',
|
||
createdAt: Date.now(),
|
||
updatedAt: Date.now()
|
||
} as NounMetadata)
|
||
await s.saveNoun({ id, vector: VEC, connections: new Map(), level: 0 })
|
||
}
|
||
|
||
/** Find the on-disk file(s) for an entity leg (metadata.json / vectors.json), .gz or plain. */
|
||
function findEntityFiles(dir: string, id: string, leg: 'metadata' | 'vectors'): string[] {
|
||
const found: string[] = []
|
||
const walk = (d: string): void => {
|
||
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
||
const p = path.join(d, entry.name)
|
||
if (entry.isDirectory()) walk(p)
|
||
else if (
|
||
p.includes(`${path.sep}${id}${path.sep}`) &&
|
||
(entry.name === `${leg}.json` || entry.name === `${leg}.json.gz`)
|
||
) {
|
||
found.push(p)
|
||
}
|
||
}
|
||
}
|
||
walk(path.join(dir, 'entities'))
|
||
return found
|
||
}
|
||
|
||
/** Overwrite a file with bytes that can never decode as gzip or JSON. */
|
||
function corruptFile(filePath: string): void {
|
||
fs.writeFileSync(filePath, Buffer.from('{"noun":"thing","crea |