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:
David Snelling 2026-06-10 14:14:07 -07:00
parent 49e49483d1
commit 431cd64406
16 changed files with 6244 additions and 5 deletions

View file

@ -954,6 +954,254 @@ export class FileSystemStorage extends BaseStorage {
}
}
// ===========================================================================
// Generational record layer (8.0 MVCC) — durability + snapshot primitives
// ===========================================================================
/**
* Storage-root-relative paths that are mutated **in place** (appended to)
* rather than replaced via atomic tmp+rename. `snapshotToDirectory()` must
* byte-copy these instead of hard-linking them: a hard link shares the
* inode, so a post-snapshot append to the live file would silently mutate
* the snapshot. Every other persisted file in this adapter is written via
* tmp+rename (objects, blobs, counts, locks are excluded entirely), which
* makes hard links safe a rewrite swaps in a new inode and the snapshot
* keeps the old one.
*/
private static readonly SNAPSHOT_BYTE_COPY_PATHS = new Set<string>([
`${SYSTEM_DIR}/tx-log.jsonl`
])
/**
* Top-level directories excluded from snapshots: process-local lock state
* (writer lock, flush-request RPC files) must never travel with the data.
*/
private static readonly SNAPSHOT_EXCLUDED_TOP_DIRS = new Set<string>(['locks'])
/**
* Remove every object under a storage-root-relative prefix one recursive
* directory removal instead of the base class's list+delete loop.
*
* @param prefix - Storage-root-relative directory prefix to remove.
*/
public async removeRawPrefix(prefix: string): Promise<void> {
await this.ensureInitialized()
await fs.promises.rm(path.join(this.rootDir, prefix), { recursive: true, force: true })
}
/**
* Durability barrier: `fsync` each listed object file (resolving the
* compressed `.gz` variant when present) and then the set of parent
* directories, so both the file contents and the rename directory entries
* are durable before the commit protocol proceeds. Paths whose file no
* longer exists are skipped (a later step may have replaced them).
*
* @param paths - Storage-root-relative object paths previously written.
*/
public async syncRawObjects(paths: string[]): Promise<void> {
await this.ensureInitialized()
const parentDirs = new Set<string>()
for (const objectPath of paths) {
const fullPath = path.join(this.rootDir, objectPath)
for (const candidate of [`${fullPath}.gz`, fullPath]) {
let handle: any
try {
handle = await fs.promises.open(candidate, 'r')
} catch (error: any) {
if (error.code === 'ENOENT') continue
throw error
}
try {
await handle.sync()
} finally {
await handle.close()
}
parentDirs.add(path.dirname(fullPath))
break
}
}
for (const dir of parentDirs) {
let handle: any
try {
handle = await fs.promises.open(dir, 'r')
} catch {
continue // directory vanished or platform disallows opening dirs
}
try {
await handle.sync()
} catch {
// Some platforms (and some filesystems) reject directory fsync —
// file-level fsync above already covers the data itself.
} finally {
await handle.close()
}
}
}
/**
* Append one line to `_system/tx-log.jsonl`. Plain `appendFile` the
* tx-log is the one append-in-place file in the store (and is byte-copied,
* never hard-linked, by {@link FileSystemStorage.snapshotToDirectory}).
*
* @param line - One complete JSON document, without trailing newline.
*/
public async appendTxLogLine(line: string): Promise<void> {
await this.ensureInitialized()
const logPath = path.join(this.systemDir, 'tx-log.jsonl')
await fs.promises.appendFile(logPath, `${line}\n`, 'utf-8')
}
/**
* Read all tx-log lines, oldest first (empty array when no log exists).
* Torn trailing lines from a crashed append are returned as-is callers
* tolerate unparseable lines.
*/
public async readTxLogLines(): Promise<string[]> {
await this.ensureInitialized()
const logPath = path.join(this.systemDir, 'tx-log.jsonl')
try {
const content: string = await fs.promises.readFile(logPath, 'utf-8')
return content.split('\n').filter((l: string) => l.length > 0)
} catch (error: any) {
if (error.code === 'ENOENT') return []
throw error
}
}
/**
* Snapshot the entire store into `targetPath` as a hard-link farm
* (Cassandra-style: instant, space-shared). Safe because every data file
* is immutable-by-rename rewrites swap in new inodes, leaving the
* snapshot's links pointing at the old bytes. The two exceptions are
* handled explicitly: append-in-place files
* ({@link FileSystemStorage.SNAPSHOT_BYTE_COPY_PATHS}) are byte-copied, and
* process-local lock state
* ({@link FileSystemStorage.SNAPSHOT_EXCLUDED_TOP_DIRS}) is excluded.
* Cross-device targets (where `link(2)` fails with `EXDEV`) fall back to
* byte copies per file.
*
* @param targetPath - Absolute directory for the snapshot. Created if
* missing; must be empty or absent (refuses to overwrite).
*/
public async snapshotToDirectory(targetPath: string): Promise<void> {
await this.ensureInitialized()
try {
const existing = await fs.promises.readdir(targetPath)
if (existing.length > 0) {
throw new Error(
`snapshotToDirectory: target ${targetPath} already exists and is not empty. ` +
`Choose a fresh directory per snapshot.`
)
}
} catch (error: any) {
if (error.code !== 'ENOENT') throw error
}
await fs.promises.mkdir(targetPath, { recursive: true })
const files: string[] = []
await this.collectSnapshotFiles(this.rootDir, '', files)
for (const relPath of files) {
const sourceFile = path.join(this.rootDir, relPath)
const targetFile = path.join(targetPath, relPath)
await fs.promises.mkdir(path.dirname(targetFile), { recursive: true })
// Byte-copy list: compare against the normalized (extension-preserving)
// relative path with separators unified, so `_system/tx-log.jsonl`
// matches on every platform.
const normalized = relPath.split(path.sep).join('/')
if (FileSystemStorage.SNAPSHOT_BYTE_COPY_PATHS.has(normalized)) {
await fs.promises.copyFile(sourceFile, targetFile)
continue
}
try {
await fs.promises.link(sourceFile, targetFile)
} catch (error: any) {
// EXDEV: cross-device; EPERM/ENOTSUP: filesystem forbids links.
// ENOENT: the live file was atomically replaced mid-walk — retry as
// a copy of whatever is current (single-writer discipline means this
// only happens for derived files being flushed concurrently).
if (['EXDEV', 'EPERM', 'ENOTSUP', 'ENOENT'].includes(error.code)) {
try {
await fs.promises.copyFile(sourceFile, targetFile)
} catch (copyError: any) {
if (copyError.code !== 'ENOENT') throw copyError
}
} else {
throw error
}
}
}
}
/**
* Recursively collect snapshot-eligible files under `dirAbs`, excluding the
* lock directory and in-flight `*.tmp.*` write files.
*/
private async collectSnapshotFiles(dirAbs: string, relPrefix: string, out: string[]): Promise<void> {
let entries: any[]
try {
entries = await fs.promises.readdir(dirAbs, { withFileTypes: true })
} catch (error: any) {
if (error.code === 'ENOENT') return
throw error
}
for (const entry of entries) {
const rel = relPrefix ? path.join(relPrefix, entry.name) : entry.name
if (entry.isDirectory()) {
if (relPrefix === '' && FileSystemStorage.SNAPSHOT_EXCLUDED_TOP_DIRS.has(entry.name)) {
continue
}
await this.collectSnapshotFiles(path.join(dirAbs, entry.name), rel, out)
} else if (entry.isFile()) {
if (entry.name.includes('.tmp.')) continue // in-flight atomic write
out.push(rel)
}
}
}
/**
* Replace the store's contents from a snapshot directory: every current
* top-level entry except `locks/` (the live writer lock must survive) is
* removed, the snapshot is byte-copied in (`fs.cp` never hard-linked, so
* the snapshot stays independent of the restored store), and all
* adapter-internal derived state is reloaded.
*
* @param sourcePath - Absolute path of a directory produced by
* {@link FileSystemStorage.snapshotToDirectory}.
*/
public async restoreFromDirectory(sourcePath: string): Promise<void> {
await this.ensureInitialized()
const sourceStat = await fs.promises.stat(sourcePath).catch(() => null)
if (!sourceStat || !sourceStat.isDirectory()) {
throw new Error(`restoreFromDirectory: ${sourcePath} is not a directory`)
}
// Clear current contents, preserving live lock state.
const currentEntries = await fs.promises.readdir(this.rootDir)
for (const entry of currentEntries) {
if (FileSystemStorage.SNAPSHOT_EXCLUDED_TOP_DIRS.has(entry)) continue
await fs.promises.rm(path.join(this.rootDir, entry), { recursive: true, force: true })
}
// Byte-copy the snapshot in (defensively skipping lock dirs in old snapshots).
const sourceEntries = await fs.promises.readdir(sourcePath)
for (const entry of sourceEntries) {
if (FileSystemStorage.SNAPSHOT_EXCLUDED_TOP_DIRS.has(entry)) continue
await fs.promises.cp(path.join(sourcePath, entry), path.join(this.rootDir, entry), {
recursive: true
})
}
await this.reloadDerivedState()
}
// ===========================================================================
// Raw binary-blob primitive (mmap-friendly)
// ===========================================================================