Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24fc02a884 |
+75
@@ -0,0 +1,75 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
|
||||
import { POSTGRESQL_ERROR_CODES } from 'src/engine/api/graphql/workspace-query-runner/constants/postgres-error-codes.constants';
|
||||
import { PostgresException } from 'src/engine/api/graphql/workspace-query-runner/utils/postgres-exception';
|
||||
import { ExceptionHandlerSentryDriver } from 'src/engine/core-modules/exception-handler/drivers/sentry.driver';
|
||||
|
||||
jest.mock('@sentry/node', () => ({
|
||||
withScope: jest.fn(),
|
||||
captureException: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('ExceptionHandlerSentryDriver', () => {
|
||||
const setExtra = jest.fn();
|
||||
const setUser = jest.fn();
|
||||
const addBreadcrumb = jest.fn();
|
||||
const setContext = jest.fn();
|
||||
const setTag = jest.fn();
|
||||
const setFingerprint = jest.fn();
|
||||
const setLevel = jest.fn();
|
||||
|
||||
const scope = {
|
||||
setExtra,
|
||||
setUser,
|
||||
addBreadcrumb,
|
||||
setContext,
|
||||
setTag,
|
||||
setFingerprint,
|
||||
setLevel,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
(Sentry.withScope as jest.Mock).mockImplementation((callback) => {
|
||||
callback(scope);
|
||||
});
|
||||
(Sentry.captureException as jest.Mock).mockReturnValue('event-id');
|
||||
});
|
||||
|
||||
it('should capture retryable postgres errors as warning and annotate metadata', () => {
|
||||
const driver = new ExceptionHandlerSentryDriver();
|
||||
const exception = new PostgresException(
|
||||
'deadlock detected',
|
||||
POSTGRESQL_ERROR_CODES.DEADLOCK_DETECTED,
|
||||
);
|
||||
|
||||
driver.captureExceptions([exception]);
|
||||
|
||||
expect(setTag).toHaveBeenCalledWith('postgresSqlErrorCode', '40P01');
|
||||
expect(setTag).toHaveBeenCalledWith('postgresSqlErrorType', 'retryable');
|
||||
expect(setContext).toHaveBeenCalledWith('postgres', {
|
||||
code: '40P01',
|
||||
isRetryable: true,
|
||||
});
|
||||
expect(setLevel).toHaveBeenCalledWith('warning');
|
||||
});
|
||||
|
||||
it('should keep non-retryable postgres errors at error level', () => {
|
||||
const driver = new ExceptionHandlerSentryDriver();
|
||||
const exception = new PostgresException(
|
||||
'relation does not exist',
|
||||
POSTGRESQL_ERROR_CODES.UNDEFINED_TABLE,
|
||||
);
|
||||
|
||||
driver.captureExceptions([exception]);
|
||||
|
||||
expect(setTag).toHaveBeenCalledWith('postgresSqlErrorCode', '42P01');
|
||||
expect(setTag).toHaveBeenCalledWith(
|
||||
'postgresSqlErrorType',
|
||||
'non-retryable',
|
||||
);
|
||||
expect(setLevel).toHaveBeenCalledWith('error');
|
||||
expect(setLevel).not.toHaveBeenCalledWith('warning');
|
||||
});
|
||||
});
|
||||
+27
@@ -7,11 +7,18 @@ import {
|
||||
|
||||
import { type ExceptionHandlerOptions } from 'src/engine/core-modules/exception-handler/interfaces/exception-handler-options.interface';
|
||||
|
||||
import { POSTGRESQL_ERROR_CODES } from 'src/engine/api/graphql/workspace-query-runner/constants/postgres-error-codes.constants';
|
||||
import { PostgresException } from 'src/engine/api/graphql/workspace-query-runner/utils/postgres-exception';
|
||||
import { type ExceptionHandlerDriverInterface } from 'src/engine/core-modules/exception-handler/interfaces';
|
||||
import { MessageImportDriverException } from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
const RETRYABLE_POSTGRES_ERROR_CODES = new Set([
|
||||
POSTGRESQL_ERROR_CODES.DEADLOCK_DETECTED,
|
||||
POSTGRESQL_ERROR_CODES.SERIALIZATION_FAILURE,
|
||||
POSTGRESQL_ERROR_CODES.LOCK_NOT_AVAILABLE,
|
||||
]);
|
||||
|
||||
export class ExceptionHandlerSentryDriver implements ExceptionHandlerDriverInterface {
|
||||
captureExceptions(
|
||||
// oxlint-disable-next-line @typescripttypescript/no-explicit-any
|
||||
@@ -48,6 +55,8 @@ export class ExceptionHandlerSentryDriver implements ExceptionHandlerDriverInter
|
||||
}
|
||||
|
||||
for (const exception of exceptions) {
|
||||
scope.setLevel('error');
|
||||
|
||||
const errorPath = (exception.path ?? [])
|
||||
.map((v: string | number) => (typeof v === 'number' ? '$index' : v))
|
||||
.join(' > ');
|
||||
@@ -84,7 +93,24 @@ export class ExceptionHandlerSentryDriver implements ExceptionHandlerDriverInter
|
||||
}
|
||||
|
||||
if (exception instanceof PostgresException) {
|
||||
const isRetryablePostgresError = RETRYABLE_POSTGRES_ERROR_CODES.has(
|
||||
exception.code,
|
||||
);
|
||||
|
||||
scope.setTag('postgresSqlErrorCode', exception.code);
|
||||
scope.setTag(
|
||||
'postgresSqlErrorType',
|
||||
isRetryablePostgresError ? 'retryable' : 'non-retryable',
|
||||
);
|
||||
scope.setContext('postgres', {
|
||||
code: exception.code,
|
||||
isRetryable: isRetryablePostgresError,
|
||||
});
|
||||
|
||||
if (isRetryablePostgresError) {
|
||||
scope.setLevel('warning');
|
||||
}
|
||||
|
||||
const fingerPrint = [exception.code];
|
||||
const genericOperationName = getGenericOperationName(
|
||||
options?.operation?.name,
|
||||
@@ -93,6 +119,7 @@ export class ExceptionHandlerSentryDriver implements ExceptionHandlerDriverInter
|
||||
if (isDefined(genericOperationName)) {
|
||||
fingerPrint.push(genericOperationName);
|
||||
}
|
||||
|
||||
scope.setFingerprint(fingerPrint);
|
||||
exception.name = exception.message;
|
||||
}
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import { QueryFailedError } from 'typeorm';
|
||||
|
||||
import { POSTGRESQL_ERROR_CODES } from 'src/engine/api/graphql/workspace-query-runner/constants/postgres-error-codes.constants';
|
||||
import { PostgresException } from 'src/engine/api/graphql/workspace-query-runner/utils/postgres-exception';
|
||||
import {
|
||||
TwentyORMException,
|
||||
TwentyORMExceptionCode,
|
||||
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
|
||||
|
||||
import { computeTwentyORMException } from '../compute-twenty-orm-exception';
|
||||
|
||||
const createQueryFailedError = ({
|
||||
message,
|
||||
code,
|
||||
}: {
|
||||
message: string;
|
||||
code: string;
|
||||
}) => {
|
||||
const queryFailedError = new QueryFailedError(
|
||||
'SELECT 1',
|
||||
[],
|
||||
new Error(message),
|
||||
);
|
||||
|
||||
Object.assign(queryFailedError, { code });
|
||||
|
||||
return queryFailedError;
|
||||
};
|
||||
|
||||
describe('computeTwentyORMException', () => {
|
||||
it('should map invalid text representation to INVALID_INPUT', async () => {
|
||||
const queryFailedError = createQueryFailedError({
|
||||
message: 'invalid input value for enum "task_status_enum": "NOT_A_STATUS"',
|
||||
code: POSTGRESQL_ERROR_CODES.INVALID_TEXT_REPRESENTATION,
|
||||
});
|
||||
|
||||
const exception = await computeTwentyORMException(queryFailedError);
|
||||
|
||||
expect(exception).toBeInstanceOf(TwentyORMException);
|
||||
expect((exception as TwentyORMException).code).toBe(
|
||||
TwentyORMExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
});
|
||||
|
||||
it('should keep a generic data validation message for validation postgres errors', async () => {
|
||||
const queryFailedError = createQueryFailedError({
|
||||
message: 'insert or update on table "person" violates foreign key constraint',
|
||||
code: POSTGRESQL_ERROR_CODES.FOREIGN_KEY_VIOLATION,
|
||||
});
|
||||
|
||||
await expect(computeTwentyORMException(queryFailedError)).rejects.toMatchObject(
|
||||
new PostgresException(
|
||||
'Data validation error.',
|
||||
POSTGRESQL_ERROR_CODES.FOREIGN_KEY_VIOLATION,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve transient postgres error messages', async () => {
|
||||
const queryFailedError = createQueryFailedError({
|
||||
message: 'deadlock detected',
|
||||
code: POSTGRESQL_ERROR_CODES.DEADLOCK_DETECTED,
|
||||
});
|
||||
|
||||
await expect(computeTwentyORMException(queryFailedError)).rejects.toMatchObject(
|
||||
new PostgresException(
|
||||
'deadlock detected',
|
||||
POSTGRESQL_ERROR_CODES.DEADLOCK_DETECTED,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
+15
-1
@@ -18,6 +18,16 @@ interface QueryFailedErrorWithCode extends QueryFailedError {
|
||||
code?: string;
|
||||
}
|
||||
|
||||
const POSTGRESQL_DATA_VALIDATION_ERROR_CODES = new Set([
|
||||
POSTGRESQL_ERROR_CODES.NOT_NULL_VIOLATION,
|
||||
POSTGRESQL_ERROR_CODES.FOREIGN_KEY_VIOLATION,
|
||||
POSTGRESQL_ERROR_CODES.CHECK_VIOLATION,
|
||||
POSTGRESQL_ERROR_CODES.STRING_DATA_RIGHT_TRUNCATION,
|
||||
POSTGRESQL_ERROR_CODES.NUMERIC_VALUE_OUT_OF_RANGE,
|
||||
POSTGRESQL_ERROR_CODES.INVALID_DATETIME_FORMAT,
|
||||
POSTGRESQL_ERROR_CODES.INVALID_PARAMETER_VALUE,
|
||||
]);
|
||||
|
||||
export const computeTwentyORMException = async (
|
||||
error: Error,
|
||||
objectMetadata?: FlatObjectMetadata,
|
||||
@@ -62,7 +72,11 @@ export const computeTwentyORMException = async (
|
||||
isDefined(errorCode) &&
|
||||
Object.values(POSTGRESQL_ERROR_CODES).includes(errorCode)
|
||||
) {
|
||||
throw new PostgresException('Data validation error.', errorCode);
|
||||
if (POSTGRESQL_DATA_VALIDATION_ERROR_CODES.has(errorCode)) {
|
||||
throw new PostgresException('Data validation error.', errorCode);
|
||||
}
|
||||
|
||||
throw new PostgresException(error.message, errorCode);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
-44
@@ -189,50 +189,6 @@ describe('WorkflowDatabaseEventTriggerListener', () => {
|
||||
expect(messageQueueService.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should trigger workflow when configured fields only match updated fields by case', async () => {
|
||||
mockRepository.find.mockResolvedValue([
|
||||
{
|
||||
...mockEventListeners[0],
|
||||
settings: {
|
||||
eventName: databaseEventName,
|
||||
fields: ['Field1'],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await listener.handleObjectRecordUpdateEvent(mockPayload);
|
||||
|
||||
expect(messageQueueService.add).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should trigger workflow when updated fields are stale but configured field value changed', async () => {
|
||||
mockRepository.find.mockResolvedValue([
|
||||
{
|
||||
...mockEventListeners[0],
|
||||
settings: {
|
||||
eventName: databaseEventName,
|
||||
fields: ['field1'],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await listener.handleObjectRecordUpdateEvent({
|
||||
...mockPayload,
|
||||
events: [
|
||||
{
|
||||
...mockPayload.events[0],
|
||||
properties: {
|
||||
updatedFields: ['differentFieldName'],
|
||||
before: { field1: 'old-value' },
|
||||
after: { field1: 'new-value' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(messageQueueService.add).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle create events correctly', async () => {
|
||||
const createPayload: WorkspaceEventBatch<any> = {
|
||||
...mockPayload,
|
||||
|
||||
+29
-106
@@ -9,7 +9,7 @@ import {
|
||||
type ObjectRecordUpsertEvent,
|
||||
} from 'twenty-shared/database-events';
|
||||
import { type ObjectRecord } from 'twenty-shared/types';
|
||||
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { In, Raw } from 'typeorm';
|
||||
|
||||
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
|
||||
@@ -314,8 +314,10 @@ export class WorkflowDatabaseEventTriggerListener {
|
||||
const databaseEventName = payload.name;
|
||||
|
||||
if (!workspaceId || !databaseEventName) {
|
||||
this.logger.warn(
|
||||
`Ignoring database event batch with missing metadata (workspaceId=${workspaceId}, eventName=${databaseEventName}, eventsCount=${payload.events.length})`,
|
||||
this.logger.error(
|
||||
`Missing workspaceId or eventName in payload ${JSON.stringify(
|
||||
payload,
|
||||
)}`,
|
||||
);
|
||||
|
||||
return true;
|
||||
@@ -356,49 +358,27 @@ export class WorkflowDatabaseEventTriggerListener {
|
||||
},
|
||||
});
|
||||
|
||||
let matchedEventCount = 0;
|
||||
let filteredEventCount = 0;
|
||||
let enqueuedJobCount = 0;
|
||||
|
||||
for (const eventListener of eventListeners) {
|
||||
for (const eventPayload of payload.events) {
|
||||
matchedEventCount += 1;
|
||||
|
||||
const shouldTriggerJob = this.shouldTriggerJob({
|
||||
eventPayload,
|
||||
eventListener,
|
||||
action,
|
||||
});
|
||||
|
||||
if (!shouldTriggerJob) {
|
||||
filteredEventCount += 1;
|
||||
|
||||
continue;
|
||||
if (shouldTriggerJob) {
|
||||
await this.messageQueueService.add<WorkflowTriggerJobData>(
|
||||
WorkflowTriggerJob.name,
|
||||
{
|
||||
workspaceId,
|
||||
workflowId: eventListener.workflowId,
|
||||
payload: eventPayload,
|
||||
},
|
||||
{ retryLimit: 3 },
|
||||
);
|
||||
}
|
||||
|
||||
enqueuedJobCount += 1;
|
||||
|
||||
await this.messageQueueService.add<WorkflowTriggerJobData>(
|
||||
WorkflowTriggerJob.name,
|
||||
{
|
||||
workspaceId,
|
||||
workflowId: eventListener.workflowId,
|
||||
payload: eventPayload,
|
||||
},
|
||||
{ retryLimit: 3 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
payload.events.length > 0 &&
|
||||
eventListeners.length > 0 &&
|
||||
enqueuedJobCount === 0
|
||||
) {
|
||||
this.logger.warn(
|
||||
`Database event batch produced no workflow jobs (workspaceId=${workspaceId}, eventName=${databaseEventName}, action=${action}, listeners=${eventListeners.length}, events=${payload.events.length}, matched=${matchedEventCount}, filtered=${filteredEventCount})`,
|
||||
);
|
||||
}
|
||||
}, authContext);
|
||||
}
|
||||
|
||||
@@ -415,85 +395,28 @@ export class WorkflowDatabaseEventTriggerListener {
|
||||
const settings = eventListener.settings as UpdateEventTriggerSettings;
|
||||
const updateEventPayload = eventPayload as ObjectRecordUpdateEvent;
|
||||
|
||||
return this.shouldTriggerJobForFieldFilteredEvent({
|
||||
fields: settings.fields,
|
||||
eventPayload: updateEventPayload,
|
||||
});
|
||||
return (
|
||||
!settings.fields ||
|
||||
settings.fields.length === 0 ||
|
||||
settings.fields.some((field) =>
|
||||
updateEventPayload?.properties?.updatedFields?.includes(field),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (action === DatabaseEventAction.UPSERTED) {
|
||||
const settings = eventListener.settings as UpsertEventTriggerSettings;
|
||||
const upsertEventPayload = eventPayload as ObjectRecordUpsertEvent;
|
||||
|
||||
return this.shouldTriggerJobForFieldFilteredEvent({
|
||||
fields: settings.fields,
|
||||
eventPayload: upsertEventPayload,
|
||||
});
|
||||
return (
|
||||
!settings.fields ||
|
||||
settings.fields.length === 0 ||
|
||||
settings.fields.some((field) =>
|
||||
upsertEventPayload?.properties?.updatedFields?.includes(field),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private shouldTriggerJobForFieldFilteredEvent({
|
||||
fields,
|
||||
eventPayload,
|
||||
}: {
|
||||
fields?: string[];
|
||||
eventPayload: ObjectRecordUpdateEvent | ObjectRecordUpsertEvent;
|
||||
}) {
|
||||
if (!isNonEmptyArray(fields)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const updatedFields = eventPayload.properties.updatedFields ?? [];
|
||||
|
||||
if (
|
||||
this.hasIntersection({
|
||||
left: fields,
|
||||
right: updatedFields,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return fields.some((field) =>
|
||||
this.didFieldValueChange({
|
||||
field,
|
||||
before: eventPayload.properties.before as Record<string, unknown>,
|
||||
after: eventPayload.properties.after as Record<string, unknown>,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private hasIntersection({
|
||||
left,
|
||||
right,
|
||||
}: {
|
||||
left: string[];
|
||||
right: string[];
|
||||
}) {
|
||||
const normalizedRight = new Set(
|
||||
right.map((field) => field.trim().toLowerCase()),
|
||||
);
|
||||
|
||||
return left.some((field) =>
|
||||
normalizedRight.has(field.trim().toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
private didFieldValueChange({
|
||||
field,
|
||||
before,
|
||||
after,
|
||||
}: {
|
||||
field: string;
|
||||
before?: Record<string, unknown>;
|
||||
after?: Record<string, unknown>;
|
||||
}) {
|
||||
if (!before || !after) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !Object.is(before[field], after[field]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user