brainy/tests/unit/db/generationStore.test.ts
David Snelling 5c3bb2c864 feat(8.0): Model-B per-write generation-stamping + adaptive retention knob
Every write — transact() AND single-op add/update/remove/relate — is now its
own immutable generation (Model-B), so a now() pin always freezes and
asOf/since/diff/history/transactionLog reflect single-ops exactly like
transacts. Closes the Model-A hole where pins did not freeze against single-op
writes.

Generation-stamping:
- GenerationStore.commitSingleOp: a one-operation commitTransaction with
  deferred durability. Wired into add/update/remove/relate/updateRelation/
  unrelate + removeMany (the *Many and VFS paths delegate to these).
- Async group-commit (flushPendingSingleOps): the live write is acknowledged
  immediately; its before-image is buffered in an in-memory pending tier that
  resolveAt/chains/changedBetween/tx-log read like on-disk generations, so the
  synchronous now() freezes with no forced flush. One fsync per window
  (triggers: size / 50ms timer / flush / close / transact / compactHistory).
- Crash recovery is drop-without-restore for group-commit generations (marked
  groupCommit:true): a crash mid-flush discards the partial generation and
  never restores its before-images, which would otherwise revert the
  already-acknowledged live write.
- Init-time infrastructure (the VFS root) is the un-versioned generation-0
  baseline: a fresh brain reports generation()===0 and an empty
  transactionLog(); the first user write is generation 1.
- Historical find()/related() overlay bound is the full reserved watermark
  (generation()), so un-flushed single-op writes are overlaid too.

Retention knob:
- config `history` -> `retention`: 'all' | 'adaptive' |
  { maxGenerations?, maxAge?, maxBytes?, budgetBytes?, autoCompact? }; unset ->
  adaptive (disk/RAM pressure, zero-config). CompactHistoryOptions floors ->
  caps (retainGenerations->maxGenerations, retainMs->maxAge, +maxBytes):
  reclaim oldest-unpinned while ANY cap is exceeded; pins always exempt.
- brain.setRetentionBudget(bytes) drives the adaptive byte budget at runtime
  (a coordinator's fair-share input). Per-generation bytes recorded in each
  delta enable historyBytes() introspection without a storage size API.

Tests: per-write generation resolution, pin freeze vs add/update/remove,
drop-without-restore corruption-trap (fault injector), clean-reopen replay,
maxBytes/maxAge/no-cap reclamation, retention-then-reopen. 107 db/generation/
temporal tests green, tsc clean. Docs (ADR-001, consistency-model, snapshots
guide, api reference, RELEASES) updated to per-write granularity + retention.
2026-06-22 15:19:58 -07:00

470 lines
19 KiB
TypeScript

/**
* @module tests/unit/db/generationStore
* @description Unit tests for the generational MVCC record layer
* (`src/db/generationStore.ts`) against a real `MemoryStorage` adapter —
* counter monotonicity, the commit protocol (staging → execute → manifest
* rename → tx-log), point-in-time resolution from before-images, pin
* refcounting, compaction retention rules, CAS conflicts, timestamp
* resolution, and crash rollback of uncommitted generations.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { MemoryStorage } from '../../../src/storage/adapters/memoryStorage.js'
import {
GenerationStore,
GENERATIONS_PREFIX,
MANIFEST_PATH
} from '../../../src/db/generationStore.js'
import {
GenerationCompactedError,
GenerationConflictError
} from '../../../src/db/errors.js'
import { NounType } from '../../../src/types/graphTypes.js'
/** Stored-metadata fixture in the canonical shape the live write paths use. */
function metadataFixture(version: number): Record<string, unknown> {
return {
noun: NounType.Document,
subtype: 'note',
data: `payload-v${version}`,
version,
createdAt: 1000,
updatedAt: 1000 + version,
_rev: version
}
}
// Entity ids must be UUID-shaped (the sharded storage layout derives the
// shard from the UUID hex). Fixed values keep assertions deterministic;
// suffix ordering (…aa < …bb < …cc) matches `changedBetween`'s sorted output.
const ID_A = '00000000-0000-4000-8000-0000000000aa'
const ID_B = '00000000-0000-4000-8000-0000000000bb'
const ID_C = '00000000-0000-4000-8000-0000000000cc'
const ID_SOLO = '00000000-0000-4000-8000-0000000000d0'
const ID_LATER = '00000000-0000-4000-8000-0000000000e0'
describe('db/GenerationStore', () => {
let storage: MemoryStorage
let store: GenerationStore
beforeEach(async () => {
storage = new MemoryStorage()
await storage.init()
store = new GenerationStore(storage)
await store.open()
})
/** Commit one transaction that writes `metadata` for `id`. */
async function commitWrite(
id: string,
version: number,
options?: { meta?: Record<string, unknown>; ifAtGeneration?: number }
): Promise<{ generation: number; timestamp: number }> {
return store.commitTransaction({
touched: { nouns: [id], verbs: [] },
meta: options?.meta,
ifAtGeneration: options?.ifAtGeneration,
execute: async () => {
await storage.saveNounMetadata(id, metadataFixture(version))
}
})
}
describe('generation counter', () => {
it('starts at 0 on a fresh store', () => {
expect(store.generation()).toBe(0)
expect(store.committedGeneration()).toBe(0)
})
it('advances by exactly one per committed transaction', async () => {
await commitWrite(ID_A, 1)
expect(store.generation()).toBe(1)
await commitWrite(ID_A, 2)
expect(store.generation()).toBe(2)
expect(store.committedGeneration()).toBe(2)
})
it('bumps on single-operation writes via the storage hook (suppressed inside transact)', async () => {
// Single-op write outside any transaction → hook bumps.
await storage.saveNounMetadata(ID_SOLO, metadataFixture(1))
expect(store.generation()).toBe(1)
expect(store.committedGeneration()).toBe(0) // no transact committed
// A transact batch writing twice bumps ONCE (the batch is one generation).
await store.commitTransaction({
touched: { nouns: [ID_A, ID_B], verbs: [] },
execute: async () => {
await storage.saveNounMetadata(ID_A, metadataFixture(1))
await storage.saveNounMetadata(ID_B, metadataFixture(1))
}
})
expect(store.generation()).toBe(2)
})
it('survives close + reopen monotonically', async () => {
await commitWrite(ID_A, 1)
await storage.saveNounMetadata(ID_SOLO, metadataFixture(1)) // single-op bump
const before = store.generation()
await store.close()
const reopened = new GenerationStore(storage)
await reopened.open()
expect(reopened.generation()).toBeGreaterThanOrEqual(before)
expect(reopened.committedGeneration()).toBe(1)
})
})
describe('CAS (ifAtGeneration)', () => {
it('commits when the expectation matches and rejects with expected/actual when stale', async () => {
await commitWrite(ID_A, 1)
const pinned = store.generation()
await commitWrite(ID_A, 2, { ifAtGeneration: pinned }) // fresh → commits
expect(store.generation()).toBe(pinned + 1)
try {
await commitWrite(ID_A, 3, { ifAtGeneration: pinned }) // stale → rejects
expect.unreachable('should have thrown GenerationConflictError')
} catch (err) {
expect(err).toBeInstanceOf(GenerationConflictError)
expect((err as GenerationConflictError).expected).toBe(pinned)
expect((err as GenerationConflictError).actual).toBe(pinned + 1)
}
expect(store.generation()).toBe(pinned + 1) // nothing moved
})
})
describe('failed transactions', () => {
it('removes the staging directory and returns the generation reservation', async () => {
await commitWrite(ID_A, 1)
const before = store.generation()
await expect(
store.commitTransaction({
touched: { nouns: [ID_A], verbs: [] },
execute: async () => {
throw new Error('boom mid-batch')
}
})
).rejects.toThrow('boom mid-batch')
expect(store.generation()).toBe(before)
const leftovers = await storage.listRawObjects(`${GENERATIONS_PREFIX}/${before + 1}`)
expect(leftovers).toEqual([])
})
})
describe('point-in-time resolution', () => {
it('resolves untouched ids to the live fast path', async () => {
await commitWrite(ID_A, 1)
const pinned = store.generation()
await commitWrite(ID_B, 1) // later commit touches a DIFFERENT id
expect(await store.resolveAt('noun', ID_A, pinned)).toEqual({ source: 'current' })
})
it('resolves changed ids from the first before-image after the pin', async () => {
await commitWrite(ID_A, 1)
const pinned = store.generation()
await commitWrite(ID_A, 2)
await commitWrite(ID_A, 3)
const resolved = await store.resolveAt('noun', ID_A, pinned)
expect(resolved.source).toBe('record')
if (resolved.source === 'record') {
expect(resolved.metadata.version).toBe(1) // state as of the pin
}
})
it('resolves ids created after the pin as absent', async () => {
await commitWrite(ID_A, 1)
const pinned = store.generation()
await commitWrite(ID_LATER, 1)
expect(await store.resolveAt('noun', ID_LATER, pinned)).toEqual({ source: 'absent' })
})
it('changedBetween reports the ids touched in the interval', async () => {
await commitWrite(ID_A, 1)
const from = store.generation()
await commitWrite(ID_B, 1)
await commitWrite(ID_C, 1)
const to = store.generation()
const changed = await store.changedBetween(from, to)
expect(changed.nouns).toEqual([ID_B, ID_C])
expect(changed.verbs).toEqual([])
expect(changed.fromGeneration).toBe(from)
expect(changed.toGeneration).toBe(to)
})
})
describe('timestamp resolution + tx-log', () => {
it('records meta in the tx-log and resolves timestamps to generations', async () => {
const t0 = Date.now() - 10
const first = await commitWrite(ID_A, 1, { meta: { author: 'unit-test' } })
// Distinct commit timestamps: resolution picks the NEWEST generation
// committed at-or-before the instant, so same-millisecond commits tie.
await new Promise((resolve) => setTimeout(resolve, 5))
const second = await commitWrite(ID_A, 2)
const lines = await storage.readTxLogLines()
expect(lines.length).toBe(2)
const firstEntry = JSON.parse(lines[0])
expect(firstEntry.generation).toBe(first.generation)
expect(firstEntry.meta).toEqual({ author: 'unit-test' })
expect((await store.resolveTimestamp(t0)).generation).toBe(0)
expect((await store.resolveTimestamp(first.timestamp)).generation).toBe(first.generation)
expect((await store.resolveTimestamp(Date.now() + 1000)).generation).toBe(
second.generation
)
})
})
describe('pins + compaction', () => {
it('refcounts pins and never reclaims records a live pin needs', async () => {
await commitWrite(ID_A, 1)
const pinned = store.generation()
store.pin(pinned)
store.pin(pinned) // refcount 2
await commitWrite(ID_A, 2)
await commitWrite(ID_A, 3)
// Pin at G protects every record-set ABOVE G (resolution reads
// before-images from generations strictly greater than the pin).
await store.compact()
const resolved = await store.resolveAt('noun', ID_A, pinned)
expect(resolved.source).toBe('record')
if (resolved.source === 'record') {
expect(resolved.metadata.version).toBe(1)
}
store.release(pinned)
await store.compact()
// Still one live pin — records above it still resolve correctly.
const stillResolved = await store.resolveAt('noun', ID_A, pinned)
expect(stillResolved.source).toBe('record')
store.release(pinned)
const result = await store.compact()
expect(result.removedGenerations).toBeGreaterThan(0)
const remaining = await storage.listRawObjects(GENERATIONS_PREFIX)
expect(remaining).toEqual([])
})
it('maxGenerations cap keeps the most recent record-sets', async () => {
await commitWrite(ID_A, 1)
await commitWrite(ID_A, 2)
await commitWrite(ID_A, 3)
const result = await store.compact({ maxGenerations: 2 })
expect(result.removedGenerations).toBe(1)
expect(result.horizon).toBe(1)
// Removing record-set N breaks pins BELOW N (their reads need N's
// before-images) — the horizon itself stays reachable: state at the
// horizon resolves from the retained record-sets above it.
expect(() => store.assertReachable(0)).toThrow(GenerationCompactedError)
expect(store.assertReachable(1)).toBe(1)
expect(store.assertReachable(2)).toBe(2)
})
it('rejects future and negative generations', async () => {
await commitWrite(ID_A, 1)
expect(() => store.assertReachable(99)).toThrow(RangeError)
expect(() => store.assertReachable(-1)).toThrow(RangeError)
expect(() => store.assertReachable(1.5)).toThrow(RangeError)
})
})
describe('crash recovery', () => {
it('rolls back an uncommitted generation on open (manifest rename is the commit point)', async () => {
await commitWrite(ID_A, 1)
// Simulate a crash between record staging and the manifest rename: a
// throwing fault injector skips the abort cleanup, exactly as a dead
// process would, leaving canonical files mutated and the staging
// directory present — but the manifest still at the prior generation.
store.setCommitFaultInjector((phase) => {
if (phase === 'before-manifest-rename') {
throw new Error('simulated crash')
}
})
await expect(commitWrite(ID_A, 2)).rejects.toThrow('simulated crash')
store.setCommitFaultInjector(undefined)
// The canonical file currently holds the EXECUTED (uncommitted) state.
const dirty = await storage.readNounRaw(ID_A)
expect((dirty.metadata as { version: number }).version).toBe(2)
// Recovery on the next open restores the before-image byte-identically.
const reopened = new GenerationStore(storage)
const openResult = await reopened.open()
expect(openResult.rolledBackGenerations).toBe(1)
const repaired = await storage.readNounRaw(ID_A)
expect((repaired.metadata as { version: number }).version).toBe(1)
expect(reopened.committedGeneration()).toBe(1)
// The crashed generation number is never reused.
expect(reopened.generation()).toBeGreaterThanOrEqual(2)
})
it('keeps a transaction whose manifest rename landed (tx-log append is advisory)', async () => {
await commitWrite(ID_A, 1)
store.setCommitFaultInjector((phase) => {
if (phase === 'after-manifest-rename') {
throw new Error('simulated crash after commit point')
}
})
await expect(commitWrite(ID_A, 2)).rejects.toThrow('simulated crash after commit point')
store.setCommitFaultInjector(undefined)
const reopened = new GenerationStore(storage)
const openResult = await reopened.open()
expect(openResult.rolledBackGenerations).toBe(0)
expect(reopened.committedGeneration()).toBe(2)
const kept = await storage.readNounRaw(ID_A)
expect((kept.metadata as { version: number }).version).toBe(2)
const manifest = await storage.readRawObject(MANIFEST_PATH)
expect(manifest.generation).toBe(2)
})
})
// ==========================================================================
// Model-B single-op generation-stamping (per-write history + group-commit)
// ==========================================================================
describe('Model-B single-op generations', () => {
/** Apply one single-op write as its own generation (live write + buffered history). */
async function singleOpWrite(id: string, version: number): Promise<number> {
const { generation } = await store.commitSingleOp({
touched: { nouns: [id] },
execute: async () => {
await storage.saveNounMetadata(id, metadataFixture(version))
}
})
return generation
}
it('each single-op write is its own generation, resolvable BEFORE any flush', async () => {
const g1 = await singleOpWrite(ID_A, 1)
const g2 = await singleOpWrite(ID_A, 2)
expect(g1).toBe(1)
expect(g2).toBe(2)
// Counter advanced per write; nothing persisted to disk yet.
expect(store.generation()).toBe(2)
expect(store.committedGeneration()).toBe(0)
// Reads resolve through the in-memory pending tier with NO forced flush:
// the state as-of g1 is v1 (the before-image buffered by g2).
const atG1 = await store.resolveAt('noun', ID_A, g1)
expect(atG1.source).toBe('record')
expect(((atG1 as { metadata: { version: number } }).metadata).version).toBe(1)
// hasCommittedAfter counts pending → a now()-style pin at g1 is historical.
expect(store.hasCommittedAfter(g1)).toBe(true)
})
it('flush persists pending single-op generations (groupCommit-marked) and advances committed', async () => {
await singleOpWrite(ID_A, 1)
await singleOpWrite(ID_A, 2)
await store.flushPendingSingleOps()
expect(store.committedGeneration()).toBe(2)
const delta = await storage.readRawObject(`${GENERATIONS_PREFIX}/2/tx.json`)
expect(delta.groupCommit).toBe(true)
expect(typeof delta.bytes).toBe('number')
// asOf resolution survives the flush (now reading before-images from disk).
const atG1 = await store.resolveAt('noun', ID_A, 1)
expect(((atG1 as { metadata: { version: number } }).metadata).version).toBe(1)
})
it('🛑 a crash mid group-commit flush DROPS the generation WITHOUT reverting the live write', async () => {
// gen 1: X = v1, flushed (durable).
await singleOpWrite(ID_A, 1)
await store.flushPendingSingleOps()
expect(store.committedGeneration()).toBe(1)
// gen 2: X = v2 acknowledged to canonical storage; its history is buffered.
await singleOpWrite(ID_A, 2)
expect((await storage.readNounRaw(ID_A)).metadata).toMatchObject({ version: 2 })
// Crash before the manifest rename of the group-commit flush: the gen-2
// dir (before-image v1) lands on disk; the manifest stays at gen 1.
store.setCommitFaultInjector((phase) => {
if (phase === 'before-manifest-rename') throw new Error('simulated crash mid-flush')
})
await expect(store.flushPendingSingleOps()).rejects.toThrow('simulated crash mid-flush')
store.setCommitFaultInjector(undefined)
expect(await storage.readRawObject(`${GENERATIONS_PREFIX}/2/tx.json`)).not.toBeNull()
expect((await storage.readRawObject(MANIFEST_PATH)).generation).toBe(1)
// Recover on a FRESH store (process restart: in-memory pending is gone).
const reopened = new GenerationStore(storage)
const result = await reopened.open()
expect(result.rolledBackGenerations).toBe(1)
expect(reopened.committedGeneration()).toBe(1)
// THE CORRUPTION TRAP: drop-without-restore — the acknowledged live write
// (v2) is NOT reverted to the before-image (v1). Only the crashed flush
// window's HISTORY is lost; live data stays correct.
expect((await storage.readNounRaw(ID_A)).metadata).toMatchObject({ version: 2 })
// The orphan dir is gone and the dropped generation number is never reissued.
expect(await storage.readRawObject(`${GENERATIONS_PREFIX}/2/tx.json`)).toBeNull()
expect(reopened.generation()).toBeGreaterThanOrEqual(2)
})
it('a clean reopen replays committed single-op history (asOf survives restart)', async () => {
await singleOpWrite(ID_A, 1)
await singleOpWrite(ID_A, 2)
await store.flushPendingSingleOps()
const reopened = new GenerationStore(storage)
await reopened.open()
expect(reopened.committedGeneration()).toBe(2)
const atG1 = await reopened.resolveAt('noun', ID_A, 1)
expect(((atG1 as { metadata: { version: number } }).metadata).version).toBe(1)
})
})
// ==========================================================================
// Retention CAPS (Model-B `retention` knob)
// ==========================================================================
describe('retention caps', () => {
async function manyGens(n: number): Promise<void> {
for (let v = 1; v <= n; v++) await commitWrite(ID_A, v)
}
it('maxBytes reclaims the oldest generations until total history fits the budget', async () => {
await manyGens(5)
const total = await store.historyBytes()
expect(total).toBeGreaterThan(0)
const perGen = total / 5
const budget = Math.floor(perGen * 2.5)
const result = await store.compact({ maxBytes: budget })
expect(result.removedGenerations).toBeGreaterThan(0)
expect(await store.historyBytes()).toBeLessThanOrEqual(budget)
})
it('maxAge keeps generations within the window and reclaims older ones', async () => {
await manyGens(3)
// A wide window keeps everything.
expect((await store.compact({ maxAge: 60_000 })).removedGenerations).toBe(0)
// Advance the clock 10s past the commits, then a 1s window reclaims all
// three (fake timers make the age comparison deterministic).
vi.useFakeTimers()
try {
vi.setSystemTime(Date.now() + 10_000)
expect((await store.compact({ maxAge: 1_000 })).removedGenerations).toBe(3)
} finally {
vi.useRealTimers()
}
})
it('no caps reclaims every unpinned generation; a pin protects newer ones', async () => {
await manyGens(3)
store.pin(2) // protects generations > 2 (i.e. gen 3) from reclamation
const result = await store.compact()
// gens 1 and 2 are ≤ the pin → reclaimable; gen 3 is pin-protected.
expect(result.removedGenerations).toBe(2)
store.release(2)
})
})
})