334 lines
13 KiB
TypeScript
334 lines
13 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 } 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)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
})
|