Files
PaperClipAI/server/src/__tests__/cloud-warm-standby.test.ts
T
DottaandPaperclip ce024457f3 perf(server): wake email work from commits and deadlines (#15750)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Email connects agent tasks to durable incoming and outgoing
messages.
> - The email worker scans the database every second, even with no
inboxes.
> - These queries prevent idle database connections from closing.
> - This pull request wakes email work from committed writes and known
deadlines.
> - Empty email installations stop polling. Existing delivery and
catch-up guarantees remain.

## Linked Issues or Issue Description

Refs #15625. Related standby work: #15314.

**What existing behavior does this improve?**

The AgentMail background worker and its database traffic when idle.

**Current behavior**

Every instance queries active email endpoints each second. Active
inboxes also scan delivery queues each second even when no work is due.

**Proposed behavior**

Scan once at startup. Wake after endpoint and queue transactions settle.
Restore the next retry or catch-up deadline from existing rows. An
instance with no active inboxes has no recurring email queries or timer.

**Reason and benefit**

Thousands of empty instances should not keep their databases awake to
discover that email is still unconfigured.

## What Changed

- Use the shared transaction-aware coordinator for email work, including
changes made by separate service objects on the same database owner.
- Signal endpoint activation and control changes, incoming events, and
outgoing sends inside their transactions.
- Add an optional deadline to the coordinator. Email uses persisted
retry times, mailbox catch-up times, and socket reconnect times. Only
the first pending send in each conversation determines its send
deadline.
- Preserve startup recovery, lease fences, idempotency, standby
suppression, idle drain, and shutdown.
- Update the sleep inventory and add quiet-idle, commit, activation,
retry, and lifecycle tests.

## Verification

- Focused coordinator, email idle, and warm standby tests: 20 passed
locally.
- Database-backed email integration suite: 41 passed locally with real
PostgreSQL and a simulated AgentMail provider.
- After review, the updated delayed-reply regression passed separately:
1 selected test passed, 40 intentionally filtered out. It verifies no
scans before the first reply deadline and delivery of both replies in
order.
- `pnpm -r typecheck` and `pnpm build` passed locally.
- The local `pnpm test:run` was interrupted after its checkout was
removed externally. Completed local checks above are preserved. The
[final-commit hosted CI
matrix](https://github.com/paperclipai/paperclip/actions/runs/37997072212)
passed: 54 successful checks, 2 expected Storybook skips, no failures.
It includes the full general and serialized test matrix, runner checks,
browser tests, build, typecheck, and canary packaging.
- Greptile: 5/5 on `ec98f3b86ee476d75e0edf3366b357377ea09d69`, with no
actionable findings.

## Risks

- New email producers must register work inside their write transaction.
No new tables or migrations are required.
- Signals are local to one database owner and its dedicated pools.
Independent processes and direct SQL do not emit them. Startup recovers
existing work; configured inboxes also retain their recovery deadline.
- Active inboxes still catch up each minute. WebSocket mode still renews
its lease every 20 seconds. This change does not provide external waking
or make active sockets sleep.
- Provider calls were simulated in tests. No live email was sent.

## Model Used

OpenAI Codex, GPT-6, with reasoning, repository inspection, code
execution, and test tools. The exact deployment model ID and
context-window size are not exposed in this session.

## 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-09 17:19:41 -05:00

151 lines
6.7 KiB
TypeScript

import { createServer, request as httpRequest } from "node:http";
import express from "express";
import request from "supertest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Db } from "@paperclipai/db";
import { installDatabaseWorkSignals } from "../../../packages/db/src/work-signals.js";
import { cloudWarmStandbyMiddleware, cloudWarmStandbyServerOptions } from "../middleware/cloud-warm-standby.js";
import { healthRoutes } from "../routes/health.js";
import { emailChannelService } from "../services/email-channels.js";
import { createPluginJobScheduler } from "../services/plugin-job-scheduler.js";
import { createPublicMcpEvents } from "../services/public-mcp/events.js";
import type { PublicMcpOAuth } from "../services/public-mcp/oauth.js";
const healthOptions = {
deploymentMode: "authenticated" as const,
deploymentExposure: "public" as const,
authReady: true,
companyDeletionEnabled: false,
runtimeEnv: { PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: "test-token" },
};
afterEach(() => vi.useRealTimers());
describe("unclaimed Cloud background work", () => {
it("keeps probes and tenant requests out of SQL and auth until claim", async () => {
let standby = true;
const execute = vi.fn().mockResolvedValue([]);
const db = { execute } as unknown as Db;
const health = healthRoutes(db, { ...healthOptions, isWarmStandby: () => standby });
const app = express();
const auth = vi.fn((_req, _res, next) => next());
const ui = express.Router();
ui.get("/", (_req, res) => res.type("html").send("ready"));
ui.get("/assets/app.js", (_req, res) => res.type("js").send("ready"));
app.use(cloudWarmStandbyMiddleware(() => standby, health, ui));
app.use(auth);
app.use("/api/health", health);
app.get("/api/companies", (_req, res) => res.json([]));
for (let i = 0; i < 3; i++) {
const probe = await request(app).get("/api/health").set("authorization", "Bearer ignored-in-standby");
expect(probe.status).toBe(200);
expect(probe.body.warmStandby).toBe(true);
expect(probe.body.serverInfo).toBeUndefined();
}
expect((await request(app).head("/api/health")).status).toBe(200);
expect((await request(app).get("/api/companies")).status).toBe(503);
expect((await request(app).post("/api/health/dev-server/restart")).status).toBe(503);
for (const path of ["/", "/assets/app.js"]) {
const page = await request(app).get(path)
.set("authorization", "Bearer test-bearer")
.set("x-paperclip-cloud-tenant-token", "test-tenant")
.set("cookie", "paperclip.session_token=test-session");
expect(page.status).toBe(200);
}
expect((await request(app).post("/mcp/gateways/gw_test")).status).toBe(503);
expect((await request(app).get("/unknown-dynamic-handler")).status).toBe(503);
expect(auth).not.toHaveBeenCalled();
expect(execute).not.toHaveBeenCalled();
standby = false;
expect((await request(app).get("/api/companies")).status).toBe(200);
const claimed = await request(app).get("/api/health");
expect(claimed.status).toBe(200);
expect(claimed.body.warmStandby).toBeUndefined();
expect(execute).toHaveBeenCalledOnce();
execute.mockRejectedValueOnce(new Error("offline"));
expect((await request(app).get("/api/health")).status).toBe(503);
});
it("rejects every WebSocket upgrade before auth and admits upgrades after claim", async () => {
let standby = true;
const app = express();
const health = healthRoutes({} as Db, { ...healthOptions, isWarmStandby: () => standby });
const authenticate = vi.fn();
app.use(cloudWarmStandbyMiddleware(() => standby, health));
const server = createServer(cloudWarmStandbyServerOptions(() => standby), app);
server.on("upgrade", (_req, socket) => {
authenticate();
socket.end("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n");
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as { port: number };
const upgrade = (path: string) => new Promise<number>((resolve, reject) => {
const req = httpRequest({
host: "127.0.0.1", port: address.port, path,
headers: { connection: "Upgrade", upgrade: "websocket", authorization: "Bearer test-bearer" },
});
req.on("response", (res) => { res.resume(); resolve(res.statusCode!); });
req.on("upgrade", (res, socket) => { socket.destroy(); resolve(res.statusCode!); });
req.on("error", reject);
req.end();
});
try {
for (const path of ["/api/companies/company/events/ws?token=anything", "/api/health", "/prp", "/"]) {
expect(await upgrade(path)).toBe(503);
}
expect(authenticate).not.toHaveBeenCalled();
standby = false;
expect(await upgrade("/api/companies/company/events/ws?token=anything")).toBe(101);
expect(authenticate).toHaveBeenCalledOnce();
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
});
it("MCP event timers do not read the persisted setting until claim", async () => {
vi.useFakeTimers();
let standby = true;
const isEnabled = vi.fn().mockResolvedValue(false);
const events = createPublicMcpEvents({} as Db, { isEnabled } as PublicMcpOAuth, vi.fn(), {
isBackgroundWorkEnabled: () => !standby,
});
try {
events.start();
await vi.advanceTimersByTimeAsync(10 * 60_000);
await events.tick();
expect(isEnabled).not.toHaveBeenCalled();
standby = false;
await vi.advanceTimersByTimeAsync(2000);
expect(isEnabled).toHaveBeenCalledOnce();
} finally {
await events.stop();
}
});
it("email and plugin timers leave SQL idle, then resume without restarting", async () => {
vi.useFakeTimers();
let enabled = false;
const select = vi.fn(() => { throw new Error("SQL probe"); });
const db = installDatabaseWorkSignals({ select, execute: vi.fn(), transaction: vi.fn() }) as unknown as Db;
const email = emailChannelService(db, { heartbeat: { wakeup: vi.fn() }, isBackgroundWorkEnabled: () => enabled });
const scheduler = createPluginJobScheduler({
db,
jobStore: {} as never,
workerManager: {} as never,
tickIntervalMs: 1000,
isBackgroundWorkEnabled: () => enabled,
});
try {
email.start();
scheduler.start();
await vi.advanceTimersByTimeAsync(10 * 60_000);
expect(select).not.toHaveBeenCalled();
enabled = true;
await vi.advanceTimersByTimeAsync(1000);
expect(select.mock.calls.length).toBeGreaterThanOrEqual(2);
} finally {
scheduler.stop();
await email.shutdown();
}
});
});