Skip to content

Commit 1ae9413

Browse files
committed
feat(replication): auto-resubscribe on a failed handoff (self-healing deploys)
Replication clients now optionally re-subscribe (with exponential backoff) after a lost leader election or a failed START_REPLICATION, instead of logging once and staying dead. This makes a rolling deploy self-heal: the incoming pod retries until the draining pod releases the slot, then takes over — no stop-before-start. Safety: - #cleanupAttempt() unconditionally ends the pg client (freeing the walsender) and releases the leader lock before rescheduling, so retries never leak connections/locks (the plain stop() early-returns while _isStopped is set). - shutdown() (used for intentional stop) sets an intentional-stop latch that is re-checked after every await in subscribe() and aborts #acquireLeaderLock's spin, so a resubscribe can never race or outlive a shutdown. - backoff resets only on genuine stream start (replicationStart), so a permanently stuck slot backs off to the ceiling and logs loudly rather than tight-looping; a subscribeEpoch neutralises stale START_REPLICATION catches. Runs- and sessions-replication opt in and use shutdown() for all intentional stops. Adds container tests for the leak, shutdown-race, reset, and self-heal.
1 parent abab6cd commit 1ae9413

4 files changed

Lines changed: 526 additions & 114 deletions

File tree

apps/webapp/app/services/runsReplicationService.server.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ export class RunsReplicationService {
286286
table: "TaskRun",
287287
redisOptions: options.redisOptions,
288288
autoAcknowledge: false,
289+
resubscribeOnFailure: true,
289290
publicationActions: ["insert", "update", "delete"],
290291
logger:
291292
options.logger ?? new Logger("LogicalReplicationClient", options.logLevel ?? "info"),
@@ -428,7 +429,9 @@ export class RunsReplicationService {
428429

429430
if (!hasCurrentTransaction) {
430431
this.logger.info("No transaction to commit, shutting down immediately");
431-
await Promise.all(Array.from(this._sources.values()).map((runtime) => runtime.client.stop()));
432+
await Promise.all(
433+
Array.from(this._sources.values()).map((runtime) => runtime.client.shutdown())
434+
);
432435
this._isShutDownComplete = true;
433436
return;
434437
}
@@ -458,7 +461,7 @@ export class RunsReplicationService {
458461
for (const runtime of this._sources.values()) {
459462
this.logger.info("Stopping replication client", { sourceId: runtime.source.id });
460463

461-
await runtime.client.stop();
464+
await runtime.client.shutdown();
462465

463466
if (runtime.acknowledgeInterval) {
464467
clearInterval(runtime.acknowledgeInterval);
@@ -636,7 +639,7 @@ export class RunsReplicationService {
636639
// swallow client.stop() rejections so they don't surface as unhandled.
637640
if (!this._shutdownStopInFlight) {
638641
this._shutdownStopInFlight = true;
639-
Promise.all(Array.from(this._sources.values()).map((r) => r.client.stop()))
642+
Promise.all(Array.from(this._sources.values()).map((r) => r.client.shutdown()))
640643
.catch((error) => {
641644
this.logger.error("Error stopping replication clients during shutdown", { error });
642645
})

apps/webapp/app/services/sessionsReplicationService.server.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ export class SessionsReplicationService {
187187
table: "Session",
188188
redisOptions: options.redisOptions,
189189
autoAcknowledge: false,
190+
resubscribeOnFailure: true,
190191
publicationActions: ["insert", "update", "delete"],
191192
logger: options.logger ?? new Logger("LogicalReplicationClient", options.logLevel ?? "info"),
192193
leaderLockTimeoutMs: options.leaderLockTimeoutMs ?? 30_000,
@@ -265,7 +266,7 @@ export class SessionsReplicationService {
265266

266267
if (!this._currentTransaction) {
267268
this.logger.info("No transaction to commit, shutting down immediately");
268-
await this._replicationClient.stop();
269+
await this._replicationClient.shutdown();
269270
this._isSubscribed = false;
270271
this._isShutDownComplete = true;
271272
return;
@@ -294,7 +295,7 @@ export class SessionsReplicationService {
294295
async stop() {
295296
this.logger.info("Stopping replication client");
296297

297-
await this._replicationClient.stop();
298+
await this._replicationClient.shutdown();
298299

299300
if (this._acknowledgeInterval) {
300301
clearInterval(this._acknowledgeInterval);
@@ -430,7 +431,7 @@ export class SessionsReplicationService {
430431
if (this._isShutDownComplete) return;
431432

432433
if (this._isShuttingDown) {
433-
this._replicationClient.stop().finally(() => {
434+
this._replicationClient.shutdown().finally(() => {
434435
this._isSubscribed = false;
435436
this._isShutDownComplete = true;
436437
});

internal-packages/replication/src/client.test.ts

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,4 +235,234 @@ describe("Replication Client", () => {
235235
await b.stop();
236236
}
237237
);
238+
239+
postgresAndRedisTest(
240+
"resubscribeOnFailure self-heals once the leader releases the slot",
241+
async ({ postgresContainer, prisma, redisOptions }) => {
242+
await prisma.$executeRawUnsafe(`ALTER TABLE public."TaskRun" REPLICA IDENTITY FULL;`);
243+
244+
const shared = {
245+
slotName: "resub_slot",
246+
publicationName: "resub_pub",
247+
redisOptions,
248+
table: "TaskRun",
249+
pgConfig: { connectionString: postgresContainer.getConnectionUri() },
250+
};
251+
252+
// Leader holds the slot.
253+
const a = new LogicalReplicationClient({ ...shared, name: "leader-a" });
254+
a.events.on("error", () => {});
255+
await a.subscribe();
256+
await setTimeout(1000);
257+
258+
// Contender with resubscribe on: loses the election while A holds the slot,
259+
// then must self-heal (win) once A releases it — the rolling-deploy handoff.
260+
const b = new LogicalReplicationClient({
261+
...shared,
262+
name: "contender-b",
263+
resubscribeOnFailure: true,
264+
resubscribeMinDelayMs: 200,
265+
resubscribeMaxDelayMs: 400,
266+
leaderLockTimeoutMs: 500,
267+
leaderLockAcquireAdditionalTimeMs: 100,
268+
leaderLockRetryIntervalMs: 100,
269+
});
270+
const bElections: boolean[] = [];
271+
b.events.on("leaderElection", (won) => bElections.push(won));
272+
b.events.on("error", () => {});
273+
await b.subscribe();
274+
await setTimeout(1500);
275+
276+
// Still contending, not leader, while A holds the slot.
277+
expect(bElections).toContain(false);
278+
expect(bElections).not.toContain(true);
279+
280+
// Release the leader — a scheduled resubscribe should now win.
281+
await a.shutdown();
282+
283+
let becameLeader = false;
284+
for (let i = 0; i < 40; i++) {
285+
if (bElections.includes(true)) {
286+
becameLeader = true;
287+
break;
288+
}
289+
await setTimeout(250);
290+
}
291+
expect(becameLeader).toBe(true);
292+
293+
await b.shutdown();
294+
}
295+
);
296+
297+
postgresAndRedisTest(
298+
"a failing START_REPLICATION retry loop must not leak connections or locks",
299+
async ({ postgresContainer, prisma, redisOptions }) => {
300+
await prisma.$executeRawUnsafe(`ALTER TABLE public."TaskRun" REPLICA IDENTITY FULL;`);
301+
302+
const shared = {
303+
slotName: "leak_slot",
304+
publicationName: "leak_pub",
305+
table: "TaskRun",
306+
pgConfig: { connectionString: postgresContainer.getConnectionUri() },
307+
};
308+
309+
const a = new LogicalReplicationClient({ ...shared, redisOptions, name: "leak-leader" });
310+
a.events.on("error", () => {});
311+
await a.subscribe();
312+
await setTimeout(1000);
313+
314+
// B elects on a separate lock namespace so every attempt reaches
315+
// START_REPLICATION and dies there ("slot is active") — the stuck-slot shape.
316+
const b = new LogicalReplicationClient({
317+
...shared,
318+
redisOptions: { ...redisOptions, keyPrefix: `${redisOptions.keyPrefix ?? ""}other:` },
319+
name: "leak-contender",
320+
resubscribeOnFailure: true,
321+
resubscribeMinDelayMs: 200,
322+
resubscribeMaxDelayMs: 400,
323+
leaderLockTimeoutMs: 1000,
324+
leaderLockAcquireAdditionalTimeMs: 300,
325+
leaderLockRetryIntervalMs: 100,
326+
});
327+
const bErrors: Array<unknown> = [];
328+
b.events.on("error", (error) => bErrors.push(error));
329+
await b.subscribe();
330+
331+
for (let i = 0; i < 80 && bErrors.length < 3; i++) {
332+
await setTimeout(250);
333+
}
334+
expect(bErrors.length).toBeGreaterThanOrEqual(3);
335+
336+
// Every failed attempt must end its pg client: at most the one in-flight
337+
// attempt's backend may exist, never an accrual across cycles.
338+
const backends = await prisma.$queryRaw<{ count: bigint }[]>`
339+
SELECT count(*) AS count FROM pg_stat_activity WHERE application_name = 'leak-contender'
340+
`;
341+
expect(Number(backends[0].count)).toBeLessThanOrEqual(1);
342+
343+
const active = await prisma.$queryRaw<{ count: bigint }[]>`
344+
SELECT count(*) AS count FROM pg_replication_slots WHERE slot_name = 'leak_slot' AND active
345+
`;
346+
expect(Number(active[0].count)).toBe(1);
347+
348+
await b.shutdown();
349+
await a.shutdown();
350+
}
351+
);
352+
353+
postgresAndRedisTest(
354+
"shutdown during an in-flight subscribe must not leave a zombie leader",
355+
async ({ postgresContainer, prisma, redisOptions }) => {
356+
await prisma.$executeRawUnsafe(`ALTER TABLE public."TaskRun" REPLICA IDENTITY FULL;`);
357+
358+
const shared = {
359+
slotName: "zombie_slot",
360+
publicationName: "zombie_pub",
361+
redisOptions,
362+
table: "TaskRun",
363+
pgConfig: { connectionString: postgresContainer.getConnectionUri() },
364+
};
365+
366+
const a = new LogicalReplicationClient({ ...shared, name: "zombie-leader" });
367+
a.events.on("error", () => {});
368+
await a.subscribe();
369+
await setTimeout(1000);
370+
371+
// B's election spins against A's held lock; shut it down mid-subscribe.
372+
const b = new LogicalReplicationClient({
373+
...shared,
374+
name: "zombie-contender",
375+
resubscribeOnFailure: true,
376+
leaderLockTimeoutMs: 5000,
377+
leaderLockAcquireAdditionalTimeMs: 5000,
378+
leaderLockRetryIntervalMs: 100,
379+
});
380+
const bElections: boolean[] = [];
381+
b.events.on("leaderElection", (won) => bElections.push(won));
382+
b.events.on("error", () => {});
383+
384+
const inflight = b.subscribe();
385+
await setTimeout(300);
386+
await b.shutdown();
387+
388+
// Release the real leader; a zombie B would now win the lock and the slot.
389+
await a.shutdown();
390+
await inflight.catch(() => {});
391+
await setTimeout(1500);
392+
393+
const zombieWon = bElections.includes(true);
394+
const active = await prisma.$queryRaw<{ count: bigint }[]>`
395+
SELECT count(*) AS count FROM pg_replication_slots WHERE slot_name = 'zombie_slot' AND active
396+
`;
397+
const backends = await prisma.$queryRaw<{ count: bigint }[]>`
398+
SELECT count(*) AS count FROM pg_stat_activity WHERE application_name = 'zombie-contender'
399+
`;
400+
// Reap a zombie (if any) so the test exits cleanly, then assert.
401+
await b.shutdown();
402+
403+
expect(zombieWon).toBe(false);
404+
expect(Number(active[0].count)).toBe(0);
405+
expect(Number(backends[0].count)).toBe(0);
406+
}
407+
);
408+
409+
postgresAndRedisTest(
410+
"subscribe after shutdown re-arms resubscribeOnFailure",
411+
async ({ postgresContainer, prisma, redisOptions }) => {
412+
await prisma.$executeRawUnsafe(`ALTER TABLE public."TaskRun" REPLICA IDENTITY FULL;`);
413+
414+
const shared = {
415+
slotName: "rearm_slot",
416+
publicationName: "rearm_pub",
417+
redisOptions,
418+
table: "TaskRun",
419+
pgConfig: { connectionString: postgresContainer.getConnectionUri() },
420+
};
421+
422+
const b = new LogicalReplicationClient({
423+
...shared,
424+
name: "rearm-client",
425+
resubscribeOnFailure: true,
426+
resubscribeMinDelayMs: 200,
427+
resubscribeMaxDelayMs: 400,
428+
leaderLockTimeoutMs: 500,
429+
leaderLockAcquireAdditionalTimeMs: 100,
430+
leaderLockRetryIntervalMs: 100,
431+
});
432+
const bElections: boolean[] = [];
433+
b.events.on("leaderElection", (won) => bElections.push(won));
434+
b.events.on("error", () => {});
435+
436+
// Admin stop -> start: shutdown latches the intentional stop...
437+
await b.subscribe();
438+
await setTimeout(500);
439+
await b.shutdown();
440+
441+
const a = new LogicalReplicationClient({ ...shared, name: "rearm-leader" });
442+
a.events.on("error", () => {});
443+
await a.subscribe();
444+
await setTimeout(1000);
445+
446+
// ...then an explicit re-subscribe loses the election and must self-heal
447+
// once the leader goes away (self-heal re-armed by the subscribe).
448+
bElections.length = 0;
449+
await b.subscribe();
450+
expect(bElections).toContain(false);
451+
expect(bElections).not.toContain(true);
452+
453+
await a.shutdown();
454+
455+
let becameLeader = false;
456+
for (let i = 0; i < 40; i++) {
457+
if (bElections.includes(true)) {
458+
becameLeader = true;
459+
break;
460+
}
461+
await setTimeout(250);
462+
}
463+
expect(becameLeader).toBe(true);
464+
465+
await b.shutdown();
466+
}
467+
);
238468
});

0 commit comments

Comments
 (0)