feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
This commit is contained in:
parent
49e49483d1
commit
431cd64406
16 changed files with 6244 additions and 5 deletions
333
tests/unit/db/generationStore.test.ts
Normal file
333
tests/unit/db/generationStore.test.ts
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
/**
|
||||
* @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 } 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('retainGenerations 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({ retainGenerations: 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue