Files
PaperClipAI/packages/db/src/test-embedded-postgres.test.ts
Nicky LeachandPaperclip 4813ed3f0c fix(db): harden embedded Postgres test start with bounded retry (#10540)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The database layer uses embedded Postgres for isolated test runs
> - A port probe can fail when another process takes the same port
before Postgres binds it
> - That race can make a test fail even when the code under test is fine
> - This pull request adds bounded retry and clearer error text to the
embedded Postgres start path
> - The benefit is more stable tests and faster diagnosis when startup
still fails

## Linked Issues or Issue Description

No public GitHub issue exists for this change.

This PR fixes a flaky embedded Postgres test start path.
The helper can lose a port between probe and bind.
This PR retries the start with a fresh port and a fresh data directory.

Related public context:
- Refs #7259
- Refs #9769

## What Changed

- Add bounded retry around embedded Postgres initialization and start.
- Stop each failed attempt and remove its data directory before the next
attempt.
- Capture Postgres output in the thrown error so the failure is easier
to read.
- Add unit coverage for retry success, retry exhaustion, and the
improved error text.

## Verification

- `pnpm --filter @paperclipai/db exec vitest run
src/test-embedded-postgres.test.ts src/embedded-postgres-error.test.ts`
- `pnpm --filter @paperclipai/db exec vitest run`
- `pnpm --filter @paperclipai/db exec vitest run` passed in the worktree
after the change.
- `worktree.test.ts > quarantines copied live execution state in seeded
worktree databases` passed.
- A real cluster loop of 100 starts passed with 0 failures.

## Risks

- The retry can hide a real startup fault until the fifth try.
- The bound keeps the wait short, and the final error still shows the
captured Postgres log.
- This change only affects the embedded Postgres test start helper.

## Model Used

OpenAI Codex, GPT-5, tool-use 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] 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-07-30 22:22:31 -07:00

114 lines
4.1 KiB
TypeScript

import fs from "node:fs";
import { afterEach, describe, expect, it } from "vitest";
import {
__embeddedPostgresStartMaxAttemptsForTests as MAX_ATTEMPTS,
__setEmbeddedPostgresCtorProviderForTests,
__startEmbeddedPostgresWithRetryForTests as startWithRetry,
} from "./test-embedded-postgres.js";
// A fake embedded-postgres constructor. It records every constructed instance so
// the test can assert the retry uses a fresh port and a fresh data directory each
// attempt. `start()` emits the same output the real cluster writes for a port
// conflict, then rejects with an empty message (the real rejection shape). The
// option type matches the real constructor so no type cast is needed.
type FakeOptions = {
databaseDir: string;
user: string;
password: string;
port: number;
persistent: boolean;
initdbFlags?: string[];
onLog?: (message: unknown) => void;
onError?: (message: unknown) => void;
};
const BIND_CONFLICT_LOG = 'could not bind IPv4 address "127.0.0.1": Address already in use';
function makeFakeCtor(failFirst: number) {
const constructed: FakeOptions[] = [];
let started = 0;
class FakeEmbeddedPostgres {
private readonly options: FakeOptions;
constructor(options: FakeOptions) {
this.options = options;
constructed.push(options);
}
async initialise(): Promise<void> {}
async start(): Promise<void> {
started += 1;
if (started <= failFirst) {
// Mirror the real failure: Postgres logs the reason, then `start()`
// rejects with an Error whose message is empty.
this.options.onLog?.(BIND_CONFLICT_LOG);
throw new Error();
}
}
async stop(): Promise<void> {}
}
return { ctor: FakeEmbeddedPostgres, constructed };
}
describe("startEmbeddedPostgresWithRetry", () => {
afterEach(() => {
__setEmbeddedPostgresCtorProviderForTests(null);
});
it("recovers from a transient port conflict and returns on a later attempt", async () => {
const { ctor, constructed } = makeFakeCtor(2);
__setEmbeddedPostgresCtorProviderForTests(async () => ctor);
const started = await startWithRetry("paperclip-retry-recover-");
// The first two attempts fail, the third succeeds.
expect(constructed).toHaveLength(3);
// Each attempt uses a fresh data directory. The two failed directories are
// removed. The returned directory still exists.
const dataDirs = constructed.map((options) => options.databaseDir);
expect(new Set(dataDirs).size).toBe(3);
expect(fs.existsSync(dataDirs[0])).toBe(false);
expect(fs.existsSync(dataDirs[1])).toBe(false);
expect(started.dataDir).toBe(dataDirs[2]);
expect(fs.existsSync(started.dataDir)).toBe(true);
// Each attempt allocates a port.
for (const options of constructed) {
expect(Number.isInteger(options.port)).toBe(true);
expect(options.port).toBeGreaterThan(0);
}
// Clean up the returned attempt.
await started.instance.stop();
fs.rmSync(started.dataDir, { recursive: true, force: true });
});
it("throws with the real Postgres output after the attempt bound", async () => {
const { ctor, constructed } = makeFakeCtor(Number.POSITIVE_INFINITY);
__setEmbeddedPostgresCtorProviderForTests(async () => ctor);
await expect(startWithRetry("paperclip-retry-fail-")).rejects.toThrow(/after \d+ attempts/);
// The retry stops at the bound and does not loop forever.
expect(constructed).toHaveLength(MAX_ATTEMPTS);
// Every failed attempt removes its data directory.
for (const options of constructed) {
expect(fs.existsSync(options.databaseDir)).toBe(false);
}
});
it("keeps the real failure reason instead of a generic fallback", async () => {
const { ctor } = makeFakeCtor(Number.POSITIVE_INFINITY);
__setEmbeddedPostgresCtorProviderForTests(async () => ctor);
const error = await startWithRetry("paperclip-retry-reason-").catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(Error);
// The thrown message carries the captured Postgres output, not only the
// generic "embedded Postgres startup failed" text.
expect((error as Error).message).toContain("Address already in use");
});
});