restore() removed the entire live brain directory and then fs.cp'd the snapshot in, so any copy failure left the store destroyed with only a partial copy. The sharpest edge: fs.cp materialized the holes of sparse mmap blob files, so a snapshot that fits on disk could balloon and ENOSPC mid-copy — the recovery tool destroying the brain it was asked to recover. Rewrite restoreFromDirectory as stage → verify → atomic swap: - Copy the snapshot into a _restore_staging area BEFORE touching live data, sparse-aware (copyTreeSparse/copyFileSparse skip all-zero 4 MiB chunks, so a mostly-hole store restores at its true allocated size, not its apparent size). - On any copy failure (ENOSPC included) remove only the half-written staging area and throw — the live store is left exactly as it was. - Only after the copy succeeds, fsync a completion marker naming the staged entries, then swapStagedRestoreIn(): an idempotent per-entry rm-old + rename-staged-in (same-filesystem renames that cannot ENOSPC), removing stale live entries the snapshot lacks. - completeInterruptedRestore(), wired into init() right after the root dir is ensured, finishes a crash mid-swap FORWARD (resume a committed swap) or discards an uncommitted staging area (live still authoritative). _restore_staging is excluded from snapshots. persist() (hard-link snapshot) was already safe and is unchanged; no public API change. Regression (tests/integration/restore-nondestructive.test.ts): a forced copy failure leaves live data fully intact and cleans staging; a normal restore round-trips to the snapshot; an interrupted-but-committed restore completes on reopen; an uncommitted staging area is discarded on open; the sparse copy is byte-identical with allocated blocks far below apparent size.
168 lines
7.2 KiB
TypeScript
168 lines
7.2 KiB
TypeScript
/**
|
|
* @module tests/integration/restore-nondestructive
|
|
* @description Regression for a consumer-reported recovery hazard: `restore()`
|
|
* removed the entire live brain directory and THEN `fs.cp`'d the snapshot in, so
|
|
* a copy failure (most dangerously ENOSPC — `fs.cp` also materializes the holes
|
|
* of sparse mmap blobs, ballooning a snapshot that would otherwise fit) left the
|
|
* store destroyed with only a partial copy: the recovery tool could destroy the
|
|
* brain it was meant to recover.
|
|
*
|
|
* Fix: the snapshot is copied into a staging area (sparse-aware) BEFORE any live
|
|
* data is touched; only after the copy succeeds and a marker is fsync'd does an
|
|
* atomic per-entry swap move it into place. A copy failure leaves the live store
|
|
* exactly as it was; a crash mid-swap is resumed forward on the next open.
|
|
*
|
|
* These tests exercise the property directly: a forced copy failure leaves live
|
|
* data intact, a normal restore round-trips correctly, an interrupted-but-
|
|
* committed staging area is completed on reopen, and the sparse copy preserves
|
|
* holes byte-for-byte.
|
|
*/
|
|
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'
|
|
|
|
const STAGING = '_restore_staging'
|
|
const MARKER = '.restore-manifest.json'
|
|
|
|
describe('non-destructive restore (BRAINY-RESTORE-DESTRUCTIVE)', () => {
|
|
let root: string
|
|
let snap: string
|
|
let brain: any
|
|
|
|
const openBrain = async (dir: string) => {
|
|
const b = new Brainy({
|
|
requireSubtype: false,
|
|
storage: { type: 'filesystem', path: dir },
|
|
dimensions: 384,
|
|
silent: true
|
|
})
|
|
await b.init()
|
|
return b
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true'
|
|
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-restore-'))
|
|
root = path.join(base, 'store')
|
|
snap = path.join(base, 'snapshot')
|
|
brain = await openBrain(root)
|
|
})
|
|
|
|
afterEach(async () => {
|
|
try { await brain.close() } catch { /* already closed in a test */ }
|
|
fs.rmSync(path.dirname(root), { recursive: true, force: true })
|
|
})
|
|
|
|
it('a copy failure leaves the LIVE store completely intact (the core property)', async () => {
|
|
const keep = await brain.add({ id: '00000000-0000-4000-8000-0000000000c1', data: 'in snapshot', type: NounType.Thing })
|
|
await brain.flush()
|
|
await brain['storage'].snapshotToDirectory(snap)
|
|
// Mutate live AFTER the snapshot — this state must survive a failed restore.
|
|
const extra = await brain.add({ id: '00000000-0000-4000-8000-0000000000c2', data: 'live only', type: NounType.Thing })
|
|
|
|
// Force the staging copy to fail on its second entry (partial staging).
|
|
const storage = brain['storage']
|
|
const realCopy = storage.copyTreeSparse.bind(storage)
|
|
let calls = 0
|
|
storage.copyTreeSparse = async (src: string, dest: string) => {
|
|
if (++calls === 2) throw new Error('simulated ENOSPC')
|
|
return realCopy(src, dest)
|
|
}
|
|
|
|
await expect(brain.restore(snap, { confirm: true })).rejects.toThrow(/untouched/)
|
|
|
|
// Live data — BOTH the snapshot entity and the live-only mutation — survives.
|
|
expect(await brain.get(keep)).not.toBeNull()
|
|
expect(await brain.get(extra)).not.toBeNull()
|
|
// The half-written staging area was cleaned up.
|
|
expect(fs.existsSync(path.join(root, STAGING))).toBe(false)
|
|
})
|
|
|
|
it('a normal restore round-trips to the snapshot state', async () => {
|
|
const a = await brain.add({ id: '00000000-0000-4000-8000-0000000000c3', data: 'A', type: NounType.Thing })
|
|
await brain.flush()
|
|
await brain['storage'].snapshotToDirectory(snap)
|
|
// Diverge from the snapshot: add B, remove A.
|
|
const b = await brain.add({ id: '00000000-0000-4000-8000-0000000000c4', data: 'B', type: NounType.Thing })
|
|
await brain.remove(a)
|
|
expect(await brain.get(a)).toBeNull()
|
|
expect(await brain.get(b)).not.toBeNull()
|
|
|
|
await brain.restore(snap, { confirm: true })
|
|
|
|
// Back to exactly the snapshot: A present, B gone.
|
|
expect(await brain.get(a)).not.toBeNull()
|
|
expect(await brain.get(b)).toBeNull()
|
|
expect(fs.existsSync(path.join(root, STAGING))).toBe(false)
|
|
})
|
|
|
|
it('an interrupted-but-committed restore is completed on the next open (resume forward)', async () => {
|
|
const a = await brain.add({ id: '00000000-0000-4000-8000-0000000000c5', data: 'A', type: NounType.Thing })
|
|
await brain.flush()
|
|
await brain['storage'].snapshotToDirectory(snap)
|
|
const b = await brain.add({ id: '00000000-0000-4000-8000-0000000000c6', data: 'B', type: NounType.Thing })
|
|
await brain.flush()
|
|
await brain.close()
|
|
|
|
// Simulate the state right after the staging copy committed but before the
|
|
// swap ran: a _restore_staging holding the snapshot's entries + the marker.
|
|
const staging = path.join(root, STAGING)
|
|
fs.mkdirSync(staging, { recursive: true })
|
|
const entries: string[] = []
|
|
for (const entry of fs.readdirSync(snap)) {
|
|
if (entry === 'locks' || entry === STAGING) continue
|
|
fs.cpSync(path.join(snap, entry), path.join(staging, entry), { recursive: true })
|
|
entries.push(entry)
|
|
}
|
|
fs.writeFileSync(path.join(staging, MARKER), JSON.stringify({ entries }))
|
|
|
|
// Reopen → init resumes the swap → the store is the snapshot (A, no B).
|
|
brain = await openBrain(root)
|
|
expect(await brain.get(a)).not.toBeNull()
|
|
expect(await brain.get(b)).toBeNull()
|
|
expect(fs.existsSync(staging)).toBe(false)
|
|
})
|
|
|
|
it('an uncommitted staging area (no marker) is discarded on open, live untouched', async () => {
|
|
const a = await brain.add({ id: '00000000-0000-4000-8000-0000000000c7', data: 'A live', type: NounType.Thing })
|
|
await brain.flush()
|
|
await brain.close()
|
|
|
|
// A staging dir with NO marker = a copy that never committed → debris.
|
|
const staging = path.join(root, STAGING)
|
|
fs.mkdirSync(staging, { recursive: true })
|
|
fs.writeFileSync(path.join(staging, 'entities'), 'garbage partial copy')
|
|
|
|
brain = await openBrain(root)
|
|
expect(await brain.get(a)).not.toBeNull() // live authoritative
|
|
expect(fs.existsSync(staging)).toBe(false) // debris removed
|
|
})
|
|
|
|
it('the sparse copy preserves holes: content byte-identical, allocation far below apparent size', async () => {
|
|
const base = path.dirname(root)
|
|
const src = path.join(base, 'sparse-src.bin')
|
|
const dest = path.join(base, 'sparse-dest.bin')
|
|
const SIZE = 32 * 1024 * 1024 // 32 MiB apparent
|
|
const DATA = Buffer.from('hello sparse world')
|
|
|
|
// Create a sparse source: 32 MiB of holes with a little real data at the start.
|
|
const fh = fs.openSync(src, 'w')
|
|
fs.ftruncateSync(fh, SIZE)
|
|
fs.writeSync(fh, DATA, 0, DATA.length, 0)
|
|
fs.closeSync(fh)
|
|
|
|
const storage = brain['storage']
|
|
const stat = fs.statSync(src)
|
|
await storage.copyFileSparse(src, dest, stat.size, stat.mode)
|
|
|
|
// Same apparent size and byte-identical content…
|
|
const destStat = fs.statSync(dest)
|
|
expect(destStat.size).toBe(SIZE)
|
|
expect(fs.readFileSync(dest).equals(fs.readFileSync(src))).toBe(true)
|
|
// …but the destination is actually sparse (allocated bytes far below size).
|
|
expect(destStat.blocks * 512).toBeLessThan(SIZE / 2)
|
|
})
|
|
})
|