Skip to content

Commit 1fd14f1

Browse files
Andrei DudumanPaperclip-Paperclip
andcommitted
fix(server): fail closed on Postgres handoff errors (FAI-8942)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
1 parent fbffc99 commit 1fd14f1

5 files changed

Lines changed: 101 additions & 37 deletions

File tree

doc/DEVELOPING.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ Use `--drain-required` only when the deploy intentionally requires the old termi
119119

120120
When the server owns embedded PostgreSQL, a validated hot restart leaves that database process running and opts out of the dependency's package-global signal hook. The predecessor issues a short-lived, one-time handoff bound to the validated restart intent and the PostgreSQL PID, start time, canonical data directory, and port. Exactly one replacement process can claim that handoff after the predecessor exits; an unrelated or mismatched server may reuse the live database but does not acquire authority to stop it. Normal shutdowns stop either the originally started or validly adopted embedded database.
121121

122+
Handoff persistence fails closed. If the handoff cannot be written or the PostgreSQL identity changes before it is written, the predecessor stops its owned database instead of exiting and leaving it ownerless. If that stop also fails, the predecessor aborts its process exit so it retains ownership for operator recovery.
123+
122124
A healthy guarded deploy must compare the report against `/api/health` (`version` or `serverVersion`) and treat any `lostRunIds` entry as a continuity failure that needs recovery before marking deployment complete.
123125

124126
Tailscale/private-auth dev mode:

server/src/__tests__/heartbeat-process-recovery.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1456,6 +1456,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
14561456
mode: "hot_restart",
14571457
skipDrain: true,
14581458
activeRunIds: [runId],
1459+
previousServerPid: process.pid,
1460+
requestedAt: "2026-03-19T00:05:00.000Z",
1461+
shutdownSnapshotCapturedAt: "2026-03-19T00:06:00.000Z",
14591462
});
14601463
expect(isPidAlive(child.pid)).toBe(true);
14611464
const run = await db

server/src/index.ts

Lines changed: 57 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,49 +1414,63 @@ export async function startServer(): Promise<StartedServer> {
14141414

14151415
if (stopOwnedEmbeddedPostgres) {
14161416
try {
1417-
if (
1417+
const hotRestart = heartbeatShutdown.hotRestart;
1418+
const hotRestartHandoffContext = (
14181419
shutdownLifecycle.preserveEmbeddedPostgres
1419-
&& heartbeatShutdown.hotRestart?.previousServerPid === process.pid
1420-
&& heartbeatShutdown.hotRestart.requestedAt
1421-
&& heartbeatShutdown.hotRestart.shutdownSnapshotCapturedAt
1422-
) {
1423-
const currentIdentity = await readEmbeddedPostgresProcessIdentity(
1424-
ownedEmbeddedPostgresIdentity?.dataDir ?? config.embeddedPostgresDataDir,
1425-
);
1426-
if (
1427-
currentIdentity
1428-
&& ownedEmbeddedPostgresIdentity
1429-
&& currentIdentity.pid === ownedEmbeddedPostgresIdentity.pid
1430-
&& currentIdentity.startedAtEpochSeconds === ownedEmbeddedPostgresIdentity.startedAtEpochSeconds
1431-
&& currentIdentity.port === ownedEmbeddedPostgresIdentity.port
1432-
&& resolve(currentIdentity.dataDir) === resolve(ownedEmbeddedPostgresIdentity.dataDir)
1433-
) {
1434-
const handoff = await writeEmbeddedPostgresHandoff({
1435-
hotRestartRequestedAt: heartbeatShutdown.hotRestart.requestedAt,
1436-
shutdownSnapshotCapturedAt: heartbeatShutdown.hotRestart.shutdownSnapshotCapturedAt,
1437-
predecessorServerPid: process.pid,
1438-
predecessorServerStartedAtEpochMs: serverStartedAtEpochMs,
1439-
postgres: currentIdentity,
1440-
});
1441-
logger.info({
1442-
postgresPid: currentIdentity.pid,
1443-
port: currentIdentity.port,
1444-
transferToken: handoff.transferToken,
1445-
expiresAt: handoff.expiresAt,
1446-
}, "Embedded PostgreSQL hot-restart ownership handoff issued");
1447-
} else {
1448-
logger.warn(
1449-
{ expectedPostgresPid: ownedEmbeddedPostgresIdentity?.pid ?? null },
1450-
"Embedded PostgreSQL identity changed; preserving without issuing stop authority",
1451-
);
1452-
}
1453-
}
1420+
&& hotRestart?.previousServerPid === process.pid
1421+
&& hotRestart.requestedAt
1422+
&& hotRestart.shutdownSnapshotCapturedAt
1423+
)
1424+
? {
1425+
requestedAt: hotRestart.requestedAt,
1426+
shutdownSnapshotCapturedAt: hotRestart.shutdownSnapshotCapturedAt,
1427+
}
1428+
: null;
1429+
const persistedHandoff: {
1430+
value: Awaited<ReturnType<typeof writeEmbeddedPostgresHandoff>> | null;
1431+
} = { value: null };
14541432
const postgresShutdown = await coordinateEmbeddedPostgresShutdown({
14551433
ownedByThisProcess: true,
14561434
stop: stopOwnedEmbeddedPostgres,
14571435
lifecycle: shutdownLifecycle,
1436+
persistHotRestartHandoff: hotRestartHandoffContext
1437+
? async () => {
1438+
const currentIdentity = await readEmbeddedPostgresProcessIdentity(
1439+
ownedEmbeddedPostgresIdentity?.dataDir ?? config.embeddedPostgresDataDir,
1440+
);
1441+
if (
1442+
!currentIdentity
1443+
|| !ownedEmbeddedPostgresIdentity
1444+
|| currentIdentity.pid !== ownedEmbeddedPostgresIdentity.pid
1445+
|| currentIdentity.startedAtEpochSeconds !== ownedEmbeddedPostgresIdentity.startedAtEpochSeconds
1446+
|| currentIdentity.port !== ownedEmbeddedPostgresIdentity.port
1447+
|| resolve(currentIdentity.dataDir) !== resolve(ownedEmbeddedPostgresIdentity.dataDir)
1448+
) {
1449+
throw new Error("Embedded PostgreSQL identity changed before hot-restart handoff persistence");
1450+
}
1451+
persistedHandoff.value = await writeEmbeddedPostgresHandoff({
1452+
hotRestartRequestedAt: hotRestartHandoffContext.requestedAt,
1453+
shutdownSnapshotCapturedAt: hotRestartHandoffContext.shutdownSnapshotCapturedAt,
1454+
predecessorServerPid: process.pid,
1455+
predecessorServerStartedAtEpochMs: serverStartedAtEpochMs,
1456+
postgres: currentIdentity,
1457+
});
1458+
}
1459+
: undefined,
1460+
onHotRestartHandoffFailure: (err) => {
1461+
logger.error(
1462+
{ err, expectedPostgresPid: ownedEmbeddedPostgresIdentity?.pid ?? null },
1463+
"Embedded PostgreSQL hot-restart handoff failed; stopping owned database",
1464+
);
1465+
},
14581466
});
14591467
if (postgresShutdown === "preserved_for_hot_restart") {
1468+
logger.info({
1469+
postgresPid: persistedHandoff.value?.postgres.pid,
1470+
port: persistedHandoff.value?.postgres.port,
1471+
transferToken: persistedHandoff.value?.transferToken,
1472+
expiresAt: persistedHandoff.value?.expiresAt,
1473+
}, "Embedded PostgreSQL hot-restart ownership handoff issued");
14601474
logger.info(
14611475
{ shutdownLifecycle },
14621476
"Preserving embedded PostgreSQL for hot-restart adoption",
@@ -1466,6 +1480,13 @@ export async function startServer(): Promise<StartedServer> {
14661480
}
14671481
} catch (err) {
14681482
logger.error({ err, shutdownLifecycle }, "Failed to stop embedded PostgreSQL cleanly");
1483+
if (shutdownLifecycle.preserveEmbeddedPostgres) {
1484+
logger.error(
1485+
{ shutdownLifecycle },
1486+
"Aborting hot-restart process exit to retain embedded PostgreSQL ownership",
1487+
);
1488+
return;
1489+
}
14691490
}
14701491
}
14711492

server/src/shutdown.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ describe("coordinateEmbeddedPostgresShutdown", () => {
9090
ownedByThisProcess: true,
9191
stop,
9292
lifecycle,
93+
persistHotRestartHandoff: vi.fn(async () => undefined),
9394
});
9495

9596
expect(result).toBe("preserved_for_hot_restart");
@@ -171,6 +172,29 @@ describe("coordinateEmbeddedPostgresShutdown", () => {
171172
});
172173
});
173174

175+
it("stops the owned database when hot-restart handoff persistence fails", async () => {
176+
const writeError = new Error("forced handoff write failure");
177+
const stop = vi.fn(async () => undefined);
178+
const onHotRestartHandoffFailure = vi.fn();
179+
180+
const result = await coordinateEmbeddedPostgresShutdown({
181+
ownedByThisProcess: true,
182+
stop,
183+
lifecycle: createShutdownLifecycleContext({
184+
signal: "SIGTERM",
185+
hotRestart: { skipDrain: true },
186+
}),
187+
persistHotRestartHandoff: vi.fn(async () => {
188+
throw writeError;
189+
}),
190+
onHotRestartHandoffFailure,
191+
});
192+
193+
expect(result).toBe("stopped");
194+
expect(stop).toHaveBeenCalledOnce();
195+
expect(onHotRestartHandoffFailure).toHaveBeenCalledWith(writeError);
196+
});
197+
174198
it("reads the PostgreSQL PID, start time, canonical data directory, and port as one identity", async () => {
175199
const homeDir = await fs.mkdtemp(resolve(os.tmpdir(), "paperclip-postgres-identity-"));
176200
const dataDir = resolve(homeDir, "postgres");
@@ -251,6 +275,7 @@ describe("coordinateEmbeddedPostgresShutdown", () => {
251275
signal: "SIGTERM",
252276
hotRestart: { skipDrain: true },
253277
}),
278+
persistHotRestartHandoff: vi.fn(async () => undefined),
254279
});
255280

256281
const stopAdoptedDatabase = adoptEmbeddedPostgres(replacement, {

server/src/shutdown.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,22 @@ export async function coordinateEmbeddedPostgresShutdown(input: {
4646
ownedByThisProcess: boolean;
4747
stop: (() => Promise<void>) | null;
4848
lifecycle: ShutdownLifecycleContext;
49+
persistHotRestartHandoff?: () => Promise<void>;
50+
onHotRestartHandoffFailure?: (error: unknown) => void;
4951
}): Promise<"not_owned" | "preserved_for_hot_restart" | "stopped"> {
5052
if (!input.ownedByThisProcess || !input.stop) return "not_owned";
51-
if (input.lifecycle.preserveEmbeddedPostgres) return "preserved_for_hot_restart";
53+
if (input.lifecycle.preserveEmbeddedPostgres && input.persistHotRestartHandoff) {
54+
try {
55+
await input.persistHotRestartHandoff();
56+
return "preserved_for_hot_restart";
57+
} catch (error) {
58+
try {
59+
input.onHotRestartHandoffFailure?.(error);
60+
} catch {
61+
// Diagnostic callbacks must never bypass the failure-closed stop below.
62+
}
63+
}
64+
}
5265
await input.stop();
5366
return "stopped";
5467
}

0 commit comments

Comments
 (0)