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).
This commit is contained in:
David Snelling 2026-07-12 08:54:14 -07:00
parent a9fc1f3f9b
commit 3b8fa51161
4 changed files with 270 additions and 0 deletions

View file

@ -0,0 +1,151 @@
/**
* @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()
// 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()
})
})