fix(gateway): preserve restart hooks across coalescing

This commit is contained in:
Ayaan Zaidi
2026-04-23 07:53:14 +05:30
parent 46ce666b04
commit ab32c53103
2 changed files with 71 additions and 2 deletions

View File

@@ -116,6 +116,63 @@ describe("infra runtime", () => {
}
});
it("uses the latest preparation hook when scheduled restarts coalesce", async () => {
const firstBeforeEmit = vi.fn(async () => {});
const latestBeforeEmit = vi.fn(async () => {});
const emitSpy = vi.spyOn(process, "emit");
const handler = () => {};
process.on("SIGUSR1", handler);
try {
const first = scheduleGatewaySigusr1Restart({
delayMs: 1_000,
reason: "first",
emitHooks: { beforeEmit: firstBeforeEmit },
});
const second = scheduleGatewaySigusr1Restart({
delayMs: 1_000,
reason: "second",
emitHooks: { beforeEmit: latestBeforeEmit },
});
expect(first.coalesced).toBe(false);
expect(second.coalesced).toBe(true);
await vi.advanceTimersByTimeAsync(1_000);
expect(firstBeforeEmit).not.toHaveBeenCalled();
expect(latestBeforeEmit).toHaveBeenCalledTimes(1);
expect(emitSpy).toHaveBeenCalledWith("SIGUSR1");
} finally {
process.removeListener("SIGUSR1", handler);
}
});
it("keeps existing preparation hook when a hookless restart coalesces", async () => {
const beforeEmit = vi.fn(async () => {});
const emitSpy = vi.spyOn(process, "emit");
const handler = () => {};
process.on("SIGUSR1", handler);
try {
scheduleGatewaySigusr1Restart({
delayMs: 1_000,
emitHooks: { beforeEmit },
});
const second = scheduleGatewaySigusr1Restart({
delayMs: 1_000,
reason: "hookless",
});
expect(second.coalesced).toBe(true);
await vi.advanceTimersByTimeAsync(1_000);
expect(beforeEmit).toHaveBeenCalledTimes(1);
expect(emitSpy).toHaveBeenCalledWith("SIGUSR1");
} finally {
process.removeListener("SIGUSR1", handler);
}
});
it("rolls back prepared restart state when emission is rejected", async () => {
const beforeEmit = vi.fn(async () => {});
const afterEmitRejected = vi.fn(async () => {});

View File

@@ -36,6 +36,7 @@ let lastRestartEmittedAt = 0;
let pendingRestartTimer: ReturnType<typeof setTimeout> | null = null;
let pendingRestartDueAt = 0;
let pendingRestartReason: string | undefined;
let pendingRestartEmitHooks: RestartEmitHooks | undefined;
const activeDeferralPolls = new Set<ReturnType<typeof setInterval>>();
function hasUnconsumedRestartSignal(): boolean {
@@ -49,6 +50,7 @@ function clearPendingScheduledRestart(): void {
pendingRestartTimer = null;
pendingRestartDueAt = 0;
pendingRestartReason = undefined;
pendingRestartEmitHooks = undefined;
}
function clearActiveDeferralPolls(): void {
@@ -202,6 +204,12 @@ export type RestartEmitHooks = {
afterEmitRejected?: () => Promise<void>;
};
function updatePendingRestartEmitHooks(hooks?: RestartEmitHooks): void {
if (hooks) {
pendingRestartEmitHooks = hooks;
}
}
async function emitPreparedGatewayRestart(hooks?: RestartEmitHooks): Promise<void> {
try {
await hooks?.beforeEmit?.();
@@ -482,6 +490,7 @@ export function scheduleGatewaySigusr1Restart(opts?: {
restartLog.warn(
`restart request coalesced (already scheduled) reason=${reason ?? "unspecified"} pendingReason=${pendingRestartReason ?? "unspecified"} delayMs=${remainingMs} ${formatRestartAudit(opts?.audit)}`,
);
updatePendingRestartEmitHooks(opts?.emitHooks);
return {
ok: true,
pid: process.pid,
@@ -497,21 +506,24 @@ export function scheduleGatewaySigusr1Restart(opts?: {
pendingRestartDueAt = requestedDueAt;
pendingRestartReason = reason;
pendingRestartEmitHooks = opts?.emitHooks;
pendingRestartTimer = setTimeout(
() => {
pendingRestartTimer = null;
pendingRestartDueAt = 0;
pendingRestartReason = undefined;
const emitHooks = pendingRestartEmitHooks;
pendingRestartEmitHooks = undefined;
const pendingCheck = preRestartCheck;
if (!pendingCheck) {
void emitPreparedGatewayRestart(opts?.emitHooks);
void emitPreparedGatewayRestart(emitHooks);
return;
}
const cfg = getRuntimeConfig();
deferGatewayRestartUntilIdle({
getPendingCount: pendingCheck,
maxWaitMs: cfg.gateway?.reload?.deferralTimeoutMs,
emitHooks: opts?.emitHooks,
emitHooks,
});
},
Math.max(0, requestedDueAt - nowMs),