Skip to content

Commit fbffc99

Browse files
Andrei DudumanPaperclip-Paperclip
andcommitted
fix(server): authorize Postgres hot-restart adoption (FAI-8942)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
1 parent 154e1ed commit fbffc99

6 files changed

Lines changed: 467 additions & 10 deletions

File tree

doc/DEVELOPING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ systemctl restart paperclip.service
117117

118118
Use `--drain-required` only when the deploy intentionally requires the old terminate-and-retry behavior. Without that flag, the old server verifies that the marker targets its own PID, snapshots currently running heartbeat run IDs and child PIDs, and skips the shutdown drain so eligible detached local-agent processes can keep running. On startup the new server writes `$PAPERCLIP_HOME/hot-restart-report.json` with `previousServerPid`, `newServerPid`, `previousServerVersion`, `newServerVersion`, `adoptedRunIds`, `finalizedWhileDownRunIds`, `lostRunIds`, and per-run classifications before the normal orphan reaper runs.
119119

120-
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 replacement server adopts the same data directory and port, including responsibility for a later normal shutdown, before it reconciles the captured agent runs. Normal shutdowns stop either the originally started or adopted embedded database.
120+
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

122122
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.
123123

server/src/index.ts

Lines changed: 96 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@ import {
7878
createShutdownLifecycleContext,
7979
} from "./shutdown.js";
8080
import { flushInFlightRunLogMirrors } from "./services/run-log-store.js";
81+
import {
82+
claimEmbeddedPostgresHandoff,
83+
readEmbeddedPostgresProcessIdentity,
84+
readHotRestartIntent,
85+
writeEmbeddedPostgresHandoff,
86+
type EmbeddedPostgresProcessIdentity,
87+
} from "./services/hot-restart.js";
8188
import type {
8289
InstanceDatabaseBackupRunResult,
8390
InstanceDatabaseBackupTrigger,
@@ -123,6 +130,7 @@ export interface StartedServer {
123130
}
124131

125132
export async function startServer(): Promise<StartedServer> {
133+
const serverStartedAtEpochMs = Math.round(Date.now() - process.uptime() * 1_000);
126134
// Tracing must be active (or have failed and logged) before the first DB
127135
// connection or the HTTP server exists — see instrumentation.ts.
128136
await instrumentationReady;
@@ -328,6 +336,7 @@ export async function startServer(): Promise<StartedServer> {
328336
let pluginMigrationDb;
329337
let embeddedPostgres: EmbeddedPostgresInstance | null = null;
330338
let stopOwnedEmbeddedPostgres: (() => Promise<void>) | null = null;
339+
let ownedEmbeddedPostgresIdentity: EmbeddedPostgresProcessIdentity | null = null;
331340
let migrationSummary: MigrationSummary = "skipped";
332341
let activeDatabaseConnectionString: string;
333342
let resolvedEmbeddedPostgresPort: number | null = null;
@@ -415,8 +424,8 @@ export async function startServer(): Promise<StartedServer> {
415424
try {
416425
process.kill(pid, 0);
417426
return true;
418-
} catch {
419-
return false;
427+
} catch (error) {
428+
return (error as NodeJS.ErrnoException).code !== "ESRCH";
420429
}
421430
};
422431

@@ -435,9 +444,47 @@ export async function startServer(): Promise<StartedServer> {
435444

436445
const runningPid = getRunningPid();
437446
if (runningPid) {
438-
embeddedPostgres = createEmbeddedPostgres(port);
439-
stopOwnedEmbeddedPostgres = adoptEmbeddedPostgres(embeddedPostgres);
440-
logger.warn(`Embedded PostgreSQL already running; adopting existing process (pid=${runningPid}, port=${port})`);
447+
const runningIdentity = await readEmbeddedPostgresProcessIdentity(dataDir);
448+
if (runningIdentity?.pid === runningPid) {
449+
port = runningIdentity.port;
450+
let handoffClaim = null;
451+
try {
452+
const hotRestartIntent = await readHotRestartIntent();
453+
if (hotRestartIntent?.shutdownSnapshot) {
454+
handoffClaim = await claimEmbeddedPostgresHandoff({
455+
expectedHotRestartRequestedAt: hotRestartIntent.requestedAt,
456+
expectedShutdownSnapshotCapturedAt: hotRestartIntent.shutdownSnapshot.capturedAt,
457+
expectedPredecessorServerPid: hotRestartIntent.previousServerPid,
458+
expectedPostgres: runningIdentity,
459+
});
460+
}
461+
} catch (err) {
462+
logger.warn({ err }, "Embedded PostgreSQL handoff validation failed; continuing without stop authority");
463+
}
464+
465+
if (handoffClaim) {
466+
embeddedPostgres = createEmbeddedPostgres(port);
467+
stopOwnedEmbeddedPostgres = adoptEmbeddedPostgres(embeddedPostgres, handoffClaim);
468+
ownedEmbeddedPostgresIdentity = runningIdentity;
469+
logger.warn({
470+
postgresPid: runningIdentity.pid,
471+
port,
472+
predecessorServerPid: handoffClaim.predecessorServerPid,
473+
replacementServerPid: handoffClaim.replacementServerPid,
474+
transferToken: handoffClaim.transferToken,
475+
}, "Embedded PostgreSQL hot-restart ownership handoff claimed");
476+
} else {
477+
logger.warn(
478+
{ postgresPid: runningIdentity.pid, port },
479+
"Embedded PostgreSQL already running; reusing without lifecycle ownership",
480+
);
481+
}
482+
} else {
483+
logger.warn(
484+
{ postgresPid: runningPid, port },
485+
"Embedded PostgreSQL already running but its process identity is incomplete; reusing without lifecycle ownership",
486+
);
487+
}
441488
} else {
442489
const configuredAdminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${configuredPort}/postgres`;
443490
try {
@@ -489,6 +536,7 @@ export async function startServer(): Promise<StartedServer> {
489536
});
490537
}
491538
stopOwnedEmbeddedPostgres = () => embeddedPostgres!.stop();
539+
ownedEmbeddedPostgresIdentity = await readEmbeddedPostgresProcessIdentity(dataDir);
492540
}
493541
}
494542

@@ -892,7 +940,12 @@ export async function startServer(): Promise<StartedServer> {
892940
}
893941

894942
let drainHeartbeatRunsForShutdown: ((signal: "SIGINT" | "SIGTERM") => Promise<unknown>) | null = null;
895-
let prepareHotRestartShutdown: ((signal: "SIGINT" | "SIGTERM") => Promise<{ skipDrain: boolean }>) | null = null;
943+
let prepareHotRestartShutdown: ((signal: "SIGINT" | "SIGTERM") => Promise<{
944+
skipDrain: boolean;
945+
previousServerPid?: number;
946+
requestedAt?: string;
947+
shutdownSnapshotCapturedAt?: string;
948+
}>) | null = null;
896949
let heartbeatSchedulerStopped = false;
897950
let heartbeatSchedulerInterval: ReturnType<typeof setInterval> | null = null;
898951
const heartbeatSchedulerInFlight = new Set<Promise<void>>();
@@ -1361,6 +1414,43 @@ export async function startServer(): Promise<StartedServer> {
13611414

13621415
if (stopOwnedEmbeddedPostgres) {
13631416
try {
1417+
if (
1418+
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+
}
13641454
const postgresShutdown = await coordinateEmbeddedPostgresShutdown({
13651455
ownedByThisProcess: true,
13661456
stop: stopOwnedEmbeddedPostgres,

server/src/services/heartbeat.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9585,7 +9585,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
95859585
previousServerVersion: intent.previousServerVersion ?? serverVersion,
95869586
};
95879587

9588-
await writeHotRestartShutdownSnapshot({
9588+
const preparedIntent = await writeHotRestartShutdownSnapshot({
95899589
intent: intentWithVersion,
95909590
signal,
95919591
activeRuns: snapshotRuns,
@@ -9617,6 +9617,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
96179617
mode: "hot_restart" as const,
96189618
skipDrain: true as const,
96199619
activeRunIds: snapshotRuns.map((run) => run.runId),
9620+
previousServerPid: intent.previousServerPid,
9621+
requestedAt: intent.requestedAt,
9622+
shutdownSnapshotCapturedAt: preparedIntent.shutdownSnapshot!.capturedAt,
96209623
};
96219624
}
96229625

0 commit comments

Comments
 (0)