server/checkpoints-perf.test.ts
Rama main · réplica de lectura del repositorio original.
// Real git does the work; execFile is wrapped only to record calls and inject a timeout or object counts, readdirSync only to record calls.
import { createHash } from "node:crypto";
import { execFile as realExecFile, execFileSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, realpathSync, renameSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
// Shadow repos live under DATA_DIR, which server/testing/setup.ts points at a
// disposable test home before any test module imports config.ts.
import { CHECKPOINTS_DIR, diffWorkingTree, release, snapshot } from "./checkpoints.ts";
import { removeTempDir } from "./testing/cleanup.ts";
import { hostTimeout } from "./testing/host-timeout.ts";
type Injected = { error: Error; stdout?: string; stderr?: string } | { stdout: string };
const hooks = vi.hoisted(() => ({
calls: [] as string[][],
inject: null as null | ((args: string[]) => Injected | undefined),
readdirSync: [] as string[],
}));
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
const readdirSync = ((path: Parameters<typeof actual.readdirSync>[0], ...rest: unknown[]) => {
hooks.readdirSync.push(String(path));
return (actual.readdirSync as (...args: unknown[]) => unknown)(path, ...rest);
}) as typeof actual.readdirSync;
return { ...actual, readdirSync, default: { ...actual, readdirSync } };
});
vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
const execFile = (file: string, args: string[], options: object, callback: (...rest: unknown[]) => void) => {
if (file === "git") {
hooks.calls.push(args);
const injected = hooks.inject?.(args);
if (injected) {
setImmediate(() => callback("error" in injected ? injected.error : null, injected.stdout ?? "", "stderr" in injected ? injected.stderr ?? "" : ""));
return {};
}
}
return actual.execFile(file, args, options, callback);
};
return { ...actual, execFile };
});
const scratchDirs: string[] = [];
afterAll(async () => {
for (const dir of scratchDirs) await removeTempDir(dir);
});
afterEach(() => {
hooks.inject = null;
hooks.calls = [];
hooks.readdirSync = [];
vi.restoreAllMocks();
});
let seq = 0;
function workspace() {
const cwd = mkdtempSync(join(tmpdir(), "omb-ckpt-perf-ws-"));
scratchDirs.push(cwd);
seq += 1;
return { bot: `ckpt-perf-bot-${seq}`, cwd };
}
/** Plain git on the folder's shadow repo; drain() first so no queued GC is
* running. */
function shadowGit(bot: string, cwd: string, ...args: string[]): string {
const shadow = join(CHECKPOINTS_DIR, bot, createHash("sha256").update(realpathSync(cwd)).digest("hex").slice(0, 16));
return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env: { ...process.env, GIT_DIR: join(shadow, ".git") } });
}
function snapshotHashes(bot: string, cwd: string): string[] {
const hashes = shadowGit(bot, cwd, "log", "--format=%H").split("\n").filter(Boolean);
hashes.pop(); // the root of the log is always the empty base marker
return hashes;
}
function commitExists(bot: string, cwd: string, hash: string): boolean {
try {
shadowGit(bot, cwd, "cat-file", "-e", `${hash}^{commit}`);
return true;
} catch {
return false;
}
}
/** release() queues behind the folder's operations, and releasing a pin never taken is a no-op. */
async function drain(bot: string, cwd: string): Promise<void> {
await release(bot, cwd, "test-drain");
}
/** Exactly what Node's execFile hands back when its `timeout` kills git. */
function timeoutError(command: string): Error {
return Object.assign(new Error(`Command failed: git ${command}`), { killed: true, signal: "SIGTERM", code: null });
}
const subcommands = () => hooks.calls.map(args => args[0]);
/** `git count-objects -v` output; sizes in KiB. */
function countObjects({ count = 0, size = 0, sizePack = 0 }: { count?: number; size?: number; sizePack?: number }): string {
return `count: ${count}\nsize: ${size}\nin-pack: 0\npacks: ${sizePack ? 1 : 0}\nsize-pack: ${sizePack}\nprune-packable: 0\ngarbage: 0\nsize-garbage: 0\n`;
}
/** Is the temp volume case-insensitive (macOS default APFS, NTFS)? */
const caseInsensitiveTmp = (() => {
const probe = mkdtempSync(join(tmpdir(), "omb-ckpt-perf-case-"));
scratchDirs.push(probe);
writeFileSync(join(probe, "probe"), "");
return existsSync(join(probe, "PROBE"));
})();
describe("incremental shadow index", () => {
it("re-snapshots an unchanged 3,000-file tree without rebuilding the index or collecting", async () => {
const { bot, cwd } = workspace();
for (let d = 0; d < 60; d += 1) {
mkdirSync(join(cwd, `dir${d}`));
for (let f = 0; f < 50; f += 1) writeFileSync(join(cwd, `dir${d}`, `file${f}.txt`), `dir ${d} file ${f}\n`.repeat(20));
}
const first = await snapshot(bot, cwd, "first");
expect(first).toMatch(/^[0-9a-f]{40}$/);
await drain(bot, cwd);
hooks.calls = [];
const steady = await snapshot(bot, cwd, "steady");
expect(steady).toBe(first);
expect(subcommands()).not.toContain("read-tree");
expect(subcommands()).not.toContain("gc");
expect(subcommands()).not.toContain("reflog");
expect(subcommands().filter(command => command === "add")).toHaveLength(1);
hooks.calls = [];
expect(await diffWorkingTree(bot, cwd, first!)).toEqual({ changed: [], added: [], deleted: [] });
expect(subcommands()).not.toContain("read-tree");
// 3,000 files take ~2 s on macOS but run past vitest's 20 s default on CI's Windows runners; hostTimeout doubles it there.
}, hostTimeout(120_000));
it("takes a turn's snapshot in a handful of git calls, moving HEAD and the turn's pin in one", async () => {
const { bot, cwd } = workspace();
writeFileSync(join(cwd, "a.txt"), "one");
await snapshot(bot, cwd, "first");
writeFileSync(join(cwd, "a.txt"), "two");
hooks.calls = [];
const changed = await snapshot(bot, cwd, "changed", undefined, { pin: "dispatch-1" });
expect(changed).toMatch(/^[0-9a-f]{40}$/);
expect(subcommands()).toEqual(["ls-files", "log", "ls-files", "ls-files", "add", "write-tree", "commit-tree", "update-ref", "count-objects"]);
expect(shadowGit(bot, cwd, "rev-parse", "HEAD").trim()).toBe(changed);
expect(shadowGit(bot, cwd, "for-each-ref", "--format=%(objectname)", "refs/omb-live/").trim()).toBe(changed);
hooks.calls = [];
expect(await snapshot(bot, cwd, "unchanged")).toBe(changed);
expect(subcommands()).toEqual(["ls-files", "log", "ls-files", "ls-files", "add", "write-tree"]);
await release(bot, cwd, "dispatch-1");
});
it("falls back to a full rebuild when the index cannot be listed", async () => {
const { bot, cwd } = workspace();
writeFileSync(join(cwd, "a.txt"), "one");
const first = await snapshot(bot, cwd, "first");
writeFileSync(join(cwd, "a.txt"), "two");
hooks.inject = args => args[0] === "ls-files" && args.includes("--stage")
? { error: Object.assign(new Error("stdout maxBuffer length exceeded"), { code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" }) }
: undefined;
hooks.calls = [];
const second = await snapshot(bot, cwd, "second");
expect(second).toMatch(/^[0-9a-f]{40}$/);
expect(second).not.toBe(first);
expect(subcommands()).toContain("read-tree");
hooks.inject = null;
writeFileSync(join(cwd, "a.txt"), "three");
expect(await snapshot(bot, cwd, "third")).toMatch(/^[0-9a-f]{40}$/);
});
it("collects early once loose objects pass the threshold, and not otherwise", async () => {
const { bot, cwd } = workspace();
writeFileSync(join(cwd, "a.txt"), "one");
await snapshot(bot, cwd, "first");
writeFileSync(join(cwd, "a.txt"), "two");
hooks.calls = [];
const below = await snapshot(bot, cwd, "below threshold");
await diffWorkingTree(bot, cwd, below!);
await drain(bot, cwd);
expect(subcommands()).toContain("count-objects");
expect(subcommands()).not.toContain("gc");
hooks.inject = args => args[0] === "count-objects" ? { stdout: countObjects({ count: 7000, size: 28000 }) } : undefined;
writeFileSync(join(cwd, "a.txt"), "three");
hooks.calls = [];
const hash = await snapshot(bot, cwd, "over threshold");
await diffWorkingTree(bot, cwd, hash!);
await drain(bot, cwd);
expect(subcommands()).toEqual(expect.arrayContaining(["reflog", "gc"]));
expect(snapshotHashes(bot, cwd)).toEqual([hash]);
});
it("collects once 256 MiB of new object data piles up, but not for a big live snapshot alone", async () => {
const { bot, cwd } = workspace();
let counts = countObjects({ count: 5, size: 100, sizePack: 400_000 });
hooks.inject = args => args[0] === "count-objects" ? { stdout: counts } : undefined;
const turn = async (text: string) => {
writeFileSync(join(cwd, "a.txt"), text);
hooks.calls = [];
const hash = await snapshot(bot, cwd, text);
expect(hash).toMatch(/^[0-9a-f]{40}$/);
await diffWorkingTree(bot, cwd, hash!);
await drain(bot, cwd);
expect(subcommands()).toContain("count-objects");
return subcommands().includes("gc");
};
// a 400 MiB pack is the live snapshot, not garbage
expect(await turn("one")).toBe(false);
expect(await turn("two")).toBe(false);
// loose data is all new since the last collection
counts = countObjects({ count: 5, size: 270_000, sizePack: 400_000 });
expect(await turn("three")).toBe(true);
counts = countObjects({ count: 5, size: 10, sizePack: 400_000 });
expect(await turn("four")).toBe(false);
// so is pack growth (big files are streamed straight into new packs)
counts = countObjects({ count: 5, size: 10, sizePack: 700_000 });
expect(await turn("five")).toBe(true);
});
it("runs a due collection behind that turn's settled diff, never ahead of it or inside the snapshot", async () => {
const { bot, cwd } = workspace();
writeFileSync(join(cwd, "a.txt"), "one");
await snapshot(bot, cwd, "first");
hooks.inject = args => args[0] === "count-objects" ? { stdout: countObjects({ count: 7000 }) } : undefined;
writeFileSync(join(cwd, "a.txt"), "two");
hooks.calls = [];
const before = await snapshot(bot, cwd, "collection due");
hooks.inject = null;
expect(subcommands()).not.toContain("gc");
// the turn writes a file; its settled diff must not queue behind GC
writeFileSync(join(cwd, "b.txt"), "made by the turn");
expect(await diffWorkingTree(bot, cwd, before!)).toEqual({ changed: [], added: ["b.txt"], deleted: [] });
await drain(bot, cwd);
const settledDiff = hooks.calls.findIndex(args => args[0] === "diff" && args.includes("--name-status"));
expect(settledDiff).toBeGreaterThanOrEqual(0);
expect(subcommands().indexOf("reflog")).toBeGreaterThan(settledDiff);
expect(subcommands().indexOf("gc")).toBeGreaterThan(settledDiff);
expect(snapshotHashes(bot, cwd)).toEqual([before]);
});
it("runs a collection left by a turn without a diff ahead of the next snapshot, once", async () => {
const { bot, cwd } = workspace();
writeFileSync(join(cwd, "a.txt"), "one");
await snapshot(bot, cwd, "first");
// loose objects stay over the threshold until a gc has actually run
hooks.inject = args => args[0] === "count-objects" && !subcommands().includes("gc") ? { stdout: countObjects({ count: 7000 }) } : undefined;
writeFileSync(join(cwd, "a.txt"), "two");
await snapshot(bot, cwd, "collection due"); // this turn settles without a diff
await drain(bot, cwd);
expect(subcommands()).not.toContain("gc");
hooks.calls = [];
writeFileSync(join(cwd, "a.txt"), "three");
const next = await snapshot(bot, cwd, "next turn");
const collected = subcommands().indexOf("gc");
expect(collected).toBeGreaterThanOrEqual(0);
expect(subcommands().indexOf("reflog")).toBeLessThan(collected);
expect(subcommands().indexOf("add")).toBeGreaterThan(collected);
writeFileSync(join(cwd, "b.txt"), "made by the turn");
expect(await diffWorkingTree(bot, cwd, next!)).toEqual({ changed: [], added: ["b.txt"], deleted: [] });
await drain(bot, cwd);
expect(subcommands().filter(command => command === "gc")).toHaveLength(1);
expect(snapshotHashes(bot, cwd)).toEqual([next]);
});
it("keeps a pinned commit through a collection and reclaims it once released", async () => {
const { bot, cwd } = workspace();
const collectionDue = () => {
hooks.inject = args => args[0] === "count-objects" ? { stdout: countObjects({ count: 7000 }) } : undefined;
};
writeFileSync(join(cwd, "a.txt"), "before A");
const hashA = await snapshot(bot, cwd, "turn A", undefined, { pin: "dispatch-A" });
// a sibling thread's turn starts while A runs; its collection falls due
writeFileSync(join(cwd, "a.txt"), "A edited");
collectionDue();
hooks.calls = [];
const hashB = await snapshot(bot, cwd, "turn B", undefined, { pin: "dispatch-B" });
hooks.inject = null;
expect(await diffWorkingTree(bot, cwd, hashA!)).toEqual({ changed: ["a.txt"], added: [], deleted: [] });
await release(bot, cwd, "dispatch-A");
await drain(bot, cwd);
expect(subcommands()).toContain("gc");
expect(commitExists(bot, cwd, hashA!)).toBe(true); // not HEAD: only the pin kept it
await release(bot, cwd, "dispatch-B");
writeFileSync(join(cwd, "a.txt"), "turn C");
collectionDue();
hooks.calls = [];
const hashC = await snapshot(bot, cwd, "turn C");
hooks.inject = null;
await diffWorkingTree(bot, cwd, hashC!);
await drain(bot, cwd);
expect(subcommands()).toContain("gc");
expect(commitExists(bot, cwd, hashA!)).toBe(false);
expect(commitExists(bot, cwd, hashB!)).toBe(false);
expect(snapshotHashes(bot, cwd)).toEqual([hashC]);
});
});
describe.runIf((process.platform === "darwin" || process.platform === "win32") && caseInsensitiveTmp)("case-only renames", () => {
it("rebuilds the shadow index once for a case-only rename, then stays incremental", async () => {
const { bot, cwd } = workspace();
mkdirSync(join(cwd, "dir"));
for (let f = 0; f < 20; f += 1) writeFileSync(join(cwd, "dir", `file${f}.txt`), `file ${f}`);
await snapshot(bot, cwd, "first");
hooks.calls = [];
await snapshot(bot, cwd, "unchanged");
expect(subcommands()).not.toContain("read-tree");
renameSync(join(cwd, "dir", "file7.txt"), join(cwd, "dir", "FILE7.txt"));
hooks.calls = [];
await snapshot(bot, cwd, "renamed");
expect(subcommands()).toContain("read-tree");
hooks.calls = [];
await snapshot(bot, cwd, "after rename");
expect(subcommands()).not.toContain("read-tree");
});
it("reads folder listings without blocking the event loop (no readdirSync)", async () => {
const { bot, cwd } = workspace();
for (let d = 0; d < 5; d += 1) {
mkdirSync(join(cwd, `Dir${d}`, "sub"), { recursive: true });
writeFileSync(join(cwd, `Dir${d}`, "sub", "file.txt"), `file ${d}`);
}
const first = await snapshot(bot, cwd, "first");
renameSync(join(cwd, "Dir3"), join(cwd, "dir3"));
hooks.calls = [];
hooks.readdirSync = [];
expect(await diffWorkingTree(bot, cwd, first!)).toEqual({ changed: [], added: ["dir3/sub/file.txt"], deleted: ["Dir3/sub/file.txt"] });
const renamed = await snapshot(bot, cwd, "renamed");
expect(renamed).not.toBe(first);
expect(subcommands()).toContain("read-tree");
expect(hooks.readdirSync.filter(path => path.startsWith(cwd) || path.startsWith(realpathSync(cwd)))).toEqual([]);
});
});
describe("git timeout", () => {
it("matches the error shape Node gives a timed-out child", async () => {
const err = await new Promise<Error & { killed?: boolean }>(resolve => {
realExecFile(process.execPath, ["-e", "setTimeout(() => {}, 10000)"], { timeout: 200 }, error => resolve(error!));
});
expect(err.killed).toBe(true);
expect(err.name).not.toBe("AbortError");
});
it("disables only that bot+folder, logs once, and later turns skip without spawning git", async () => {
const { bot, cwd } = workspace();
const other = workspace().cwd;
writeFileSync(join(cwd, "a.txt"), "slow folder");
writeFileSync(join(other, "a.txt"), "fine folder");
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
hooks.inject = args => args[0] === "add" ? { error: timeoutError("add") } : undefined;
expect(await snapshot(bot, cwd, "turn 1")).toBeNull();
expect(warn).toHaveBeenCalledTimes(1);
const line = String(warn.mock.calls[0]![0]);
expect(line).toContain(bot);
expect(line).toContain(realpathSync(cwd));
expect(line).toContain("timed out");
hooks.calls = [];
expect(await snapshot(bot, cwd, "turn 2")).toBeNull();
expect(await diffWorkingTree(bot, cwd, "0".repeat(40))).toBeNull();
expect(hooks.calls).toEqual([]);
expect(warn).toHaveBeenCalledTimes(1);
hooks.inject = null;
expect(await snapshot(bot, other, "turn 3")).toMatch(/^[0-9a-f]{40}$/);
expect(warn).toHaveBeenCalledTimes(1);
});
it("treats a timeout in any snapshot git call the same way", async () => {
const { bot, cwd } = workspace();
writeFileSync(join(cwd, "a.txt"), "one");
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
hooks.inject = args => args[0] === "write-tree" ? { error: timeoutError("write-tree") } : undefined;
expect(await snapshot(bot, cwd, "turn 1")).toBeNull();
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0]![0])).toContain(`for bot ${bot} in ${realpathSync(cwd)}`);
const other = workspace().cwd;
hooks.inject = null;
expect(await snapshot(bot, other, "turn 2")).toMatch(/^[0-9a-f]{40}$/);
});
it("disables that bot+folder when the settled diff times out", async () => {
const { bot, cwd } = workspace();
writeFileSync(join(cwd, "a.txt"), "one");
const before = await snapshot(bot, cwd, "turn 1");
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
hooks.inject = args => args[0] === "add" ? { error: timeoutError("add") } : undefined;
expect(await diffWorkingTree(bot, cwd, before!)).toBeNull();
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0]![0])).toContain(`for bot ${bot} in ${realpathSync(cwd)}`);
hooks.inject = null;
hooks.calls = [];
expect(await snapshot(bot, cwd, "turn 2")).toBeNull();
expect(hooks.calls).toEqual([]);
});
it("does not disable anything for an unreadable-file add failure", async () => {
const { bot, cwd } = workspace();
writeFileSync(join(cwd, "a.txt"), "one");
const first = await snapshot(bot, cwd, "turn 1");
writeFileSync(join(cwd, "a.txt"), "two");
hooks.inject = args => args[0] === "add" ? { error: Object.assign(new Error("Command failed"), { code: 1 }), stderr: "error: open(\"x\"): Permission denied" } : undefined;
expect(await snapshot(bot, cwd, "turn 2")).toBeNull();
hooks.inject = null;
await drain(bot, cwd);
expect(snapshotHashes(bot, cwd)).toEqual([first]);
const third = await snapshot(bot, cwd, "turn 3");
expect(third).toMatch(/^[0-9a-f]{40}$/);
expect(third).not.toBe(first);
});
});