Skip to content

Commit 5bcd240

Browse files
committed
perf(test): clone a migrated template DB per TestD1Database instead of replaying 178 migrations
Executing the full migrations/*.sql chain into :memory: on every construction (~640ms x ~1500 sites) was ~950s of pure schema replay per full run and put ordinary tests in the 3s+ range. Build one content-hash-keyed template file per migration-chain content (atomic rename, shared across workers, page_size=1024 + VACUUM), then copyFileSync a clone per instance: ~1.5ms per construction with a verified write. Full suite: 510s -> 171s wall, 4288s -> 870s aggregate, same 21472-test pass set. Clones stay on disk until a single exit sweep -- SQLite fails writes with SQLITE_READONLY_DBMOVED if the main file is unlinked while open -- and all memoization lives on globalThis so vitest's per-file module isolation cannot silently rebuild the template per test file. serialize()/deserialize() remain unusable on the pinned Node 22 (the prior attempt crashed CI).
1 parent 1d83593 commit 5bcd240

1 file changed

Lines changed: 87 additions & 17 deletions

File tree

test/helpers/d1.ts

Lines changed: 87 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,104 @@
1-
import { readFileSync, readdirSync } from "node:fs";
1+
import { createHash } from "node:crypto";
2+
import {
3+
copyFileSync,
4+
existsSync,
5+
readFileSync,
6+
readdirSync,
7+
renameSync,
8+
unlinkSync,
9+
} from "node:fs";
10+
import { tmpdir } from "node:os";
11+
import { join } from "node:path";
212
import { DatabaseSync } from "node:sqlite";
313

414
type BoundValue = string | number | null | Uint8Array;
515

6-
// Listing + reading migrations/*.sql (~90 files) on every TestD1Database construction (~1500 call sites
7-
// across the suite) is pure overhead: the file list and contents never change within a worker process's
8-
// lifetime. Cache the concatenated SQL once per process instead of re-reading it every call.
16+
// Executing the full migrations/*.sql chain (178 files and growing) into a fresh :memory: database on
17+
// EVERY TestD1Database construction (~1500 call sites across the suite) was the suite's single biggest
18+
// time sink: ~640ms per construction, ~950s aggregate per full run, and it inflated ordinary tests into
19+
// the 3s+ range. Instead, build ONE fully-migrated template database file per migration-chain content
20+
// (shared across every vitest worker), then give each instance its own copyFileSync clone: ~1.5ms per
21+
// construction including a verified write, identical schema, fully isolated state.
922
//
10-
// NOT using node:sqlite's serialize()/deserialize() here: they would let one migrated template be cloned
11-
// per instance instead of re-executing the SQL each time (a much bigger win), but they don't exist on this
12-
// repo's pinned Node 22 (`.nvmrc`) at all -- confirmed absent from DatabaseSync's prototype on Node 22.23.1,
13-
// present only from Node 24+. An earlier version of this cache used them and passed locally on a newer Node,
14-
// but crashed every test in CI (`db.deserialize is not a function`) since CI runs the pinned Node 22. Stick
15-
// to what Node 22 actually supports.
16-
let migratedSql: string | null = null;
17-
function getMigratedSql(): string {
18-
if (migratedSql) return migratedSql;
19-
migratedSql = readdirSync("migrations")
23+
// Design constraints this shape answers (each learned the hard way):
24+
// - node:sqlite's serialize()/deserialize() (the in-memory equivalent of this clone) don't exist on the
25+
// pinned Node 22 (`.nvmrc`) at all -- absent from DatabaseSync's prototype on 22.23.1, present only
26+
// from Node 24+. An earlier attempt used them and crashed every test in CI. File copy is Node-22-safe.
27+
// - The clone file must NOT be unlinked while its database is open: SQLite detects the missing main file
28+
// and fails every later write with SQLITE_READONLY_DBMOVED ("attempt to write a readonly database").
29+
// Clones therefore stay on disk until the single exit sweep below removes them.
30+
// - Vitest's per-file module isolation re-evaluates this module constantly, so ALL memoization lives on
31+
// globalThis, never in module-scope state -- module-scope memos would rebuild the template once per
32+
// test FILE (~640ms each), silently giving back most of the win.
33+
// - The template is keyed by a hash of the concatenated migration SQL, and built at a `.tmp` sibling
34+
// then renameSync'd (atomic) into place -- concurrent workers can double-build harmlessly, but no
35+
// reader can ever copy a half-written template, and a schema change gets a fresh key instead of a
36+
// stale reuse. page_size=1024 + VACUUM shrink the empty-schema template ~3.4x (1.4MB -> ~410KB), which
37+
// bounds worst-case tmpdir usage for a full run's clones to a few hundred MB, swept at worker exit.
38+
type TestD1GlobalState = {
39+
templatePath?: string;
40+
cloneCounter: number;
41+
clonePaths: string[];
42+
exitSweepRegistered: boolean;
43+
};
44+
45+
function testD1State(): TestD1GlobalState {
46+
const holder = globalThis as { __loopoverTestD1State?: TestD1GlobalState };
47+
holder.__loopoverTestD1State ??= {
48+
cloneCounter: 0,
49+
clonePaths: [],
50+
exitSweepRegistered: false,
51+
};
52+
return holder.__loopoverTestD1State;
53+
}
54+
55+
function getMigratedTemplatePath(): string {
56+
const state = testD1State();
57+
if (state.templatePath) return state.templatePath;
58+
const migratedSql = readdirSync("migrations")
2059
.filter((file) => file.endsWith(".sql"))
2160
.sort()
2261
.map((file) => readFileSync(`migrations/${file}`, "utf8"))
2362
.join("\n");
24-
return migratedSql;
63+
const key = createHash("sha256").update(migratedSql).digest("hex").slice(0, 16);
64+
const path = join(tmpdir(), `loopover-test-migrated-${key}.sqlite3`);
65+
if (!existsSync(path)) {
66+
const buildPath = `${path}.${process.pid}.tmp`;
67+
const template = new DatabaseSync(buildPath);
68+
template.exec("PRAGMA page_size=1024;");
69+
template.exec(migratedSql);
70+
template.exec("VACUUM;");
71+
template.close();
72+
renameSync(buildPath, path);
73+
}
74+
state.templatePath = path;
75+
return path;
2576
}
2677

2778
export class TestD1Database {
28-
readonly db = new DatabaseSync(":memory:");
79+
readonly db: DatabaseSync;
2980

3081
constructor() {
31-
this.db.exec(getMigratedSql());
82+
const state = testD1State();
83+
const clonePath = join(
84+
tmpdir(),
85+
`loopover-test-clone-${process.pid}-${state.cloneCounter++}-${Math.random().toString(36).slice(2, 8)}.sqlite3`,
86+
);
87+
copyFileSync(getMigratedTemplatePath(), clonePath);
88+
this.db = new DatabaseSync(clonePath);
89+
state.clonePaths.push(clonePath);
90+
if (!state.exitSweepRegistered) {
91+
state.exitSweepRegistered = true;
92+
process.on("exit", () => {
93+
for (const path of state.clonePaths) {
94+
try {
95+
unlinkSync(path);
96+
} catch {
97+
// best-effort tmp hygiene only
98+
}
99+
}
100+
});
101+
}
32102
}
33103

34104
prepare(sql: string) {

0 commit comments

Comments
 (0)