open-brainy/tests/integration/sync-fail-compensation.test.ts

79 lines
3.5 KiB
TypeScript
Raw Normal View History

fix(log): pad-frame construction is total; the at-ack sync-failure compensation splits by phase — a production adoption's two write-path defects, cured at their roots An adopter's full suite found two v2 write-path defects on fresh brains, reproduced with stacks; both cured and both pinned with their exact production shapes: 1. PAD-FRAME CONSTRUCTIBILITY: a single msgpack bin filler steps its header by one byte at each size class (bin8→bin16→bin32), leaving one unreachable payload size per boundary — the sealer requested a 291-byte pad, the encoder threw 'not constructible', and sync() died whole. Construction is now TOTAL: the class-boundary holes bridge with a trailing fixint beside the bin ({bin(n)} ∪ {bin(n)+fixint} covers every size ≥ minimum). Pinned exhaustively: every size from the minimum through a full sector plus boundary spill constructs byte-exact and decodes as reader-invisible filler. 2. THE NON-MONOTONIC REFUSAL LOOP: the append-failure compensation rewound the generation counter on ANY throw — including a covering SYNC failure after a SUCCESSFUL append. The log carried generation N while the counter re-minted N, and every later append refused 'non-monotonic (N ≤ head N)' — the write path wedged in a refusal loop through deferred-embed retries and flush backoff. The compensation now splits by phase: an append failure (log never took the fact) fully compensates — un-buffer and rewind; a sync failure after append earns the rewind ONLY if the appended fact is provably dropped, otherwise the generation stays consumed and buffered — the counter never re-mints a number the log may carry. Pinned: an injected one-shot sync failure fails its write loudly and the very next write mints fresh and succeeds, with the log scanning strictly ascending end to end. Also probed against the adopter's carried report: the 9.0 vfs.rename stale-ghost shape does NOT reproduce on this head (old path cleanly unresolvable on exists/stat/readdir after rename). Gates: unit 2067/2067 (160 files) · integration 833 (97 files) · conformance 36/36.
2026-08-12 16:09:48 -07:00
/**
* @module tests/integration/sync-fail-compensation
* @description The non-monotonic refusal-loop cure (a production adoption's
* second defect): when the at-ack covering SYNC fails AFTER a successful
* append, the counter must NOT rewind unless the appended fact is provably
* removed rewinding while the log carries the generation re-mints the
* same number and every later append refuses non-monotonic, wedging the
* write path in a refusal loop ("writes REFUSED until it drains").
*/
import { describe, it, expect, afterEach, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Brainy } from '../../src/index.js'
import { NounType } from '../../src/types/graphTypes.js'
const dirs: string[] = []
const brains: Brainy[] = []
afterEach(async () => {
vi.restoreAllMocks()
for (const b of brains.splice(0)) await b.close().catch(() => {})
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
})
describe('at-ack sync-failure compensation', () => {
it('a one-shot sync failure never wedges the write path: the next write mints a FRESH generation and succeeds', async () => {
const dir = mkdtempSync(join(tmpdir(), 'brainy-syncfail-'))
dirs.push(dir)
const brain = new Brainy({ storage: { type: 'filesystem', path: dir }, requireSubtype: false })
await brain.init() // adopt-default: log authority, at-ack
brains.push(brain)
expect(brain.logAuthority().authority).toBe('log')
await brain.add({ data: 'baseline', type: NounType.Document, metadata: { n: 0 } })
// Fail exactly ONE covering sync (after its append lands).
// Target ensureSynced (the ACK path's covering sync) — mocking sync()
// itself gets eaten by background flushes before the victim write.
const factLog = (brain as unknown as {
generationStore: { getFactLog(): { ensureSynced(): Promise<void> } }
}).generationStore.getFactLog()
const realEnsure = factLog.ensureSynced.bind(factLog)
let failed = false
vi.spyOn(factLog, 'ensureSynced').mockImplementation(async () => {
if (!failed) {
failed = true
throw new Error('injected sync failure (device hiccup)')
}
return realEnsure()
})
// The write whose sync fails: LOUD failure to the caller — never silent.
await expect(
brain.add({ data: 'sync victim', type: NounType.Document, metadata: { n: 1 } })
).rejects.toThrow(/sync failure/)
// THE PIN: the very next write mints a fresh generation and SUCCEEDS —
// no non-monotonic refusal, no refusal loop, regardless of whether the
// failed write's fact was dropped or retained (both are legal outcomes;
// an equal-generation re-mint is not).
const survivor = await brain.add({ data: 'after the storm', type: NounType.Document, metadata: { n: 2 } })
expect((await brain.get(survivor))!.data).toContain('after the storm')
await brain.flush()
expect(Number.isSafeInteger(brain.generation())).toBe(true)
// And the log scans clean end-to-end (no torn ordering).
const scan = brain.scanFacts()
let last = 0
if (scan) {
for await (const batch of (scan as { batches(): AsyncIterable<{ facts: Array<{ generation: number }> }> }).batches()) {
for (const f of batch.facts) {
expect(f.generation, 'strictly ascending').toBeGreaterThan(last)
last = f.generation
}
}
}
expect(last).toBeGreaterThan(0)
}, 120000)
})