open-brainy/tests/integration/transact-durability-barrier.test.ts

159 lines
6.7 KiB
TypeScript
Raw Normal View History

fix: transact durability barrier — committed transactions are durable on return A committed transact() reported success while its canonical entity writes were still only in the OS page cache (tmp+rename, not fsync'd), even though the generation counter and manifest were fsync'd. A hard kill in that window could leave the durable counter ahead of the persisted entity bytes — phantom progress for any generation-based consumer resuming from the counter. Reported from a downstream migration's crash-lifecycle forensics. Add an optional transaction durability barrier to GenerationStorage (beginWriteBarrier / flushWriteBarrier). FileSystemStorage implements it: writeObjectToPath records each successful canonical write and deleteObjectFromPath records each delete's parent dir, between begin and flush; flush fsyncs every recorded write (contents + rename dir entries, via syncRawObjects) and the parent dir of every delete. commitTransaction opens the barrier before running the planned operations and flushes it after, BEFORE persisting the counter and manifest — so the batch's entire canonical footprint is durable before the generation stamp advances. The barrier is optional: the generation store calls it through optional chaining, so in-memory and durable-per-call (cloud object-PUT) adapters treat it as a no-op. The single-op group-commit path is unchanged (deferred durability is the Model-B design that avoids a 3-5x per-write fsync regression), but its durability contract is now documented explicitly on commitSingleOp: transact = durable on return; single-op = durable at the next flush()/close(), with the counter buffered alongside the data so a crash loses both together (never a torn counter-ahead-of-state store). Regression (tests/integration/transact-durability-barrier.test.ts): the entity writes fsync in an earlier syncRawObjects batch than the manifest for single-op and multi-op (add+relate) transactions; a precommit-rejected batch opens no barrier and advances nothing; MemoryStorage exposes no barrier (optional-chain no-op).
2026-07-12 08:54:14 -07:00
/**
* @module tests/integration/transact-durability-barrier
* @description Regression for a consumer-reported durability gap: a committed
* `transact()` reported success while its canonical entity writes were still
* only in the page cache (tmp+rename, not yet fsync'd), whereas the generation
* counter/manifest WERE fsync'd so a hard kill could leave the counter ahead
* of the persisted entity bytes ("phantom progress" for any generation-based
* consumer resuming from the counter).
*
* Fix: `commitTransaction` opens a write barrier before running the planned
* operations and flushes it (fsync of every canonical write + the parent dir of
* every canonical delete) BEFORE advancing the counter and writing the manifest.
* These tests assert the ordering property directly by recording the storage's
* `syncRawObjects` calls: the entity writes are fsync'd in an earlier call than
* the manifest, and a precommit-rejected batch opens no barrier and advances
* nothing.
*/
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 } from '../../src/brainy.js'
import { NounType, VerbType } from '../../src/types/graphTypes.js'
import { MemoryStorage } from '../../src/storage/adapters/memoryStorage.js'
const MANIFEST_REL = '_system/manifest.json'
describe('transact durability barrier — entity writes fsync before the counter advances', () => {
let dir: string
let brain: any
let syncCalls: string[][]
let beginCount: number
let flushCount: number
beforeEach(async () => {
process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true'
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-durability-'))
brain = new Brainy({
requireSubtype: false,
storage: { type: 'filesystem', path: dir },
dimensions: 384,
silent: true
})
await brain.init()
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
// Drain the pending tier BEFORE instrumenting: the adopt-at-open fleet
// default re-commits the init-time baseline as a buffered single-op
// generation, and transact() flushes buffered single-ops first — that
// flush's manifest sync would otherwise be recorded ahead of the
// transact's own commit point and break the first-index ordering pins.
await brain.flush()
fix: transact durability barrier — committed transactions are durable on return A committed transact() reported success while its canonical entity writes were still only in the OS page cache (tmp+rename, not fsync'd), even though the generation counter and manifest were fsync'd. A hard kill in that window could leave the durable counter ahead of the persisted entity bytes — phantom progress for any generation-based consumer resuming from the counter. Reported from a downstream migration's crash-lifecycle forensics. Add an optional transaction durability barrier to GenerationStorage (beginWriteBarrier / flushWriteBarrier). FileSystemStorage implements it: writeObjectToPath records each successful canonical write and deleteObjectFromPath records each delete's parent dir, between begin and flush; flush fsyncs every recorded write (contents + rename dir entries, via syncRawObjects) and the parent dir of every delete. commitTransaction opens the barrier before running the planned operations and flushes it after, BEFORE persisting the counter and manifest — so the batch's entire canonical footprint is durable before the generation stamp advances. The barrier is optional: the generation store calls it through optional chaining, so in-memory and durable-per-call (cloud object-PUT) adapters treat it as a no-op. The single-op group-commit path is unchanged (deferred durability is the Model-B design that avoids a 3-5x per-write fsync regression), but its durability contract is now documented explicitly on commitSingleOp: transact = durable on return; single-op = durable at the next flush()/close(), with the counter buffered alongside the data so a crash loses both together (never a torn counter-ahead-of-state store). Regression (tests/integration/transact-durability-barrier.test.ts): the entity writes fsync in an earlier syncRawObjects batch than the manifest for single-op and multi-op (add+relate) transactions; a precommit-rejected batch opens no barrier and advances nothing; MemoryStorage exposes no barrier (optional-chain no-op).
2026-07-12 08:54:14 -07:00
// Instrument the real filesystem storage: record every fsync batch in order,
// and count barrier open/flush, delegating to the originals.
syncCalls = []
beginCount = 0
flushCount = 0
const storage = brain['storage']
const origSync = storage.syncRawObjects.bind(storage)
storage.syncRawObjects = async (paths: string[]) => {
syncCalls.push([...paths])
return origSync(paths)
}
const origBegin = storage.beginWriteBarrier.bind(storage)
storage.beginWriteBarrier = () => {
beginCount++
return origBegin()
}
const origFlush = storage.flushWriteBarrier.bind(storage)
storage.flushWriteBarrier = async () => {
flushCount++
return origFlush()
}
})
afterEach(async () => {
await brain.close()
fs.rmSync(dir, { recursive: true, force: true })
})
/** Index of the first sync call whose paths satisfy `pred` (or -1). */
const findSync = (pred: (p: string) => boolean): number =>
syncCalls.findIndex((paths) => paths.some(pred))
it('a committed transact fsyncs the entity write BEFORE the manifest', async () => {
const id = '00000000-0000-4000-8000-00000000ab01'
await brain.transact([{ op: 'add', id, data: 'durable entity', type: NounType.Thing }])
// The barrier opened and flushed exactly once.
expect(beginCount).toBe(1)
expect(flushCount).toBe(1)
// The entity's canonical metadata write was fsync'd…
const entitySyncIdx = findSync((p) => p.includes(`/${id}/`) && p.includes('entities/nouns'))
expect(entitySyncIdx).toBeGreaterThanOrEqual(0)
// …and the manifest (the commit point) was fsync'd in a LATER call.
const manifestSyncIdx = findSync((p) => p === MANIFEST_REL)
expect(manifestSyncIdx).toBeGreaterThanOrEqual(0)
expect(entitySyncIdx).toBeLessThan(manifestSyncIdx)
})
it('a multi-op transact (add + relate) fsyncs both endpoints and the edge before the manifest', async () => {
const a = '00000000-0000-4000-8000-00000000ab02'
const b = '00000000-0000-4000-8000-00000000ab03'
await brain.transact([
{ op: 'add', id: a, data: 'A', type: NounType.Thing },
{ op: 'add', id: b, data: 'B', type: NounType.Thing },
{ op: 'relate', from: a, to: b, type: VerbType.Contains }
])
expect(flushCount).toBe(1)
const aIdx = findSync((p) => p.includes(`/${a}/`))
const bIdx = findSync((p) => p.includes(`/${b}/`))
const manifestIdx = findSync((p) => p === MANIFEST_REL)
expect(aIdx).toBeGreaterThanOrEqual(0)
expect(bIdx).toBeGreaterThanOrEqual(0)
expect(manifestIdx).toBeGreaterThanOrEqual(0)
expect(Math.max(aIdx, bIdx)).toBeLessThan(manifestIdx)
// Sanity: the data is actually there and traverses.
expect((await brain.related(a)).map((r: any) => r.to)).toContain(b)
})
it('a precommit-rejected batch opens no barrier and advances nothing', async () => {
const anchor = await brain.add({ id: '00000000-0000-4000-8000-00000000ab04', data: 'anchor', type: NounType.Thing })
const genBefore = brain.generationStore.generation()
syncCalls.length = 0
beginCount = 0
flushCount = 0
const never = '00000000-0000-4000-8000-00000000ab05'
await expect(
brain.transact([
{ op: 'add', id: never, data: 'never', type: NounType.Thing },
{ op: 'update', id: anchor, metadata: { poke: 1 }, ifRev: 999 }
])
).rejects.toMatchObject({ name: 'RevisionConflictError' })
// Precommit throws before the barrier opens (it lives just before execute):
// the barrier never opens, nothing is flushed, and the generation counter is
// unchanged. (A manifest sync CAN appear here from transact() flushing the
// anchor's previously-buffered single-op — that is the anchor's deferred
// durability, not this rejected batch committing; beginCount is the clean
// signal that this batch's commit path never reached execute.)
expect(beginCount).toBe(0)
expect(flushCount).toBe(0)
expect(brain.generationStore.generation()).toBe(genBefore)
expect(await brain.get(never)).toBeNull()
})
it('MemoryStorage does not implement the barrier (optional-chaining no-op)', () => {
const mem = new MemoryStorage() as any
// In-memory has no durability seam; the generation store treats the absent
// barrier methods as no-ops via optional chaining.
expect(mem.beginWriteBarrier).toBeUndefined()
expect(mem.flushWriteBarrier).toBeUndefined()
})
})