79 lines
3.5 KiB
TypeScript
79 lines
3.5 KiB
TypeScript
|
|
/**
|
||
|
|
* @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)
|
||
|
|
})
|