perf: snapshot migrated in-memory DB to skip per-test migrations

db.init() replays SCHEMA_SQL + ~129 migrations on every fresh in-memory
DB (~40ms each), which is minutes of pure setup across thousands of
DB-backed tests. Add a test-only migrated-schema snapshot: migrate ONE
in-memory DB per test file, serialize it, and deserialize a fresh copy
per test instead of re-migrating. Each test still gets a brand-new,
fully-isolated in-memory DB; only the migration cost is amortized.

- sqlite-adapter: expose serialize()/deserialize() (node:sqlite + bun)
- db.ts: setInMemoryTemplateSnapshot() hook (test-only, null in prod) +
  serializeSnapshot(); constructor deserializes the snapshot for
  in-memory DBs so init() short-circuits migrate()+compat at v129
- store-test-helpers: install/clearInMemoryDbSnapshot harness
- dashboard: db-snapshot-helper mirror (core __tests__ is cross-package)
- convert agent-store, mission-store, workflow-routes suites

Measured (raw db.init(): 43ms -> 5ms, 8x):
- agent-store      13.12s -> 3.32s
- mission-store    17.62s -> 5.69s (min of 3)
- workflow-routes  tests 4.38s -> 2.79s (min of 5; not init-dominated)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-25 16:48:56 -07:00
parent 744aa2cc05
commit 6dc8eb7161
8 changed files with 179 additions and 4 deletions

View File

@@ -10,8 +10,9 @@
* agent:heartbeat, agent:stateChanged), error paths, state transition
* validation, concurrency locking, and SQLite persistence.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from "vitest";
import { AgentStore } from "../agent-store.js";
import { installInMemoryDbSnapshot, clearInMemoryDbSnapshot } from "./store-test-helpers.js";
import { TaskStore } from "../store.js";
import { validateSnapshotEnvelope } from "../shared-mesh-state.js";
import { rm } from "node:fs/promises";
@@ -31,6 +32,11 @@ function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "fn-agent-store-test-"));
}
// FNXC:CoreTests 2026-06-25-16:30: amortize the ~129-migration db.init() cost
// across this file's in-memory stores via one migrated-schema snapshot.
beforeAll(() => installInMemoryDbSnapshot());
afterAll(() => clearInMemoryDbSnapshot());
describe("AgentStore", () => {
let rootDir: string;
let store: AgentStore;

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from "vitest";
import { MissionStore, deriveMilestoneAcceptanceCriteriaFromFeatures } from "../mission-store.js";
import { installInMemoryDbSnapshot, clearInMemoryDbSnapshot } from "./store-test-helpers.js";
import { GoalStore } from "../goal-store.js";
import { Database, SCHEMA_VERSION } from "../db.js";
import type { MissionFeature } from "../mission-types.js";
@@ -13,6 +14,11 @@ function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-mission-test-"));
}
// FNXC:CoreTests 2026-06-25-16:30: amortize the ~129-migration db.init() cost
// across this file's in-memory databases via one migrated-schema snapshot.
beforeAll(() => installInMemoryDbSnapshot());
afterAll(() => clearInMemoryDbSnapshot());
function linearIr(name: string): WorkflowIr {
return {
version: "v1",

View File

@@ -24,7 +24,7 @@ vi.mock("../run-command.js", async (importOriginal) => {
import { execSync } from "node:child_process";
import { runCommandAsync } from "../run-command.js";
import { Database } from "../db.js";
import { Database, setInMemoryTemplateSnapshot } from "../db.js";
import { DEFAULT_PROJECT_SETTINGS } from "../types.js";
import { TaskStore, TaskHasDependentsError } from "../store.js";
import type { Task } from "../types.js";
@@ -74,6 +74,49 @@ export function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-store-test-"));
}
/*
* FNXC:CoreTests 2026-06-25-16:30:
* Migrated in-memory DB snapshot for DB-backed test suites.
*
* db.init() replays SCHEMA_SQL + ~129 migrations on every fresh in-memory DB
* (~30-90ms each). Suites that build a new store per test (AgentStore,
* MissionStore, TaskStore, dashboard route stores, …) pay that cost hundreds of
* times. This harness migrates ONE in-memory DB per test file, serializes it,
* and registers the bytes via setInMemoryTemplateSnapshot so every later
* in-memory Database is restored from the snapshot instead of re-migrating.
*
* Isolation is unchanged: each test still constructs its own brand-new
* in-memory DB; only the migration work is amortized. Disk-backed stores
* (cross-instance persistence tests) are never touched by the snapshot.
*
* Usage:
* beforeAll(() => installInMemoryDbSnapshot());
* afterAll(() => clearInMemoryDbSnapshot());
* Leave existing per-test `new <Store>({ inMemoryDb: true }); init()` as-is.
*/
let cachedMigratedSnapshot: Uint8Array | null = null;
export function installInMemoryDbSnapshot(): void {
if (process.env.FN_NO_SNAPSHOT === "1") return; // A/B benchmark escape hatch
if (!cachedMigratedSnapshot) {
// Build the template with the hook OFF so this DB runs real migrations once.
setInMemoryTemplateSnapshot(null);
const templateDir = makeTmpDir();
const template = new Database(templateDir, { inMemory: true });
try {
template.init();
cachedMigratedSnapshot = template.serializeSnapshot();
} finally {
template.close();
}
}
setInMemoryTemplateSnapshot(cachedMigratedSnapshot);
}
export function clearInMemoryDbSnapshot(): void {
setInMemoryTemplateSnapshot(null);
}
async function clearDirectoryContents(dir: string): Promise<void> {
try {
const entries = await readdir(dir);

View File

@@ -1830,6 +1830,26 @@ export function integrityCheckSqliteFileAsync(
// ── Database Class ───────────────────────────────────────────────────
/**
* FNXC:CoreTests 2026-06-25-16:30:
* Test-only migrated-schema snapshot. db.init() replays the full schema plus
* ~129 migrations on every fresh in-memory DB (~30-90ms each), which is minutes
* of pure setup across thousands of DB-backed tests. The snapshot harness
* (store-test-helpers.ts → installInMemoryDbSnapshot) builds ONE migrated
* in-memory DB per test file, serializes it, and registers the bytes here.
* Any subsequent in-memory Database deserializes the snapshot at open time, so
* init() finds schemaVersion === SCHEMA_VERSION and the matching compat
* fingerprint and short-circuits all migration/backfill work. Each test still
* gets a brand-new, fully-isolated in-memory DB — only the migration cost is
* amortized. Never consulted for disk-backed (production) databases.
*/
let inMemoryTemplateSnapshot: Uint8Array | null = null;
/** Register/clear the in-memory migrated-DB snapshot. Test harness only. */
export function setInMemoryTemplateSnapshot(snapshot: Uint8Array | null): void {
inMemoryTemplateSnapshot = snapshot;
}
type SharedIntegrityCheckState = {
timer: ReturnType<typeof setTimeout> | null;
subscribers: Set<Database>;
@@ -1928,6 +1948,13 @@ export class Database {
} else {
// Wait up to the configured timeout for locks to clear before returning SQLITE_BUSY.
this.db.exec(`PRAGMA busy_timeout = ${this.busyTimeoutMs}`);
// FNXC:CoreTests 2026-06-25-16:30:
// Restore the migrated-schema snapshot in place of replaying migrations.
// deserialize() swaps page content only; the connection-level pragmas set
// above/below (busy_timeout, foreign_keys) persist across the swap.
if (inMemoryTemplateSnapshot) {
this.db.deserialize(inMemoryTemplateSnapshot);
}
}
// Enable foreign key enforcement
this.db.exec("PRAGMA foreign_keys = ON");
@@ -1935,6 +1962,15 @@ export class Database {
this._fts5Available = probeFts5(this.db);
}
/**
* FNXC:CoreTests 2026-06-25-16:30:
* Serialize the entire database to a byte buffer for the test snapshot
* harness (see setInMemoryTemplateSnapshot). Test-only.
*/
serializeSnapshot(): Uint8Array {
return this.db.serialize();
}
/**
* True when the underlying SQLite build has FTS5 (`CREATE VIRTUAL TABLE … USING fts5`).
* Node's bundled SQLite only exposes FTS5 when built with `SQLITE_ENABLE_FTS5`;

View File

@@ -697,6 +697,9 @@ export {
toJsonNullable,
fromJson,
SCHEMA_VERSION,
// FNXC:CoreTests 2026-06-25-16:30: test-only migrated-DB snapshot hook so
// cross-package suites (dashboard route tests) can amortize db.init() cost.
setInMemoryTemplateSnapshot,
} from "./db.js";
export {
ProjectIdentityConflictError,

View File

@@ -44,6 +44,11 @@ interface RawDatabase {
exec(sql: string): void;
prepare(sql: string): RawStatement;
close(): void;
// Optional snapshot API (node:sqlite ≥ 22.x, bun:sqlite). Both runtimes
// expose `serialize()` → Uint8Array and `deserialize(buf)` that replaces the
// open database's contents in place. Used only by the test snapshot harness.
serialize?: () => Uint8Array;
deserialize?: (data: Uint8Array) => void;
}
type DatabaseCtor = new (path: string) => RawDatabase;
@@ -84,6 +89,31 @@ export class DatabaseSync {
this.impl.close();
}
/**
* FNXC:CoreTests 2026-06-25-16:30:
* Snapshot the entire database into a byte buffer. Backs the test-only
* migrated-DB snapshot harness so the 129-migration init() runs once per
* test file instead of once per test. Throws if the runtime lacks the API.
*/
serialize(): Uint8Array {
if (typeof this.impl.serialize !== "function") {
throw new Error("SQLite runtime does not support serialize()");
}
return this.impl.serialize();
}
/**
* FNXC:CoreTests 2026-06-25-16:30:
* Replace this (in-memory) database's contents with a previously serialized
* snapshot. Restores a fully-migrated schema without replaying migrations.
*/
deserialize(data: Uint8Array): void {
if (typeof this.impl.deserialize !== "function") {
throw new Error("SQLite runtime does not support deserialize()");
}
this.impl.deserialize(data);
}
prepare(sql: string): SqliteStatement {
const stmt = this.impl.prepare(sql);
// Both node:sqlite and bun:sqlite expose the same .all/.get/.run shape.

View File

@@ -0,0 +1,45 @@
/*
* FNXC:DashboardTests 2026-06-25-16:30:
* Migrated in-memory DB snapshot for dashboard route suites.
*
* Route tests build a fresh in-memory TaskStore/AgentStore per test, and each
* store.init() replays SCHEMA_SQL + ~129 migrations (~30-90ms). This helper
* migrates ONE in-memory DB per test file, serializes it, and registers the
* bytes via @fusion/core's setInMemoryTemplateSnapshot so every later in-memory
* Database is restored from the snapshot instead of re-migrating. Test
* isolation is unchanged — each test still gets its own fresh in-memory DB.
*
* Mirrors packages/core/src/__tests__/store-test-helpers.ts; kept separate
* because that file lives in @fusion/core's private __tests__ dir.
*
* Usage:
* beforeAll(() => installInMemoryDbSnapshot());
* afterAll(() => clearInMemoryDbSnapshot());
*/
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Database, setInMemoryTemplateSnapshot } from "@fusion/core";
let cachedMigratedSnapshot: Uint8Array | null = null;
export function installInMemoryDbSnapshot(): void {
if (process.env.FN_NO_SNAPSHOT === "1") return; // A/B benchmark escape hatch
if (!cachedMigratedSnapshot) {
// Build the template with the hook OFF so it runs real migrations once.
setInMemoryTemplateSnapshot(null);
const templateDir = mkdtempSync(join(tmpdir(), "fn-dash-db-snapshot-"));
const template = new Database(templateDir, { inMemory: true });
try {
template.init();
cachedMigratedSnapshot = template.serializeSnapshot();
} finally {
template.close();
}
}
setInMemoryTemplateSnapshot(cachedMigratedSnapshot);
}
export function clearInMemoryDbSnapshot(): void {
setInMemoryTemplateSnapshot(null);
}

View File

@@ -1,6 +1,6 @@
// @vitest-environment node
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from "vitest";
import express from "express";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
@@ -11,11 +11,17 @@ import { registerWorkflowRoutes } from "../routes/register-workflow-routes.js";
import { ApiError, sendErrorResponse } from "../api-error.js";
import { request } from "../test-request.js";
import { emitWorkflowSseEvent } from "../sse.js";
import { installInMemoryDbSnapshot, clearInMemoryDbSnapshot } from "./db-snapshot-helper.js";
vi.mock("../sse.js", () => ({
emitWorkflowSseEvent: vi.fn(),
}));
// FNXC:DashboardTests 2026-06-25-16:30: amortize the ~129-migration store.init()
// cost across this file's in-memory TaskStore/AgentStore via one snapshot.
beforeAll(() => installInMemoryDbSnapshot());
afterAll(() => clearInMemoryDbSnapshot());
function linearIr(): WorkflowIr {
return {
version: "v1",