@@ -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