157 lines
6.2 KiB
TypeScript
157 lines
6.2 KiB
TypeScript
|
|
/**
|
||
|
|
* @module tests/integration/rollback-trapdoor
|
||
|
|
* @description Regression for a consumer-reported data-integrity bug: a failed
|
||
|
|
* CANONICAL rollback undo left the record durable while the request errored (a
|
||
|
|
* post-commit response lie). Reproduced by a failure-injection coherence harness:
|
||
|
|
* a late index op throws → rollback runs → an earlier op's canonical undo
|
||
|
|
* (storage.deleteNounMetadata) fails persistently → the record stays on disk
|
||
|
|
* while Transaction.rollback threw TransactionRollbackError AND lied with
|
||
|
|
* state='rolled_back'.
|
||
|
|
*
|
||
|
|
* Fix (David's ruling — adopt-forward when safe, else fail loud):
|
||
|
|
* - SINGLE-op add whose only damage is a durably-present orphan → adopt it
|
||
|
|
* forward: commit the generation (the record IS what add() wanted), return
|
||
|
|
* success + a loud warn; the derived index heals on rebuild/repairIndex.
|
||
|
|
* - MULTI-op batch, OR any restorative LOSS → StoreInconsistentError naming the
|
||
|
|
* unreconciled ids; the brain enters write-quarantine (reads OK, writes
|
||
|
|
* refused) until repairIndex() reconciles and lifts it. Transaction state is
|
||
|
|
* 'inconsistent', never the 'rolled_back' lie.
|
||
|
|
*
|
||
|
|
* The injection mirrors that harness: fault index.addToIndex (trigger rollback) and
|
||
|
|
* storage.deleteNounMetadata (fail the canonical undo).
|
||
|
|
*/
|
||
|
|
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 } from '../../src/types/graphTypes.js'
|
||
|
|
import { StoreInconsistentError } from '../../src/db/errors.js'
|
||
|
|
|
||
|
|
let seq = 0
|
||
|
|
const freshId = (): string =>
|
||
|
|
`00000000-0000-4000-8000-${(++seq).toString(16).padStart(12, '0')}`
|
||
|
|
|
||
|
|
describe('rollback trapdoor — failed canonical undo (data integrity)', () => {
|
||
|
|
let dir: string
|
||
|
|
let brain: any
|
||
|
|
let restore: Array<() => void>
|
||
|
|
|
||
|
|
beforeEach(async () => {
|
||
|
|
process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true'
|
||
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-trapdoor-'))
|
||
|
|
brain = new Brainy({
|
||
|
|
requireSubtype: false,
|
||
|
|
storage: { type: 'filesystem', path: dir },
|
||
|
|
dimensions: 384,
|
||
|
|
silent: true
|
||
|
|
})
|
||
|
|
await brain.init()
|
||
|
|
restore = []
|
||
|
|
})
|
||
|
|
|
||
|
|
afterEach(async () => {
|
||
|
|
for (const r of restore) r()
|
||
|
|
try { await brain.close() } catch { /* ignore */ }
|
||
|
|
fs.rmSync(dir, { recursive: true, force: true })
|
||
|
|
})
|
||
|
|
|
||
|
|
/** Fault the metadata-index add so a write's rollback is triggered. */
|
||
|
|
const faultIndexAdd = () => {
|
||
|
|
const idx = brain['metadataIndex']
|
||
|
|
const orig = idx.addToIndex.bind(idx)
|
||
|
|
idx.addToIndex = async () => { throw new Error('injected: index add failed') }
|
||
|
|
restore.push(() => { idx.addToIndex = orig })
|
||
|
|
}
|
||
|
|
/** Fault the canonical additive undo (delete of a just-created record). */
|
||
|
|
const faultCanonicalDelete = () => {
|
||
|
|
const storage = brain['storage']
|
||
|
|
const orig = storage.deleteNounMetadata.bind(storage)
|
||
|
|
storage.deleteNounMetadata = async () => { throw new Error('injected: canonical undo failed') }
|
||
|
|
restore.push(() => { storage.deleteNounMetadata = orig })
|
||
|
|
}
|
||
|
|
|
||
|
|
it('ADOPT-FORWARD: a single-op add whose canonical undo fails commits as a durable, get-able record (no throw, no quarantine)', async () => {
|
||
|
|
const id = freshId()
|
||
|
|
const genBefore = brain.generationStore.generation()
|
||
|
|
|
||
|
|
faultIndexAdd() // triggers rollback
|
||
|
|
faultCanonicalDelete() // the trapdoor: the metadata record can't be un-written
|
||
|
|
|
||
|
|
// add() must NOT throw — the record it wanted is durable, so it is adopted.
|
||
|
|
const returned = await brain.add({ id, data: 'orphan adopted', type: NounType.Thing })
|
||
|
|
expect(returned).toBe(id)
|
||
|
|
|
||
|
|
// Un-fault so reads/subsequent writes use the real methods.
|
||
|
|
for (const r of restore) r()
|
||
|
|
restore = []
|
||
|
|
|
||
|
|
// The record is durable and get-able, and the generation advanced.
|
||
|
|
expect(await brain.get(id)).not.toBeNull()
|
||
|
|
expect(brain.generationStore.generation()).toBe(genBefore + 1)
|
||
|
|
|
||
|
|
// The store is NOT quarantined — a subsequent write succeeds.
|
||
|
|
const ok = await brain.add({ id: freshId(), data: 'still writable', type: NounType.Thing })
|
||
|
|
expect(await brain.get(ok)).not.toBeNull()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('FAIL-LOUD: a multi-op transact whose canonical undo fails throws StoreInconsistentError and write-quarantines the brain', async () => {
|
||
|
|
// A pre-existing record to prove reads keep working under quarantine.
|
||
|
|
const anchor = await brain.add({ id: freshId(), data: 'anchor', type: NounType.Thing })
|
||
|
|
await brain.flush()
|
||
|
|
|
||
|
|
faultIndexAdd()
|
||
|
|
faultCanonicalDelete()
|
||
|
|
|
||
|
|
const a = freshId()
|
||
|
|
const b = freshId()
|
||
|
|
await expect(
|
||
|
|
brain.transact([
|
||
|
|
{ op: 'add', id: a, data: 'batch A', type: NounType.Thing },
|
||
|
|
{ op: 'add', id: b, data: 'batch B', type: NounType.Thing }
|
||
|
|
])
|
||
|
|
).rejects.toBeInstanceOf(StoreInconsistentError)
|
||
|
|
|
||
|
|
// Writes are refused (quarantine); reads still work.
|
||
|
|
await expect(
|
||
|
|
brain.add({ id: freshId(), data: 'blocked', type: NounType.Thing })
|
||
|
|
).rejects.toThrow(/WRITE-QUARANTINED/)
|
||
|
|
expect(await brain.get(anchor)).not.toBeNull()
|
||
|
|
|
||
|
|
// repairIndex() reconciles and lifts the quarantine → writes resume.
|
||
|
|
for (const r of restore) r()
|
||
|
|
restore = []
|
||
|
|
await brain.repairIndex()
|
||
|
|
|
||
|
|
const ok = await brain.add({ id: freshId(), data: 'writable again', type: NounType.Thing })
|
||
|
|
expect(await brain.get(ok)).not.toBeNull()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('the StoreInconsistentError names the unreconciled records with dispositions', async () => {
|
||
|
|
faultIndexAdd()
|
||
|
|
faultCanonicalDelete()
|
||
|
|
|
||
|
|
const a = freshId()
|
||
|
|
const b = freshId()
|
||
|
|
let caught: unknown
|
||
|
|
await brain
|
||
|
|
.transact([
|
||
|
|
{ op: 'add', id: a, data: 'A', type: NounType.Thing },
|
||
|
|
{ op: 'add', id: b, data: 'B', type: NounType.Thing }
|
||
|
|
])
|
||
|
|
.catch((e: unknown) => (caught = e))
|
||
|
|
|
||
|
|
expect(caught).toBeInstanceOf(StoreInconsistentError)
|
||
|
|
const err = caught as StoreInconsistentError
|
||
|
|
expect(err.records.length).toBeGreaterThan(0)
|
||
|
|
// Every unreconciled record here is a durably-present orphan.
|
||
|
|
expect(err.records.every((r) => r.disposition === 'orphan')).toBe(true)
|
||
|
|
expect(err.message).toMatch(/WRITE-QUARANTINED/)
|
||
|
|
|
||
|
|
// Recover so afterEach can close cleanly.
|
||
|
|
for (const r of restore) r()
|
||
|
|
restore = []
|
||
|
|
await brain.repairIndex()
|
||
|
|
})
|
||
|
|
})
|