Files
PaperClipAI/scripts/benchmark-attention-retention-candidates.ts
T
DottaandClaude Opus 5.5 99edcd28a7 perf(attention): read each retention candidate kind in its own bounded branch (#20566)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The attention retention worker archives old "Needs you" items, such
as open decisions, pending questions, failed runs and items in review
> - To find its work, the worker reads a page of retention candidates.
It reads one page per changed scope, and one single-source read for each
check of a due item
> - On a hosted stack this candidate read ran 2,835 times at about 1 s
per call, 4,201 s of database time in one afternoon window
> - The read built every candidate of every kind before it applied the
keyset limit. Its company and source filters used the `param is null or
column = param` form. Under a generic prepared plan these filters cannot
use an index. The failed-run branch also matched retry receipts with a
bound LIKE parameter, so it could not use the partial index
`heartbeat_run_events_retry_exhausted_idx`
> - This pull request reads each kind in its own branch, puts every
filter into that branch, and limits each branch to one page
> - The benefit is a bounded read for each page, and a primary-key read
for each single-source check, with the same rows, order and cursors

## Linked Issues or Issue Description

**What existing behavior does this improve?**
The attention retention candidate scan in
`server/src/services/attention-retention.ts`.

**Subsystem affected**
Server: attention retention background work (database reads only).

**Current behavior**
Each page reads all candidates of all selected kinds, then sorts them
and keeps 100. Optional filters use `($n is null or column = $n)`. A
generic prepared plan cannot use an index for these filters. The
single-source check compares `id::text`, so it cannot use the primary
key. The retry receipt LIKE pattern is a parameter, so the plan cannot
use the partial receipt index.

**Proposed behavior**
Each kind is one branch with its company, source, issue and keyset
filters inside it and `limit 100`. Kinds before the cursor are not read.
A source id filter uses the uuid primary key. The receipt pattern is a
SQL literal that matches the partial index predicate. The page result
does not change.

Refs #20386 (adds the partial receipt index that this branch now uses).

## What Changed

- `server/src/services/attention-retention.ts`: replace the single union
scan with `candidateQuery`. Each source kind is a branch that has its
own filters, retention join and one-page limit. The outer query still
orders by `(source_kind, source_id)` and keeps 100 rows.
- Only add a filter when the scope sets it. There is no `param is null
or ...` form now.
- A single-source read compares `id = $uuid`. It returns no rows for
text that is not the canonical lowercase uuid form. The former `id::text
= $text` comparison also never matched such text.
- Skip the kinds that come before the keyset cursor kind.
`ATTENTION_RETENTION_KIND_ORDER` holds the kind order, and a test checks
it against the database collation.
- The failed-run branch matches `'Bounded retry exhausted%'` as a
literal, so it reads `heartbeat_run_events_retry_exhausted_idx`.
- Export `listAttentionRetentionCandidates` for the test and the
benchmark. The service uses it without a change in behavior.
- New test
`server/src/__tests__/attention-retention-candidates.test.ts`. It runs
on real PostgreSQL and compares every page, row and cursor with the
former scan (kept in the test as an oracle). It covers: one company, all
companies, one kind, the blocker kind (which ignores retention), an
unknown kind, issue scopes (including uppercase and malformed ids),
exact source ids (uppercase, braces, no dashes, empty and malformed text
return nothing), a `null` source id from a decoded scope, other-company
isolation, inactive companies, kept and archived retention, and failed
runs without a lifecycle receipt.
- New benchmark `scripts/benchmark-attention-retention-candidates.ts`.
It loads the baseline service from git, captures the SQL that each
version sends for the same scopes, and times both under custom and
generic plans. Each round alternates the order. It fails if the rows
differ.
- No migration. The existing indexes cover each branch: company and
status indexes on each source table, the issues company/status index,
and the partial receipt index from #20386.

## Verification

- `cd server && npx vitest run
src/__tests__/attention-retention-candidates.test.ts`: 10 passed.
- `cd server && npx vitest run
src/__tests__/attention-retention-work.test.ts`: see the note under
Risks.
- `cd server && npx tsc --noEmit`: pass.
- Benchmark (local SQL measurement only):
`pnpm --filter @paperclipai/server exec tsx
../scripts/benchmark-attention-retention-candidates.ts
--baseline-ref=8d2bf78323`
Embedded PostgreSQL 18.1. Fixture: 12 companies (1 large and 11 at 1/5
size), 38,400 issues, 64,000 runs, 1.29 M run events, 6,400 decisions,
9,600 interactions, 4,800 recovery actions, 7,534 retention rows. Seven
samples for each scope, after two warmups, with `plan_cache_mode` forced
to custom and then to generic. Rows were equal in every sample.

| Plan | Scope | Rows | Before median (min–max) | After median (min–max)
| Speedup | EXPLAIN shared blocks before → after |
| --- | --- | --- | --- | --- | --- | --- |
| generic | single source (check path) | 1 | 4.82 ms (3.81–5.88) | 0.19
ms (0.12–0.66) | 25× | 3,491 → 10 |
| generic | company failed runs | 81 | 45.0 ms (40.5–51.7) | 2.9 ms
(2.3–4.6) | 15× | 3,752 → 3,752 |
| generic | company page 1 | 100 | 39.4 ms (30.1–63.7) | 7.6 ms
(5.8–14.3) | 5.2× | 4,396 → 4,719 |
| generic | company page 2 | 100 | 33.3 ms (31.2–38.1) | 6.9 ms
(5.2–10.3) | 4.8× | 4,237 → 4,712 |
| generic | company issue scope | 4 | 11.8 ms (9.8–24.0) | 4.1 ms
(3.4–6.4) | 2.9× | 4,243 → 4,225 |
| generic | all companies page 1 | 100 | 107.2 ms (100.2–116.0) | 29.3
ms (18.8–64.4) | 3.7× | 13,657 → 14,672 |
| custom | single source (check path) | 1 | 2.68 ms (2.01–5.33) | 0.81
ms (0.65–1.50) | 3.3× | 3,491 → 10 |
| custom | company failed runs | 81 | 5.7 ms (3.9–15.8) | 4.6 ms
(3.2–7.1) | 1.2× | 3,752 → 3,752 |
| custom | company page 1 | 100 | 11.7 ms (9.2–13.3) | 10.7 ms
(9.4–19.5) | 1.1× (neutral) | 4,396 → 4,719 |
| custom | company page 2 | 100 | 11.2 ms (7.6–14.2) | 10.1 ms
(8.5–12.0) | 1.1× (neutral) | 4,237 → 4,712 |
| custom | company issue scope | 4 | 10.9 ms (9.1–13.3) | 8.4 ms
(6.4–10.4) | 1.3× | 4,243 → 4,225 |
| custom | all companies page 1 | 100 | 21.3 ms (17.4–25.9) | 24.4 ms
(20.3–72.0) | 0.9× (neutral) | 13,688 → 14,672 |

- The timing connection uses named prepared statements, as the server
does by default. The benchmark checks `pg_prepared_statements` before it
collects samples, so the samples reuse the parsed statement.
- The driver uses named prepared statements by default, so after five
runs PostgreSQL can switch a statement to a generic plan. The generic
rows are the relevant case for a busy server. Custom plans were already
good for most scopes, and the change is neutral there.
- Plan after the change (company page, generic): the failed-run branch
reads the partial receipt index (13 blocks), then probes
`heartbeat_runs` by primary key. The single-source check reads each
primary key and the retention unique index (10 blocks in all).

## Risks

- Low behavior risk. The test compares the new pages with the former
query on the same data, including cursors in the middle of a kind and at
kind boundaries.
- Kind skipping depends on the order of kind names. Every kind name is
different from the others before its first underscore, so C and
linguistic collations give the same order. A test checks the order
against the database.
- The benchmark fixture is smaller than the hosted database. The hosted
cost (about 1 s per call) is larger than the local baseline. I did not
measure the hosted improvement. It needs a fresh query-statistics window
after deploy.
- On this loaded local machine, one full run of
`attention-retention-work.test.ts` had 3 tests time out at 30 s (no-run
issue finalization, no-run workspace finalization, and interaction
create/withdraw). The same 3 tests pass when run alone. CI is the
authority for this suite.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic. Agentic coding with
tool use (shell, file edits, local PostgreSQL).

## 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>
2026-11-10 10:15:48 -06:00

230 lines
16 KiB
TypeScript

// Measures the attention retention candidate scan before and after it read each
// source kind in its own bounded branch.
// Usage (from the repository root):
// pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-attention-retention-candidates.ts \
// --baseline-ref=<pre-fix commit> [--companies=12] [--issues=12000] [--runs=20000] \
// [--events-per-run=20] [--samples=7] [--plans]
// The script starts a disposable embedded PostgreSQL and loads one large company
// plus smaller decoy companies. It loads the baseline service from git, captures
// the candidate SQL that each service generates for the same scopes, and times
// that SQL under custom and generic plans. Baseline and treatment alternate, and
// the script asserts identical positional rows for every scope.
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import { fileURLToPath } from "node:url";
import { createDb, startEmbeddedPostgresTestDatabase, type Db } from "../packages/db/src/index.js";
type Query = { sql: string; params: unknown[] };
type Scope = Record<string, unknown>;
const arg = (name: string, fallback: string) =>
process.argv.find((value) => value.startsWith(`--${name}=`))?.slice(name.length + 3) ?? fallback;
const baselineRef = arg("baseline-ref", "");
if (!baselineRef) throw new Error("Pass --baseline-ref=<pre-fix commit>");
const companyCount = Number(arg("companies", "12"));
const issueCount = Number(arg("issues", "12000"));
const runCount = Number(arg("runs", "20000"));
const eventsPerRun = Number(arg("events-per-run", "20"));
const sampleCount = Number(arg("samples", "7"));
const git = (...args: string[]) => execFileSync("git", args, { encoding: "utf8" }).trim();
const baselineSha = git("rev-parse", baselineRef);
const revision = git("rev-parse", "HEAD");
const servicePath = "server/src/services/attention-retention.ts";
const baselineFile = fileURLToPath(new URL("../server/src/services/attention-retention.benchmark-baseline.ts", import.meta.url));
const keepAlive = setInterval(() => {}, 1000);
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
let db: Db | undefined;
let timer: Db["$client"] | undefined;
// Sizes scale with `scale`: the main company uses 1, each decoy 1/5.
async function seedCompany(client: Db["$client"], companyId: string, prefix: string, scale: number) {
const issuesN = Math.max(20, Math.floor(issueCount * scale));
const runsN = Math.max(20, Math.floor(runCount * scale));
await client.unsafe(`INSERT INTO companies (id, name, issue_prefix) VALUES ($1, $2, $3)`, [companyId, `Synthetic ${prefix}`, prefix]);
await client.unsafe(`INSERT INTO agents (id, company_id, name, adapter_type, status)
SELECT gen_random_uuid(), $1::uuid, 'Agent ' || a, 'codex_local', CASE WHEN a <= 3 THEN 'error' ELSE 'idle' END
FROM generate_series(1, 40) a`, [companyId]);
// Status mix: 6% blocked, 6% in review, a few hidden or harness issues among them.
await client.unsafe(`INSERT INTO issues (id, company_id, title, status, hidden_at, harness_kind, created_at, updated_at)
SELECT gen_random_uuid(), $1::uuid, 'Issue ' || n,
CASE WHEN n % 17 = 0 THEN 'blocked' WHEN n % 17 = 1 THEN 'in_review' WHEN n % 5 = 0 THEN 'done' ELSE 'todo' END,
CASE WHEN n % 97 = 0 THEN timestamptz '2026-09-01' END,
CASE WHEN n % 89 = 0 THEN 'eval' END,
timestamptz '2026-09-01' + n * interval '1 minute', timestamptz '2026-09-01' + n * interval '1 minute'
FROM generate_series(1, $2::int) n`, [companyId, issuesN]);
await client.unsafe(`CREATE TABLE IF NOT EXISTS bench_issues (company_id uuid, n int, id uuid)`);
await client.unsafe(`INSERT INTO bench_issues SELECT company_id, row_number() OVER (ORDER BY created_at), id FROM issues WHERE company_id = $1::uuid`, [companyId]);
await client.unsafe(`CREATE TABLE IF NOT EXISTS bench_runs (company_id uuid, n int, id uuid, agent_id uuid, issue_id uuid)`);
await client.unsafe(`INSERT INTO bench_runs
SELECT $1::uuid, g.n, gen_random_uuid(),
(SELECT id FROM agents WHERE company_id = $1::uuid ORDER BY name OFFSET (g.n % 40) LIMIT 1),
(SELECT b.id FROM bench_issues b WHERE b.company_id = $1::uuid AND b.n = 1 + g.n % $3::int)
FROM generate_series(1, $2::int) AS g(n)`, [companyId, runsN, issuesN]);
// About 10% failed and 3% timed out, with a wide context snapshot.
await client.unsafe(`INSERT INTO heartbeat_runs (id, company_id, agent_id, status, created_at, updated_at, finished_at, context_snapshot)
SELECT r.id, r.company_id, r.agent_id,
CASE WHEN r.n % 10 = 0 THEN 'failed' WHEN r.n % 33 = 0 THEN 'timed_out' ELSE 'succeeded' END,
timestamptz '2026-10-01' + r.n * interval '1 second', timestamptz '2026-10-01' + r.n * interval '1 second',
CASE WHEN r.n % 7 <> 0 THEN timestamptz '2026-10-01' + r.n * interval '1 second' + interval '5 minutes' END,
CASE r.n % 3 WHEN 0 THEN jsonb_build_object('issueId', r.issue_id::text, 'prompt', repeat(md5(r.n::text), 48))
WHEN 1 THEN jsonb_build_object('taskId', r.issue_id::text) ELSE '{}'::jsonb END
FROM bench_runs r WHERE r.company_id = $1::uuid`, [companyId]);
await client.unsafe(`INSERT INTO heartbeat_run_events (company_id, run_id, agent_id, seq, event_type, stream, message, created_at)
SELECT r.company_id, r.id, r.agent_id, s,
CASE WHEN s % 10 = 1 THEN 'lifecycle' ELSE 'item.completed' END,
CASE WHEN s % 10 = 1 THEN 'system' ELSE 'stdout' END,
CASE WHEN s % 10 = 1 THEN 'Run step ' || s ELSE md5(r.n::text || ':' || s) || md5(s::text) END,
timestamptz '2026-10-01' + r.n * interval '1 second' + s * interval '1 millisecond'
FROM bench_runs r CROSS JOIN generate_series(1, $2::int) s
WHERE r.company_id = $1::uuid ORDER BY r.n / 20, s, r.n`, [companyId, eventsPerRun]);
// Retry-exhaustion receipts on 1 in 23 runs (most are not failed) plus an output decoy.
await client.unsafe(`INSERT INTO heartbeat_run_events (company_id, run_id, agent_id, seq, event_type, stream, message, created_at)
SELECT r.company_id, r.id, r.agent_id, $2::int + x.k, x.event_type, 'system', x.message, now()
FROM bench_runs r CROSS JOIN LATERAL (VALUES
(1, 'lifecycle', 'Bounded retry exhausted after 3 scheduled attempts'),
(2, 'stdout', 'Bounded retry exhausted quoted in output')) AS x(k, event_type, message)
WHERE r.company_id = $1::uuid AND (r.n % 23 = 0 OR (x.k = 2 AND r.n % 10 = 0))`, [companyId, eventsPerRun]);
// Decisions on 1 in 6 issues; a third are open.
await client.unsafe(`INSERT INTO decisions (company_id, origin_agent_id, origin_issue_id, origin_run_id, title, body, options, status, expires_at, signed_spec, target_snapshots, updated_at)
SELECT r.company_id, r.agent_id, r.issue_id, r.id, 'Decision', 'Choose', '[]'::jsonb,
CASE WHEN r.n % 3 = 0 THEN 'open' ELSE 'resolved' END, timestamptz '2026-12-01', 'fixture', '{}'::jsonb,
timestamptz '2026-10-01' + r.n * interval '1 second'
FROM bench_runs r WHERE r.company_id = $1::uuid AND r.n % 6 = 0 AND r.n <= $2::int`, [companyId, issuesN]);
// Thread interactions on 1 in 4 issues; 15% pending, a few addressed to a user.
await client.unsafe(`INSERT INTO issue_thread_interactions (company_id, issue_id, kind, status, addressee_user_id, payload, updated_at)
SELECT i.company_id, i.id, 'ask_user_questions', CASE WHEN i.n % 7 = 0 THEN 'pending' ELSE 'answered' END,
CASE WHEN i.n % 21 = 0 THEN 'board' END, '{"version":1,"questions":[]}'::jsonb, timestamptz '2026-10-01' + i.n * interval '1 second'
FROM bench_issues i WHERE i.company_id = $1::uuid AND i.n % 4 = 0`, [companyId]);
// Recovery actions on 1 in 8 issues; a fifth active and human-owned.
await client.unsafe(`INSERT INTO issue_recovery_actions (company_id, source_issue_id, kind, status, owner_type, cause, fingerprint, next_action, updated_at)
SELECT i.company_id, i.id, 'execution', CASE WHEN i.n % 5 = 0 THEN 'active' ELSE 'resolved' END,
CASE WHEN i.n % 3 = 0 THEN 'agent' ELSE 'board' END, 'fixture', 'fp-' || i.n, 'Choose', timestamptz '2026-10-01' + i.n * interval '1 second'
FROM bench_issues i WHERE i.company_id = $1::uuid AND i.n % 8 = 0`, [companyId]);
// Retention state for about a third of the candidates: kept, archived, or plain.
await client.unsafe(`INSERT INTO decision_retention (company_id, source_kind, source_id, source_activity_at, keep, archived_at)
SELECT company_id, kind, id, now(), n % 3 = 0, CASE WHEN n % 3 = 1 THEN now() END FROM (
SELECT company_id, 'review' AS kind, id::text AS id, row_number() OVER () AS n FROM issues WHERE company_id = $1::uuid AND status = 'in_review'
UNION ALL SELECT company_id, 'failed_run', id::text, row_number() OVER () FROM heartbeat_runs WHERE company_id = $1::uuid AND status = 'failed'
UNION ALL SELECT company_id, 'decision', id::text, row_number() OVER () FROM decisions WHERE company_id = $1::uuid AND status = 'open'
) x WHERE n % 2 = 0`, [companyId]);
}
const stats = (samples: number[]) => {
const sorted = [...samples].sort((a, b) => a - b);
return {
medianMs: Math.round(sorted[Math.floor(sorted.length / 2)] * 100) / 100,
minMs: Math.round(sorted[0] * 100) / 100,
maxMs: Math.round(sorted[sorted.length - 1] * 100) / 100,
};
};
try {
database = await startEmbeddedPostgresTestDatabase("attention-retention-candidates-benchmark-");
db = createDb(database.connectionString);
const client = db.$client;
const mainCompany = randomUUID();
const seedStart = performance.now();
await seedCompany(client, mainCompany, "BENCH", 1);
for (let index = 1; index < companyCount; index++) await seedCompany(client, randomUUID(), `DEC${index}`, 0.2);
await client.unsafe(`DROP TABLE bench_runs; DROP TABLE bench_issues`);
await client.unsafe("VACUUM ANALYZE");
const seedMs = performance.now() - seedStart;
const [{ server_version: version }] = await client.unsafe("SHOW server_version");
const [counts] = await client.unsafe(`SELECT (SELECT count(*) FROM issues)::int AS issues, (SELECT count(*) FROM heartbeat_runs)::int AS runs,
(SELECT count(*) FROM heartbeat_run_events)::int AS events, (SELECT count(*) FROM decisions)::int AS decisions,
(SELECT count(*) FROM issue_thread_interactions)::int AS interactions, (SELECT count(*) FROM issue_recovery_actions)::int AS recoveries,
(SELECT count(*) FROM decision_retention)::int AS retention`);
// Capture the candidate SQL that each service version issues for a scope.
await fs.writeFile(baselineFile, git("show", `${baselineSha}:${servicePath}`));
const captured: Query[] = [];
const capture = createDb(database.connectionString, { maxConnections: 1 });
capture.$client.options.debug = (_connection: number, query: string, parameters: unknown[]) => {
if (query.includes("with source_candidates")) captured.push({ sql: query, params: [...parameters] });
};
const failedRun = (await client.unsafe(`SELECT r.id::text AS id FROM heartbeat_runs r WHERE r.company_id = $1::uuid AND r.status = 'failed'
AND EXISTS (SELECT 1 FROM heartbeat_run_events e WHERE e.run_id = r.id AND e.event_type = 'lifecycle' AND e.message LIKE 'Bounded retry exhausted%')
ORDER BY r.id LIMIT 1`, [mainCompany]))[0].id as string;
const blockedIssues = (await client.unsafe(`SELECT id::text AS id FROM issues WHERE company_id = $1::uuid AND status = 'blocked' ORDER BY id LIMIT 3`, [mainCompany]))
.map((row) => row.id as string);
const scenarios: Array<{ name: string; scope: Scope; page?: number }> = [
{ name: "company page 1", scope: { companyId: mainCompany } },
{ name: "company page 2", scope: { companyId: mainCompany }, page: 2 },
{ name: "company issue scope", scope: { companyId: mainCompany, issueIds: blockedIssues } },
{ name: "single source (check)", scope: { companyId: mainCompany, sourceKind: "failed_run", sourceId: failedRun } },
{ name: "company failed runs", scope: { companyId: mainCompany, sourceKind: "failed_run" } },
{ name: "all companies page 1", scope: {} },
];
async function queriesFor(modulePath: string) {
const module = await import(modulePath);
const result: Query[] = [];
for (const scenario of scenarios) {
const service = module.attentionRetentionService(capture);
for (let page = 1; page <= (scenario.page ?? 1); page++) {
captured.length = 0;
await service.load(scenario.scope);
}
assert.ok(captured[0], `no candidate query captured for ${scenario.name}`);
result.push(captured[0]);
}
return result;
}
const baselineQueries = await queriesFor(baselineFile);
const treatmentQueries = await queriesFor(fileURLToPath(new URL(`../${servicePath}`, import.meta.url)));
await fs.rm(baselineFile, { force: true });
await capture.$client.end({ timeout: 0 });
// One connection, so the plan_cache_mode setting applies to every sample.
// Named prepared statements, as the server uses by default: samples reuse
// the parsed statement and its cached plan, like a busy server.
timer = createDb(database.connectionString, { maxConnections: 1, prepare: true }).$client;
timer.options.onnotice = () => {};
const timeOnce = async (query: Query) => {
const start = performance.now();
const rows = await timer!.unsafe(query.sql, query.params as never[], { prepare: true }).values();
return { ms: performance.now() - start, rows };
};
const plan = async (query: Query) => {
const [row] = await timer!.unsafe(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${query.sql}`, query.params as never[]);
const root = (row["QUERY PLAN"] as Array<Record<string, any>>)[0];
return { executionMs: Math.round(root["Execution Time"] * 100) / 100,
sharedBlocks: root.Plan["Shared Hit Blocks"] + root.Plan["Shared Read Blocks"], plan: process.argv.includes("--plans") ? root.Plan : undefined };
};
const report: Record<string, unknown>[] = [];
for (const mode of ["force_custom_plan", "force_generic_plan"]) {
await timer.unsafe(`SET plan_cache_mode = ${mode}`);
for (const [index, scenario] of scenarios.entries()) {
const baseline = baselineQueries[index], treatment = treatmentQueries[index];
const before: number[] = [], after: number[] = [];
let rows = 0;
for (let warm = 0; warm < 2; warm++) { await timeOnce(baseline); await timeOnce(treatment); }
const prepared = await timer.unsafe(`SELECT count(*)::int AS n FROM pg_prepared_statements WHERE statement IN ($1, $2)`, [baseline.sql, treatment.sql]);
assert.equal(prepared[0].n, 2, `${scenario.name}: both queries must run as prepared statements`);
// Alternate the order in each round to expose cache bias.
for (let sample = 0; sample < sampleCount; sample++) {
const order = sample % 2 === 0 ? [baseline, treatment] : [treatment, baseline];
const results = [];
for (const query of order) results.push(await timeOnce(query));
const [b, t] = sample % 2 === 0 ? results : [results[1], results[0]];
assert.deepEqual(t.rows, b.rows, `${scenario.name}: rows differ`);
rows = b.rows.length;
before.push(b.ms); after.push(t.ms);
}
const beforeStats = stats(before), afterStats = stats(after);
report.push({ mode, scenario: scenario.name, rows, before: beforeStats, after: afterStats,
speedup: Math.round(beforeStats.medianMs / afterStats.medianMs * 10) / 10,
explainBefore: await plan(baseline), explainAfter: await plan(treatment) });
}
}
console.log(JSON.stringify({ baselineSha, revision, postgresVersion: version, fixture: { companies: companyCount, ...counts,
seedMs: Math.round(seedMs) }, samples: sampleCount, report }, null, 2));
} finally {
try {
await fs.rm(baselineFile, { force: true });
await timer?.end({ timeout: 0 });
await db?.$client.end({ timeout: 0 });
await database?.cleanup();
} finally { clearInterval(keepAlive); }
}