2026-09-02 10:56:32 -07:00
/ * *
* @module tests / integration / readonly - close - no - marker
* @description A READ - ONLY BRAIN WRITES NO CLEAN - SHUTDOWN EVIDENCE .
*
* ` _system/clean-shutdown.json ` is the WRITER 's own word about the writer' s
* own process : "everything above this line, from THIS session, is durable."
* Two call sites treated a reader exactly like a writer :
*
* 1 . ` Brainy#closeDurableSteps() ` called ` generationStore.close() `
* unconditionally — a reader ' s close re - stamped the marker at the
* generation the reader merely OBSERVED , never committed .
* 2 . ` GenerationStore#open() ` consumed ( deleted ) the marker on every open ,
* reader or writer alike , so a reader that never got to a matching
* close left the store looking crashed to the next writer .
*
* Both are fixed by making a read - only brain leave ` _system/ ` exactly as it
* found it — at open AND at close . Pinned here :
*
* 1 . ` _system/ ` is byte - for - byte identical ( file set + contents ) before and
* after a reader opens a cleanly - closed store , reads it , and closes .
* 2 . After the reader ' s close , the next WRITER open adopts the marker as
* clean — no recovery fold narrates .
* 3 . A reader creates no file under ` _system/ ` merely by opening ( before it
* ever closes ) .
* 4 . A reader that opens and is then abandoned ( crash - style , no close ) does
* not force the next writer to pay a recovery fold — the concrete harm
* the fix closes .
* /
import { describe , it , expect , beforeEach , afterEach } from 'vitest'
import { mkdtempSync , rmSync , readdirSync , readFileSync , statSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Brainy } from '../../src/brainy.js'
import { NounType } from '../../src/types/graphTypes.js'
import { abandonAsCrashed } from '../helpers/durabilityKillMatrix.js'
function makeTempDir ( ) : string {
return mkdtempSync ( join ( tmpdir ( ) , 'brainy-readonly-close-' ) )
}
/** Recursively hash every regular file under `dir`, keyed by its path relative to `dir`. */
function snapshotDir ( dir : string ) : Map < string , string > {
const out = new Map < string , string > ( )
const walk = ( rel : string ) : void = > {
const abs = rel ? join ( dir , rel ) : dir
let entries : string [ ]
try {
entries = readdirSync ( abs )
} catch {
return
}
for ( const name of entries ) {
const childRel = rel ? join ( rel , name ) : name
const childAbs = join ( dir , childRel )
const st = statSync ( childAbs )
if ( st . isDirectory ( ) ) {
walk ( childRel )
} else if ( st . isFile ( ) ) {
const hash = createHash ( 'sha256' ) . update ( readFileSync ( childAbs ) ) . digest ( 'hex' )
out . set ( childRel , hash )
}
}
}
walk ( '' )
return out
}
/** Capture console.warn lines (the narration channel — see `prodLog.narrate`) while `fn` runs. */
async function captureWarn < T > ( fn : ( ) = > Promise < T > ) : Promise < { result : T ; lines : string [ ] } > {
const lines : string [ ] = [ ]
const orig = console . warn
console . warn = ( ( . . . args : unknown [ ] ) = > {
lines . push ( args . map ( ( a ) = > String ( a ) ) . join ( ' ' ) )
} ) as typeof console . warn
try {
return { result : await fn ( ) , lines }
} finally {
console . warn = orig
}
}
describe ( 'a read-only brain writes no clean-shutdown evidence' , ( ) = > {
let dir : string
let brain : Brainy | null = null
beforeEach ( ( ) = > {
dir = makeTempDir ( )
} )
afterEach ( async ( ) = > {
if ( brain ) {
try {
await brain . close ( )
} catch {
/* already closed */
}
brain = null
}
try {
rmSync ( dir , { recursive : true , force : true } )
} catch {
/* ignore */
}
} )
const systemDir = ( ) = > join ( dir , '_system' )
/ * *
* The marker file ' s actual on - disk name — ` clean-shutdown.json ` or , under
* FileSystemStorage ' s default gzip compression , ` clean-shutdown.json.gz ` .
* Returns null when absent .
* /
const findMarkerPath = ( ) : string | null = > {
let entries : string [ ]
try {
entries = readdirSync ( systemDir ( ) )
} catch {
return null
}
const name = entries . find ( ( n ) = > n . startsWith ( 'clean-shutdown.json' ) )
return name ? join ( systemDir ( ) , name ) : null
}
it ( 'leaves `_system/`\'s file set and the clean-shutdown marker\'s bytes identical across a reader open → read → close' , async ( ) = > {
// A writer opens, writes, and closes cleanly — the marker lands at
// whatever generation the writer actually committed.
const writer = new Brainy ( { requireSubtype : false , storage : { type : 'filesystem' , path : dir } } )
await writer . init ( )
await writer . add ( { data : 'seed entity' , type : NounType . Concept } )
await writer . add ( { data : 'second entity' , type : NounType . Concept } )
await writer . flush ( )
await writer . close ( )
const markerBeforePath = findMarkerPath ( )
expect ( markerBeforePath , 'the writer left a clean-shutdown marker' ) . not . toBeNull ( )
const before = snapshotDir ( systemDir ( ) )
expect ( before . size ) . toBeGreaterThan ( 0 )
const markerBeforeHash = before . get (
( markerBeforePath as string ) . slice ( systemDir ( ) . length + 1 )
)
expect ( markerBeforeHash ) . toBeTruthy ( )
// A reader opens the same store, reads, and closes.
brain = await Brainy . openReadOnly ( { storage : { type : 'filesystem' , path : dir } } )
expect ( brain . isReadOnly ) . toBe ( true )
await brain . stats ( )
await brain . close ( )
brain = null
// The FILE SET under `_system/` is unchanged — a reader creates and
fix(close): a read-only brain writes nothing under `_system/`
`readonly-close-no-marker` closed the clean-shutdown-marker half of this law and
named the rest as a known residual. This is that residual, closed.
MEASURED on the base: a read-only open → read → close rewrote FOUR files —
`_system/__metadata_field_registry__.json.gz`, `type-statistics.json.gz`,
`subtype-statistics.json.gz` and `verb-subtype-statistics.json.gz`. An IDLE
reader that only opened and closed rewrote all four as well.
The cause was not the closes the marker fix guarded. It was Phase 1 of
closeDurableSteps, where every component flush ran unconditionally. A flush is a
write by definition: MetadataIndexManager#flush() saves the field registry "even
with no dirty fields" (its own comment), and the storage adapter's count flush
re-stamps the three statistics files. A session that committed nothing re-stamped
all four. Phase 2's closes were ungated too — the graph index's close drains both
LSM MemTables to SSTables and stamps its watermark, and the optional
vector/metadata `close` hooks (unimplemented in the reference engine, filled in
by a native provider) persist buffered state.
Every one of those calls now carries the same `!isReadOnly` guard the generation
store already had.
A reader still RELEASES what it holds, so Phase 2 is a branch rather than a skip:
GraphAdjacencyIndex gains `stopBackgroundFlush()`, the non-writing half of its
close, which clears the auto-flush interval that would otherwise outlive the
session. `close()` now calls it too, so there is one place that owns the timer.
Why this matters beyond tidiness: `_system/` is where a store keeps its evidence
about itself — what the writer committed, what the projections have seen. A
reader that rewrites any of it vouches for a state it only observed, and on
shared or snapshot storage it mutates bytes another process owns.
The pin hashes every file under `_system/` (and, in one case, the whole store)
across a reader's open → read → close, names the four paths that used to move so
a regression says which subsystem did it, and asserts the asymmetry holds in the
other direction — a WRITER's close still persists.
2026-09-02 13:48:25 -07:00
// removes nothing. This pin is specifically about the generation store's
// clean-shutdown evidence. The wider law — that a reader leaves EVERY
// file under `_system/` byte-identical, which this fix left open as a
// known residual (the metadata field registry and the three statistics
// files were still re-stamped by a reader's close) — is closed and pinned
// in `readonly-close-writes-nothing.test.ts`.
2026-09-02 10:56:32 -07:00
const after = snapshotDir ( systemDir ( ) )
expect ( [ . . . after . keys ( ) ] . sort ( ) ) . toEqual ( [ . . . before . keys ( ) ] . sort ( ) )
// The MARKER's bytes are byte-for-byte identical — the reader neither
// consumed it at open nor re-stamped it at close.
const markerAfterPath = findMarkerPath ( )
expect ( markerAfterPath , 'the marker must still exist, under the same name' ) . toBe ( markerBeforePath )
const markerAfterHash = after . get ( ( markerAfterPath as string ) . slice ( systemDir ( ) . length + 1 ) )
expect ( markerAfterHash ) . toBe ( markerBeforeHash )
} , 120 _000 )
it ( 'creates no file under `_system/` merely by opening read-only' , async ( ) = > {
const writer = new Brainy ( { requireSubtype : false , storage : { type : 'filesystem' , path : dir } } )
await writer . init ( )
await writer . add ( { data : 'seed entity' , type : NounType . Concept } )
await writer . flush ( )
await writer . close ( )
const baselineNames = [ . . . snapshotDir ( systemDir ( ) ) . keys ( ) ] . sort ( )
expect ( baselineNames . length ) . toBeGreaterThan ( 0 )
// Open the reader and inspect `_system/` BEFORE it ever closes — open()
// alone must create nothing.
brain = await Brainy . openReadOnly ( { storage : { type : 'filesystem' , path : dir } } )
const whileOpenNames = [ . . . snapshotDir ( systemDir ( ) ) . keys ( ) ] . sort ( )
expect ( whileOpenNames ) . toEqual ( baselineNames )
await brain . close ( )
brain = null
} , 120 _000 )
it ( 'a writer reopening after the reader closes adopts the marker — no recovery fold' , async ( ) = > {
const writer1 = new Brainy ( { requireSubtype : false , storage : { type : 'filesystem' , path : dir } } )
await writer1 . init ( )
await writer1 . add ( { data : 'seed entity' , type : NounType . Concept } )
await writer1 . flush ( )
await writer1 . close ( )
// A reader opens and closes in between — must not disturb the marker.
const reader = await Brainy . openReadOnly ( { storage : { type : 'filesystem' , path : dir } } )
await reader . stats ( )
await reader . close ( )
// The next writer open must be a clean, no-fold open: no
// "log-authority recovery" / "WHOLE-LOG fold" narration line.
const { result : writer2 , lines } = await captureWarn ( async ( ) = > {
const w = new Brainy ( { requireSubtype : false , storage : { type : 'filesystem' , path : dir } } )
await w . init ( )
return w
} )
brain = writer2
const foldLines = lines . filter ( ( l ) = > /log-authority recovery|WHOLE-LOG fold|recovery fold/i . test ( l ) )
expect ( foldLines , ` unexpected recovery narration: \ n ${ foldLines . join ( '\n' ) } ` ) . toEqual ( [ ] )
// And the store is exactly what the first writer left — the seed row is
// still there, nothing was rolled back or re-derived.
const found = await writer2 . find ( { where : { } } as any )
expect ( found . length ) . toBeGreaterThanOrEqual ( 1 )
} , 120 _000 )
it ( 'a reader that opens and is then abandoned (never closes) does not force the next writer to fold' , async ( ) = > {
// This is the concrete harm the fix closes: pre-fix, a reader's open()
// unconditionally DELETED the marker (consuming it as if it were the
// writer). A reader that opened and then died — no close, exactly like
// a killed process — left the marker gone, so the actual writer's next
// open read the store as crashed and paid a full recovery fold for a
// "crash" that was really just a reader that came and went.
const writer1 = new Brainy ( { requireSubtype : false , storage : { type : 'filesystem' , path : dir } } )
await writer1 . init ( )
await writer1 . add ( { data : 'seed entity' , type : NounType . Concept } )
await writer1 . flush ( )
await writer1 . close ( )
const reader = await Brainy . openReadOnly ( { storage : { type : 'filesystem' , path : dir } } )
await reader . stats ( )
// NEVER calls reader.close() — abandon it exactly like a killed process.
await abandonAsCrashed ( reader )
const { result : writer2 , lines } = await captureWarn ( async ( ) = > {
const w = new Brainy ( { requireSubtype : false , storage : { type : 'filesystem' , path : dir } } )
await w . init ( )
return w
} )
brain = writer2
const foldLines = lines . filter ( ( l ) = > /log-authority recovery|WHOLE-LOG fold|recovery fold/i . test ( l ) )
expect (
foldLines ,
` an abandoned READER forced a recovery fold on the next writer open: \ n ${ foldLines . join ( '\n' ) } `
) . toEqual ( [ ] )
} , 120 _000 )
} )