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
|
|
@ -17,6 +17,7 @@ import {
|
|||
WriterLockInfo
|
||||
} from '../baseStorage.js'
|
||||
import { getBrainyVersion } from '../../utils/index.js'
|
||||
import { isAbsentError } from '../../utils/errorClassification.js'
|
||||
|
||||
// Node.js modules - dynamically imported to avoid issues in browser environments
|
||||
let fs: any
|
||||
|
|
@ -446,8 +447,12 @@ export class FileSystemStorage extends BaseStorage {
|
|||
return null
|
||||
}
|
||||
|
||||
console.error(`Error reading object from ${pathStr}:`, error)
|
||||
return null
|
||||
// A real storage fault (EIO/EACCES/EMFILE/…) is NOT "object absent". The
|
||||
// ENOENT branch (above) already returns null, and the corrupted-JSON
|
||||
// branch (above) is a deliberate concurrent-write tolerance; a genuine
|
||||
// fault reaching here must propagate loudly rather than masquerade as a
|
||||
// missing object — which would corrupt reads and drive needless rebuilds.
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1207,8 +1212,14 @@ export class FileSystemStorage extends BaseStorage {
|
|||
await this.ensureInitialized()
|
||||
try {
|
||||
return await fs.promises.readFile(this.blobPath(key))
|
||||
} catch {
|
||||
return null
|
||||
} catch (err) {
|
||||
// Absent blob → null (the documented contract). A real fault
|
||||
// (EIO/EACCES/EMFILE/…) must NOT be masked as "absent": doing so makes a
|
||||
// present-but-unreadable blob look missing and drives a needless rebuild
|
||||
// or an empty read (the native provider consumes this). Mandate: loud
|
||||
// errors, never quiet losses.
|
||||
if (isAbsentError(err)) return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue