mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The terminal workspace sweep checks each Git workspace for recent activity and for live consumers before it expires or moves the workspace > - These checks find the runs of a workspace with one SQL scope, `expirationScope`. The scope used one `OR` over all run bindings, and one branch decoded the context JSON of each run > - PostgreSQL could not use an index for that `OR`. Each check read every run of the company and its large context from TOAST. On a hosted database, each call took 2 to 5 seconds, about 5,200 s in total in one window > - This pull request splits the scope into one index probe per binding and adds one concurrent expression index for the legacy `executionWorkspaceId` context key > - The benefit is that each check reads a few index entries instead of all company runs. The cleanup decisions do not change ## Linked Issues or Issue Description **What existing behavior does this improve?** The workspace activity and cleanup-safety checks: `gitWorkspaceLastActivityAt`, `isGitWorkspaceExpirationEligible`, `workspaceMoveEligible` and the orphan-folder consumer check. All of them use `expirationScope` in `server/src/services/git-workspace-expiration.ts`. **Subsystem affected** Execution workspace cleanup in the server (`git-workspace-expiration.ts`, `isolated-repository-rehome.ts`, `isolated-task-directory-retention.ts` through the shared scope) and the `heartbeat_runs` schema in `packages/db`. **Current behavior** The `relevant_runs` CTE selects company runs where any of these is true: `issue_id` or `native_issue_id` is in the issue tree or the bound issues, the run is a bound issue's checkout or execution run, or the context JSON has a matching `issueId`, `taskId` or `executionWorkspaceId`. The planner does one sequential scan of the company runs and reads each context from TOAST. The cost grows with the run history of the company, not with the size of the workspace. **Proposed behavior** Each binding is a separate index probe. The `UNION` of run IDs goes to a primary-key probe. The SQL gives the same rows. **Reason and benefit** Local SQL measurements show a 34x to 169x decrease in time. The decrease grows with the run history (see Verification). ## What Changed - `expirationScope` builds `relevant_runs` from a `UNION` of index probes, one for each binding. The key sets go in as arrays (`= ANY(ARRAY(...))`). With `IN (subquery)`, the planner chose a hash join over a full scan, because it does not count the cost of reading TOASTed contexts. - The legacy context keys use `context_snapshot ->> 'key'`. This expression matches the existing indexes `heartbeat_runs_company_ctx_issue_created_idx` and `heartbeat_runs_company_ctx_task_created_idx`. For a non-object context, `->>` gives `NULL`, as the strict JSONPath decode did. Such a run can still match through a native binding. - New partial index `heartbeat_runs_company_ctx_execution_workspace_idx` on `(company_id, context_snapshot ->> 'executionWorkspaceId') WHERE context_snapshot ? 'executionWorkspaceId'`. The query repeats the predicate. For an array context, `?` can be true, but `->>` gives `NULL`, so the result does not change. The index is partial so that it serves only this probe. A full index that starts with `company_id` made the planner choose it for an unrelated company-scoped query. Migration 0460 builds it with `CREATE INDEX CONCURRENTLY IF NOT EXISTS`. The migration is in the list of migrations that run outside a transaction. - New tests: a legacy `issueId` or `executionWorkspaceId` binding on a run that is bound to a different issue still blocks cleanup. A plan test with `enable_seqscan = off` checks that no binding scans `heartbeat_runs` and that the three context indexes are used. - The benchmark script `scripts/benchmark-workspace-activity.ts` gets a `--runs-per-workspace` option. Unrelated fixture runs now have distinct context IDs, as real history does. - `doc/DATABASE.md` describes the probes and the new index. ## Verification - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/git-workspace-activity.test.ts src/__tests__/native-harness-backup-expiration.test.ts`: 132 of 132 tests pass. The second file checks that the native session index stays the planner's choice for its query. These include the existing route matrix (root, child, bound, native, context issue, context task, context workspace, checkout, execution run), exactly-once counts, malformed and uppercase contexts, company isolation, controller leases and lease vetoes. - `pnpm --filter @paperclipai/server typecheck` and `pnpm --filter @paperclipai/db check:migrations` pass. - Benchmark (local SQL measurement, PostgreSQL 18.1, synthetic fixture, 8 workspaces per sample, before and after alternate in each iteration). The script compares the complete ordered caller results and the complete `relevant_runs` multisets of the baseline and treatment. They are equal in every run. `pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-workspace-activity.ts --baseline-ref=origin/master --runs-per-workspace=<N>` | Fixture | Check | Before median (min–max) | After median (min–max) | Speedup | | --- | --- | --- | --- | --- | | 4,800 runs, 330 MB | last activity | 523 ms (460–598) | 12 ms (8–15) | 44x | | 4,800 runs, 330 MB | eligibility | 452 ms (432–473) | 13 ms (11–15) | 34x | | 4,800 runs, 330 MB | idle eligibility | 465 ms (442–573) | 12 ms (10–13) | 40x | | 24,000 runs, 1.7 GB | last activity | 2,240 ms (2,214–2,454) | 13 ms (11–14) | 169x | | 24,000 runs, 1.7 GB | eligibility | 2,245 ms (2,097–2,377) | 17 ms (14–19) | 133x | | 24,000 runs, 1.7 GB | idle eligibility | 2,196 ms (2,179–2,381) | 15 ms (13–18) | 143x | Before: one `Seq Scan` on `heartbeat_runs`. After: `heartbeat_runs_company_issue_created_idx`, `heartbeat_runs_company_native_issue_contract_id_uq`, the three context indexes and `heartbeat_runs_pkey`. - Index build time: on a synthetic table with 200,000 runs and 8 KiB contexts (1.8 GB), the full form of this index took 3.7 s to build concurrently and used 15 MB. The partial index reads the same rows and holds only runs that have the key. ## Risks - Cleanup safety: a wrong "allowed" result could delete an active workspace. The scope keeps every binding of the old query. The benchmark checks exact equality of the run sets, and the existing tests check each binding route and each veto. - Migration: the index build reads every run context once. It runs concurrently and does not block writes. Until migration 0460 builds the index, the new query is still correct but the `executionWorkspaceId` probe scans. - Plan choice: the array form makes index probes the cheapest plan for a small tree. A very large issue tree gives a large array, but each element is still one index probe. - The sweep frequency does not change. This pull request reduces only the cost of each check. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
206 lines
17 KiB
TypeScript
206 lines
17 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
import { execFileSync } from "node:child_process";
|
|
import { promises as fs } from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { agents, applyPendingMigrations, companies, createDb, executionWorkspaces, issues, startEmbeddedPostgresTestDatabase, type Db } from "../packages/db/src/index.js";
|
|
import * as expiration from "../server/src/services/git-workspace-expiration.js";
|
|
|
|
type Query = { sql: string; params: unknown[] };
|
|
type Workspace = typeof executionWorkspaces.$inferSelect;
|
|
type Plan = { "Node Type": string; "Relation Name"?: string; "Index Name"?: string; "Actual Rows"?: number; "Actual Loops"?: number; "Shared Hit Blocks"?: number; "Shared Read Blocks"?: number; "Temp Read Blocks"?: number; "Temp Written Blocks"?: number; Plans?: Plan[] };
|
|
const baselineRef = process.argv.find(arg => arg.startsWith("--baseline-ref="))?.slice(15);
|
|
assert.ok(baselineRef, "Pass an explicit pre-fix --baseline-ref=<commit>");
|
|
const repositoryRoot = fileURLToPath(new URL("../", import.meta.url));
|
|
const gitRaw = (...args: string[]) => execFileSync("git", args, { encoding: "utf8", cwd: repositoryRoot });
|
|
const git = (...args: string[]) => gitRaw(...args).trim();
|
|
const baselineRevision = git("rev-parse", "--verify", `${baselineRef}^{commit}`);
|
|
const modulePath = "server/src/services/git-workspace-expiration.ts";
|
|
const treatmentSource = await fs.readFile(path.join(repositoryRoot, modulePath), "utf8");
|
|
const sha256 = (source: string) => createHash("sha256").update(source).digest("hex");
|
|
const baselineOnly = process.argv.includes("--baseline-only");
|
|
const dockerPostgres = process.argv.includes("--docker-postgres=17");
|
|
const small = process.argv.includes("--small");
|
|
const runsPerWorkspace = Number(process.argv.find(arg => arg.startsWith("--runs-per-workspace="))?.slice(21) ?? (small ? 16 : 600));
|
|
assert.ok(Number.isInteger(runsPerWorkspace) && runsPerWorkspace >= 16 && runsPerWorkspace <= 20000,
|
|
"--runs-per-workspace must be an integer between 16 and 20000");
|
|
const bindingRows = Math.max(1, Math.floor(runsPerWorkspace / 20));
|
|
const descendantsPerWorkspace = Number(process.argv.find(arg => arg.startsWith("--descendants="))?.slice(14) ?? 1);
|
|
assert.ok(Number.isInteger(descendantsPerWorkspace) && descendantsPerWorkspace >= 1 && descendantsPerWorkspace <= 500,
|
|
"--descendants must be an integer between 1 and 500");
|
|
const measuredSamples = Number(process.argv.find(arg => arg.startsWith("--samples="))?.slice(10) ?? 5);
|
|
const warmups = Number(process.argv.find(arg => arg.startsWith("--warmups="))?.slice(10) ?? 2);
|
|
assert.ok(Number.isInteger(measuredSamples) && measuredSamples >= 1 && measuredSamples <= 10);
|
|
assert.ok(Number.isInteger(warmups) && warmups >= 0 && warmups <= 5);
|
|
const outputDir = process.argv.find(arg => arg.startsWith("--output-dir="))?.slice(13) ?? await fs.mkdtemp(path.join(os.tmpdir(), "workspace-activity-evidence-"));
|
|
const require = createRequire(new URL("../server/package.json", import.meta.url));
|
|
const { sql } = require("drizzle-orm");
|
|
const { PgDialect } = require("drizzle-orm/pg-core");
|
|
const dialect = new PgDialect();
|
|
const now = new Date("2026-10-20T12:00:00Z");
|
|
const old = new Date("2026-10-01T12:00:00Z");
|
|
const keepAlive = setInterval(() => {}, 1000);
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
|
let db: Db | undefined;
|
|
let baselineDir: string | undefined;
|
|
|
|
async function startDatabase() {
|
|
if (!dockerPostgres) return startEmbeddedPostgresTestDatabase("workspace-activity-benchmark-");
|
|
const container = `paperclip-workspace-activity-${randomUUID()}`;
|
|
const docker = (...args: string[]) => execFileSync("docker", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
docker("run", "--detach", "--rm", "--name", container, "--publish", "127.0.0.1::5432", "--env", "POSTGRES_USER=paperclip", "--env", "POSTGRES_PASSWORD=paperclip", "--env", "POSTGRES_DB=paperclip", "postgres:17");
|
|
const cleanup = async () => { docker("rm", "--force", container); };
|
|
try {
|
|
let ready = false;
|
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
try { docker("exec", container, "pg_isready", "-h", "127.0.0.1", "-U", "paperclip"); ready = true; break; } catch { await new Promise(resolve => setTimeout(resolve, 100)); }
|
|
}
|
|
assert.ok(ready, "Disposable Docker PostgreSQL did not become ready");
|
|
const port = docker("port", container, "5432/tcp").split(":").at(-1);
|
|
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
|
await applyPendingMigrations(connectionString);
|
|
return { connectionString, cleanup };
|
|
} catch (error) { await cleanup(); throw error; }
|
|
}
|
|
|
|
function capture(invoke: (db: Db) => Promise<unknown>): Promise<Query> {
|
|
let captured: Query | undefined;
|
|
const captureDb = new Proxy(db!, { get(target, key) {
|
|
if (key === "execute") return async (query: Parameters<typeof dialect.sqlToQuery>[0]) => { captured = dialect.sqlToQuery(query); return []; };
|
|
return Reflect.get(target, key);
|
|
}});
|
|
return invoke(captureDb).then(() => { assert.ok(captured); return captured; });
|
|
}
|
|
const normalize = (rows: unknown[][]) => rows.map(row => JSON.stringify(row)).sort();
|
|
const summarize = (samples: number[]) => {
|
|
const sorted = [...samples].sort((a, b) => a - b);
|
|
return { samples, medianMs: sorted[Math.floor(sorted.length / 2)], minMs: sorted[0], maxMs: sorted.at(-1) };
|
|
};
|
|
function scans(plan: Plan): unknown[] {
|
|
const flatten = (node: Plan): Plan[] => [node, ...(node.Plans ?? []).flatMap(flatten)];
|
|
return flatten(plan).filter(node => node["Relation Name"] === "heartbeat_runs" || node["Index Name"])
|
|
.map(({ Plans: _children, ...node }) => node);
|
|
}
|
|
|
|
try {
|
|
await fs.mkdir(outputDir, { recursive: true });
|
|
database = await startDatabase();
|
|
db = createDb(database.connectionString, { maxConnections: 1 });
|
|
// Keep historical legacy bindings independent from native bindings; current
|
|
// insert triggers would otherwise rewrite the fixture's issue_id from JSON.
|
|
await db.$client.unsafe("ALTER TABLE heartbeat_runs DISABLE TRIGGER heartbeat_runs_set_scope_kind");
|
|
baselineDir = await fs.mkdtemp(path.join(fileURLToPath(new URL("../server/scripts/", import.meta.url)), ".workspace-activity-baseline-"));
|
|
const original = new URL(`../${modulePath}`, import.meta.url);
|
|
const source = gitRaw("show", `${baselineRevision}:${modulePath}`);
|
|
const rewritten = source.replace(/from "(\.{1,2}\/[^\"]+)"/g,
|
|
(_match, specifier: string) => `from ${JSON.stringify(fileURLToPath(new URL(specifier, original)))}`);
|
|
const baselinePath = path.join(baselineDir, "git-workspace-expiration.ts");
|
|
await fs.writeFile(baselinePath, rewritten);
|
|
await fs.writeFile(path.join(outputDir, "baseline-git-workspace-expiration.ts"), source);
|
|
await fs.writeFile(path.join(outputDir, "treatment-git-workspace-expiration.ts"), treatmentSource);
|
|
const baseline = await import(pathToFileURL(baselinePath).href) as typeof expiration;
|
|
const companyId = randomUUID(), agentId = randomUUID(), otherCompanyId = randomUUID(), otherAgentId = randomUUID(), unrelatedIssueId = randomUUID();
|
|
await db.insert(companies).values([{ id: companyId, name: "Synthetic workspace activity", issuePrefix: "WAB" }, { id: otherCompanyId, name: "Other tenant", issuePrefix: "WAO" }]);
|
|
await db.insert(agents).values([{ id: agentId, companyId, name: "Fixture" }, { id: otherAgentId, companyId: otherCompanyId, name: "Other fixture" }]);
|
|
await db.insert(issues).values({ id: unrelatedIssueId, companyId, title: "Unrelated", updatedAt: old });
|
|
const workspaces: Workspace[] = [];
|
|
for (let n = 0; n < 8; n++) {
|
|
const issueId = randomUUID();
|
|
await db.insert(issues).values({ id: issueId, companyId, title: `Target ${n}`, status: "done", completedAt: old, updatedAt: old });
|
|
const [workspace] = await db.insert(executionWorkspaces).values({ companyId, sourceIssueId: issueId, mode: "isolated_workspace", strategyType: "git_worktree", name: `Target ${n}`, cwd: `/tmp/synthetic-workspace-activity-${n}`, updatedAt: old }).returning();
|
|
workspaces.push(workspace);
|
|
await db.insert(issues).values(Array.from({ length: descendantsPerWorkspace }, (_, child) => ({ companyId, parentId: issueId,
|
|
title: `Child ${n}/${child}`, status: "done", completedAt: old, updatedAt: old })));
|
|
}
|
|
const padding = randomBytes(24 * 1024).toString("base64");
|
|
// Most history is unrelated, as in the reported scan, and unrelated runs carry
|
|
// distinct legacy issue, task and workspace IDs as real history does; contexts and results are
|
|
// incompressible and wide enough to live in TOAST. Existing indexes are retained.
|
|
for (let n = 0; n < 8; n++) {
|
|
const workspace = workspaces[n];
|
|
await db.$client.unsafe(`INSERT INTO heartbeat_runs (company_id, agent_id, status, scope_kind, issue_id, native_issue_id, created_at, started_at, finished_at, context_snapshot, result_json)
|
|
SELECT $1::uuid, $2::uuid, 'succeeded', CASE WHEN i <= ${bindingRows} THEN 'issue' ELSE 'company' END,
|
|
CASE WHEN i <= ${bindingRows} THEN $3::uuid END, CASE WHEN i > ${bindingRows} AND i <= ${bindingRows * 2} THEN $3::uuid END,
|
|
$6::timestamptz + i * interval '1 second', $6::timestamptz + i * interval '2 seconds', $6::timestamptz + i * interval '3 seconds',
|
|
jsonb_build_object('issueId', CASE WHEN i > ${bindingRows * 2} AND i <= ${bindingRows * 3} THEN $3::text WHEN i % 50 = 0 THEN $5::text ELSE gen_random_uuid()::text END,
|
|
'taskId', CASE WHEN i > ${bindingRows * 3} AND i <= ${bindingRows * 4} THEN $3::text ELSE gen_random_uuid()::text END,
|
|
'executionWorkspaceId', CASE WHEN i > ${bindingRows * 4} AND i <= ${bindingRows * 5} THEN $4::text ELSE gen_random_uuid()::text END,
|
|
'fixtureRun', ${n * runsPerWorkspace} + i, 'padding', $7::text),
|
|
jsonb_build_object('stdout', $7::text)
|
|
FROM generate_series(1, ${runsPerWorkspace}) i`, [companyId, agentId, workspace.sourceIssueId, workspace.id, unrelatedIssueId, old.toISOString(), padding]);
|
|
}
|
|
// Malformed JSON shapes and noncanonical IDs must retain ->> semantics, never
|
|
// throw on a cast or accidentally match another tenant's run.
|
|
const unusualContexts = [null, [], [{ issueId: workspaces[0].sourceIssueId }], [[{ issueId: workspaces[0].sourceIssueId }]], true,
|
|
12, "string", { issueId: workspaces[0].sourceIssueId!.toUpperCase() }, { issueId: 12 }, { issueId: { value: workspaces[0].sourceIssueId } },
|
|
{ taskId: workspaces[0].sourceIssueId }, { executionWorkspaceId: workspaces[0].id }];
|
|
for (const context of unusualContexts) {
|
|
await db.$client.unsafe(`INSERT INTO heartbeat_runs (company_id, agent_id, status, created_at, finished_at, context_snapshot) VALUES ($1::uuid, $2::uuid, 'succeeded', $3::timestamptz, $3::timestamptz, $4::jsonb)`, [companyId, agentId, old.toISOString(), JSON.stringify(context)]);
|
|
}
|
|
await db.$client.unsafe(`INSERT INTO heartbeat_runs (company_id, agent_id, status, created_at, context_snapshot) VALUES ($1::uuid, $2::uuid, 'running', $3::timestamptz, $4::jsonb)`, [otherCompanyId, otherAgentId, now.toISOString(), JSON.stringify({ issueId: workspaces[0].sourceIssueId, executionWorkspaceId: workspaces[0].id })]);
|
|
await db.$client.unsafe("ALTER TABLE heartbeat_runs ENABLE TRIGGER heartbeat_runs_set_scope_kind");
|
|
await db.$client.unsafe("ANALYZE");
|
|
const [settings] = await db.$client.unsafe("SELECT version(), current_setting('work_mem') AS work_mem, pg_total_relation_size('heartbeat_runs') AS total_run_bytes");
|
|
const results = [];
|
|
for (const kind of ["lastActivity", "eligibility", "idleEligibility"] as const) {
|
|
const invoke = (implementation: typeof expiration, d: Db, workspace: Workspace) => kind === "lastActivity"
|
|
? implementation.gitWorkspaceLastActivityAt(d, workspace)
|
|
: implementation.isGitWorkspaceExpirationEligible(d as never, workspace, now, kind === "idleEligibility" ? now : undefined);
|
|
const beforeQueries = await Promise.all(workspaces.map(workspace => capture(d => invoke(baseline, d, workspace))));
|
|
const afterQueries = baselineOnly ? beforeQueries : await Promise.all(workspaces.map(workspace => capture(d => invoke(expiration, d, workspace))));
|
|
await fs.writeFile(path.join(outputDir, `${kind}-queries.json`), JSON.stringify({ beforeQueries, afterQueries }, null, 2));
|
|
if (process.argv.includes("--reject-new-jit")) {
|
|
const [beforePlan] = await db.$client.unsafe(`EXPLAIN (FORMAT JSON) ${beforeQueries[0].sql}`, beforeQueries[0].params as never[]);
|
|
const [afterPlan] = await db.$client.unsafe(`EXPLAIN (FORMAT JSON) ${afterQueries[0].sql}`, afterQueries[0].params as never[]);
|
|
await fs.writeFile(path.join(outputDir, `${kind}-preflight-plans.json`), JSON.stringify({ beforePlan, afterPlan }, null, 2));
|
|
assert.ok(!afterPlan["QUERY PLAN"][0].JIT || beforePlan["QUERY PLAN"][0].JIT,
|
|
`${kind}: reject new JIT compilation before repeating a known expensive planner shape`);
|
|
}
|
|
const execute = async (queries: Query[]) => {
|
|
const rows = [];
|
|
const start = performance.now();
|
|
for (const query of queries) rows.push(await db!.$client.unsafe(query.sql, query.params as never[]).values());
|
|
return { ms: performance.now() - start, rows };
|
|
};
|
|
const beforeSamples: number[] = [], afterSamples: number[] = [];
|
|
for (let n = 0; n < (baselineOnly ? 1 : warmups + measuredSamples); n++) {
|
|
const order = n % 2 === 0 ? ["before", "after"] : ["after", "before"];
|
|
const iteration: Record<string, Awaited<ReturnType<typeof execute>>> = {};
|
|
for (const treatment of order) iteration[treatment] = await execute(treatment === "before" ? beforeQueries : afterQueries);
|
|
assert.deepEqual(iteration.after.rows, iteration.before.rows, `${kind}: complete ordered positional results differ`);
|
|
if (baselineOnly || n >= warmups) { beforeSamples.push(iteration.before.ms); afterSamples.push(iteration.after.ms); }
|
|
}
|
|
const [beforeExplain] = await db.$client.unsafe(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${beforeQueries[0].sql}`, beforeQueries[0].params as never[]);
|
|
const [afterExplain] = await db.$client.unsafe(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${afterQueries[0].sql}`, afterQueries[0].params as never[]);
|
|
const before = summarize(beforeSamples), after = summarize(afterSamples);
|
|
results.push({ kind, workspaceCount: workspaces.length, equalCompleteResults: true, before, after, speedup: before.medianMs / after.medianMs,
|
|
baselinePlan: beforeExplain["QUERY PLAN"], treatmentPlan: afterExplain["QUERY PLAN"] });
|
|
await fs.writeFile(path.join(outputDir, "partial-results.json"), JSON.stringify(results, null, 2));
|
|
}
|
|
for (const workspace of workspaces) {
|
|
const projection = sql`SELECT id, status, created_at, started_at, finished_at, controller_lease_expires_at FROM relevant_runs`;
|
|
const before = dialect.sqlToQuery(sql`${baseline.expirationScope(workspace)} ${projection}`);
|
|
const after = dialect.sqlToQuery(sql`${expiration.expirationScope(workspace)} ${projection}`);
|
|
const beforeRows = await db.$client.unsafe(before.sql, before.params as never[]).values();
|
|
const afterRows = await db.$client.unsafe(after.sql, after.params as never[]).values();
|
|
assert.deepEqual(normalize(afterRows), normalize(beforeRows), "Complete relevant-run multisets differ");
|
|
}
|
|
assert.equal(await fs.readFile(path.join(repositoryRoot, modulePath), "utf8"), treatmentSource, "Treatment source changed during measurement");
|
|
const report = { baselineRevision, treatmentRevision: git("rev-parse", "HEAD"),
|
|
baselineSourceSha256: sha256(source), treatmentSourceSha256: sha256(treatmentSource),
|
|
treatmentDiff: gitRaw("diff", "--", modulePath), baselineDependencies: "Unchanged current imports; only git-workspace-expiration.ts is loaded from baseline",
|
|
settings, fixture: { runRows: runsPerWorkspace * 8 + unusualContexts.length + 1, workspaceCount: 8, descendantsPerWorkspace, contextPaddingBytes: Buffer.byteLength(padding), synthetic: true, small },
|
|
acceptanceCriterion: "Complete result equality and materially lower stress-fixture medians; report small-control overhead separately",
|
|
measurement: { baselineOnly, measuredSamples: baselineOnly ? 1 : measuredSamples, warmups: baselineOnly ? 0 : warmups, alternatingOrder: true },
|
|
safety: { exactRelevantRunMultisets: true, completeOrderedCallerResults: true }, results };
|
|
await fs.writeFile(path.join(outputDir, "report.json"), JSON.stringify(report, null, 2));
|
|
console.log(JSON.stringify({ outputDir, baselineRevision, settings, safety: report.safety, results: results.map(({ baselinePlan, treatmentPlan, ...result }) => ({ ...result,
|
|
beforeScans: scans(baselinePlan[0].Plan), afterScans: scans(treatmentPlan[0].Plan) })) }, null, 2));
|
|
} finally {
|
|
try { await db?.$client.end({ timeout: 0 }); await database?.cleanup(); if (baselineDir) await fs.rm(baselineDir, { recursive: true, force: true }); }
|
|
finally { clearInterval(keepAlive); }
|
|
}
|