feat: entity-tree family stamp — sourceGeneration + rollup coherence at open
The canonical entity tree now carries a FAMILY STAMP (_system/family-stamps/entity-tree.json, JSON — forensics stay terminal-readable): which committed generation the tree reflects (sourceGeneration) plus the rollup invariants (entity/relationship counts) that verify a millions-of-files projection whole where per-file checks cannot scale. Written at flush/close boundaries; open-time coherence is a COMPARISON, not a walk: - coherent / absent (legacy store) → silent - behind → benign for the tree (it is written BY the commit; only the stamp is stale after a crash between commit and flush) — refreshes at the next flush - incoherent (counts diverged at equal generation, or a stamp AHEAD of the log head) → loud, names the failing invariant; repairIndex()'s unconditional recount heals and RE-STAMPS so repair leaves a coherent stamp behind - a fault reading the stamp is UNVERIFIABLE — never conflated with absence One verifier (verifyFamilyStamp, exported) reads both member modes: enumerated (exact byte size per member, bounded families) and rollup (invariants, unbounded families). New exports: readFamilyStamp, verifyFamilyStamp, ENTITY_TREE_STAMP_PATH, FamilyStamp, StampMembers, StampVerdict.
This commit is contained in:
parent
38b0041464
commit
2888ae6b40
4 changed files with 404 additions and 0 deletions
108
src/brainy.ts
108
src/brainy.ts
|
|
@ -168,6 +168,13 @@ import {
|
|||
} from './db/portableGraph.js'
|
||||
import { GenerationStore, type CommitBeforeImages } from './db/generationStore.js'
|
||||
import type { FactScanHandle } from './db/factLog.js'
|
||||
import {
|
||||
ENTITY_TREE_STAMP_PATH,
|
||||
readFamilyStamp,
|
||||
verifyFamilyStamp,
|
||||
writeFamilyStamp,
|
||||
type FamilyStamp
|
||||
} from './db/familyStamp.js'
|
||||
import {
|
||||
ChangeFeed,
|
||||
type BrainyChangeEvent,
|
||||
|
|
@ -992,6 +999,12 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
})
|
||||
}
|
||||
|
||||
// Entity-tree stamp coherence: compare the stamped sourceGeneration +
|
||||
// rollup invariants against the log head + live counters. Loud on
|
||||
// genuine incoherence (repairIndex heals), silent on absent/coherent,
|
||||
// benign-behind refreshes at the next flush. Never blocks open.
|
||||
await this.verifyEntityTreeStamp()
|
||||
|
||||
// 8.0 ⇄ native-provider version handshake: load the on-disk brain-format
|
||||
// marker (`_system/brain-format.json`) into an in-memory field NOW —
|
||||
// after the store-open phase, but BEFORE any derived index or native
|
||||
|
|
@ -10354,11 +10367,94 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
// Db pins and an explicit autoCompact: false.
|
||||
await this.autoCompactHistory()
|
||||
|
||||
// 7. Stamp the entity tree: which source generation the canonical tree
|
||||
// reflects + the rollup invariants that verify it whole (the counters
|
||||
// persisted in step 1). Written at flush boundaries — the tree tracks
|
||||
// every commit by construction, so the stamp is a durable checkpoint,
|
||||
// not a per-commit cost. Open compares stamp vs log head + rollups.
|
||||
await this.stampEntityTree()
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
|
||||
console.log(`All indexes flushed to disk in ${elapsed}ms`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Write the entity tree's FAMILY STAMP: `sourceGeneration` (the
|
||||
* committed generation the canonical tree reflects — equal by construction,
|
||||
* since the tree is written by the commit itself) plus the rollup invariants
|
||||
* (entity/relationship counts) that verify the tree whole where per-file
|
||||
* checks cannot scale. Verified at open by {@link verifyEntityTreeStamp};
|
||||
* healed by `repairIndex()`, whose unconditional recount rebuilds the
|
||||
* rollups from a canonical walk and re-stamps. Best-effort: a stamp-write
|
||||
* fault warns loudly but never fails the flush that carried real data.
|
||||
*/
|
||||
private async stampEntityTree(): Promise<void> {
|
||||
if (this.isReadOnly) return
|
||||
try {
|
||||
const [nounCount, verbCount] = await Promise.all([
|
||||
this.storage.getNounCount(),
|
||||
this.storage.getVerbCount()
|
||||
])
|
||||
await writeFamilyStamp(this.storage, ENTITY_TREE_STAMP_PATH, {
|
||||
family: 'entity-tree',
|
||||
sourceGeneration: this.generationStore.generation(),
|
||||
members: { mode: 'rollup', invariants: { nounCount, verbCount } }
|
||||
})
|
||||
} catch (error) {
|
||||
prodLog.warn(
|
||||
`[Brainy] entity-tree stamp write failed (coherence checking degrades until the ` +
|
||||
`next successful flush): ${(error as Error).message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Open-time coherence check for the entity tree's family stamp:
|
||||
* compare `sourceGeneration` against the log head and the stamped rollup
|
||||
* invariants against the live counters. Verdicts:
|
||||
* - `coherent` / `absent` (legacy store; first flush stamps) → silent.
|
||||
* - `behind` → benign for the tree (it is written BY the commit; only the
|
||||
* stamp is stale — a crash landed between commit and flush). Refreshed at
|
||||
* the next flush.
|
||||
* - `incoherent` → LOUD: the tree or its counters diverged from what was
|
||||
* stamped — `repairIndex()` recounts from canonical and re-stamps.
|
||||
* Never blocks open; a fault reading the stamp is surfaced as unverifiable,
|
||||
* never conflated with absence.
|
||||
*/
|
||||
private async verifyEntityTreeStamp(): Promise<void> {
|
||||
let stamp: FamilyStamp | null
|
||||
try {
|
||||
stamp = await readFamilyStamp(this.storage, ENTITY_TREE_STAMP_PATH)
|
||||
} catch (error) {
|
||||
prodLog.warn(
|
||||
`[Brainy] entity-tree stamp is UNVERIFIABLE (read fault, not absence): ` +
|
||||
`${(error as Error).message}`
|
||||
)
|
||||
return
|
||||
}
|
||||
const [nounCount, verbCount] = await Promise.all([
|
||||
this.storage.getNounCount(),
|
||||
this.storage.getVerbCount()
|
||||
])
|
||||
const verdict = verifyFamilyStamp(stamp, this.generationStore.generation(), {
|
||||
nounCount,
|
||||
verbCount
|
||||
})
|
||||
if (verdict.state === 'incoherent') {
|
||||
prodLog.warn(
|
||||
`[Brainy] entity-tree stamp INCOHERENT at open: ${verdict.failures.join('; ')}. ` +
|
||||
`The canonical tree or its counters diverged from the stamped state — run ` +
|
||||
`brain.repairIndex() to recount from canonical and re-stamp.`
|
||||
)
|
||||
} else if (verdict.state === 'behind') {
|
||||
prodLog.debug(
|
||||
`[Brainy] entity-tree stamp is behind the log head (${verdict.stampSource} < ` +
|
||||
`${verdict.head}) — benign (stamped at last flush); refreshes at the next flush.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the writer process serving this data directory to flush its in-memory
|
||||
* indexes to disk, so a read-only inspector can observe fresh state.
|
||||
|
|
@ -15373,6 +15469,11 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
await pruner.rebuildTypeCounts?.()
|
||||
await pruner.rebuildSubtypeCounts?.()
|
||||
|
||||
// The recount changed the rollup truth — re-stamp the entity tree so the
|
||||
// stamp's invariants match the healed counters (repair leaves a coherent
|
||||
// stamp, not a stale one that warns on the next open).
|
||||
await this.stampEntityTree()
|
||||
|
||||
// VFS containment reconciliation: heal "cosmetic ghost" edges left by
|
||||
// pre-fix renames (an entity Contains-linked from BOTH its old and new
|
||||
// directory — readdir listed it in two places) and duplicate edges from
|
||||
|
|
@ -15775,6 +15876,13 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
})()
|
||||
])
|
||||
|
||||
// Stamp the entity tree at the close boundary (counters + counter now
|
||||
// durable from Phase 1/0a), so a cleanly-closed store reopens COHERENT
|
||||
// instead of benign-behind. Best-effort, never blocks the close.
|
||||
if (this.generationStore && !this.isReadOnly) {
|
||||
await this.stampEntityTree()
|
||||
}
|
||||
|
||||
// Phase 2: Close components to release resources (timers, file handles)
|
||||
// Data is already safe on disk from Phase 1
|
||||
await Promise.all([
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue