fix: surface segment/entity read faults loudly instead of masking as absent

ColumnStore silently skipped a manifest-listed segment it could not load: a
corrupt or missing segment dropped every one of its entities out of
filter/rangeQuery/sortTopK with no error, so an inconsistent index read as a
merely short result. It now throws a typed ColumnSegmentLoadError when a listed
segment yields undecodable or no bytes, and lets a genuine storage IO fault
propagate verbatim. Only a field with no manifest at all stays benign (nothing
was ever written for it).

baseStorage.getNoun_internal / getVerb_internal likewise caught every error and
returned null, reporting a present-but-unreadable entity as "not found". They
now return null only for genuine ENOENT-class absence (via isAbsentError) and
rethrow real faults and deserialize errors.

Pattern-B (blind catch) hardening: absence -> null, fault -> loud.
This commit is contained in:
David Snelling 2026-07-13 09:14:52 -07:00
parent 119087a75c
commit af5d2f389b
3 changed files with 216 additions and 33 deletions

View file

@ -0,0 +1,128 @@
/**
* @module tests/unit/indexes/columnStore/segment-load-fault
* @description Pattern-B acceptance for the ColumnStore (finding 4): a segment
* the manifest LISTS but that cannot be loaded must never be silently skipped
* doing so dropped every entity in that segment out of `filter`/`rangeQuery`/
* `sortTopK` with no error, so a corrupt index looked like a merely short result.
*
* The three failure classes and their required behaviour:
* - a real storage IO fault (EIO) PROPAGATES verbatim a present-but-unreadable
* segment is not "absent", so it must not read as an empty result;
* - a manifest-listed segment with undecodable bytes throws `ColumnSegmentLoadError`;
* - a manifest-listed segment with NO bytes (gone on disk) throws `ColumnSegmentLoadError`.
* Only genuine absence stays benign: querying a field that has no manifest at all
* returns empty (nothing was ever written for it) that is not a fault.
*/
import { describe, it, expect, beforeEach } from 'vitest'
import {
ColumnStore,
ColumnSegmentLoadError
} from '../../../../src/indexes/columnStore/ColumnStore.js'
import { MemoryStorage } from '../../../../src/storage/adapters/memoryStorage.js'
import { EntityIdMapper } from '../../../../src/utils/entityIdMapper.js'
type FaultMode = 'none' | 'io' | 'corrupt' | 'missing'
/**
* A MemoryStorage that can fault reads of persisted column SEGMENTS only
* (keys/paths containing the `L0-` segment marker). Manifest and DELETED-bitmap
* reads pass through untouched so a fresh store still initialises normally the
* fault is isolated to the exact seam finding 4 hardened.
*/
class FaultInjectingStorage extends MemoryStorage {
public faultMode: FaultMode = 'none'
private eio(): Error {
const e = new Error('simulated disk read fault') as Error & { code: string }
e.code = 'EIO'
return e
}
public async loadBinaryBlob(key: string): Promise<Buffer | null> {
if (this.faultMode !== 'none' && key.includes('/L0-')) {
if (this.faultMode === 'io') throw this.eio()
// Too small to hold even a header → readSegmentFromBuffer throws → wrapped.
if (this.faultMode === 'corrupt') return Buffer.from([1, 2, 3, 4, 5])
if (this.faultMode === 'missing') return null
}
return super.loadBinaryBlob(key)
}
}
describe('ColumnStore segment-load faults surface loudly, absence stays benign (finding 4)', () => {
let storage: FaultInjectingStorage
let idMapper: EntityIdMapper
beforeEach(async () => {
storage = new FaultInjectingStorage()
await storage.init()
idMapper = new EntityIdMapper({ storage, storageKey: 'test:idMapper' })
await idMapper.init()
// Write one persisted L0 segment for `createdAt`, then close the writer.
const writer = new ColumnStore({ flushThreshold: 10 })
await writer.init(storage, idMapper)
for (let i = 0; i < 5; i++) {
writer.addEntity(BigInt(idMapper.getOrAssign(`e${i}`)), {
createdAt: (i + 1) * 100
})
}
await writer.flush()
await writer.close()
storage.faultMode = 'none'
})
// Fresh reader over the same storage: empty segment cache, so every query is
// forced to actually load the persisted segment (that is the seam under test).
const reopen = async (): Promise<ColumnStore> => {
const s = new ColumnStore({ flushThreshold: 10 })
await s.init(storage, idMapper)
return s
}
it('propagates a storage IO fault verbatim — not [] and not a ColumnSegmentLoadError', async () => {
storage.faultMode = 'io'
const store = await reopen()
await expect(store.filter('createdAt', 300)).rejects.toMatchObject({
code: 'EIO'
})
await store.close()
})
it('throws ColumnSegmentLoadError when a manifest-listed segment is undecodable', async () => {
storage.faultMode = 'corrupt'
const store = await reopen()
await expect(
store.sortTopK('createdAt', 'desc', 10)
).rejects.toBeInstanceOf(ColumnSegmentLoadError)
await store.close()
})
it('throws ColumnSegmentLoadError when a manifest-listed segment has no loadable bytes', async () => {
storage.faultMode = 'missing'
const store = await reopen()
await expect(
store.rangeQuery('createdAt', 100, 500)
).rejects.toBeInstanceOf(ColumnSegmentLoadError)
await store.close()
})
it('a field with no manifest is genuine absence — returns empty, never throws', async () => {
storage.faultMode = 'none'
const store = await reopen()
const bitmap = await store.filter('no_such_field', 'x')
expect(bitmap.size).toBe(0)
const sorted = await store.sortTopK('no_such_field', 'asc', 10)
expect(sorted).toEqual([])
await store.close()
})
it('with no fault, the persisted segment still loads and answers queries', async () => {
storage.faultMode = 'none'
const store = await reopen()
const sorted = await store.sortTopK('createdAt', 'desc', 10)
const uuids = sorted.map((id) => idMapper.getUuid(Number(id)))
expect(uuids).toEqual(['e4', 'e3', 'e2', 'e1', 'e0'])
await store.close()
})
})