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

@ -69,6 +69,34 @@ interface HeapEntry {
* await store.flush()
* const newest = await store.sortTopK('createdAt', 'desc', 10) // bigint[]
*/
/**
* @description Thrown when a segment the manifest lists cannot be loaded
* either it yields no bytes (missing/empty on disk) or its bytes are
* undecodable (corruption). Previously such a segment was silently skipped,
* dropping ALL of its entities from every `filter`/`rangeQuery`/`sortTopK` with
* no error a manifestsegments divergence that looked like a short result.
* Surfacing it loudly makes the divergence visible and repairable. (A genuine
* storage IO fault is a different class it propagates as the underlying error,
* not wrapped in this.)
*/
export class ColumnSegmentLoadError extends Error {
/** The indexed field whose segment failed to load. */
public readonly field: string
/** The manifest-listed segment id that could not be loaded. */
public readonly segmentId: number | string
constructor(field: string, segmentId: number | string, reason: string) {
super(
`ColumnStore segment ${field}:${segmentId} is listed in the manifest but ` +
`could not be loaded (${reason}). The index is inconsistent with its ` +
`manifest — rebuild/repair the metadata index rather than trusting a ` +
`short query result.`
)
this.name = 'ColumnSegmentLoadError'
this.field = field
this.segmentId = segmentId
}
}
export class ColumnStore implements ColumnStoreProvider {
private storage!: StorageAdapter
private idMapper!: EntityIdMapper
@ -594,14 +622,15 @@ export class ColumnStore implements ColumnStoreProvider {
let cursor = this.segmentCache.get(cacheKey)
if (!cursor) {
const loaded = await this.loadSegmentCursor(field, seg)
if (loaded) {
cursor = loaded
this.segmentCache.set(cacheKey, cursor)
}
// loadSegmentCursor either returns a cursor or THROWS — a corrupt /
// missing manifest-listed segment raises ColumnSegmentLoadError and a
// real storage fault propagates, so a listed segment is never silently
// dropped from the result set.
cursor = await this.loadSegmentCursor(field, seg)
this.segmentCache.set(cacheKey, cursor)
}
if (cursor) cursors.push(cursor)
cursors.push(cursor)
}
return cursors
@ -615,39 +644,51 @@ export class ColumnStore implements ColumnStoreProvider {
* `.cidx` object-path so indexes written before the format unification keep
* loading correctly. Mirror of cortex's 2.3.1 read-side fallback.
*/
private async loadSegmentCursor(field: string, seg: SegmentMeta): Promise<ColumnSegmentCursor | null> {
private async loadSegmentCursor(field: string, seg: SegmentMeta): Promise<ColumnSegmentCursor> {
const manifest = this.manifests.get(field)
if (!manifest) return null
if (!manifest) {
// Defensive: getSegmentCursors guards on the manifest before calling.
throw new ColumnSegmentLoadError(field, seg.id, 'no manifest for field')
}
try {
let buf: Buffer | null = null
let buf: Buffer | null = null
const storage = this.storage as unknown as {
loadBinaryBlob?: (key: string) => Promise<Buffer | null>
readObjectFromPath: (path: string) => Promise<any>
}
const storage = this.storage as unknown as {
loadBinaryBlob?: (key: string) => Promise<Buffer | null>
readObjectFromPath: (path: string) => Promise<any>
}
// Preferred: raw blob at the cor-shared key.
if (typeof storage.loadBinaryBlob === 'function') {
const key = `${this.basePath}/${field}/L${seg.level}-${String(seg.id).padStart(6, '0')}`
const blob = await storage.loadBinaryBlob(key)
if (blob && blob.length > 0) buf = blob
}
// Preferred: raw blob at the cor-shared key. A real IO fault PROPAGATES —
// loadBinaryBlob throws on a fault and returns null only for genuine absence
// (a present-but-unreadable segment must not read as "missing").
if (typeof storage.loadBinaryBlob === 'function') {
const key = `${this.basePath}/${field}/L${seg.level}-${String(seg.id).padStart(6, '0')}`
const blob = await storage.loadBinaryBlob(key)
if (blob && blob.length > 0) buf = blob
}
// Legacy fallback: `{ _binary, base64 }` envelope at the .cidx object-path.
if (!buf) {
const segPath = manifest.segmentPath(seg.level, seg.id)
const stored = await storage.readObjectFromPath(segPath)
if (!stored) return null
// Legacy fallback: `{ _binary, base64 }` envelope at the .cidx object-path.
if (!buf) {
const segPath = manifest.segmentPath(seg.level, seg.id)
const stored = await storage.readObjectFromPath(segPath)
if (stored) {
if (stored._binary && stored.data) {
buf = Buffer.from(stored.data, 'base64')
} else if (Buffer.isBuffer(stored)) {
buf = stored
} else {
return null
}
}
}
// A manifest-listed segment that yields NO loadable bytes is corruption, not
// benign absence: swallowing it silently dropped all of the segment's
// entities from every filter/rangeQuery/sortTopK with no error. Surface it
// loudly so the manifest↔segments divergence is visible (and repairable).
if (!buf) {
throw new ColumnSegmentLoadError(field, seg.id, 'manifest-listed segment has no loadable bytes')
}
try {
const parsed = readSegmentFromBuffer(buf)
return new ColumnSegmentCursor(
parsed.header,
@ -655,8 +696,13 @@ export class ColumnStore implements ColumnStoreProvider {
parsed.entityIds,
parsed.tombstones
)
} catch {
return null
} catch (err) {
// Undecodable bytes for a listed segment — corruption, not a short result.
throw new ColumnSegmentLoadError(
field,
seg.id,
`segment decode failed: ${(err as Error).message}`
)
}
}

View file

@ -29,6 +29,7 @@ import { getShardId } from './sharding.js'
import { BlobStorage, type BlobStoreAdapter } from './blobStorage.js'
import { unwrapBinaryData } from './binaryDataCodec.js'
import { prodLog } from '../utils/logger.js'
import { isAbsentError } from '../utils/errorClassification.js'
import { BrainyError } from '../errors/brainyError.js'
import { MetadataWriteBuffer } from '../utils/metadataWriteBuffer.js'
import {
@ -4064,8 +4065,12 @@ export abstract class BaseStorage extends BaseStorageAdapter {
return this.deserializeNoun(noun)
}
} catch (error) {
// Entity not found
return null
// A real storage/deserialize fault is NOT "entity not found":
// readCanonicalObject returns null for genuine absence and only throws on
// a real fault, so masking it here reports a present-but-unreadable entity
// as missing. Absence → null, fault → propagate loudly.
if (isAbsentError(error)) return null
throw error
}
return null
@ -4175,8 +4180,12 @@ export abstract class BaseStorage extends BaseStorageAdapter {
return this.deserializeVerb(verb)
}
} catch (error) {
// Entity not found
return null
// A real storage/deserialize fault is NOT "relationship not found":
// readCanonicalObject returns null for genuine absence and only throws on
// a real fault, so masking it here reports a present-but-unreadable edge
// as missing. Absence → null, fault → propagate loudly.
if (isAbsentError(error)) return null
throw error
}
return null