fix: spine hardening pass 1 (part) — count symmetry, honest partial-load, flush durability, read-fault propagation
Write/index-spine hardening, first batch of Pass 1. Each fix restores an invariant the surrounding code already intended; every one has a fail-before/pass-after test. - Pattern C, finding 5 (baseStorage): delete now decrements the user-facing scalar total symmetrically — deleteNounMetadata was decrementing only the per-type bucket, deleteVerbMetadata neither the bucket nor the scalar, so getNounCount()/getVerbCount() inflated permanently (the stale scalar wins pagination via Math.max and is persisted). Invariant now holds: scalar total === Σ per-type across add/update/delete and reopen. - Pattern A, finding 3 (graph/lsm/LSMTree): a partial SSTable-load failure no longer publishes the manifest's full relationship count as healthy. Any per-SSTable load failure throws after the batch, which resets to honest-empty and lets the existing size()===0 self-heal rebuild run — size()/isHealthy() can no longer lie about a partial load. - Pattern B, finding 6 (hnsw/hnswIndex): deferred flush() no longer clears dirty nodes whose connections failed to persist — failed nodes stay in the retry set, and flush() throws HnswFlushError instead of returning a lying node count. The immediate-mode first-noun saveHNSWSystem is un-swallowed, so addItem() rejects rather than returning an id for a rootless index. - Pattern B, finding 11 (part — storage reads): new shared isAbsentError() helper (utils/errorClassification, ENOENT-only absence) applied to loadBinaryBlob and readObjectFromPath — a real IO fault (EIO/EACCES/EMFILE) now propagates loudly instead of masquerading as "absent", which had driven needless rebuilds / empty reads (loadBinaryBlob feeds the native provider). Regression: 78 green across the 3 new suites + db-mvcc, generationStore, temporal-vfs, rollback-trapdoor, restore-nondestructive. Full gate runs before the Pass-1 release (David-gated). Remaining Pass 1: finding 11 getNoun/getVerb legs, finding 4 (ColumnStore), finding 8 (pending-flush), finding 10 (degraded), finding 7 (clear). Pattern A guards (1,2,9) as a follow-up release.
This commit is contained in:
parent
eb9c4eb963
commit
119087a75c
8 changed files with 518 additions and 30 deletions
37
src/utils/errorClassification.ts
Normal file
37
src/utils/errorClassification.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* @module utils/errorClassification
|
||||
* @description Shared classification of caught errors into "genuine absence" vs
|
||||
* "real fault" — the antidote to blind `catch { return null }` handlers that
|
||||
* cannot tell ENOENT (the object is legitimately not on disk) from EIO / EACCES
|
||||
* / EMFILE / … (a transient or permission fault on data that IS on disk).
|
||||
* Masking a fault as absence yields wrong results (a present record read as
|
||||
* "not found") or a needless rebuild. Mandate: loud errors, never quiet losses.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The error `code`s that denote GENUINE absence of a file/object. Only `ENOENT`
|
||||
* ("no such file or directory") qualifies — on every platform Node maps a
|
||||
* missing file/directory to ENOENT, and no other errno means "simply not
|
||||
* there". Every other errno (EIO, EACCES, EPERM, EMFILE, ENFILE, EBUSY,
|
||||
* ENOTDIR, EISDIR, ELOOP) is a real fault and must propagate, as must any error
|
||||
* without an errno `code` (parse/decompress failures, generic Errors).
|
||||
* `ENOTDIR`/`EISDIR` are deliberately faults: a path component of the wrong
|
||||
* type is corruption, not benign absence. Named constant so a future
|
||||
* genuine-absence code can be added in one reviewed place.
|
||||
*/
|
||||
const ABSENCE_CODES: ReadonlySet<string> = new Set(['ENOENT'])
|
||||
|
||||
/**
|
||||
* @description True IFF `e` represents genuine absence (an ENOENT-class errno),
|
||||
* for which returning `null`/`[]`/`undefined` is the correct answer. Returns
|
||||
* `false` for every real fault, so the canonical call site is:
|
||||
* `catch (e) { if (isAbsentError(e)) return null; throw e }`.
|
||||
*
|
||||
* @param e - The caught value (typed `unknown`; non-objects are never absence).
|
||||
* @returns Whether the error means "the thing is simply not there".
|
||||
*/
|
||||
export function isAbsentError(e: unknown): boolean {
|
||||
if (e === null || typeof e !== 'object') return false
|
||||
const code = (e as { code?: unknown }).code
|
||||
return typeof code === 'string' && ABSENCE_CODES.has(code)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue