Files
twenty/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerEventStreamCreation.ts
T
Lucas Bordeau f6e182ac23 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
2026-02-02 11:16:28 +00:00

131 lines
4.4 KiB
TypeScript

import { ON_EVENT_SUBSCRIPTION } from '@/sse-db-event/graphql/subscriptions/OnEventSubscription';
import { useDispatchObjectRecordEventsFromSseToBrowserEvents } from '@/sse-db-event/hooks/useDispatchObjectRecordEventsFromSseToBrowserEvents';
import { useTriggerOptimisticEffectFromSseEvents } from '@/sse-db-event/hooks/useTriggerOptimisticEffectFromSseEvents';
import { disposeFunctionForEventStreamState } from '@/sse-db-event/states/disposeFunctionByEventStreamMapState';
import { isCreatingSseEventStreamState } from '@/sse-db-event/states/isCreatingSseEventStreamState';
import { isDestroyingEventStreamState } from '@/sse-db-event/states/isDestroyingEventStreamState';
import { shouldDestroyEventStreamState } from '@/sse-db-event/states/shouldDestroyEventStreamState';
import { sseEventStreamIdState } from '@/sse-db-event/states/sseEventStreamIdState';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { captureException } from '@sentry/react';
import { isNonEmptyString } from '@sniptt/guards';
import { print, type ExecutionResult } from 'graphql';
import { type Client } from 'graphql-sse';
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { type EventSubscription } from '~/generated/graphql';
export const useTriggerEventStreamCreation = () => {
const setIsCreatingSseEventStream = useSetRecoilState(
isCreatingSseEventStreamState,
);
const { dispatchObjectRecordEventsFromSseToBrowserEvents } =
useDispatchObjectRecordEventsFromSseToBrowserEvents();
const { triggerOptimisticEffectFromSseEvents } =
useTriggerOptimisticEffectFromSseEvents();
const triggerEventStreamCreation = useRecoilCallback(
({ snapshot, set }) =>
(sseClient: Client) => {
const isCreatingSseEventStream = snapshot
.getLoadable(isCreatingSseEventStreamState)
.getValue();
const isDestroyingEventStream = snapshot
.getLoadable(isDestroyingEventStreamState)
.getValue();
const currentSseEventStreamId = getSnapshotValue(
snapshot,
sseEventStreamIdState,
);
if (
isCreatingSseEventStream ||
isDestroyingEventStream ||
!isDefined(sseClient) ||
isNonEmptyString(currentSseEventStreamId)
) {
return;
}
setIsCreatingSseEventStream(true);
const newSseEventStreamId = v4();
set(sseEventStreamIdState, newSseEventStreamId);
const dispose = sseClient.subscribe(
{
query: print(ON_EVENT_SUBSCRIPTION),
variables: {
eventStreamId: newSseEventStreamId,
},
},
{
next: (
value: ExecutionResult<{
onEventSubscription: EventSubscription;
}>,
) => {
const objectRecordEventsWithQueryIds =
value?.data?.onEventSubscription?.eventWithQueryIdsList ?? [];
const objectRecordEvents = objectRecordEventsWithQueryIds.map(
(eventWithQueryIds) => {
return eventWithQueryIds.event;
},
);
triggerOptimisticEffectFromSseEvents({
objectRecordEvents,
});
dispatchObjectRecordEventsFromSseToBrowserEvents(
objectRecordEventsWithQueryIds,
);
},
error: (error) => {
captureException(error);
},
complete: () => {},
},
{
message: ({ data, event }) => {
if (event === 'next') {
if (isDefined(data?.errors)) {
const subCode = data.errors[0]?.extensions?.subCode;
switch (subCode) {
case 'EVENT_STREAM_ALREADY_EXISTS': {
set(shouldDestroyEventStreamState, true);
break;
}
}
}
}
},
},
);
set(disposeFunctionForEventStreamState, { dispose });
setIsCreatingSseEventStream(false);
},
[
dispatchObjectRecordEventsFromSseToBrowserEvents,
setIsCreatingSseEventStream,
triggerOptimisticEffectFromSseEvents,
],
);
return {
triggerEventStreamCreation,
};
};