fix: restore loadBinaryBlob fault-propagation (native column-store lockstep)
The Pass-1 hardening that makes loadBinaryBlob distinguish genuine absence (ENOENT -> null) from a real fault (EIO/EACCES/... -> throw) was held out of 8.2.6 because the native accelerator's two column-store read sites still relied on null-on-error. That accelerator release has now hardened those sites to handle the throw (a faulted segment read marks the field unavailable and throws a named error), so the fault-propagating form is restored and ships lockstep. A present-but-unreadable index blob no longer masquerades as absent (which drove needless rebuilds / empty reads). Adds the loadBinaryBlob leg to the blob durability suite (absent -> null; present -> bytes; real fault -> throws).
This commit is contained in:
parent
76843b782e
commit
b6c7039769
3 changed files with 65 additions and 19 deletions
18
RELEASES.md
18
RELEASES.md
|
|
@ -10,6 +10,24 @@ Full auto-generated changelog: `CHANGELOG.md` · Releases: https://github.com/so
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## v8.2.7 — 2026-07-13 (loadBinaryBlob fault-propagation — the lockstep completion of 8.2.6)
|
||||||
|
|
||||||
|
Completes the Pass-1 spine hardening. `loadBinaryBlob` (the raw-blob read a native accelerator mmaps
|
||||||
|
for its index files) previously returned `null` on ANY read error, so a real IO fault
|
||||||
|
(EIO/EACCES/EMFILE) on a present-but-unreadable index blob masqueraded as "the blob is absent" —
|
||||||
|
driving a needless full rebuild or an empty read. It now distinguishes genuine absence (ENOENT →
|
||||||
|
`null`, the documented contract) from a real fault (→ throw), so a transient disk fault surfaces
|
||||||
|
loudly instead of silently degrading the index.
|
||||||
|
|
||||||
|
This one change was deliberately held out of 8.2.6 and ships now, in lockstep with the native
|
||||||
|
accelerator release that hardened its two column-store read sites to handle the throw (a faulted
|
||||||
|
segment read marks the field unavailable and throws a named error, instead of relying on
|
||||||
|
null-on-error). A consumer on new brainy + an older accelerator was never at risk: 8.2.6 kept the
|
||||||
|
prior swallow-on-fault behavior for this method until the accelerator was ready.
|
||||||
|
|
||||||
|
No API change. Regression: `tests/unit/storage/blob-save-durability.test.ts` gains the loadBinaryBlob
|
||||||
|
leg (absent → null; present → bytes; real fault → throws).
|
||||||
|
|
||||||
## v8.2.6 — 2026-07-13 (write/index-spine hardening — loud errors, never quiet losses)
|
## v8.2.6 — 2026-07-13 (write/index-spine hardening — loud errors, never quiet losses)
|
||||||
|
|
||||||
Durability + integrity hardening across the write and index paths. Every fix converts a place that
|
Durability + integrity hardening across the write and index paths. Every fix converts a place that
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
WriterLockInfo
|
WriterLockInfo
|
||||||
} from '../baseStorage.js'
|
} from '../baseStorage.js'
|
||||||
import { getBrainyVersion } from '../../utils/index.js'
|
import { getBrainyVersion } from '../../utils/index.js'
|
||||||
|
import { isAbsentError } from '../../utils/errorClassification.js'
|
||||||
|
|
||||||
// Node.js modules - dynamically imported to avoid issues in browser environments
|
// Node.js modules - dynamically imported to avoid issues in browser environments
|
||||||
let fs: any
|
let fs: any
|
||||||
|
|
@ -1228,25 +1229,17 @@ export class FileSystemStorage extends BaseStorage {
|
||||||
await this.ensureInitialized()
|
await this.ensureInitialized()
|
||||||
try {
|
try {
|
||||||
return await fs.promises.readFile(this.blobPath(key))
|
return await fs.promises.readFile(this.blobPath(key))
|
||||||
} catch {
|
} catch (err) {
|
||||||
// COORDINATION HOLD — restore the fault-propagating form lockstep with
|
// Absent blob → null (the documented contract). A real fault
|
||||||
// cortex 3.0.13 (CORTEX-BILLION-SCALE-SPINE). The correct behavior
|
// (EIO/EACCES/EMFILE/…) must NOT be masked as "absent": doing so makes a
|
||||||
// distinguishes genuine absence from a real fault so a present-but-
|
// present-but-unreadable blob look missing and drives a needless rebuild
|
||||||
// unreadable blob no longer masquerades as "absent" (which drove needless
|
// or an empty read (the native provider consumes this). Mandate: loud
|
||||||
// rebuilds / empty reads). Restore to:
|
// errors, never quiet losses. Fault-propagation restored lockstep with
|
||||||
// } catch (err) {
|
// cortex 3.0.13, whose two column-store call sites now handle the throw
|
||||||
// // re-add: import { isAbsentError } from '../../utils/errorClassification.js'
|
// (they mark the field unavailable + throw a named error) instead of
|
||||||
// if (isAbsentError(err)) return null
|
// relying on null-on-error (CORTEX-BILLION-SCALE-SPINE).
|
||||||
// throw err
|
if (isAbsentError(err)) return null
|
||||||
// }
|
throw err
|
||||||
// The throw is HELD because the native column-store still has 2 call sites
|
|
||||||
// that rely on null-on-error; cortex hardens them in 3.0.13, then this
|
|
||||||
// reverts to the throwing form and ships lockstep. Until then this
|
|
||||||
// preserves the shipped 8.2.5 swallow-on-fault behavior, so a consumer on
|
|
||||||
// new-brainy + old-cortex can never break. All other Pass-1 hardening
|
|
||||||
// (saveBinaryBlob honesty, clear(), pending-flush durability, count
|
|
||||||
// symmetry, degraded surfacing, aggregation) ships now, independent of this.
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,3 +74,38 @@ describe('FileSystemStorage.saveBinaryBlob durable-write honesty (finding 13)',
|
||||||
).rejects.toMatchObject({ code: 'EIO' })
|
).rejects.toMatchObject({ code: 'EIO' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('FileSystemStorage.loadBinaryBlob fault-propagation (finding 11; cor 3.0.13 lockstep)', () => {
|
||||||
|
let dir: string
|
||||||
|
let storage: any
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-blob-load-'))
|
||||||
|
storage = new FileSystemStorage(dir)
|
||||||
|
await storage.init()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for a genuinely absent blob (ENOENT)', async () => {
|
||||||
|
expect(await storage.loadBinaryBlob('never/written')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads back a present blob', async () => {
|
||||||
|
await storage.saveBinaryBlob('present/blob', Buffer.from([1, 2, 3]))
|
||||||
|
expect(await storage.loadBinaryBlob('present/blob')).toEqual(Buffer.from([1, 2, 3]))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('propagates a real IO fault instead of masking it as absent', async () => {
|
||||||
|
await storage.saveBinaryBlob('present/blob', Buffer.from([1, 2, 3]))
|
||||||
|
vi.spyOn(fs.promises, 'readFile').mockRejectedValue(
|
||||||
|
Object.assign(new Error('disk fault'), { code: 'EIO' })
|
||||||
|
)
|
||||||
|
// A present-but-unreadable blob must NOT read as null (that drove needless
|
||||||
|
// native rebuilds / empty reads); cor 3.0.13's column-store handles the throw.
|
||||||
|
await expect(storage.loadBinaryBlob('present/blob')).rejects.toMatchObject({ code: 'EIO' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue