f3aedf8d6c
- Rewrite fork to create StateNode pointing to fork point (zero duplication) - Rewrite GC as mark-and-sweep: roots from threads.json + history, findReachableHashes via refs[] - Remove .data.jsonl code paths - Fix all 7 previously failing CLI tests - New: gc-mark-sweep.test.ts verifying shared nodes survive GC - All 166 tests pass Refs #155, closes #159 小橘 <xiaoju@shazhou.work>
160 lines
5.3 KiB
TypeScript
160 lines
5.3 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
import { spawnSync } from "node:child_process";
|
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { createCasStore, putStartNode } from "@uncaged/workflow-cas";
|
|
import { garbageCollectCas, getBundleDir, upsertThreadEntry } from "@uncaged/workflow-execute";
|
|
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
|
import { cmdThreadRemove } from "../src/commands/thread/index.js";
|
|
import { pathExists } from "../src/fs-utils.js";
|
|
|
|
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
|
|
|
describe("gc cli and garbageCollectCas", () => {
|
|
let prevEnv: string | undefined;
|
|
let storageRoot: string;
|
|
|
|
beforeEach(async () => {
|
|
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
|
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-gc-"));
|
|
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (prevEnv === undefined) {
|
|
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
|
} else {
|
|
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = prevEnv;
|
|
}
|
|
await rm(storageRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
test("garbageCollectCas keeps CAS entries reachable from threads.json roots", async () => {
|
|
const bundleHash = "C9NMV6V2TQT81";
|
|
const threadId = "01AAA1111111111111111111";
|
|
const bundleDir = getBundleDir(storageRoot, bundleHash);
|
|
await mkdir(bundleDir, { recursive: true });
|
|
|
|
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
|
const orphanHash = await cas.put("orphan-blob");
|
|
const promptHash = await cas.put("prompt-text");
|
|
const startHash = await putStartNode(
|
|
cas,
|
|
{
|
|
name: "demo",
|
|
hash: bundleHash,
|
|
maxRounds: 5,
|
|
depth: 0,
|
|
},
|
|
promptHash,
|
|
);
|
|
|
|
await upsertThreadEntry(bundleDir, threadId, {
|
|
head: startHash,
|
|
start: startHash,
|
|
updatedAt: 100,
|
|
});
|
|
|
|
const gc = await garbageCollectCas(storageRoot);
|
|
expect(gc.ok).toBe(true);
|
|
if (!gc.ok) {
|
|
return;
|
|
}
|
|
expect(gc.value.scannedThreads).toBe(2);
|
|
expect(gc.value.deletedEntries).toBe(1);
|
|
expect(gc.value.deletedHashes).toEqual([orphanHash]);
|
|
|
|
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${promptHash}.txt`))).toBe(true);
|
|
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${startHash}.txt`))).toBe(true);
|
|
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${orphanHash}.txt`))).toBe(false);
|
|
});
|
|
|
|
test("garbageCollectCas deletes orphaned CAS when no threads reference them", async () => {
|
|
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
|
const orphanHash = await cas.put("lonely");
|
|
|
|
const gc = await garbageCollectCas(storageRoot);
|
|
expect(gc.ok).toBe(true);
|
|
if (!gc.ok) {
|
|
return;
|
|
}
|
|
expect(gc.value.scannedThreads).toBe(0);
|
|
expect(gc.value.activeRefs).toBe(0);
|
|
expect(gc.value.deletedEntries).toBe(1);
|
|
expect(gc.value.deletedHashes).toEqual([orphanHash]);
|
|
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${orphanHash}.txt`))).toBe(false);
|
|
});
|
|
|
|
test("cli gc prints stats", async () => {
|
|
const bundleHash = "C9NMV6V2TQT81";
|
|
const threadId = "01BBB2222222222222222222";
|
|
const bundleDir = getBundleDir(storageRoot, bundleHash);
|
|
await mkdir(bundleDir, { recursive: true });
|
|
|
|
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
|
const promptHash = await cas.put("prompt-text");
|
|
const startHash = await putStartNode(
|
|
cas,
|
|
{
|
|
name: "demo",
|
|
hash: bundleHash,
|
|
maxRounds: 5,
|
|
depth: 0,
|
|
},
|
|
promptHash,
|
|
);
|
|
await cas.put("drop-me");
|
|
|
|
await upsertThreadEntry(bundleDir, threadId, {
|
|
head: startHash,
|
|
start: startHash,
|
|
updatedAt: 100,
|
|
});
|
|
|
|
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
|
const proc = spawnSync(process.execPath, [cliEntryPath, "cas", "gc"], {
|
|
env,
|
|
encoding: "utf8",
|
|
});
|
|
expect(proc.status).toBe(0);
|
|
expect(String(proc.stdout).trim()).toBe("scanned 2 threads, 2 active refs, deleted 1 entries");
|
|
});
|
|
|
|
test("thread rm triggers gc so unreferenced CAS is removed", async () => {
|
|
const bundleHash = "C9NMV6V2TQT81";
|
|
const threadId = "01CCC3333333333333333333";
|
|
const bundleDir = getBundleDir(storageRoot, bundleHash);
|
|
await mkdir(bundleDir, { recursive: true });
|
|
|
|
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
|
const promptHash = await cas.put("prompt-text");
|
|
const startHash = await putStartNode(
|
|
cas,
|
|
{
|
|
name: "demo",
|
|
hash: bundleHash,
|
|
maxRounds: 5,
|
|
depth: 0,
|
|
},
|
|
promptHash,
|
|
);
|
|
|
|
await upsertThreadEntry(bundleDir, threadId, {
|
|
head: startHash,
|
|
start: startHash,
|
|
updatedAt: 100,
|
|
});
|
|
|
|
const orphanHash = await cas.put("orphan-after-rm");
|
|
const orphanPath = join(getGlobalCasDir(storageRoot), `${orphanHash}.txt`);
|
|
|
|
const removed = await cmdThreadRemove(storageRoot, threadId);
|
|
expect(removed.ok).toBe(true);
|
|
|
|
expect(await pathExists(orphanPath)).toBe(false);
|
|
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${promptHash}.txt`))).toBe(false);
|
|
});
|
|
});
|