brainy/src/db/errors.ts
David Snelling 54c183668c fix: refuse writes when single-op history cannot be made durable
The async group-commit flush that persists single-op generation history
swallowed persist failures as a bare warn: writes kept succeeding while their
before-images piled up in memory, never durable and unbounded, with no signal.

The generation store now accounts for every failed flush at one place
(flushPendingSingleOps), tolerates a transient blip (retry with capped
exponential backoff), and after PENDING_FLUSH_FAILURE_THRESHOLD consecutive
failures LATCHES a durability failure and refuses further single-op and transact
writes with a typed, exported PendingFlushDurabilityError — rather than promise a
durability it cannot deliver. Live canonical data is untouched; only the
immutable history is stuck. The latch self-heals: the moment a flush succeeds
(a retry, or an explicit flush()/close()) it lifts and writes resume.

Loud errors, never quiet losses.
2026-07-13 09:31:25 -07:00

291 lines
13 KiB
TypeScript

/**
* @module db/errors
* @description Error types for the 8.0 generational-MVCC Db API.
*
* Three failure modes have dedicated classes so callers can branch on them
* with `instanceof` instead of string matching:
*
* - {@link GenerationConflictError} — the store's current generation differs
* from what the caller's operation requires: a `transact()`
* compare-and-swap (`ifAtGeneration`) observed a different generation than
* expected, or `db.persist()` was called on a view that history has moved
* past (snapshots capture current bytes, so persisting requires the view
* to still be the latest generation). The standard retry pattern is:
* re-read via `brain.now()`, re-derive the operation, and re-submit.
* - {@link SpeculativeOverlayError} — an index-accelerated query (vector /
* hybrid / graph-traversal search, cursors, aggregation) or `persist()`
* was issued against a speculative `db.with()` overlay. Overlays are pure
* in-memory values whose entities carry no embeddings (`with()` never
* invokes the embedder), so a "full" index query over one would silently
* miss the overlay's own entities — an honest error beats silently-wrong
* results. Commit the operations with `brain.transact()` to get the full
* query surface. Historical (non-overlay) views do NOT throw this: they
* serve the full query surface via at-generation index materialization.
* - {@link GenerationCompactedError} — `asOf()` asked for a generation whose
* immutable records were reclaimed by `compactHistory()`.
*
* All three are exported from the package root (`@soulcraft/brainy`).
*/
/**
* @description Thrown when an operation requires the store to be at a
* specific generation and it is not:
*
* - `brain.transact(ops, { ifAtGeneration })` — the whole-store
* compare-and-swap counterpart to the per-entity `ifRev` /
* `RevisionConflictError` pair: it guarantees that *nothing* was committed
* between the caller's read (`brain.now()`) and this transaction. The
* transaction is rejected before any record is staged — the store is
* untouched and the generation counter is unchanged.
* - `db.persist(path)` — snapshots capture current bytes, so persisting
* requires the view to still be the store's **latest** generation. A view
* that history has moved past throws this error: pin with `brain.now()`
* and persist before further writes.
*
* @example
* const db = brain.now()
* try {
* await brain.transact(ops, { ifAtGeneration: db.generation })
* } catch (err) {
* if (err instanceof GenerationConflictError) {
* // Someone committed since we pinned — re-read and retry.
* console.log(`expected ${err.expected}, store is at ${err.actual}`)
* }
* }
*/
export class GenerationConflictError extends Error {
/** The generation the operation required the store to be at. */
public readonly expected: number
/** The generation the store was actually at. */
public readonly actual: number
/**
* @param expected - The required generation (`ifAtGeneration`, or the
* pinned generation of the view being persisted).
* @param actual - The store's current generation.
*/
constructor(expected: number, actual: number) {
super(
`Generation conflict: the operation requires the store at generation ${expected}, ` +
`but it is at generation ${actual}. Another write committed in between. ` +
`Re-read with brain.now(), rebuild the operation, and retry.`
)
this.name = 'GenerationConflictError'
this.expected = expected
this.actual = actual
}
}
/**
* @description Thrown when an operation on a speculative `db.with()` overlay
* requires index machinery (vector / hybrid / graph-traversal search, cursor
* pagination, aggregation) or durability (`persist()`).
*
* Why this single boundary exists: overlays are pure in-memory values —
* `with()` never touches disk, the generation counter, or the embedder, so
* overlay entities carry **no embeddings**. A "full" vector or hybrid query
* over an overlay would silently exclude the overlay's own entities, and a
* persisted overlay would be a store whose entities cannot be semantically
* searched. An honest error beats silently-wrong results. Commit the
* operations with `brain.transact()` to get the full query surface, or query
* the overlay's base view (historical views serve the complete surface via
* at-generation index materialization).
*
* `get()`, metadata-filter `find()`, and filter-based `related()` remain
* fully supported on overlays.
*
* @example
* const spec = await brain.now().with([{ op: 'add', type, data: 'draft' }])
* await spec.find({ where: { draft: true } }) // ✅ metadata find — supported
* await spec.find({ query: 'semantic query' }) // ❌ throws this error
* await brain.transact([{ op: 'add', type, data: 'draft' }]) // → full surface
*/
export class SpeculativeOverlayError extends Error {
/** The capability that is not supported on speculative overlays. */
public readonly capability: string
/** The overlay's pinned base generation. */
public readonly generation: number
/**
* @param capability - Human-readable name of the unsupported capability
* (e.g. `'vector search'`, `'persist'`).
* @param generation - The overlay's pinned base generation.
*/
constructor(capability: string, generation: number) {
super(
`${capability} is not supported on a speculative with() overlay ` +
`(pinned at base generation ${generation}). Overlays are pure in-memory ` +
`values whose entities carry no embeddings, so index-accelerated reads ` +
`over them would be silently incomplete. Supported on overlays: get(), ` +
`metadata-filter find(), and filter-based related(). Commit the operations ` +
`with brain.transact() for the full query surface, or query the base view.`
)
this.name = 'SpeculativeOverlayError'
this.capability = capability
this.generation = generation
}
}
/**
* @description Thrown by `brain.asOf(generationOrTimestamp)` when the
* requested generation's history was reclaimed by `brain.compactHistory()`.
* The store records the compaction horizon (the highest reclaimed
* generation) in its manifest; any generation BELOW the horizon is
* unreachable — its reads would need the reclaimed before-images. The
* horizon itself stays reachable, resolved from the record-sets above it.
*
* To keep a generation readable forever, `persist()` it to a snapshot
* directory before compacting — snapshots are self-contained and unaffected
* by compaction of the source store.
*/
export class GenerationCompactedError extends Error {
/** The generation that was requested. */
public readonly requested: number
/** The compaction horizon — generations < this value are unreachable. */
public readonly horizon: number
/**
* @param requested - The generation the caller asked for.
* @param horizon - The store's current compaction horizon.
*/
constructor(requested: number, horizon: number) {
super(
`Generation ${requested} has been compacted away (compaction horizon: ${horizon}). ` +
`Only generations at or above the horizon are reachable via asOf(). ` +
`Use db.persist(path) before compactHistory() to keep a generation readable.`
)
this.name = 'GenerationCompactedError'
this.requested = requested
this.horizon = horizon
}
}
/** One entity/relationship left in an unreconciled state by a failed rollback. */
export interface UnreconciledRecord {
/** The entity or relationship id. */
id: string
/** `'noun'` (entity) or `'verb'` (relationship). */
kind: 'noun' | 'verb'
/**
* `'orphan'` — the record is durably PRESENT but the transaction aborted
* (an add whose delete-undo failed); `'loss'` — the record is durably GONE
* or wrong when it should have been restored (a remove/update whose
* restore-undo failed — actual data loss).
*/
disposition: 'orphan' | 'loss'
}
/**
* @description Thrown when a transaction's rollback could not be fully applied
* and the resulting inconsistency cannot be safely adopted forward — i.e. a
* multi-operation batch, or ANY case where a record was lost (a remove/update
* whose restore-undo failed). The store is left in a known-inconsistent state:
* the {@link records} name every entity/relationship whose canonical state no
* longer matches what the aborted transaction should have produced.
*
* On throw, the brain enters **write-quarantine** — reads continue to work, but
* further writes are refused until {@link } `repairIndex()` reconciles the
* derived indexes against canonical storage and lifts the quarantine. This is
* the honest, loud failure: a visible inconsistency the operator must repair,
* never a silent partial write. The generation counter is NOT advanced.
*
* (A SINGLE-op add whose only damage is a durably-present orphan is NOT this
* error: it is adopted forward as a committed generation and returned as a
* degraded-but-successful write — the record the caller asked for exists.)
*
* @example
* try {
* await brain.transact(ops)
* } catch (err) {
* if (err instanceof StoreInconsistentError) {
* console.error('store inconsistent:', err.records) // ids + dispositions
* await brain.repairIndex() // reconcile + lift the write-quarantine
* }
* }
*/
export class StoreInconsistentError extends Error {
/** Every record left in an unreconciled state by the failed rollback. */
public readonly records: UnreconciledRecord[]
/** The original error that triggered the (then-failed) rollback. */
public override readonly cause: Error
/**
* @param records - The unreconciled entities/relationships (ids + disposition).
* @param cause - The error that triggered the rollback.
*/
constructor(records: UnreconciledRecord[], cause: Error) {
const orphans = records.filter((r) => r.disposition === 'orphan').length
const losses = records.filter((r) => r.disposition === 'loss').length
super(
`Store left inconsistent by a failed transaction rollback: ` +
`${records.length} record(s) could not be reconciled ` +
`(${orphans} orphaned, ${losses} lost). ` +
`The brain is now WRITE-QUARANTINED (reads still work) — run repairIndex() ` +
`to reconcile the derived indexes against canonical storage and lift the ` +
`quarantine. Ids: ${records.map((r) => `${r.id}:${r.disposition}`).join(', ')}. ` +
`Cause: ${cause.message}`
)
this.name = 'StoreInconsistentError'
this.records = records
this.cause = cause
}
}
/**
* @description Thrown by a write when the store cannot make single-op
* generation **history** durable: the asynchronous group-commit flush that
* persists buffered before-images to disk has failed repeatedly (a real
* storage fault — a full disk, an EIO, a permission loss — not a transient
* blip). Rather than keep accepting writes whose history silently piles up in
* memory and is never persisted — an invisible durability loss, and an
* unbounded leak — the store LATCHES this failure and refuses further writes
* until the pending tier drains.
*
* This is the loud, honest response to a broken history-durability path:
* - Live canonical data is unaffected (single-op live bytes are written before
* the history flush; only the immutable before-image history is stuck).
* - The background flush keeps retrying with backoff; when the underlying fault
* clears and a flush succeeds, the latch lifts and writes resume
* automatically. An explicit `flush()` also clears it on success.
* - {@link cause} is the underlying storage error from the last failed flush.
*
* A caller seeing this should treat it exactly like a full disk: stop writing,
* resolve the storage fault, and retry. It is NOT a data-corruption error — no
* committed generation is lost — it is a refusal to *promise* durability the
* store currently cannot deliver.
*
* @example
* try {
* await brain.add({ ... })
* } catch (err) {
* if (err instanceof PendingFlushDurabilityError) {
* // History can't be persisted right now (disk fault). Resolve storage,
* // then retry — the store self-heals once a flush succeeds.
* console.error('history durability stalled:', err.cause)
* }
* }
*/
export class PendingFlushDurabilityError extends Error {
/** The underlying storage error from the most recent failed flush. */
public override readonly cause: Error
/** How many consecutive flush attempts had failed when the latch tripped. */
public readonly failedAttempts: number
/**
* @param cause - The storage error from the last failed pending-tier flush.
* @param failedAttempts - Consecutive failed flush attempts at latch time.
*/
constructor(cause: Error, failedAttempts: number) {
super(
`Write refused: single-op generation history could not be made durable ` +
`after ${failedAttempts} consecutive flush attempts (${cause.message}). ` +
`Live data is intact, but buffered history is not yet persisted — the ` +
`store refuses further writes rather than silently accumulate ` +
`un-durable history. Resolve the underlying storage fault; the store ` +
`self-heals and resumes writes once a flush succeeds. Cause: ${cause.message}`
)
this.name = 'PendingFlushDurabilityError'
this.cause = cause
this.failedAttempts = failedAttempts
}
}