Two halves of one defect, found by a seeded-SIGKILL crash lane.
THE FALSE POSITIVE. stampEntityTree() recorded generationStore.generation()
— the ALLOCATED counter, a number a write in flight has claimed and may
never commit — while the JSDoc beside it already said the source is the
committed generation. Every crash inside a write window therefore produced
a spurious verdict at the next open: either 'sourceGeneration N is ahead of
the log head N-1' (the allocated generation died with the process) or
'rollup invariant nounCount: stamped X, observed Y' (the recovery fold
folded facts the stamp's counts predate). Both told the operator to run
repairIndex() — a whole-store recount — for a store that was coherent.
Measured before this commit: 4 of 11 SIGKILL cycles on a healthy store
raised one of the two. The stamp and the open now both read
committedGeneration(), which is what every other open-time watermark in the
class already reasons about.
THE TERMINAL VERDICT. A stamp still ahead of committed truth after the
recovery fold witnesses a generation that is not in the log — the stamp's
fsync outlived the tail's, and there is nothing to arrive. That is its own
verdict state now ('torn'), never folded in with 'incoherent': the two have
opposite cures. A writer open demotes it — the unusable stamped surface is
re-derived at the committed generation from the live counters, O(1),
straight-line, no loop and no await on external progress, narrated with
both count sets, the stamp's path and its committedAt. A read-only open
cannot re-stamp, so it says so and names the cure instead of guessing, and
still serves. Neither branch waits, and neither locks an owner out of a
canonical tree the stamp only describes.
Pins: the verifier returns the torn verdict with both generations; a
fabricated head-behind-source store narrates precisely, demotes inside a
bounded open, serves its rows, and is quiet at the next open (the demotion
converges); a read-only open narrates the same verdict and leaves the bytes
untouched.
(cherry picked from commit 298cb6daca)
166 lines
6.9 KiB
TypeScript
166 lines
6.9 KiB
TypeScript
/**
|
|
* @module db/familyStamp
|
|
* @description The generalized FAMILY STAMP — one JSON shape that declares,
|
|
* for any derived projection, WHICH source state it reflects and HOW to verify
|
|
* it is whole. The entity tree (canonical current-state files) carries the
|
|
* first brainy-side stamp; native index families carry the same shape. One
|
|
* verifier reads both member modes:
|
|
*
|
|
* - `enumerated` — bounded families: exact byte size per member file,
|
|
* verified at open.
|
|
* - `rollup` — unbounded families (the entity tree: millions of files):
|
|
* the verified surface is a small set of rollup invariants (entity/
|
|
* relationship counts) plus `sourceGeneration`.
|
|
*
|
|
* `sourceGeneration` is the COMMITTED generation of the source-of-truth log
|
|
* this projection reflects — never the allocated counter, which names a
|
|
* generation that may never commit (see {@link StampVerdict.torn}) — so
|
|
* open-time coherence becomes a COMPARISON (stamp vs committed head), not a
|
|
* walk:
|
|
*
|
|
* - equal + invariants hold → coherent, serve.
|
|
* - behind → the projection missed the tail (crash between commit and stamp);
|
|
* for the Stage-1 tree this is benign by construction (the tree is written
|
|
* BY the commit), so the stamp refreshes; a DERIVED projection would replay
|
|
* the gap instead.
|
|
* - invariants FAIL at equal generation → genuine incoherence: loud, and the
|
|
* repair ritual (`repairIndex()`, whose recount rebuilds the rollups from a
|
|
* canonical walk) heals it.
|
|
* - AHEAD → a torn generation-log tail: the stamp's fsync outlived the log
|
|
* tail's. TERMINAL, never a wait — the generation the stamp names does not
|
|
* exist to arrive.
|
|
*
|
|
* Stamps are JSON on purpose — every incident gets debugged by reading a
|
|
* stamp in a terminal.
|
|
*/
|
|
|
|
/** Storage-root-relative directory holding family stamps. */
|
|
export const FAMILY_STAMPS_PREFIX = '_system/family-stamps'
|
|
|
|
/** The entity tree's stamp path. */
|
|
export const ENTITY_TREE_STAMP_PATH = `${FAMILY_STAMPS_PREFIX}/entity-tree.json`
|
|
|
|
/** One enumerated member: a file and its exact expected byte size. */
|
|
export interface EnumeratedMember {
|
|
path: string
|
|
bytes: number
|
|
}
|
|
|
|
/**
|
|
* The stamp's verified surface, in one of the two member modes. Rollup
|
|
* invariant values may be numbers (counts, byte sizes) or strings (content
|
|
* fingerprints, e.g. a per-tree SHA-256) — the verifier compares by strict
|
|
* equality either way, so a type mismatch reads as incoherence, never a pass.
|
|
*/
|
|
export type StampMembers =
|
|
| { mode: 'enumerated'; files: EnumeratedMember[] }
|
|
| { mode: 'rollup'; invariants: Record<string, number | string> }
|
|
|
|
/** The generalized family stamp (one shape, one verifier, both engines). */
|
|
export interface FamilyStamp {
|
|
/** Which projection this stamps (e.g. `'entity-tree'`). */
|
|
family: string
|
|
/** Monotonic per-family stamp generation — bumps on every committed stamp. */
|
|
generation: number
|
|
/** ISO timestamp of the stamp write. */
|
|
committedAt: string
|
|
/** The source-of-truth generation this projection reflects. */
|
|
sourceGeneration: number
|
|
/** The verified surface. */
|
|
members: StampMembers
|
|
}
|
|
|
|
/** The verdict of an open-time stamp verification. */
|
|
export type StampVerdict =
|
|
| { state: 'coherent' }
|
|
| { state: 'absent' } // legacy store — first stamp writes at the next flush
|
|
| { state: 'behind'; stampSource: number; head: number }
|
|
/**
|
|
* TORN GENERATION-LOG TAIL: the stamp witnesses a source generation the
|
|
* store's committed watermark can no longer show. TERMINAL — there is no
|
|
* generation to wait for, so the open demotes (or refuses) and never spins.
|
|
*/
|
|
| { state: 'torn'; stampSource: number; head: number }
|
|
| { state: 'incoherent'; failures: string[] }
|
|
| { state: 'unverifiable'; reason: string } // a FAULT reading the stamp — never conflated with absence
|
|
|
|
/** The narrow storage surface stamps ride (JSON objects + fsync). */
|
|
export interface StampStorage {
|
|
readRawObject(path: string): Promise<any | null>
|
|
writeRawObject(path: string, data: any): Promise<void>
|
|
syncRawObjects(paths: string[]): Promise<void>
|
|
}
|
|
|
|
/** Read a family's stamp; `null` when none was ever written. */
|
|
export async function readFamilyStamp(
|
|
storage: StampStorage,
|
|
path: string
|
|
): Promise<FamilyStamp | null> {
|
|
const stored = (await storage.readRawObject(path)) as FamilyStamp | null
|
|
if (!stored || typeof stored !== 'object' || typeof stored.family !== 'string') return null
|
|
return stored
|
|
}
|
|
|
|
/** Write a family's stamp durably (atomic object write + fsync). */
|
|
export async function writeFamilyStamp(
|
|
storage: StampStorage,
|
|
path: string,
|
|
stamp: Omit<FamilyStamp, 'generation' | 'committedAt'> & { generation?: number }
|
|
): Promise<void> {
|
|
const prior = await readFamilyStamp(storage, path)
|
|
const full: FamilyStamp = {
|
|
...stamp,
|
|
generation: (prior?.generation ?? 0) + 1,
|
|
committedAt: new Date().toISOString()
|
|
}
|
|
await storage.writeRawObject(path, full)
|
|
await storage.syncRawObjects([path])
|
|
}
|
|
|
|
/**
|
|
* The ONE verifier, both member modes. `actual` supplies the observed rollup
|
|
* values (rollup mode) or file sizes (enumerated mode, keyed by path);
|
|
* `head` is the source-of-truth generation now.
|
|
*/
|
|
export function verifyFamilyStamp(
|
|
stamp: FamilyStamp | null,
|
|
head: number,
|
|
actual: Record<string, number | string>
|
|
): StampVerdict {
|
|
if (stamp === null) return { state: 'absent' }
|
|
if (stamp.sourceGeneration > head) {
|
|
// A stamp AHEAD of committed truth witnesses a generation the store can no
|
|
// longer show: the stamp's fsync survived a crash that the log tail did
|
|
// not. This is the TORN GENERATION-LOG TAIL — its own class, never folded
|
|
// in with `incoherent` (a count that drifted at a generation both sides
|
|
// agree on), because the two have opposite cures: incoherence is recounted,
|
|
// a tear is DEMOTED. It is also terminal by construction — there is no
|
|
// generation the open can wait for, because the one the stamp names is
|
|
// gone.
|
|
return { state: 'torn', stampSource: stamp.sourceGeneration, head }
|
|
}
|
|
if (stamp.sourceGeneration < head) {
|
|
return { state: 'behind', stampSource: stamp.sourceGeneration, head }
|
|
}
|
|
const failures: string[] = []
|
|
if (stamp.members.mode === 'rollup') {
|
|
for (const [name, expected] of Object.entries(stamp.members.invariants)) {
|
|
const observed = actual[name]
|
|
if (observed === undefined) {
|
|
failures.push(`rollup invariant '${name}' has no observed value`)
|
|
} else if (observed !== expected) {
|
|
failures.push(`rollup invariant '${name}': stamped ${expected}, observed ${observed}`)
|
|
}
|
|
}
|
|
} else {
|
|
for (const member of stamp.members.files) {
|
|
const observed = actual[member.path]
|
|
if (observed === undefined) {
|
|
failures.push(`member '${member.path}' is missing`)
|
|
} else if (observed !== member.bytes) {
|
|
failures.push(`member '${member.path}': stamped ${member.bytes} bytes, observed ${observed}`)
|
|
}
|
|
}
|
|
}
|
|
return failures.length > 0 ? { state: 'incoherent', failures } : { state: 'coherent' }
|
|
}
|