diff --git a/RELEASES.md b/RELEASES.md index f67cb452..759fcfeb 100644 --- a/RELEASES.md +++ b/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) Durability + integrity hardening across the write and index paths. Every fix converts a place that diff --git a/src/storage/adapters/fileSystemStorage.ts b/src/storage/adapters/fileSystemStorage.ts index f676d091..9ff0539e 100644 --- a/src/storage/adapters/fileSystemStorage.ts +++ b/src/storage/adapters/fileSystemStorage.ts @@ -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 @@ -1228,25 +1229,17 @@ export class FileSystemStorage extends BaseStorage { await this.ensureInitialized() try { return await fs.promises.readFile(this.blobPath(key)) - } catch { - // COORDINATION HOLD — restore the fault-propagating form lockstep with - // cortex 3.0.13 (CORTEX-BILLION-SCALE-SPINE). The correct behavior - // distinguishes genuine absence from a real fault so a present-but- - // unreadable blob no longer masquerades as "absent" (which drove needless - // rebuilds / empty reads). Restore to: - // } catch (err) { - // // re-add: import { isAbsentError } from '../../utils/errorClassification.js' - // 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 + } 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. Fault-propagation restored lockstep with + // cortex 3.0.13, whose two column-store call sites now handle the throw + // (they mark the field unavailable + throw a named error) instead of + // relying on null-on-error (CORTEX-BILLION-SCALE-SPINE). + if (isAbsentError(err)) return null + throw err } } diff --git a/tests/unit/storage/blob-save-durability.test.ts b/tests/unit/storage/blob-save-durability.test.ts index 3c4b0a1a..f775ebdf 100644 --- a/tests/unit/storage/blob-save-durability.test.ts +++ b/tests/unit/storage/blob-save-durability.test.ts @@ -74,3 +74,38 @@ describe('FileSystemStorage.saveBinaryBlob durable-write honesty (finding 13)', ).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' }) + }) +})