2026-07-15 12:44:52 -07:00
|
|
|
/**
|
|
|
|
|
* @module tests/integration/fact-log-contracts
|
|
|
|
|
* @description Pinned durability + stability contracts for the fact log.
|
|
|
|
|
*
|
|
|
|
|
* (1) FSYNC-BEFORE-ACK: an acknowledged write's fact survives an abrupt
|
|
|
|
|
* process end (no flush, no close — reopen from disk).
|
feat(log): log authority is the fleet default — adopt-at-open, oracle-gated; plus the power-cut throw-site cures and the loud torn-record contract
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.
2026-08-11 08:37:38 -07:00
|
|
|
* - transact(): HOLDS — the fact is fsync'd before transact returns.
|
|
|
|
|
* - single-op: HOLDS (was pinned `it.fails` until the ack-at-log
|
|
|
|
|
* destination landed): the 10.0.0 adopt-at-open fleet default flips a
|
|
|
|
|
* fresh brain to log authority at open, so single-op acks await the
|
|
|
|
|
* covering group fsync (durable-at-ack) and recovery REPLAYS intact
|
|
|
|
|
* facts above the manifest at the next open. The contract is now
|
|
|
|
|
* permanent on every path.
|
2026-07-15 12:44:52 -07:00
|
|
|
*
|
|
|
|
|
* (2) SCAN STABILITY UNDER ROTATION: a scan handle opened before segment
|
|
|
|
|
* rotation yields exactly its snapshot — byte-identical facts, no gaps,
|
|
|
|
|
* no duplicates, and no bleed-in of facts appended after the snapshot.
|
|
|
|
|
* (The reclaim-during-scan variant lands with fact-log compaction, which
|
|
|
|
|
* does not exist yet — segments only rotate today, never reclaim.)
|
|
|
|
|
*/
|
|
|
|
|
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 { Brainy, type CommitFact } from '../../src/index.js'
|
|
|
|
|
import { MemoryStorage } from '../../src/storage/adapters/memoryStorage.js'
|
|
|
|
|
import { FactLog, type FactLogStorage } from '../../src/db/factLog.js'
|
|
|
|
|
|
|
|
|
|
describe('fsync-before-ack contract (fact durability at the ack boundary)', () => {
|
|
|
|
|
let dir: string
|
|
|
|
|
let brain: any
|
|
|
|
|
|
|
|
|
|
const open = async () => {
|
|
|
|
|
const b: any = new Brainy({
|
|
|
|
|
requireSubtype: false,
|
|
|
|
|
storage: { type: 'filesystem', path: dir },
|
|
|
|
|
silent: true,
|
|
|
|
|
dimensions: 384
|
|
|
|
|
})
|
|
|
|
|
await b.init()
|
|
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
|
|
|
|
process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true'
|
|
|
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-factack-'))
|
|
|
|
|
brain = await open()
|
|
|
|
|
})
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
await brain.close?.().catch(() => {})
|
|
|
|
|
fs.rmSync(dir, { recursive: true, force: true })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('transact(): the fact is durable the moment the ack returns (kill-after-ack safe)', async () => {
|
|
|
|
|
const receipt = await brain.transact([
|
|
|
|
|
{ op: 'add', type: 'document', metadata: { durable: 1 }, data: 'ack-at-commit' }
|
|
|
|
|
])
|
|
|
|
|
// Abrupt end: no flush(), no close() — a new instance reads only disk.
|
|
|
|
|
brain = await open()
|
|
|
|
|
const facts: CommitFact[] = []
|
|
|
|
|
for await (const b of brain.scanFacts()!.batches()) facts.push(...b.facts)
|
|
|
|
|
expect(facts.some((f) => f.generation === receipt.generation)).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
feat(log): log authority is the fleet default — adopt-at-open, oracle-gated; plus the power-cut throw-site cures and the loud torn-record contract
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.
2026-08-11 08:37:38 -07:00
|
|
|
// THE ACK-AT-LOG CONTRACT, HELD (was `.fails` until it landed): under the
|
|
|
|
|
// adopt-at-open fleet default this brain runs durable-at-ack from open —
|
|
|
|
|
// the ack waits for the covering log fsync, and the log-authority recovery
|
|
|
|
|
// path replays the intact fact at the next open instead of truncating it.
|
|
|
|
|
it('single-op: the fact is durable the moment the ack returns (the ack-at-log target)', async () => {
|
2026-07-15 12:44:52 -07:00
|
|
|
await brain.add({ data: 'acked single-op', type: 'document', metadata: { n: 1 } })
|
|
|
|
|
const ackedHead = brain.scanFacts()!.headGeneration
|
|
|
|
|
// Abrupt end immediately after the ack — before any flush window.
|
|
|
|
|
brain = await open()
|
|
|
|
|
const facts: CommitFact[] = []
|
|
|
|
|
for await (const b of brain.scanFacts()!.batches()) facts.push(...b.facts)
|
|
|
|
|
expect(facts.some((f) => f.generation === ackedHead)).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('scan stability under rotation (the snapshot contract)', () => {
|
|
|
|
|
const UUID = (n: number): string => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`
|
|
|
|
|
const fact = (generation: number): CommitFact => ({
|
|
|
|
|
generation,
|
|
|
|
|
timestamp: 1_700_000_000_000 + generation,
|
|
|
|
|
ops: [
|
|
|
|
|
{
|
|
|
|
|
kind: 'noun',
|
|
|
|
|
id: UUID(generation),
|
|
|
|
|
// Padding makes each frame ~1KB so a small rotateBytes forces rotations.
|
|
|
|
|
record: { metadata: { noun: 'document', pad: 'x'.repeat(900), g: generation }, vector: null }
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('a scan opened before rotations yields its exact snapshot — no gaps, dups, or bleed-in', async () => {
|
|
|
|
|
const mem: any = new MemoryStorage()
|
|
|
|
|
await mem.init()
|
|
|
|
|
const log = new FactLog(mem as FactLogStorage, { rotateBytes: 4096 }) // ~4 facts per segment
|
|
|
|
|
await log.open(0)
|
|
|
|
|
for (let g = 1; g <= 10; g++) await log.append(fact(g))
|
|
|
|
|
await log.sync()
|
|
|
|
|
|
|
|
|
|
// Open the snapshot, THEN keep appending — forcing further rotations.
|
|
|
|
|
const scan = log.scanFacts()
|
|
|
|
|
expect(scan.headGeneration).toBe(10)
|
|
|
|
|
for (let g = 11; g <= 25; g++) await log.append(fact(g))
|
|
|
|
|
await log.sync()
|
|
|
|
|
expect(log.headGeneration()).toBe(25)
|
|
|
|
|
|
|
|
|
|
const seen: number[] = []
|
|
|
|
|
for await (const batch of scan.batches()) {
|
|
|
|
|
for (const f of batch.facts) seen.push(f.generation)
|
|
|
|
|
}
|
|
|
|
|
// Exactly the snapshot: 1..10 in order, nothing appended-after bleeds in.
|
|
|
|
|
expect(seen).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
|
|
|
|
|
expect(scan.summary().factsYielded).toBe(10)
|
|
|
|
|
|
|
|
|
|
// And a fresh scan sees everything, across all rotated segments.
|
|
|
|
|
const all: number[] = []
|
|
|
|
|
for await (const batch of log.scanFacts().batches()) {
|
|
|
|
|
for (const f of batch.facts) all.push(f.generation)
|
|
|
|
|
}
|
|
|
|
|
expect(all).toEqual(Array.from({ length: 25 }, (_, i) => i + 1))
|
|
|
|
|
expect(log.segmentPaths().length).toBeGreaterThanOrEqual(2) // rotations actually happened
|
|
|
|
|
})
|
|
|
|
|
})
|