Handle SSE event stream reconnection (#17523)

This PR handles SSE event stream edge cases. 

- When a pod restarts, the front clients have to reconnect to SSE
- When the dev server restarts or is hot reloaded, the front client has
to reconnect to SSE
- When redis server restarts or the redis key is cleared for any reason,
the server has to recreate the event stream in redis, this can happen
when navigating for example.
- Log in / log out flow

With this PR we avoid error messages in the front end due to TTL or pod
crash, we implement a resilient way of reconnecting silently.

To avoid DDoSing our servers if pods crash or a full restart of the
cluster is made, we evenly space retry attempts to reconnect from all
the clients, to avoid n clients reconnection at the same time, we use a
random wait time between 0 and a constant max wait time (set to 2 mins
for now). This is the cheapest and most effective solution, clients who
want to force reconnect have to refresh or navigate to another page.

Fixes https://github.com/twentyhq/core-team-issues/issues/2045
This commit is contained in:
Lucas Bordeau
2026-02-02 12:16:28 +01:00
committed by GitHub
parent 82619d9e66
commit f6e182ac23
23 changed files with 443 additions and 369 deletions
@@ -7,6 +7,7 @@ import { CustomException } from 'src/utils/custom-exception';
export enum EventStreamExceptionCode {
EVENT_STREAM_ALREADY_EXISTS = 'EVENT_STREAM_ALREADY_EXISTS',
NOT_AUTHORIZED = 'NOT_AUTHORIZED',
EVENT_STREAM_DOES_NOT_EXIST = 'EVENT_STREAM_DOES_NOT_EXIST',
}
const getEventStreamExceptionUserFriendlyMessage = (
@@ -14,6 +15,7 @@ const getEventStreamExceptionUserFriendlyMessage = (
) => {
switch (code) {
case EventStreamExceptionCode.EVENT_STREAM_ALREADY_EXISTS:
case EventStreamExceptionCode.EVENT_STREAM_DOES_NOT_EXIST:
return msg`Failed to receive real time updates.`;
case EventStreamExceptionCode.NOT_AUTHORIZED:
return msg`You are not authorized to perform this action.`;
@@ -157,23 +157,12 @@ export class EventStreamService implements OnModuleInit {
}
async isAuthorized({
workspaceId,
eventStreamChannelId,
authContext,
streamData,
}: {
workspaceId: string;
eventStreamChannelId: string;
authContext: SerializableAuthContext;
streamData: EventStreamData;
}): Promise<boolean> {
const streamData = await this.getStreamData(
workspaceId,
eventStreamChannelId,
);
if (!isDefined(streamData)) {
return false;
}
if (isDefined(authContext.userWorkspaceId)) {
return (
streamData.authContext.userWorkspaceId === authContext.userWorkspaceId
@@ -262,7 +251,7 @@ export class EventStreamService implements OnModuleInit {
return `workspace:${workspaceId}:activeStreams`;
}
private async getStreamData(
async getStreamData(
workspaceId: string,
eventStreamChannelId: string,
): Promise<EventStreamData | undefined> {