Files
openclaw/test/scripts/bench-sqlite-reliability.test.ts
Vincent Koc 4afcbea5c5 test(sqlite): harden compaction and restore reliability proof (#105811)
* test(sqlite): chain compaction and restore reliability proof

* test(sqlite): bound reliability stress storage

* test(sqlite): monitor transient WAL growth
2026-07-13 09:09:21 +08:00

269 lines
8.9 KiB
TypeScript

// SQLite reliability proof tests cover CLI safety and one real snapshot round trip.
import { fork, spawnSync, type ChildProcess } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterEach, describe, expect, it } from "vitest";
import { monitorSqliteWalDuring } from "../../scripts/lib/sqlite-reliability-wal-monitor.js";
const tempDirs: string[] = [];
function makeTempDir(): string {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-reliability-test-"));
tempDirs.push(tempDir);
return tempDir;
}
function runProof(args: string[]) {
return spawnSync(
process.execPath,
["--import", "tsx", "scripts/bench-sqlite-reliability.ts", ...args],
{
cwd: process.cwd(),
encoding: "utf8",
timeout: 120_000,
},
);
}
async function waitForChildReady(child: ChildProcess): Promise<void> {
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(new Error("writer child did not become ready"));
}, 10_000);
const onMessage = (message: unknown) => {
if (
message &&
typeof message === "object" &&
(message as { kind?: unknown }).kind === "ready"
) {
cleanup();
resolve();
}
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
const onExit = () => {
cleanup();
reject(new Error("writer child exited before ready"));
};
const cleanup = () => {
clearTimeout(timeout);
child.off("message", onMessage);
child.off("error", onError);
child.off("exit", onExit);
};
child.on("message", onMessage);
child.on("error", onError);
child.on("exit", onExit);
});
}
async function waitForChildExit(child: ChildProcess): Promise<{
code: number | null;
signal: NodeJS.Signals | null;
}> {
return await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(new Error("writer child did not exit after IPC disconnect"));
}, 10_000);
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
cleanup();
resolve({ code, signal });
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
const cleanup = () => {
clearTimeout(timeout);
child.off("exit", onExit);
child.off("error", onError);
};
child.on("exit", onExit);
child.on("error", onError);
});
}
afterEach(() => {
for (const tempDir of tempDirs.splice(0)) {
fs.rmSync(tempDir, { force: true, recursive: true });
}
});
describe("scripts/bench-sqlite-reliability", () => {
it("detects a transient WAL overrun before the file shrinks", async () => {
const walPath = path.join(makeTempDir(), "database.sqlite-wal");
let stopRequests = 0;
await expect(
monitorSqliteWalDuring({
maxWalBytes: 1024,
onLimitExceeded: () => {
stopRequests += 1;
},
operation: async () => {
fs.writeFileSync(walPath, Buffer.alloc(2048));
await new Promise((resolve) => setTimeout(resolve, 25));
fs.truncateSync(walPath, 0);
return "complete";
},
pollIntervalMs: 5,
walPath,
}),
).rejects.toThrow("SQLite reliability WAL exceeded the 1024-byte profile limit: 2048 bytes");
expect(stopRequests).toBe(1);
});
it("rejects malformed arguments before creating state", () => {
const unknown = runProof(["--wat"]);
expect(unknown.status).toBe(2);
expect(unknown.stdout).toBe("");
expect(unknown.stderr.trim()).toBe("error: Unknown argument: --wat");
const duplicate = runProof(["--profile", "smoke", "--profile", "large"]);
expect(duplicate.status).toBe(2);
expect(duplicate.stdout).toBe("");
expect(duplicate.stderr.trim()).toBe("error: --profile was provided more than once");
const invalid = runProof(["--profile", "huge"]);
expect(invalid.status).toBe(2);
expect(invalid.stdout).toBe("");
expect(invalid.stderr.trim()).toBe(
'error: --profile must be one of smoke, default, large; got "huge"',
);
});
it("reuses a state directory without stale rows or restore collisions", () => {
const stateDir = makeTempDir();
const firstOutput = path.join(stateDir, "report-first.json");
const firstResult = runProof([
"--profile",
"smoke",
"--state-dir",
stateDir,
"--output",
firstOutput,
]);
expect(firstResult.status, firstResult.stderr).toBe(0);
expect(firstResult.stderr).toBe("");
expect(firstResult.stdout).toContain("SQLITE_RELIABILITY_TARGET=global");
expect(firstResult.stdout).toContain("SQLITE_RELIABILITY_RESTORES_VERIFIED=5");
expect(firstResult.stdout).toContain("SQLITE_RELIABILITY_POST_COMPACT_RESTORE=verified");
const firstReport = JSON.parse(fs.readFileSync(firstOutput, "utf8")) as {
concurrentRestoresVerified: number;
maintenanceProof: {
bloatBytes: number;
compaction: {
autoVacuum: { after: number };
freelistPages: { after: number; before: number };
reclaimedBytes: number;
walBytes: { after: number };
};
postCompact: {
restoreVerified: boolean;
state: {
batches: number;
rows: number;
sha256: string;
};
};
};
paths: {
sourceDatabase: string;
syncedRepository: string;
};
restoresVerified: number;
transactionProof: {
committedWalSentinel: boolean;
heldRows: number;
visibleAfterRestore: boolean;
};
walBytes: {
limit: number;
peak: number;
};
writer: {
rowsCommitted: number;
};
};
expect(firstReport.concurrentRestoresVerified).toBe(4);
expect(firstReport.restoresVerified).toBe(5);
expect(firstReport.transactionProof.committedWalSentinel).toBe(true);
expect(firstReport.transactionProof.heldRows).toBeGreaterThan(0);
expect(firstReport.transactionProof.visibleAfterRestore).toBe(false);
expect(firstReport.writer.rowsCommitted).toBeGreaterThan(0);
expect(firstReport.maintenanceProof.bloatBytes).toBeGreaterThan(0);
expect(firstReport.maintenanceProof.compaction.autoVacuum.after).toBe(2);
expect(firstReport.maintenanceProof.compaction.freelistPages.before).toBeGreaterThan(0);
expect(firstReport.maintenanceProof.compaction.freelistPages.after).toBe(0);
expect(firstReport.maintenanceProof.compaction.reclaimedBytes).toBeGreaterThan(0);
expect(firstReport.maintenanceProof.compaction.walBytes.after).toBe(0);
expect(firstReport.maintenanceProof.postCompact.restoreVerified).toBe(true);
expect(firstReport.maintenanceProof.postCompact.state.batches).toBeGreaterThan(0);
expect(firstReport.maintenanceProof.postCompact.state.rows).toBeGreaterThan(0);
expect(firstReport.maintenanceProof.postCompact.state.sha256).toMatch(/^[a-f0-9]{64}$/);
expect(firstReport.walBytes.limit).toBeGreaterThan(0);
expect(firstReport.walBytes.peak).toBeGreaterThan(0);
expect(firstReport.walBytes.peak).toBeLessThanOrEqual(firstReport.walBytes.limit);
const database = new DatabaseSync(firstReport.paths.sourceDatabase);
try {
database
.prepare(
"INSERT INTO openclaw_reliability_entries (batch, ordinal, payload) VALUES (?, ?, ?)",
)
.run(999_999, 0, "stale-profile-row");
} finally {
database.close();
}
const secondOutput = path.join(stateDir, "report-second.json");
const secondResult = runProof([
"--profile",
"smoke",
"--state-dir",
stateDir,
"--output",
secondOutput,
]);
expect(secondResult.status, secondResult.stderr).toBe(0);
const secondReport = JSON.parse(fs.readFileSync(secondOutput, "utf8")) as {
paths: { syncedRepository: string };
restoresVerified: number;
};
expect(secondReport.restoresVerified).toBe(5);
expect(secondReport.paths.syncedRepository).not.toBe(firstReport.paths.syncedRepository);
});
it("stops the writer when its parent IPC channel disconnects", async () => {
const databasePath = path.join(makeTempDir(), "writer.sqlite");
const child = fork(
path.resolve("scripts/lib/sqlite-reliability-writer.ts"),
[databasePath, "8", "64", "4", "256", String(64 * 1024 * 1024), "1"],
{
cwd: process.cwd(),
execArgv: ["--import", "tsx"],
serialization: "json",
stdio: ["ignore", "ignore", "pipe", "ipc"],
},
);
try {
await waitForChildReady(child);
const exitPromise = waitForChildExit(child);
child.disconnect();
await expect(exitPromise).resolves.toEqual({ code: 0, signal: null });
} finally {
if (child.exitCode === null && child.signalCode === null) {
child.kill();
}
}
});
});