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
|
|
@ -3234,10 +3234,22 @@ export abstract class BaseStorage extends BaseStorageAdapter {
|
|||
const priorCounted = isCountedVisibility(record?.visibility)
|
||||
if (priorType) {
|
||||
if (priorCounted) {
|
||||
// Symmetric with the counted-add increment in saveNounMetadata_internal():
|
||||
// decrement BOTH the user-facing scalar total (getNounCount / counts.json) AND
|
||||
// the per-type bucket, then persist. The scalar decrement was previously
|
||||
// omitted here, so deletes permanently inflated getNounCount() — the stale
|
||||
// scalar wins pagination via Math.max(totalNounCount, collected.length) and is
|
||||
// persisted by scheduleCountPersist(). With this, the invariant
|
||||
// `totalNounCount === Σ nounCountsByType` holds across add / update-flip / delete.
|
||||
this.decrementEntityCount(priorType)
|
||||
const idx = TypeUtils.getNounIndex(priorType)
|
||||
if (this.nounCountsByType[idx] > 0) {
|
||||
this.nounCountsByType[idx]--
|
||||
}
|
||||
this.scheduleCountPersist().catch(() => {
|
||||
// Ignore persist errors — the in-memory count is authoritative; a later
|
||||
// operation retries the persist.
|
||||
})
|
||||
}
|
||||
|
||||
// Symmetric subtype decrement — same non-empty-string guard as the write path.
|
||||
|
|
@ -3392,16 +3404,30 @@ export abstract class BaseStorage extends BaseStorageAdapter {
|
|||
await this.ensureInitialized()
|
||||
|
||||
// Direct O(1) delete with ID-first path. Read the canonical record BEFORE
|
||||
// removing it so the verb-subtype decrement is sourced from the edge's own
|
||||
// metadata (`verb` type + `subtype`) rather than an id-keyed cache — symmetric
|
||||
// with the increment in `saveVerbMetadata_internal()`. Verb deletes do not
|
||||
// touch `verbCountsByType` in this path (matching prior behavior), so no
|
||||
// visibility read is needed here.
|
||||
// removing it so every decrement is sourced from the edge's own metadata
|
||||
// (`verb` type, `subtype`, `visibility`) rather than an id-keyed cache —
|
||||
// symmetric with the increments in `saveVerbMetadata_internal()`.
|
||||
const path = getVerbMetadataPath(id)
|
||||
const record = await this.readCanonicalObject(path)
|
||||
await this.deleteCanonicalObject(path)
|
||||
|
||||
const priorVerb = record?.verb as VerbType | undefined
|
||||
// Symmetric count decrement (previously OMITTED — verb deletes touched neither the
|
||||
// scalar total nor the per-type bucket, so both inflated permanently). A COUNTED
|
||||
// edge bumped BOTH the scalar (incrementVerbCount in saveVerbMetadata_internal) and
|
||||
// the per-type bucket (the unconditional bump in saveVerb_internal that the metadata
|
||||
// path keeps for counted edges). Delete must undo both, gated on the SAME visibility
|
||||
// as the add, so `totalVerbCount === Σ verbCountsByType` holds across delete.
|
||||
const priorCounted = isCountedVisibility(record?.visibility)
|
||||
if (priorVerb && priorCounted) {
|
||||
this.decrementVerbCount(priorVerb)
|
||||
const idx = TypeUtils.getVerbIndex(priorVerb)
|
||||
if (this.verbCountsByType[idx] > 0) this.verbCountsByType[idx]--
|
||||
this.scheduleCountPersist().catch(() => {
|
||||
// Ignore persist errors — in-memory count is authoritative; a later op retries.
|
||||
})
|
||||
}
|
||||
|
||||
const priorSubtype = typeof record?.subtype === 'string' && (record.subtype as string).length > 0
|
||||
? (record.subtype as string)
|
||||
: undefined
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue