mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
## 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>
230 lines
16 KiB
TypeScript
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); }
|
|
}
|