🔗 fix: Preserve Stream State Across Reconnects to Prevent Reorder Buffer Desync (#11842)

When all subscribers left a stream, both RedisEventTransport and
  InMemoryEventTransport deleted the entire stream state, destroying
  the allSubscribersLeftCallbacks and abortCallbacks registered by
  GenerationJobManager.createJob(). On the next subscribe/unsubscribe
  cycle, the callback that resets hasSubscriber was gone, causing
  syncReorderBuffer to be skipped on subsequent reconnects. This led
  to the reorder buffer expecting seq 0 while the publisher was at
  seq 300+, triggering a 500ms force-flush timeout and "skipping N
  missing messages" warnings.

  Fix: preserve stream state (callbacks, abort handlers) when the last
  subscriber leaves instead of deleting it. State is fully cleaned up
  by cleanup() when the job completes, aborts, or is collected by
  periodic orphan cleanup.
This commit is contained in:
Danny Avila 2026-02-18 01:57:34 -05:00 committed by GitHub
parent 5824298125
commit 252a5cc7ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 463 additions and 4 deletions

View file

@ -58,9 +58,11 @@ export class InMemoryEventTransport implements IEventTransport {
// Check if all subscribers left - cleanup and notify
if (currentState.emitter.listenerCount('chunk') === 0) {
currentState.allSubscribersLeftCallback?.();
// Auto-cleanup the stream entry when no subscribers remain
/* Remove all EventEmitter listeners but preserve stream state
* (including allSubscribersLeftCallback) for reconnection.
* State is fully cleaned up by cleanup() when the job completes.
*/
currentState.emitter.removeAllListeners();
this.streams.delete(streamId);
}
}
},

View file

@ -425,8 +425,15 @@ export class RedisEventTransport implements IEventTransport {
logger.error(`[RedisEventTransport] Error in allSubscribersLeft callback:`, err);
}
}
this.streams.delete(streamId);
/**
* Preserve stream state (callbacks, abort handlers) for reconnection.
* Previously this deleted the entire state, which lost the
* allSubscribersLeftCallbacks and abortCallbacks registered by
* GenerationJobManager.createJob(). On the next subscribe() call,
* fresh state was created without those callbacks, causing
* hasSubscriber to never reset and syncReorderBuffer to be skipped.
* State is fully cleaned up by cleanup() when the job completes.
*/
}
},
};