perf: snapshot migrated in-memory DB to skip per-test migrations (#1774)

## Problem

Hundreds of DB-backed tests build a fresh in-memory store in
`beforeEach` and call `db.init()`, which replays `SCHEMA_SQL` + **~129
migrations every single test** (~40ms each). Across thousands of tests
this is minutes of pure setup. The slowest core store suites are
dominated entirely by this.

## Approach (Option A: serialize/deserialize snapshot)

`node:sqlite` (and `bun:sqlite`) expose `serialize()`/`deserialize()`. I
migrate **one** in-memory DB per test file, serialize it to a byte
buffer, and register it via a test-only hook. Every subsequent in-memory
`Database` deserializes the snapshot at open time, so `init()` finds
`schemaVersion === SCHEMA_VERSION` plus the matching compat fingerprint
and short-circuits `migrate()` + all backfills.

Why this over the existing truncate-based
`createSharedTaskStoreTestHarness`: the snapshot keeps the **exact same
per-test isolation model** — each test still constructs its own
brand-new, fully-isolated DB — so suites that reassign their store/db
inside test bodies (both targets do) need no restructuring. Only the
migration cost is amortized. Disk-backed (production) DBs are never
touched; the hook is `null` in production, so behavior is unchanged.

### Harness API

```ts
beforeAll(() => installInMemoryDbSnapshot());
afterAll(() => clearInMemoryDbSnapshot());
// existing per-test `new <Store>({ inMemoryDb: true }); init()` stays as-is
```

- `packages/core/src/__tests__/store-test-helpers.ts` — core suites
- `packages/dashboard/src/__tests__/db-snapshot-helper.ts` — dashboard
suites (core `__tests__` is a private cross-package dir, so it mirrors
via the new public `setInMemoryTemplateSnapshot` export)

## Before / after

Raw `db.init()` microbenchmark: **43.4ms → 5.4ms (8x)**.

| Suite | Tests | Before | After | Note |
|---|---|---|---|---|
| `agent-store.test.ts` | 199 | 13.12s | **3.32s** | ~4x; init-dominated
|
| `mission-store.test.ts` | 261 | 17.62s | **5.69s** | ~3x; min of 3 |
| `workflow-routes.test.ts` | 53 | tests 4.38s | **tests 2.79s** | min
of 5; not init-dominated, so a smaller (~36%) but real win — most of its
time is express/route logic, not DB init |

All converted suites pass with **0 failures** and every original
assertion preserved. `db.test.ts` (which tests init/migration directly)
is intentionally left unconverted and still passes. Core + dashboard
typecheck clean; lint clean.

> Honest note: `workflow-routes` machine timings were noisy (same config
varied 5–13s under load); the `tests`-portion min-of-5 is the reliable
signal. The snapshot helps every in-memory suite, but the suite-level
win scales with how init-dominated the suite is.

## No changeset

Changes are test-infra only and behavior-preserving for the published
bundle — the snapshot hook is a no-op (`null`) in production. Per
AGENTS.md, no changeset for behavior-preserving/internal changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- stage-review-badge-begin -->

---

<a href="https://stagereview.app/Runfusion/Fusion/pull/1774">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://stagereview.app/assets/gh-open-in-stage-dark.svg">
<img src="https://stagereview.app/assets/gh-open-in-stage-light.svg"
alt="Open in Stage">
  </picture>
</a>

<!-- stage-review-badge-end -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Chores**
* Improved in-memory database handling for test runs by reusing a
prepared snapshot instead of rebuilding it repeatedly.
* Added snapshot support to the database layer and SQLite adapter to
speed up initialization in test environments.
* Updated core and dashboard test suites to use shared setup/teardown
for the cached database state.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
gsxdsm
2026-06-25 17:22:08 -07:00
committed by GitHub
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",