Files
PaperClipAI/server/src/services/plugin-lifecycle-delivery.ts
T
DottaandClaude Opus 5.5 c57cd1bdb9 perf(server): read plugin lifecycle inbox heads in one pass (#20405)
## 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.
> - The inbox page query checked each unacknowledged event against all
older events of the same resource. Each check walked the acknowledged
history of that resource.
> - Agent runtime streams get a new event for each configuration change,
so one resource can have thousands of acknowledged events. The cost grew
as pending events times acknowledged history.
> - On a hosted instance, three copies of this read ran for 601 seconds
each, and one earlier copy ran for 19 minutes. The delivery worker runs
it again after each acknowledgment.
> - This pull request selects the head of each resource in one pass and
keeps the same result.
> - The benefit is that the page read is linear in the company journal
size. In local tests it is 250x to 660x faster, and the result rows are
equal.

## Linked Issues or Issue Description

**What happened?**
The plugin lifecycle inbox page query (`select "id", "company_id",
"resource_type", "resource_id", "action", "created_at" from
"resource_lifecycle_events" where ...`) ran for 601 seconds on a hosted
database. Three copies were active at the same time.

**Expected behavior**
A lifecycle page read must stay fast when a resource has a long
acknowledged history and a pending backlog.

**Steps to reproduce**
1. Give a company 40 agents with 1,500 `agent_runtime` events each.
2. Acknowledge the first 1,200 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 10 seconds for 60,000 events of that company. At
200,000 events it takes about 6 minutes.

**Paperclip version or commit**
`02bfe71a6b` (master).

**Deployment mode**
Hosted (PostgreSQL). Local repro uses embedded PostgreSQL 18.

## What Changed

- `server/src/services/plugin-lifecycle-inbox.ts`: `list` now selects
one head per resource with `DISTINCT ON (resource_type, resource_id)`.
The head is the unacknowledged creation, else the oldest unacknowledged
event. Then the query applies `afterId`, id order and the limit. This is
the same set that the old `firstForResource` predicate admits.
`acknowledge` keeps the old predicate for its single-event check.
- `plugin-lifecycle-inbox.ts`: new `hasPending(companyId, types)`. It
returns whether any unacknowledged event exists. Each resource with an
unacknowledged event has a head, so this gives the same answer as
`list(companyId, 1).length > 0`.
- `server/src/services/plugin-lifecycle-delivery.ts`: the idle-sleep
journal check uses `hasPending`. This keeps its early exit. A one-row
`list` page would read the full journal.
- `server/src/__tests__/resource-lifecycle-events.test.ts`: new database
test for heads across acknowledged history, a creation recorded after
transitions, acknowledged gaps, shared ids across resource types,
per-plugin progress, another company, `afterId` pages, limits,
`hasPending`, and the next head after an acknowledgment. The test also
passes on the old query, so it locks the contract.
- `scripts/benchmark-plugin-lifecycle-inbox.ts`: new benchmark. It loads
the baseline service from git, runs both on the same fixture, alternates
order, asserts equal rows, and prints plans.
- `doc/DATABASE.md`: describes the read cost and the benchmark command.
- No schema change, index or migration.

## Verification

Thesis: the old read cost is (unacknowledged candidates) x (acknowledged
older events per resource), because each candidate walks the
acknowledged prefix of its resource. One pass that keeps the
per-resource head removes that product and returns equal rows.

Baseline plan at 220,080 events: 40,020 candidates each walked about
3,999 older rows, which gave 160,039,980 acknowledgment probes and
562,140,833 buffer hits. The treatment plan reads the journal once with
a hash anti join and sorts the 40,020 unacknowledged rows (3,925 buffer
hits).

Local SQL timings (embedded PostgreSQL 18, Apple Silicon; event counts
include a 20,000-event second company, synthetic data, warm cache, one
warmup, order alternates):

| Fixture (events / acknowledged) | Case | Before median | After median
| Samples (after) | Speedup |
| --- | --- | --- | --- | --- | --- |
| 40,060 / 16,040 | page of 100, all types | 3,263 ms | 13.1 ms | 3
(12.0-14.1) | 250x |
| 80,080 / 48,060 | page of 100, all types | 10,137 ms | 23.9 ms | 5
(22.7-32.6) | 424x |
| 220,080 / 160,060 | page of 100, all types | 355,718 ms | 538 ms | 3
(72-785) | 662x |
| 1,020,080 / 960,060 | page of 100, all types | not run (est. > 30 min)
| 1,682 ms | 7 (1,203-2,384) | - |
| 80,080 / 48,060 | page of 100, agent and project only | 1.5 ms | 1.6
ms | 5 | neutral |
| 80,080 / 48,060 | idle-sleep check | 14.5 ms | 13.8 ms | 5 | neutral |
| 1,020,080 / 960,060 | idle-sleep check | not run | 406 ms | 7 | - |

- Result rows are equal (`assert.deepEqual` on all mapped rows, in
order) for each case where both versions ran.
- The agent runtime revision read in `plugin-agent-runtime-state.ts` and
`agent-file-store.ts` (`select id ... order by id desc limit 1`) uses a
backward index-only scan and reads 5 buffers at 1M events. It is not
changed.

Commands:

```sh
pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-plugin-lifecycle-inbox.ts --baseline-ref=02bfe71a6b --agents=40 --per-agent=1500 --acked=1200 --samples=5
pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-plugin-lifecycle-inbox.ts --baseline-ref=02bfe71a6b --agents=40 --per-agent=25000 --acked=24000 --samples=7 --treatment-only
cd server && npx vitest run src/__tests__/resource-lifecycle-events.test.ts src/__tests__/plugin-lifecycle-delivery.test.ts src/__tests__/agent-runtime-hooks.test.ts
```

- `resource-lifecycle-events.test.ts`: 28 passed.
`plugin-lifecycle-delivery.test.ts`: 40 passed (includes idle-sleep
"none" and "present"). `agent-runtime-hooks.test.ts`: 22 passed.
- `tsc --noEmit -p server`: pass.

## Risks

- The page read is still linear in the company journal size, because
acknowledgments are per plugin and the journal has no retention yet. At
1M events a page takes about 1.7 s locally. Each acknowledgment wakes
another full pass. A per-plugin cursor or retention can remove this
later.
- The planner can choose a different plan on the hosted database. The
local plans use a hash anti join and one sort. The hosted improvement is
not yet measured.
- `acknowledge` still checks one event against older events of its
resource. That check is linear in that resource's history per call, as
before.
- Low behavior risk: the new test passes on both the old and new
queries.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude
Code with tool use (shell, file edits, local PostgreSQL). Extended
reasoning on.

## 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-09 17:37:14 -06:00

323 lines
17 KiB
TypeScript

import { projectInstanceSecretService } from "./project-instance-secrets.js";
import { and, asc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { createOwnedDatabaseListener, type OwnedDatabaseListener, agents, companies, pluginCompanySettings, pluginConfig, plugins, projects, type Db } from "@paperclipai/db";
import { logger } from "../middleware/logger.js";
import { pluginLifecycleInbox } from "./plugin-lifecycle-inbox.js";
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
import { isWorkspaceProvider } from "./project-workspace-provider.js";
import { managedPluginEnabledCompanies } from "./managed-plugin-worker-env.js";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
import type { PluginLifecycleManager } from "./plugin-lifecycle.js";
import { registerIdleLocalWorkProbe, type IdleLocalWork } from "./idle-local-work.js";
import { isIdleTaskDrainActive, subscribeTaskDrainChanges, subscribeTaskDrainExpiry } from "./task-admission.js";
type Coordinator = ReturnType<typeof createDeliveryWorkCoordinator>;
/** The journal is the queue. Notifications only wake its single local dispatcher. */
export function registerPluginLifecycleDelivery(
coordinator: Coordinator,
db: Db,
manager: Pick<PluginWorkerManager, "call" | "isRunning" | "onConfigApplied">,
lifecycle: Pick<PluginLifecycleManager, "on" | "off">,
) {
// Startup and every wake remain pending until an admitted run inspects the inbox.
// The coordinator may reconcile signals while idle drain prevents dispatch.
let pending = true;
let disposed = false;
let held = false;
let epoch = 0;
let delivering = 0;
let listenerUpdates = 0;
let listenerFailure = false;
let inspectionFailure = false;
let listenerClient: OwnedDatabaseListener | undefined;
let disposePromise: Promise<void> | undefined;
const retryAt = new Map<string, number>();
const unregisterIdle = registerIdleLocalWorkProbe(() => listenerFailure || inspectionFailure ? "unknown"
: listenerUpdates || delivering || listenerClient || pending ? "present" : "none");
const refreshedRuntimeCompanies = new Set<string>();
const retryMs = 30_000;
async function managedTargets() {
const ready = await db.select().from(plugins).where(eq(plugins.status, "ready"));
const targets = ready.flatMap(plugin => managedPluginEnabledCompanies(plugin.pluginKey).map(companyId => ({ plugin, companyId })));
if (!targets.length) return targets;
const existing = await db.select({ id: companies.id }).from(companies)
.where(inArray(companies.id, targets.map(target => target.companyId)));
const existingIds = new Set(existing.map(company => company.id));
return targets.filter(target => existingIds.has(target.companyId));
}
async function enabledManagedPlugins() {
const targets = await managedTargets();
if (!targets.length) return [];
const disabled = await db.select({ pluginId: pluginCompanySettings.pluginId, companyId: pluginCompanySettings.companyId }).from(pluginCompanySettings)
.where(and(inArray(pluginCompanySettings.pluginId, targets.map(target => target.plugin.id)), eq(pluginCompanySettings.enabled, false)));
const disabledKeys = new Set(disabled.map(row => `${row.pluginId}:${row.companyId}`));
return targets.filter(target => !disabledKeys.has(`${target.plugin.id}:${target.companyId}`)).map(target => target.plugin);
}
async function deliveryTargets() {
const configured = await db.select({ plugin: plugins, companyId: pluginConfig.companyId })
.from(plugins).innerJoin(pluginConfig, eq(pluginConfig.pluginId, plugins.id))
.where(eq(plugins.status, "ready"))
.orderBy(asc(plugins.installOrder), asc(pluginConfig.companyId));
const managed = await managedTargets();
if (!managed.length) return configured;
const seen = new Set(configured.map(target => `${target.plugin.id}:${target.companyId}`));
return [...configured, ...managed.filter(target => !seen.has(`${target.plugin.id}:${target.companyId}`))].sort((a, b) =>
(a.plugin.installOrder ?? Infinity) - (b.plugin.installOrder ?? Infinity) || a.companyId.localeCompare(b.companyId));
}
async function deliverableLifecycleTypes({ plugin, companyId }: Awaited<ReturnType<typeof deliveryTargets>>[number]) {
const types = plugin.manifestJson.resourceLifecycle;
if (!types?.length || !plugin.manifestJson.capabilities.includes("events.subscribe")) return;
const [disabled] = await db.select({ id: pluginCompanySettings.id }).from(pluginCompanySettings)
.where(and(eq(pluginCompanySettings.pluginId, plugin.id), eq(pluginCompanySettings.companyId, companyId), eq(pluginCompanySettings.enabled, false)));
return disabled ? undefined : types;
}
const delivery = coordinator.register(DELIVERY_QUEUES.pluginLifecycle, {
retryMs,
hasPending: async () => pending,
onSettled: () => { pending = true; },
nextRunAt() {
if (!pending) return null;
let next: number | null = null;
for (const at of retryAt.values()) {
// An expired deadline during idle drain must not schedule a busy loop.
if (at > Date.now() && (next === null || at < next)) next = at;
}
return next;
},
async run(signal) {
delivering++;
pending = false;
try {
await ensureListening();
for (const [key, at] of retryAt) if (at <= Date.now()) retryAt.delete(key);
for (const target of await deliveryTargets()) {
const types = await deliverableLifecycleTypes(target);
if (!types) continue;
const { plugin, companyId } = target;
if (types.includes("agent_runtime") && !refreshedRuntimeCompanies.has(companyId)) {
await db.execute(sql`SELECT paperclip_invalidate_company_agent_runtimes(${companyId}::uuid)`);
refreshedRuntimeCompanies.add(companyId);
}
const inbox = pluginLifecycleInbox(db, plugin.id);
let afterId: string | undefined;
// Visit independent resources past a failed or deferred head.
for (;;) {
if (signal.aborted) return;
const events = await inbox.list(companyId, 100, afterId, types);
for (const event of events) {
if (signal.aborted) return;
const retryKey = `${plugin.id}:${event.id}`;
if (retryAt.has(retryKey)) { pending = true; continue; }
if (!manager.isRunning(plugin.id)) { pending = true; break; }
let stage = "eligibility";
try {
// Recheck enablement for each invocation; a disable during a batch must stop delivery.
const [enabled] = await db.select({ manifest: plugins.manifestJson }).from(plugins)
.leftJoin(pluginCompanySettings, and(eq(pluginCompanySettings.pluginId, plugins.id), eq(pluginCompanySettings.companyId, companyId)))
.where(and(eq(plugins.id, plugin.id), eq(plugins.status, "ready"),
or(isNull(pluginCompanySettings.enabled), eq(pluginCompanySettings.enabled, true))));
if (!enabled?.manifest.resourceLifecycle?.includes(event.resourceType) || !enabled.manifest.capabilities.includes("events.subscribe")) break;
if (event.resourceType === "project" && isWorkspaceProvider(enabled.manifest)) {
stage = "ownership";
const [project] = await db.select({ policy: projects.executionWorkspacePolicy }).from(projects)
.where(and(eq(projects.id, event.resourceId), eq(projects.companyId, companyId)));
if (project && parseProjectExecutionWorkspacePolicy(project.policy)?.storageProviderPluginId !== plugin.id) {
await inbox.acknowledge(companyId, event.id);
wake();
continue;
}
}
if (event.resourceType === "agent_runtime" && enabled.manifest.capabilities.includes("agents.lifecycle.manage")) {
stage = "ownership";
const [agent] = await db.select({ requiredPluginIds: agents.lifecycleRequiredPluginIds }).from(agents)
.where(and(eq(agents.id, event.resourceId), eq(agents.companyId, companyId)));
if (agent && !agent.requiredPluginIds?.includes(plugin.id)) {
await inbox.acknowledge(companyId, event.id);
wake();
continue;
}
}
stage = "credentials";
const credentials = event.resourceType === "project" && enabled.manifest.capabilities.includes("projects.instance-secrets")
? await projectInstanceSecretService(db, plugin.id).prepare(event) : undefined;
stage = "callback";
const result = await manager.call(plugin.id, "resourceLifecycle", credentials ? { ...event, instanceSecrets: credentials.instanceSecrets } : event, 30_000);
stage = "result";
if (result?.eventId !== event.id || !["acknowledged", "retry", "deferred"].includes(result.status)) {
throw new Error("Invalid resource lifecycle result");
}
if (signal.aborted) return;
if (result.status === "acknowledged") {
stage = "acknowledgment";
if (result.instanceSecrets !== undefined && !credentials) throw new Error("Project instance secrets outside callback scope");
await inbox.acknowledge(companyId, event.id, credentials ? { snapshot: credentials.snapshot, changes: result.instanceSecrets ?? {} } : undefined);
// An acknowledgment exposes the next event for this resource. Drain immediately.
wake();
} else if (result.status === "retry") {
retryAt.set(retryKey, Date.now() + retryMs);
pending = true;
}
} catch (error) {
retryAt.set(retryKey, Date.now() + retryMs);
// Provider error messages/data may contain secrets; retain only routing
// identifiers, the failed stage, and the protocol's numeric error code.
const code = (error as { code?: unknown } | null)?.code;
logger.warn({ pluginId: plugin.id, companyId, eventId: event.id, stage,
...(typeof code === "number" ? { rpcCode: code } : {}) }, "Resource lifecycle delivery failed; retry scheduled");
// Failed RPCs and uncertain acknowledgments leave durable work for replay.
pending = true;
}
}
if (events.length < 100) break;
afterId = events.at(-1)!.id;
}
}
} catch (error) {
pending = true; // A failed scan must survive a retry skipped during idle drain.
throw error;
} finally { delivering--; }
},
});
function wake() { if (disposed) return; epoch++; pending = true; delivery.wake(); }
let listening: Awaited<ReturnType<Db["$client"]["listen"]>> | undefined;
let listenerUpdate = Promise.resolve();
function updateListener<T>(operation: () => Promise<T>): Promise<T> {
listenerUpdates++;
const update = listenerUpdate.then(operation);
listenerUpdate = update.then(() => undefined, () => undefined);
return update.finally(() => { listenerUpdates--; });
}
async function closeListener() {
const client = listenerClient;
if (!client) return;
try { await client.close(); }
catch (error) {
listenerFailure = true;
try { logger.warn({ operation: "plugin_lifecycle_listener_close", failed: true }, "Owned plugin lifecycle listener close failed"); } catch {}
throw error;
}
if (listenerClient === client) { listening = undefined; listenerClient = undefined; }
listenerFailure = false;
}
function ensureListening() {
return updateListener(async () => {
if (disposed || held || isIdleTaskDrainActive()) return;
let installed: Array<{ manifest: (typeof plugins.$inferSelect)["manifestJson"] }>;
try {
const configured = await db.select({ manifest: plugins.manifestJson }).from(plugins)
.innerJoin(pluginConfig, eq(pluginConfig.pluginId, plugins.id))
.leftJoin(pluginCompanySettings, and(eq(pluginCompanySettings.pluginId, plugins.id), eq(pluginCompanySettings.companyId, pluginConfig.companyId)))
.where(and(eq(plugins.status, "ready"), or(isNull(pluginCompanySettings.enabled), eq(pluginCompanySettings.enabled, true))));
installed = [...configured, ...(await enabledManagedPlugins()).map(plugin => ({ manifest: plugin.manifestJson }))];
inspectionFailure = false;
} catch (error) { inspectionFailure = true; throw error; }
if (disposed || held || isIdleTaskDrainActive()) return;
const wanted = installed.some(plugin => plugin.manifest.resourceLifecycle?.includes("agent_runtime") && plugin.manifest.capabilities?.includes("events.subscribe"));
if (!wanted) { await closeListener(); listenerFailure = false; return; }
if (listenerFailure) await closeListener();
if (disposed || held || isIdleTaskDrainActive()) return;
try {
listenerClient ??= createOwnedDatabaseListener(db);
listening ??= await listenerClient.listen("paperclip_plugin_lifecycle", wake, wake);
listenerFailure = false;
} catch (error) { listenerFailure = true; throw error; }
});
}
async function hasJournalWork(current: () => boolean): Promise<IdleLocalWork> {
if (!current()) return "unknown";
const targets = await deliveryTargets();
if (!current()) return "unknown";
for (const target of targets) {
if (!current()) return "unknown";
const types = await deliverableLifecycleTypes(target);
if (!current()) return "unknown";
if (!types) continue;
const { plugin, companyId } = target;
const hasEvents = await pluginLifecycleInbox(db, plugin.id).hasPending(companyId, types);
if (!current()) return "unknown";
if (hasEvents) { wake(); inspectionFailure = false; return "present"; }
}
inspectionFailure = false;
return "none";
}
function pluginChanged() {
wake();
void ensureListening().catch(wake);
}
const resumeListening = () => {
if (disposed) return;
epoch++;
if (isIdleTaskDrainActive()) return;
held = false;
wake();
void ensureListening().catch(wake);
};
const unsubscribeDrain = subscribeTaskDrainChanges(resumeListening);
const unsubscribeExpiry = subscribeTaskDrainExpiry(resumeListening);
const listenerReady = ensureListening();
const unsubscribeConfig = manager.onConfigApplied(pluginChanged);
lifecycle.on("plugin.loaded", pluginChanged);
lifecycle.on("plugin.enabled", pluginChanged);
lifecycle.on("plugin.worker_started", pluginChanged);
lifecycle.on("plugin.status_changed", pluginChanged);
lifecycle.on("plugin.disabled", pluginChanged);
lifecycle.on("plugin.unloaded", pluginChanged);
return {
...delivery,
ready: listenerReady,
wake,
async prepareForIdleSleep({ canRetire }: { canRetire: () => boolean }): Promise<IdleLocalWork> {
if (disposed || !canRetire() || !isIdleTaskDrainActive() || (listenerFailure && !listenerClient)) return "unknown";
if (listenerUpdates || delivering || pending) return "present";
const ownerEpoch = epoch;
held = true;
const current = () => !disposed && held && isIdleTaskDrainActive() && canRetire() && epoch === ownerEpoch;
return updateListener(async () => {
if (!current()) return "unknown";
if (listenerUpdates !== 1 || delivering || pending) return "present";
try {
const before = await hasJournalWork(current);
if (before !== "none") return before;
if (!current() || (listenerFailure && !listenerClient)) return "unknown";
await closeListener();
if (!current()) return "unknown";
if (listenerFailure) return "unknown";
if (pending || delivering) return "present";
return await hasJournalWork(current);
} catch {
inspectionFailure = true;
return "unknown";
}
});
},
dispose(): Promise<void> {
if (disposePromise) return disposePromise;
disposed = true;
epoch++;
unsubscribeConfig();
unsubscribeDrain();
unsubscribeExpiry();
lifecycle.off("plugin.loaded", pluginChanged);
lifecycle.off("plugin.enabled", pluginChanged);
lifecycle.off("plugin.worker_started", pluginChanged);
lifecycle.off("plugin.status_changed", pluginChanged);
lifecycle.off("plugin.disabled", pluginChanged);
lifecycle.off("plugin.unloaded", pluginChanged);
disposePromise = (async () => {
const failures: unknown[] = [];
try { await delivery.stop(); } catch (error) { failures.push(error); }
try { await updateListener(closeListener); } catch (error) { failures.push(error); }
if (failures.length) {
listenerFailure = true;
throw failures.length === 1 ? failures[0] : new AggregateError(failures, "Plugin lifecycle delivery disposal failed");
}
unregisterIdle();
})().catch(error => { disposePromise = undefined; throw error; });
return disposePromise;
},
};
}