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:
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
45
packages/dashboard/src/__tests__/db-snapshot-helper.ts
Normal file
45
packages/dashboard/src/__tests__/db-snapshot-helper.ts
Normal 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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user