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:
@@ -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