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.
This commit is contained in:
parent
0d5ab6077d
commit
f27a777615
4 changed files with 322 additions and 16 deletions
|
|
@ -1105,13 +1105,31 @@ export class GraphAdjacencyIndex implements GraphIndexProvider {
|
|||
}
|
||||
|
||||
/**
|
||||
* Clean shutdown
|
||||
* Stop the auto-flush interval WITHOUT writing anything.
|
||||
*
|
||||
* The non-writing half of {@link close}, for a shutdown that must leave the
|
||||
* store byte-identical — a read-only brain's close. `close()` itself is a
|
||||
* writer: it drains both LSM MemTables to SSTables and stamps the watermark,
|
||||
* which is exactly right for a writer and forbidden for a reader. A reader
|
||||
* still has to release this interval, though: it is the one piece of this
|
||||
* index that outlives the close and could fire against a store the session no
|
||||
* longer owns.
|
||||
*
|
||||
* @returns Nothing.
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
stopBackgroundFlush(): void {
|
||||
if (this.flushTimer) {
|
||||
clearInterval(this.flushTimer)
|
||||
this.flushTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean shutdown — drains both trees and stamps the watermark. THIS WRITES;
|
||||
* a read-only brain must call {@link stopBackgroundFlush} instead.
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
this.stopBackgroundFlush()
|
||||
|
||||
// Close both LSM-trees (will flush MemTables to SSTables)
|
||||
if (this.initialized) {
|
||||
|
|
|
|||
Reference in a new issue