open-brainy/tests/integration/writer-lock-clean-close.test.ts

251 lines
10 KiB
TypeScript
Raw Normal View History

fix(storage): a clean close is recorded, and the writer lock is always given up A production restart made this necessary: a service stopped with exit code 0, having awaited close() on every pooled brain, and its next boot announced "Overwriting stale writer lock ... appears dead" for every store it owned. Nothing had crashed. "The recorded pid is gone" is equally true of an orderly restart and of a crash, so the verdict could not tell an operator which one they had — and when the OS recycles a pid it fails the other way, refusing to open a store whose writer died days ago. Three changes, all at the law: - close() is two parts, and the second is unconditional. The durable steps (flush, markers, component close, plugin deactivate, buffer drain) move to closeDurableSteps(); the terminal releases — the flush-request watcher, the WRITER LOCK, the VFS timers, the terminal `closed` flag — always run. The original failure is narrated with what it costs the next open, then rethrown. - releaseWriterLock() writes a CLEAN-CLOSE RECORD (`locks/_writer.close`) naming the lock generation it released; the next claim consumes it, so a record can never vouch for a later crash. An open reads the record instead of guessing: recorded → nothing to recover; absent → say so, and name the crash recovery this open will now run. - The signal path stops failing in a batch. It was one try around a loop over every open brain, so the first instance whose flush rejected stranded every remaining brain's lock and markers — at exit code 0. Now: per-instance isolation, the generation store's close (the clean-shutdown marker, without which the next open folds the whole log) is part of shutdown, the lock is given up in a finally, and the handler no longer calls process.exit() when the host application has its own signal handler — that race truncated the host's own close() mid-flight. Pins: tests/integration/writer-lock-clean-close.test.ts — completed close leaves no lock and a consumed-once record with a silent reopen; a failing durable step still releases and still rethrows; SIGKILL leaves the lock with no record and the reopen names the crash; a host SIGTERM handler runs to completion. Branch plan (10 lines): 1. writer lock: clean-close record + always-release close [this commit] 2. open narration: an always-on channel; production clamps prodLog to ERROR, which is why a three-minute open printed nothing 3. open narration: per-phase lines as each phase ENDS, with progress cadence 4. measure both real-store fixtures on the box, before/after 5. move the generation-log fold out of the foreground where the serving law allows; durable resumable progress marker 6. same for the VFS bootstrap 7. counts: a legacy container-rule ledger must not keep serving wrong denominators; counts.json written atomically 8. counts pin with scar directories; two copies of one archive agree 9. docs/canonical-layout-ratification.md — 12 facts confirmed/corrected 10. report: MEASURED before/after, findings, and whether this is 10.4.4
2026-08-28 10:17:20 -07:00
/**
* @module tests/integration/writer-lock-clean-close
* @description THE CLEAN-CLOSE CONTRACT for the writer lock.
*
* A production restart made this lane necessary: a service stopped with exit
* code 0, having awaited `close()` on every pooled brain, and its next boot
* announced `[brainy] Overwriting stale writer lock … appears dead` for every
* store it owned. "The pid is gone" is equally true of an orderly restart and
* of a crash, so the message could not tell an operator which one they had.
*
* The contract pinned here:
* 1. A completed close leaves NO lock file and DOES leave a clean-close
* record; the next open says nothing about staleness.
* 2. The next lock claim CONSUMES that record it may never outlive the
* lock generation it describes, or a later crash would read as clean.
* 3. A close whose durable steps FAIL still releases the lock (and still
* rethrows the failure).
* 4. A killed process (SIGKILL, no close at all) leaves the lock behind with
* NO record, and the next open says exactly that crash, recovery ahead.
* 5. A host application with its own SIGTERM handler is never force-exited
* out from under its own shutdown by Brainy's handler.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
import { spawn } from 'node:child_process'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Brainy } from '../../src/brainy.js'
import { NounType } from '../../src/types/graphTypes.js'
const REPO_ROOT = process.cwd()
const TSX = join(REPO_ROOT, 'node_modules', '.bin', 'tsx')
function makeTempDir(): string {
return mkdtempSync(join(tmpdir(), 'brainy-clean-close-'))
}
/**
* Write a child script to disk and start it under tsx. A file (not `tsx -e`)
* because the eval form compiles to CommonJS, which has no top-level await.
* The script imports Brainy by ABSOLUTE path, so its own dependency
* resolution still happens from inside the repository.
*/
function startChild(dir: string, body: string): ReturnType<typeof spawn> {
const scriptPath = join(dir, 'child-process.mts')
writeFileSync(scriptPath, body)
// `detached` puts the child in its own process GROUP: tsx runs the script in
// a grandchild process, and only a group-wide signal reaches the process
// that actually holds the writer lock.
return spawn(TSX, [scriptPath], {
cwd: REPO_ROOT,
stdio: ['ignore', 'pipe', 'pipe'],
detached: true
})
}
/** Capture every console.warn/error line emitted while `fn` runs. */
async function captureConsole<T>(fn: () => Promise<T>): Promise<{ result: T; lines: string[] }> {
const lines: string[] = []
const origWarn = console.warn
const origError = console.error
const sink = (...args: unknown[]) => {
lines.push(args.map((a) => String(a)).join(' '))
}
console.warn = sink as typeof console.warn
console.error = sink as typeof console.error
try {
const result = await fn()
return { result, lines }
} finally {
console.warn = origWarn
console.error = origError
}
}
/**
* Run a child process that opens `dir`, writes one row, prints `READY`, and
* then waits forever. Resolves with the child once READY is seen.
*/
function spawnHoldingChild(dir: string): Promise<{
child: ReturnType<typeof spawn>
output: () => string
}> {
const script = `
import { Brainy } from ${JSON.stringify(join(REPO_ROOT, 'src', 'brainy.ts'))}
const brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: ${JSON.stringify(dir)} } })
await brain.init()
await brain.add({ data: 'row from the child', type: 'concept' })
await brain.flush()
console.log('READY')
setInterval(() => {}, 1000)
`
const child = startChild(dir, script)
let out = ''
child.stdout.on('data', (d) => { out += String(d) })
child.stderr.on('data', (d) => { out += String(d) })
return new Promise((resolvePromise, rejectPromise) => {
const timer = setTimeout(() => rejectPromise(new Error(`child never became READY:\n${out}`)), 120_000)
child.stdout.on('data', () => {
if (out.includes('READY')) {
clearTimeout(timer)
resolvePromise({ child, output: () => out })
}
})
child.on('exit', (code) => {
clearTimeout(timer)
if (!out.includes('READY')) rejectPromise(new Error(`child exited ${code} before READY:\n${out}`))
})
})
}
describe('writer lock — the clean-close contract', () => {
let dir: string
let brain: Brainy | null = null
beforeEach(() => { dir = makeTempDir() })
afterEach(async () => {
if (brain) {
try { await brain.close() } catch { /* may already be closed */ }
brain = null
}
try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ }
})
const lockPath = () => join(dir, 'locks', '_writer.lock')
const recordPath = () => join(dir, 'locks', '_writer.close')
it('a completed close leaves no lock, leaves a record, and the reopen is silent about staleness', async () => {
brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
await brain.init()
expect(existsSync(lockPath())).toBe(true)
await brain.add({ data: 'seed entity', type: NounType.Concept })
await brain.flush()
await brain.close()
brain = null
// 1. The lock is gone and the release is RECORDED.
expect(existsSync(lockPath())).toBe(false)
expect(existsSync(recordPath())).toBe(true)
const record = JSON.parse(readFileSync(recordPath(), 'utf-8'))
expect(record.pid).toBe(process.pid)
expect(typeof record.closedAt).toBe('string')
expect(typeof record.startedAt).toBe('string')
// 2. The reopen says nothing about a stale lock.
const { result: reopened, lines } = await captureConsole(async () => {
const next = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
await next.init()
return next
})
brain = reopened
expect(lines.filter((l) => /stale writer lock|appears dead/i.test(l))).toEqual([])
// 3. The claim CONSUMED the record — it must not outlive its lock generation.
expect(existsSync(recordPath())).toBe(false)
expect(existsSync(lockPath())).toBe(true)
}, 120_000)
it('releases the writer lock even when a durable close step fails — and still rethrows', async () => {
brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
await brain.init()
await brain.add({ data: 'seed entity', type: NounType.Concept })
await brain.flush()
expect(existsSync(lockPath())).toBe(true)
// Inject a failure into a durable close step (the counts flush).
const storage = (brain as unknown as { storage: { flushCounts: () => Promise<void> } }).storage
const boom = new Error('injected: counts flush failed during close')
storage.flushCounts = async () => { throw boom }
await expect(brain.close()).rejects.toThrow(/injected: counts flush failed/)
brain = null
// The lock is released regardless: a process on its way out holds nothing.
expect(existsSync(lockPath())).toBe(false)
// And the next writer opens without a stale-lock verdict.
const { lines } = await captureConsole(async () => {
const next = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
await next.init()
await next.close()
})
expect(lines.filter((l) => /appears dead/i.test(l))).toEqual([])
}, 120_000)
it('a SIGKILLed writer leaves the lock with no record, and the next open names the crash', async () => {
const { child } = await spawnHoldingChild(dir)
expect(existsSync(lockPath())).toBe(true)
expect(existsSync(recordPath())).toBe(false)
// Group-wide: the lock holder is tsx's grandchild, not the spawned pid.
process.kill(-(child.pid as number), 'SIGKILL')
await new Promise<void>((r) => child.on('exit', () => r()))
// The grandchild's death is asynchronous with the wrapper's exit event.
await new Promise<void>((r) => setTimeout(r, 500))
// The lock survives the kill — a dead process releases nothing.
expect(existsSync(lockPath())).toBe(true)
expect(existsSync(recordPath())).toBe(false)
const { lines } = await captureConsole(async () => {
const next = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
await next.init()
await next.close()
})
const verdict = lines.filter((l) => /Overwriting stale writer lock/i.test(l))
expect(verdict.length).toBe(1)
// The verdict must name the ABSENT record and the recovery it implies —
// not merely that a pid is gone.
expect(verdict[0]).toMatch(/NO\s+clean-close record/i)
expect(verdict[0]).toMatch(/crash recovery/i)
}, 180_000)
it("does not force-exit a host application that owns its own SIGTERM handler", async () => {
const script = `
import { Brainy } from ${JSON.stringify(join(REPO_ROOT, 'src', 'brainy.ts'))}
const brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: ${JSON.stringify(dir)} } })
await brain.init()
await brain.add({ data: 'row from the host app', type: 'concept' })
await brain.flush()
// The host application's OWN graceful shutdown, registered after Brainy's.
process.on('SIGTERM', async () => {
await new Promise((r) => setTimeout(r, 1500))
console.log('APP-CLOSE-DONE')
process.exit(0)
})
console.log('READY')
setInterval(() => {}, 1000)
`
const child = startChild(dir, script)
let out = ''
child.stdout.on('data', (d) => { out += String(d) })
child.stderr.on('data', (d) => { out += String(d) })
await new Promise<void>((r, reject) => {
const timer = setTimeout(() => reject(new Error(`child never became READY:\n${out}`)), 120_000)
child.stdout.on('data', () => { if (out.includes('READY')) { clearTimeout(timer); r() } })
child.on('exit', () => { clearTimeout(timer); if (!out.includes('READY')) reject(new Error(`child died:\n${out}`)) })
})
process.kill(-(child.pid as number), 'SIGTERM')
const code = await new Promise<number | null>((r) => child.on('exit', (c) => r(c)))
expect(code).toBe(0)
// The host's own shutdown ran to completion — Brainy's handler did not
// exit the process out from under it.
expect(out).toContain('APP-CLOSE-DONE')
}, 180_000)
})