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 > - Plugins get agent, project and agent runtime changes from the `resource_lifecycle_events` journal. Each plugin acknowledges events one resource at a time. > - #20405 made the inbox page read linear in the company journal size. The journal has no retention, so each page still reads every event of the company to find the unacknowledged ones: about 1.7 s per 1M events. > - The delivery worker reads a page again after each acknowledgment. The idle-sleep check also reads the whole journal when all events are acknowledged. `acknowledge` checks every older event of the resource. > - On a hosted instance, the page read ran 181 times in one window at about 1 s each. > - This pull request adds a per-plugin, per-resource cursor. Reads start at the cursor, so they do not read acknowledged history again. > - The benefit is that page, idle-check and acknowledgment cost follow the number of resources and pending events, not the journal size. Delivery order and results stay the same. ## Linked Issues or Issue Description Refs #20405 **What happened?** The plugin lifecycle inbox page query (`select ... from "resource_lifecycle_events" ... DISTINCT ON ...`) ran 181 times at about 1 s each on a hosted database in one window. Each acknowledgment causes another full read of the company journal. The idle-sleep check (`hasPending`) reads the full journal when all events are acknowledged. `acknowledge` reads the full history of the resource. **Expected behavior** A page read, the idle-sleep check and an acknowledgment must not read acknowledged history again. Their cost must not grow with the journal size. **Steps to reproduce** 1. Give a company 400 agents with 2,500 `agent_runtime` events each (1M events). 2. Acknowledge the first 2,000 events of each agent for one plugin. 3. Call `pluginLifecycleInbox(db, pluginId).list(companyId, 100, undefined, ["agent", "project", "agent_runtime"])`. 4. The read takes about 1.7 s. `hasPending` takes about 240 ms. **Paperclip version or commit** `8d2bf78323` (master). **Deployment mode** Hosted (PostgreSQL). Local repro uses embedded PostgreSQL 18. ## What Changed - `packages/db/src/schema/plugin_lifecycle_resource_cursors.ts`: new table `plugin_lifecycle_resource_cursors`. It has one row per plugin, company, resource type and resource id. `acknowledged_through_id` means: every event of that resource at or below this id is acknowledged by the plugin. Plugin and company deletion cascade. - Migration `0462_plugin_lifecycle_resource_cursors`: creates the table and carries existing acknowledgments forward (see Risks for the previous-release story). - `server/src/services/plugin-lifecycle-inbox.ts`: - `list` and `hasPending` use a backward loose index scan on `resource_lifecycle_events_resource_order_idx`. It visits each resource stream once and gets its latest event id. A stream whose latest event is at or below its cursor is settled and is not read again. For other streams, the read gets one head: the unacknowledged creation, else the first unacknowledged transition above the cursor. Then it applies `afterId`, id order and the limit, as before. - `acknowledge` reads only the events between the cursor and the acknowledged event. It still writes the per-event `plugin_lifecycle_acknowledgments` row. It moves the cursor only over a contiguous acknowledged prefix. A creation recorded after a pending transition does not move the cursor. - An event counts as acknowledged when it is at or below the cursor, or when it has an acknowledgment row. Thus rows written by an earlier release, and gaps in old history, keep their current behavior. - `server/src/__tests__/resource-lifecycle-events.test.ts`: new database tests for the migration carry-forward, gaps, late creations, cursor reads, the rollback rows and the plugin-delete cascade. All tests from #20405 still pass without change. - `scripts/benchmark-plugin-lifecycle-inbox.ts`: runs the migration backfill on the fixture and times it. Adds an acknowledgment case. Runs `VACUUM ANALYZE`, because hosted tables are autovacuumed. - `doc/DATABASE.md`: describes the cursor, its read cost and the migration. - The delivery worker (`plugin-lifecycle-delivery.ts`) is not changed. ## Verification Thesis: the cost is a read of the whole company journal (page, idle check) or the whole resource history (acknowledgment). If a per-resource cursor marks the acknowledged prefix, each read can skip that prefix. Then the cost follows the number of resource streams and pending events. The rows must stay equal. Local SQL timings: embedded PostgreSQL 18.1 on Apple Silicon, synthetic data, warm cache, one warmup, 7 samples, baseline and treatment order alternate per sample. The machine was shared with other jobs (load average about 35), so spread is wide. Values are median (min to max) in ms. Rows are equal (`assert.deepEqual`) for every list and probe case. Fixture A: 400 agents x 2,500 runtime events, 2,000 acknowledged per agent. 1,020,440 events, 800,420 acknowledgments. | Case | Before | After | Speedup | | --- | --- | --- | --- | | page of 100, all types | 1,700 (1,633-2,438) | 11.5 (9.5-12.2) | 148x | | page of 100, agent and project | 3.3 (2.5-5.4) | 4.2 (3.6-6.7) | 0.8x (slower) | | idle-sleep check | 238 (174-367) | 2.7 (2.1-3.4) | 88x | | acknowledge one head | 15.3 (9.4-30.1) | 20.3 (12.9-29.9) | neutral (noise) | | migration backfill | - | 9.3 s | - | Fixture B: 40 agents x 25,000 runtime events, 24,000 acknowledged per agent (long history). 1,020,080 events. | Case | Before | After | Speedup | | --- | --- | --- | --- | | page of 100, all types | 1,943 (1,642-4,979) | 4.4 (2.9-5.6) | 444x | | page of 100, agent and project | 3.5 (2.1-5.7) | 4.6 (2.8-12.0) | 0.8x (slower) | | idle-sleep check | 234 (220-243) | 2.8 (1.9-6.5) | 85x | | acknowledge one head | 64.6 (48.5-71.6) | 5.0 (2.6-7.1) | 13x | | migration backfill | - | 4.6 s | - | Fixture C: 5,000 agents x 200 runtime events, 190 acknowledged per agent (many resources). 1,025,040 events. | Case | Before | After | Speedup | | --- | --- | --- | --- | | page of 100, all types | 1,867 (1,649-1,975) | 76.9 (66.2-497) | 24x | | page of 100, agent and project | 10.3 (8.7-12.4) | 21.6 (20.2-23.2) | 0.5x (slower) | | idle-sleep check | 220 (201-298) | 2.8 (2.0-5.3) | 78x | | acknowledge one head | 4.9 (3.4-8.6) | 6.0 (5.2-7.6) | neutral | | migration backfill | - | 15.6 s | - | `EXPLAIN (ANALYZE, BUFFERS)` for the all-types page on fixture A: before 2,488 ms, 13,597 shared hits plus temp files; after 7.0 ms, 7,926 shared hits. Commands: ```sh pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-plugin-lifecycle-inbox.ts --baseline-ref=8d2bf78323 --agents=400 --per-agent=2500 --acked=2000 --samples=7 pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-plugin-lifecycle-inbox.ts --baseline-ref=8d2bf78323 --agents=40 --per-agent=25000 --acked=24000 --samples=7 pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-plugin-lifecycle-inbox.ts --baseline-ref=8d2bf78323 --agents=5000 --per-agent=200 --acked=190 --samples=7 cd server && npx vitest run src/__tests__/resource-lifecycle-events.test.ts src/__tests__/plugin-lifecycle-delivery.test.ts src/__tests__/project-instance-secrets.test.ts src/__tests__/agent-runtime-hooks.test.ts pnpm --filter @paperclipai/db check:migrations ``` - `resource-lifecycle-events.test.ts`, `plugin-lifecycle-delivery.test.ts` and `project-instance-secrets.test.ts`: 86 passed. `agent-runtime-hooks.test.ts`: 22 passed. - `tsc --noEmit -p server`: pass. `check:migrations`: pass. ## Risks - **Schema change.** Migration `0462` adds one table and two foreign keys to `plugins` and `companies`. It adds no index on an existing table. The backfill reads the acknowledgment table once. It took 4.6 s to 15.6 s on 1M-event fixtures with 0.8M to 1M acknowledgments. - **Migration order.** The number `0462` is reserved after open PRs that use `0459` to `0461` (#20571, #20568, #20573). Merge this PR after them. Before merge, rebase onto master and chain the snapshot after the latest migration. - **Previous-release story.** - Upgrade: the backfill stores, per plugin and resource, the end of the contiguous acknowledged prefix. A cursor never passes an unacknowledged event, so no event is lost. Acknowledged events above a gap keep their rows, so no event is delivered again. A test runs the migration statement on rows that only an earlier release wrote and checks that `list` returns the same rows before and after. - Rolling deploy: an earlier release that still runs writes only acknowledgment rows. The new reads honor those rows. - Rollback: the new code still writes every acknowledgment row, so an earlier release sees the same progress. - **Ordering assumption.** The cursor is safe because writers record a resource's events while they hold that resource's row lock (`paperclip_record_agent_runtime` locks the agent row; agent status and project writers document the lock). Thus a later commit for the same resource always has a higher id. A writer that breaks this contract could commit an event below the cursor, and that event would not be delivered. A lower id from a different resource is still delivered (existing test). - **Slower path.** A page that asks only for `agent` and `project` streams is 1 to 11 ms slower. These streams are short, so the old full read was cheap, and the new read visits each stream once. The cost is bounded by the number of resources. - **Many resources.** The page cost grows with the number of resource streams of the requested types. At 5,000 agents a page is about 77 ms, compared with about 1.9 s before. - The hosted planner can choose a different plan. The hosted improvement is not yet measured. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude Code with tool use (shell, file edits, local PostgreSQL benchmarks). ## 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>
164 lines
12 KiB
TypeScript
164 lines
12 KiB
TypeScript
// Compares the plugin lifecycle inbox page, idle probe and acknowledgment before and after a change.
|
|
// Usage: pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-plugin-lifecycle-inbox.ts \
|
|
// --baseline-ref=<sha> [--agents=40] [--per-agent=2500] [--acked=2000] [--samples=5] [--treatment-only]
|
|
import assert from "node:assert/strict";
|
|
import { randomUUID } from "node:crypto";
|
|
import { execFileSync } from "node:child_process";
|
|
import { promises as fs } from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { createRequire } from "node:module";
|
|
import * as schema from "../packages/db/src/schema/index.js";
|
|
import { companies, plugins, startEmbeddedPostgresTestDatabase, type Db } from "../packages/db/src/index.js";
|
|
import * as current from "../server/src/services/plugin-lifecycle-inbox.js";
|
|
|
|
const arg = (name: string, fallback: string) => process.argv.find(a => a.startsWith(`--${name}=`))?.slice(name.length + 3) ?? fallback;
|
|
const baselineRevision = execFileSync("git", ["rev-parse", "--verify", `${arg("baseline-ref", "origin/master")}^{commit}`], { encoding: "utf8" }).trim();
|
|
const agentCount = Number(arg("agents", "40"));
|
|
const perAgent = Number(arg("per-agent", "2500"));
|
|
const acked = Number(arg("acked", "2000"));
|
|
const sampleCount = Number(arg("samples", "5"));
|
|
const timeoutMs = Number(arg("timeout-ms", "600000"));
|
|
// The baseline is quadratic; at large fixtures time only the treatment.
|
|
const treatmentOnly = process.argv.includes("--treatment-only");
|
|
// Resolve database drivers through the db package; the scripts folder has no own dependencies.
|
|
const dbRequire = createRequire(new URL("../packages/db/package.json", import.meta.url));
|
|
const postgres = (await import(pathToFileURL(dbRequire.resolve("postgres")).href)).default as typeof import("postgres").default;
|
|
const { drizzle } = await import(pathToFileURL(dbRequire.resolve("drizzle-orm/postgres-js")).href) as typeof import("drizzle-orm/postgres-js");
|
|
type Inbox = typeof current.pluginLifecycleInbox;
|
|
type Query = { sql: string; params: unknown[] };
|
|
|
|
const keepAlive = setInterval(() => {}, 1000);
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
|
let client: import("postgres").Sql | undefined;
|
|
let baselineDir: string | undefined;
|
|
try {
|
|
database = await startEmbeddedPostgresTestDatabase("lifecycle-inbox-benchmark-");
|
|
let recorded: Query[] = [];
|
|
client = postgres(database.connectionString, {
|
|
max: 1, onnotice: () => {}, connection: { statement_timeout: timeoutMs },
|
|
debug: (_connection, query, params) => { recorded.push({ sql: query, params }); },
|
|
});
|
|
const db = drizzle(client, { schema }) as unknown as Db;
|
|
|
|
baselineDir = await fs.mkdtemp(path.join(fileURLToPath(new URL("../server/scripts/", import.meta.url)), ".lifecycle-baseline-"));
|
|
const modulePath = "server/src/services/plugin-lifecycle-inbox.ts";
|
|
const original = new URL(`../${modulePath}`, import.meta.url);
|
|
const source = execFileSync("git", ["show", `${baselineRevision}:${modulePath}`], { encoding: "utf8" })
|
|
.replace(/from "(\.{1,2}\/[^"]+)"/g, (_m, specifier: string) => `from ${JSON.stringify(fileURLToPath(new URL(specifier, original)))}`);
|
|
const target = path.join(baselineDir, "plugin-lifecycle-inbox.ts");
|
|
await fs.writeFile(target, source);
|
|
const baseline = (await import(pathToFileURL(target).href)).pluginLifecycleInbox as Inbox;
|
|
|
|
// Fixture: agent_runtime history interleaved across agents. Each agent has `acked`
|
|
// acknowledged revisions followed by a backlog of unacknowledged revisions.
|
|
const companyId = randomUUID(), decoyId = randomUUID();
|
|
await db.insert(companies).values([{ id: companyId, name: "Bench", issuePrefix: "BNCH" }, { id: decoyId, name: "Decoy", issuePrefix: "DCOY" }]);
|
|
const [plugin] = await db.insert(plugins).values({ pluginKey: randomUUID(), packageName: "bench", version: "1.0.0", status: "ready", manifestJson: {} as never }).returning();
|
|
const agentIds = Array.from({ length: agentCount }, () => randomUUID());
|
|
const projectIds = Array.from({ length: 20 }, () => randomUUID());
|
|
await client.unsafe(`INSERT INTO resource_lifecycle_events (company_id, resource_type, resource_id, action)
|
|
SELECT $1::uuid, 'agent', a, 'create' FROM unnest($2::uuid[]) a
|
|
UNION ALL SELECT $1::uuid, 'project', p, 'create' FROM unnest($3::uuid[]) p`, [companyId, agentIds, projectIds]);
|
|
await client.unsafe(`INSERT INTO resource_lifecycle_events (company_id, resource_type, resource_id, action)
|
|
SELECT $1::uuid, 'agent_runtime', ($2::uuid[])[(n % $3) + 1], 'update' FROM generate_series(0, $4::int - 1) n ORDER BY n`,
|
|
[companyId, agentIds, agentCount, agentCount * perAgent]);
|
|
// A few project updates after the agent backlog, so heads are spread across the id range.
|
|
await client.unsafe(`INSERT INTO resource_lifecycle_events (company_id, resource_type, resource_id, action)
|
|
SELECT $1::uuid, 'project', p, 'update' FROM unnest($2::uuid[]) p`, [companyId, projectIds]);
|
|
await client.unsafe(`INSERT INTO resource_lifecycle_events (company_id, resource_type, resource_id, action)
|
|
SELECT $1::uuid, 'agent_runtime', gen_random_uuid(), 'update' FROM generate_series(1, 20000)`, [decoyId]);
|
|
await client.unsafe(`INSERT INTO plugin_lifecycle_acknowledgments (plugin_id, event_id)
|
|
SELECT $1::uuid, id FROM (
|
|
SELECT id, resource_type, row_number() OVER (PARTITION BY resource_type, resource_id ORDER BY id) AS rank
|
|
FROM resource_lifecycle_events WHERE company_id = $2::uuid) e
|
|
WHERE resource_type = 'agent' OR (resource_type = 'project' AND rank = 1) OR (resource_type = 'agent_runtime' AND rank <= $3)`,
|
|
[plugin.id, companyId, acked]);
|
|
// Upgrade path: carry the acknowledgment rows into resource cursors as the migration does.
|
|
const cursorMigration = await fs.readFile(new URL("../packages/db/src/migrations/0462_plugin_lifecycle_resource_cursors.sql", import.meta.url), "utf8");
|
|
const backfill = cursorMigration.split("--> statement-breakpoint").find(statement => statement.includes('INSERT INTO "plugin_lifecycle_resource_cursors"'));
|
|
assert.ok(backfill, "cursor backfill statement not found");
|
|
const backfillStart = performance.now();
|
|
await client.unsafe(backfill);
|
|
const backfillMs = Math.round(performance.now() - backfillStart);
|
|
// Hosted tables are autovacuumed, so index-only scans skip most heap visits.
|
|
await client.unsafe("VACUUM ANALYZE");
|
|
const [{ events }] = await client.unsafe(`SELECT count(*)::int AS events FROM resource_lifecycle_events`);
|
|
const [{ acks }] = await client.unsafe(`SELECT count(*)::int AS acks FROM plugin_lifecycle_acknowledgments`);
|
|
const [{ server_version: version }] = await client.unsafe(`SHOW server_version`);
|
|
|
|
const cases = [
|
|
{ name: "page-100-all-types", limit: 100, types: ["agent", "project", "agent_runtime"] },
|
|
{ name: "page-100-agent-project", limit: 100, types: ["agent", "project"] },
|
|
// Idle-sleep check: baseline read one listed event; the treatment asks only whether one exists.
|
|
{ name: "idle-probe-all-types", limit: 1, types: ["agent", "project", "agent_runtime"], probe: true },
|
|
] as const;
|
|
async function run(inbox: Inbox, c: (typeof cases)[number]) {
|
|
recorded = [];
|
|
const start = performance.now();
|
|
const service = inbox(db, plugin.id) as ReturnType<Inbox>;
|
|
const rows = "probe" in c
|
|
? [("hasPending" in service ? await service.hasPending(companyId, [...c.types]) : (await service.list(companyId, 1, undefined, [...c.types])).length > 0)]
|
|
: await service.list(companyId, c.limit, undefined, [...c.types]);
|
|
const ms = performance.now() - start;
|
|
const query = recorded.find(q => q.sql.includes('"resource_lifecycle_events"'));
|
|
return { ms, rows, query };
|
|
}
|
|
const results = [];
|
|
for (const c of cases) {
|
|
const samples = { baseline: [] as number[], treatment: [] as number[] };
|
|
let baseRows: unknown, treatRows: unknown, baseQuery: Query | undefined, treatQuery: Query | undefined;
|
|
// One warmup each, then alternate order per sample to limit cache bias.
|
|
for (let n = 0; n <= sampleCount; n++) {
|
|
const order = n % 2 === 0 ? (["baseline", "treatment"] as const) : (["treatment", "baseline"] as const);
|
|
for (const which of order.filter(w => !treatmentOnly || w === "treatment")) {
|
|
const r = await run(which === "baseline" ? baseline : current.pluginLifecycleInbox, c);
|
|
if (n > 0) samples[which].push(r.ms);
|
|
if (which === "baseline") { baseRows = r.rows; baseQuery = r.query; } else { treatRows = r.rows; treatQuery = r.query; }
|
|
}
|
|
}
|
|
if (!treatmentOnly) assert.deepEqual(treatRows, baseRows, `${c.name}: results differ`);
|
|
const explain = async (q?: Query) => q ? (await client!.unsafe(`EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) ${q.sql}`, q.params as never[])).map(r => r["QUERY PLAN"]).join("\n") : "";
|
|
const stats = (s: number[]) => { const v = [...s].sort((a, b) => a - b); return { median: v[Math.floor(v.length / 2)], min: v[0], max: v.at(-1), samples: s.map(x => Math.round(x * 10) / 10) }; };
|
|
const b = treatmentOnly ? undefined : stats(samples.baseline), t = stats(samples.treatment);
|
|
results.push({ case: c.name, rows: (treatRows as unknown[]).length, equal: !treatmentOnly, baselineMs: b, treatmentMs: t, speedup: b ? Math.round(b.median / t.median * 10) / 10 : undefined,
|
|
baselinePlan: await explain(baseQuery), treatmentPlan: await explain(treatQuery) });
|
|
}
|
|
// Acknowledge one resource's head, then undo it, so every sample sees the same state.
|
|
{
|
|
const ackSamples = { baseline: [] as number[], treatment: [] as number[] };
|
|
const [head] = await current.pluginLifecycleInbox(db, plugin.id).list(companyId, 1, undefined, ["agent_runtime"]);
|
|
// A fresh plugin (--acked=0) has no cursor row; restore that absence between samples.
|
|
const [cursorRow] = await client.unsafe(`SELECT acknowledged_through_id::text AS through FROM plugin_lifecycle_resource_cursors
|
|
WHERE plugin_id = $1 AND company_id = $2 AND resource_type = 'agent_runtime' AND resource_id = $3`, [plugin.id, companyId, head.resourceId]);
|
|
const through: string | undefined = cursorRow?.through;
|
|
for (let n = 0; n <= sampleCount; n++) {
|
|
const order = n % 2 === 0 ? (["baseline", "treatment"] as const) : (["treatment", "baseline"] as const);
|
|
for (const which of order.filter(w => !treatmentOnly || w === "treatment")) {
|
|
const start = performance.now();
|
|
await (which === "baseline" ? baseline : current.pluginLifecycleInbox)(db, plugin.id).acknowledge(companyId, head.id);
|
|
if (n > 0) ackSamples[which].push(performance.now() - start);
|
|
await client.unsafe(`DELETE FROM plugin_lifecycle_acknowledgments WHERE plugin_id = $1 AND event_id = $2`, [plugin.id, head.id]);
|
|
if (through === undefined) {
|
|
await client.unsafe(`DELETE FROM plugin_lifecycle_resource_cursors
|
|
WHERE plugin_id = $1 AND company_id = $2 AND resource_type = 'agent_runtime' AND resource_id = $3`, [plugin.id, companyId, head.resourceId]);
|
|
} else {
|
|
await client.unsafe(`UPDATE plugin_lifecycle_resource_cursors SET acknowledged_through_id = $4
|
|
WHERE plugin_id = $1 AND company_id = $2 AND resource_type = 'agent_runtime' AND resource_id = $3`, [plugin.id, companyId, head.resourceId, through]);
|
|
}
|
|
}
|
|
}
|
|
const stats = (s: number[]) => { const v = [...s].sort((a, b) => a - b); return { median: v[Math.floor(v.length / 2)], min: v[0], max: v.at(-1), samples: s.map(x => Math.round(x * 10) / 10) }; };
|
|
const b = treatmentOnly ? undefined : stats(ackSamples.baseline), t = stats(ackSamples.treatment);
|
|
results.push({ case: "acknowledge-head", rows: 1, equal: !treatmentOnly, baselineMs: b, treatmentMs: t, speedup: b ? Math.round(b.median / t.median * 10) / 10 : undefined, baselinePlan: "", treatmentPlan: "" });
|
|
}
|
|
// Control: the agent runtime revision read (plugin-agent-runtime-state.ts, agent-file-store.ts) is unchanged.
|
|
const revisionPlan = (await client.unsafe(`EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT id FROM resource_lifecycle_events
|
|
WHERE company_id = $1 AND resource_type = 'agent_runtime' AND resource_id = $2 ORDER BY id DESC LIMIT 1`, [companyId, agentIds[0]]))
|
|
.map(r => r["QUERY PLAN"]).join("\n");
|
|
console.log(JSON.stringify({ revisionPlan, baselineRevision, postgres: version, agents: agentCount, perAgent, acked, events, acks, backfillMs, samples: sampleCount, results }, null, 2));
|
|
} finally {
|
|
try { await client?.end({ timeout: 0 }); await database?.cleanup(); if (baselineDir) await fs.rm(baselineDir, { recursive: true, force: true }); }
|
|
finally { clearInterval(keepAlive); }
|
|
}
|