Publish batch events for sse subscriptions (#16943)

- publish batch events to avoid multiple redis / graphql sse
publications
- add event to the endpoint output
This commit is contained in:
Thomas Trompette
2026-01-06 13:22:14 +01:00
committed by GitHub
parent f8ed339f24
commit 35c7687ed8
8 changed files with 93 additions and 52 deletions
@@ -4292,12 +4292,13 @@ export enum SubscriptionInterval {
export type SubscriptionMatch = {
__typename?: 'SubscriptionMatch';
id: Scalars['String'];
event: OnDbEvent;
subscriptionIds: Array<Scalars['String']>;
};
export type SubscriptionMatches = {
__typename?: 'SubscriptionMatches';
subscriptions: Array<SubscriptionMatch>;
matches: Array<SubscriptionMatch>;
};
export enum SubscriptionStatus {
+13 -5
View File
@@ -4084,12 +4084,13 @@ export enum SubscriptionInterval {
export type SubscriptionMatch = {
__typename?: 'SubscriptionMatch';
id: Scalars['String'];
event: OnDbEvent;
subscriptionIds: Array<Scalars['String']>;
};
export type SubscriptionMatches = {
__typename?: 'SubscriptionMatches';
subscriptions: Array<SubscriptionMatch>;
matches: Array<SubscriptionMatch>;
};
export enum SubscriptionStatus {
@@ -5112,7 +5113,7 @@ export type OnSubscriptionMatchSubscriptionVariables = Exact<{
}>;
export type OnSubscriptionMatchSubscription = { __typename?: 'Subscription', onSubscriptionMatch?: { __typename?: 'SubscriptionMatches', subscriptions: Array<{ __typename?: 'SubscriptionMatch', id: string }> } | null };
export type OnSubscriptionMatchSubscription = { __typename?: 'Subscription', onSubscriptionMatch?: { __typename?: 'SubscriptionMatches', matches: Array<{ __typename?: 'SubscriptionMatch', subscriptionIds: Array<string>, event: { __typename?: 'OnDbEvent', action: DatabaseEventAction, objectNameSingular: string, eventDate: string, record: any, updatedFields?: Array<string> | null } }> } | null };
export type ViewFieldFragmentFragment = { __typename?: 'CoreViewField', id: any, fieldMetadataId: any, viewId: any, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null };
@@ -5783,8 +5784,15 @@ export type OnDbEventSubscriptionResult = Apollo.SubscriptionResult<OnDbEventSub
export const OnSubscriptionMatchDocument = gql`
subscription OnSubscriptionMatch($subscriptions: [SubscriptionInput!]!) {
onSubscriptionMatch(subscriptions: $subscriptions) {
subscriptions {
id
matches {
subscriptionIds
event {
action
objectNameSingular
eventDate
record
updatedFields
}
}
}
}
@@ -5,6 +5,7 @@ import { print } from 'graphql';
import { useEffect, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { type SubscriptionMatches } from '~/generated/graphql';
export const SubscriptionProviderEffect = () => {
const subscriptionRegistry = useRecoilValue(subscriptionRegistryState);
@@ -31,21 +32,23 @@ export const SubscriptionProviderEffect = () => {
{
next: (value) => {
const data = value.data as {
onSubscriptionMatch: { subscriptions: { id: string }[] };
onSubscriptionMatch: SubscriptionMatches;
} | null;
if (!data?.onSubscriptionMatch?.subscriptions) {
if (!data?.onSubscriptionMatch?.matches) {
return;
}
for (const subscription of data.onSubscriptionMatch.subscriptions) {
const entry = subscriptionRegistry.get(subscription.id);
for (const match of data.onSubscriptionMatch.matches) {
for (const subscriptionId of match.subscriptionIds) {
const entry = subscriptionRegistry.get(subscriptionId);
if (!isDefined(entry)) {
continue;
if (!isDefined(entry)) {
continue;
}
entry.onRefetch();
}
entry.onRefetch();
}
},
error: (error) => {
@@ -3,8 +3,15 @@ import { gql } from '@apollo/client';
export const ON_SUBSCRIPTION_MATCH = gql`
subscription OnSubscriptionMatch($subscriptions: [SubscriptionInput!]!) {
onSubscriptionMatch(subscriptions: $subscriptions) {
subscriptions {
id
matches {
subscriptionIds
event {
action
objectNameSingular
eventDate
record
updatedFields
}
}
}
}
@@ -1,13 +1,18 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { OnDbEventDTO } from './on-db-event.dto';
@ObjectType('SubscriptionMatch')
export class SubscriptionMatchDTO {
@Field(() => String)
id: string;
@Field(() => [String])
subscriptionIds: string[];
@Field(() => OnDbEventDTO)
event: OnDbEventDTO;
}
@ObjectType('SubscriptionMatches')
export class SubscriptionMatchesDTO {
@Field(() => [SubscriptionMatchDTO])
subscriptions: SubscriptionMatchDTO[];
matches: SubscriptionMatchDTO[];
}
@@ -1,4 +1,5 @@
export enum SubscriptionChannel {
DATABASE_EVENT_CHANNEL = 'DATABASE_EVENT_CHANNEL',
DATABASE_BATCH_EVENTS_CHANNEL = 'DATABASE_BATCH_EVENTS_CHANNEL',
SERVERLESS_FUNCTION_LOGS_CHANNEL = 'SERVERLESS_FUNCTION_LOGS_CHANNEL',
}
@@ -61,36 +61,41 @@ export class WorkspaceEventEmitterResolver {
nullable: true,
resolve: async function (
this: WorkspaceEventEmitterResolver,
payload: { onDbEvent: OnDbEventDTO },
payload: { onDbEvents: OnDbEventDTO[] },
args: { subscriptions: SubscriptionInput[] },
context: { req: { workspace: { id: string } } },
): Promise<SubscriptionMatchesDTO> {
const workspaceId = context.req.workspace.id;
const matchedSubscriptionIds = await Promise.all(
args.subscriptions.map(async (subscription) => {
const matches =
await this.subscriptionService.isSubscriptionMatchingEvent(
subscription,
payload.onDbEvent,
workspaceId,
);
const matches: { subscriptionIds: string[]; event: OnDbEventDTO }[] = [];
return matches ? subscription.id : null;
}),
);
for (const event of payload.onDbEvents) {
const matchedSubscriptionIds = await Promise.all(
args.subscriptions.map(async (subscription) => {
const isMatch =
await this.subscriptionService.isSubscriptionMatchingEvent(
subscription,
event,
workspaceId,
);
const filteredIds = matchedSubscriptionIds.filter(
(id): id is string => id !== null,
);
return isMatch ? subscription.id : null;
}),
);
if (filteredIds.length === 0) {
return { subscriptions: [] };
const filteredIds = matchedSubscriptionIds.filter(
(id): id is string => id !== null,
);
if (filteredIds.length > 0) {
matches.push({
subscriptionIds: filteredIds,
event,
});
}
}
return {
subscriptions: filteredIds.map((id) => ({ id })),
};
return { matches };
},
})
onSubscriptionMatch(
@@ -99,7 +104,7 @@ export class WorkspaceEventEmitterResolver {
@AuthWorkspace() workspace: WorkspaceEntity,
) {
return this.subscriptionService.subscribe({
channel: SubscriptionChannel.DATABASE_EVENT_CHANNEL,
channel: SubscriptionChannel.DATABASE_BATCH_EVENTS_CHANNEL,
workspaceId: workspace.id,
});
}
@@ -2,10 +2,10 @@ import { Injectable } from '@nestjs/common';
import { type ObjectRecordEvent } from 'twenty-shared/database-events';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
import { transformEventToWebhookEvent } from 'src/engine/core-modules/webhook/utils/transform-event-to-webhook-event';
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
import { SubscriptionChannel } from 'src/engine/subscriptions/enums/subscription-channel.enum';
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
@Injectable()
export class WorkspaceEventEmitterService {
@@ -16,25 +16,36 @@ export class WorkspaceEventEmitterService {
): Promise<void> {
const [nameSingular, operation] = workspaceEventBatch.name.split('.');
const batchEvents = [];
for (const eventData of workspaceEventBatch.events) {
const { record, updatedFields } = transformEventToWebhookEvent({
eventName: workspaceEventBatch.name,
event: eventData,
});
const event = {
action: operation,
objectNameSingular: nameSingular,
eventDate: new Date(),
record,
...(updatedFields && { updatedFields }),
};
batchEvents.push(event);
// Publish individual events to legacy channel (onDbEvent)
await this.subscriptionService.publish({
channel: SubscriptionChannel.DATABASE_EVENT_CHANNEL,
workspaceId: workspaceEventBatch.workspaceId,
payload: {
onDbEvent: {
action: operation,
objectNameSingular: nameSingular,
eventDate: new Date(),
record,
...(updatedFields && { updatedFields }),
},
},
payload: { onDbEvent: event },
});
}
await this.subscriptionService.publish({
channel: SubscriptionChannel.DATABASE_BATCH_EVENTS_CHANNEL,
workspaceId: workspaceEventBatch.workspaceId,
payload: { onDbEvents: batchEvents },
});
}
}