f6e182ac23
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
131 lines
4.4 KiB
TypeScript
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,
|
|
};
|
|
};
|