test(server): make the instance settings route suite deterministic under CPU contention (#12789)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Paperclip tests its server routes with mocked services and database
calls
> - The instance settings route suite reset and reloaded its module
graph before each test
> - Two concurrent module imports could bind a route to the real service
module under CPU contention
> - The task-drain overlap test also relied on a fixed delay and
operating system request order
> - This pull request loads the mocked graph once and waits for real
events that prove request order
> - The benefit is a deterministic 48-test suite with no production code
change

## Linked Issues or Issue Description

**What happened?**

The instance settings route suite failed intermittently under CPU
contention. A request that expected a 200 or 403 response sometimes
received 500. The failing test changed between runs.

**Expected behavior**

The suite must use the configured service mocks for every test and must
produce the expected response on every run.

**Steps to reproduce**

1. Run `npx vitest run
server/src/__tests__/instance-settings-routes.test.ts` many times in
parallel on a busy host.
2. Compare the result with the same command on the base branch.
3. Observe intermittent 500 responses on the base branch and stable
results on this branch.

**Paperclip version or commit**

Commit `02ae87010e621cf46bfbdf0d48b6f73887448a83`.

**Deployment mode**

Local dev (`pnpm dev`). The change affects tests only.

**Installation method**

Built from source.

**Agent adapter(s) involved**

Not adapter-specific (core bug).

**Database mode**

Not database-related. The test uses a mocked database layer.

**Access context**

Not applicable.

**Node.js version**

The CI environment runs the repository-supported Node.js version.

**Operating system**

Linux in continuous integration.

**Relevant logs or output**

The base branch reproduced `expected 500 to be 200` and `expected 500 to
be 403` under parallel contention.

**Relevant config (if applicable)**

Not applicable.

**Additional context**

The branch loads the mocked module graph once per suite, restores mock
behavior before each test, waits for the real transaction events, and
sends the DELETE request after the POST holds the transition queue.

## What Changed

- Load the mocked instance settings module graph once for the suite.
- Restore each mock implementation before every test.
- Wait for two real transaction events instead of a fixed 30 millisecond
delay.
- Send the overlapping DELETE request after the POST proves that it
holds the transition queue.
- Keep the test count at 48 with no skipped tests.

## Verification

- Run `npx vitest run
server/src/__tests__/instance-settings-routes.test.ts`.
- Confirm that all 48 tests pass.
- Run the 20-way parallel contention differential.
- Confirm that the base arm passed 18 of 20 runs and reproduced two
failures.
- Confirm that the branch arm passed 20 of 20 runs, with 48 tests in
each run.
- Confirm that `git status --porcelain` is clean at the submitted
commit.

## Risks

Low risk. The change affects one test file and does not change
production code, route behavior, database schema, or public API
behavior.

## Model Used

OpenAI Codex, GPT-5, current deployment, tool use and code execution
enabled.

## 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>
This commit is contained in:
Nicky Leach 2026-09-03 14:23:07 -07:00 committed by GitHub
parent b872cd3d1b
commit 66ea41812d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 78 additions and 33 deletions

View File

@ -1,6 +1,7 @@
import express from "express";
import request from "supertest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { hoistModuleGraph } from "./helpers/hoist-module-graph.js";
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(),
@ -40,41 +41,51 @@ function registerModuleMocks() {
// Identity object the mocked db.transaction hands to writers; tests assert
// both the marker clear and the settings update receive THIS same tx.
const TX_SENTINEL = { __tx: true };
// Runs the callback with a sentinel tx and propagates throws, so a failing
// write inside rejects the whole request exactly like a real transaction
// rollback. This is the default mockDb.transaction implementation; a test
// that installs its own mockImplementation loses this default, so
// beforeEach below reinstalls it before every test.
function defaultTransactionImplementation(fn: (tx: unknown) => Promise<unknown>) {
return fn(TX_SENTINEL);
}
// Module-scoped (not rebuilt per createApp call) so a test can assert how
// many times a request opened a transaction — the task-drain audit writes
// for every company must share ONE transaction, not one each.
const mockDb = {
// Runs the callback with a sentinel tx and propagates throws, so a
// failing write inside rejects the whole request exactly like a real
// transaction rollback.
transaction: vi.fn(async (fn: (tx: unknown) => Promise<unknown>) => fn(TX_SENTINEL)),
transaction: vi.fn(defaultTransactionImplementation),
};
async function createApp(actor: any) {
const [{ errorHandler }, { instanceSettingsRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/instance-settings.js")>("../routes/instance-settings.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = actor;
next();
});
app.use("/api", instanceSettingsRoutes(mockDb as any));
app.use(errorHandler);
return app;
}
describe("instance settings routes", () => {
const routeModules = hoistModuleGraph(registerModuleMocks, async () => {
const [{ errorHandler }, { instanceSettingsRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/instance-settings.js")>("../routes/instance-settings.js"),
]);
return { errorHandler, instanceSettingsRoutes };
});
function createApp(actor: any) {
const { errorHandler, instanceSettingsRoutes } = routeModules.value;
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = actor;
next();
});
app.use("/api", instanceSettingsRoutes(mockDb as any));
app.use(errorHandler);
return app;
}
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/instance-settings.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.clearAllMocks();
// vi.clearAllMocks() clears recorded calls only; it does not remove a
// mockImplementation a prior test installed. Reinstall the default here
// so a stateful implementation from one test can never leak into the
// next one.
mockDb.transaction.mockReset();
mockDb.transaction.mockImplementation(defaultTransactionImplementation);
mockInstanceSettingsService.get.mockReset();
mockInstanceSettingsService.getGeneral.mockReset();
mockInstanceSettingsService.getExperimental.mockReset();
@ -1151,10 +1162,19 @@ describe("instance settings routes", () => {
const transactionCalls: string[] = [];
let releasePostTransaction: (() => void) | undefined;
let sawFirstCall = false;
// Resolves the instant the first (blocked) transaction call starts.
// The test then waits for this real event, not a fixed duration.
// Under CPU contention the event loop can take far longer than any
// fixed budget to reach this call, so a timer would flake here.
let notifyFirstTransactionStarted: (() => void) | undefined;
const firstTransactionStarted = new Promise<void>((resolve) => {
notifyFirstTransactionStarted = resolve;
});
mockDb.transaction.mockImplementation((fn: (tx: unknown) => Promise<unknown>) => {
if (!sawFirstCall) {
sawFirstCall = true;
transactionCalls.push("post-start");
notifyFirstTransactionStarted?.();
return new Promise((resolve) => {
releasePostTransaction = () => {
transactionCalls.push("post-commit");
@ -1166,19 +1186,44 @@ describe("instance settings routes", () => {
return fn(TX_SENTINEL);
});
// Each route handler awaits listCompanyIds as its last step before it
// enters the task-drain transition queue, so a second call proves the
// DELETE passed authorization and reached the queue — not merely that
// it has not arrived yet.
let listCompanyIdsCallCount = 0;
let notifySecondListCompanyIdsCall: (() => void) | undefined;
const secondListCompanyIdsCall = new Promise<void>((resolve) => {
notifySecondListCompanyIdsCall = resolve;
});
mockInstanceSettingsService.listCompanyIds.mockImplementation(async () => {
listCompanyIdsCallCount += 1;
if (listCompanyIdsCallCount === 2) notifySecondListCompanyIdsCall?.();
return ["company-1", "company-2"];
});
const app = await createApp(adminActor);
// supertest only sends the request once something calls .then() on
// it, so kick both off eagerly instead of waiting for the final
// Promise.all below to do it — otherwise neither request would even
// reach the (still-pending) POST transaction during the wait.
// supertest only sends a request once something calls .then() on it,
// so force the POST to send now instead of waiting for the final
// Promise.all below to do it.
const postPromise = request(app).post("/api/instance/task-drain").send({});
postPromise.then(() => {}, () => {});
// Wait for the POST's transaction call to start before the test sends
// the DELETE. At that point the POST already called listCompanyIds,
// already entered the task-drain transition queue, and sits blocked
// inside the mocked db.transaction call — the POST holds the queue.
// Only a real event proves this; a fixed wait would not, because the
// event loop can take far longer than any fixed budget under CPU
// contention. Sending the DELETE only after this event fixes the
// request order by the queue, not by which socket the operating
// system happens to service first.
await firstTransactionStarted;
const deletePromise = request(app).delete("/api/instance/task-drain");
deletePromise.then(() => {}, () => {});
// Give both requests time to reach as far as they can go before the
// POST's transaction is released.
await new Promise((resolve) => setTimeout(resolve, 30));
// Wait for the DELETE's own listCompanyIds call. It proves the DELETE
// passed authorization and reached the transition queue behind the
// POST — not merely that it has not shown up yet.
await secondListCompanyIdsCall;
expect(transactionCalls).toEqual(["post-start"]);
expect(mockHeartbeatService.applyTaskDrain).not.toHaveBeenCalled();
expect(mockHeartbeatService.stopTaskDrain).not.toHaveBeenCalled();