Refactor workspace migration runner exception handling (#17310)

# Introduction
In this PR we catch all the runner errors coming from a single workspace
migration action execution.

**1. `WorkspaceMigrationActionExecutionException`** (low-level,
action-specific)
- Thrown from action handlers, utils, and helper functions
- Contains specific error codes: `FIELD_METADATA_NOT_FOUND`,
`OBJECT_METADATA_NOT_FOUND`, `ENUM_OPERATION_FAILED`, `NOT_SUPPORTED`,
etc.
- Simple structure: `message`, `code`, `userFriendlyMessage`
- No action context - just describes what went wrong

**2. `WorkspaceMigrationRunnerException`** (high-level, runner-scoped)
- Only two codes: `INTERNAL_SERVER_ERROR` and `EXECUTION_FAILED`
- `EXECUTION_FAILED` **requires** `action` + `errors` (contains the
action context)
- `INTERNAL_SERVER_ERROR` **requires** `message` (no action context)

## Refactor
- Removed the `relatedFlatEntityMapsKeys` from the `WorkspaceMigration`
type as they're directly inferred from passed actions
- Swallowing actions rollbacks errors in order to iterate over all of
them

## Testing
Created a very straigthforward install application from workspace
migration endpoint in order to start testing the introduced
`WorkspaceMigrationActionExecutionException`
Introduced a feature flag that stop the access to the endpoint if not
enabled
Whole taken direction are totally subjective and highly prone to
mutations ( endpoint location, naming and input schema see
`ts-expect-error` comment )
cc @martmull 

## Response error
```ts
{
  "eventId": "evt_a1b2c3d4-5678-90ab-cdef-1234567890ab",
  "extensions": {
    "action": {
      "metadataName": "fieldMetadata",
      "type": "delete",
      "universalIdentifier": "20202020-6110-4547-9fd0-2525257a2c3f"
    },
    "code": "APPLICATION_INSTALLATION_FAILED",
    "errors": {
      "metadata": {
        "code": "ENTITY_NOT_FOUND",
        "message": "Could not find flat entity with universal identifier 20202020-6110-4547-9fd0-2525257a2c3f"
      },
      "workspaceSchema": {
        "code": "ENTITY_NOT_FOUND",
        "message": "Could not find flat entity in maps"
      }
    },
    "exceptionEventId": "exc_f9e8d7c6-5432-10ba-fedc-ba0987654321",
    "userFriendlyMessage": "Migration execution failed."
  },
  "message": "Migration action 'delete' for 'fieldMetadata' failed",
  "name": "GraphQLError"
}
```
This commit is contained in:
Paul Rastoin
2026-01-22 16:05:21 +01:00
committed by GitHub
parent a68e05682b
commit 2a28c34a37
53 changed files with 829 additions and 294 deletions
@@ -12,9 +12,11 @@ import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { workspaceMigrationBuilderExceptionFormatter } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-builder-exception-formatter';
import { workspaceMigrationRunnerExceptionFormatter } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-runner-exception-formatter';
import { WorkspaceMigrationRunnerException } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception';
@Injectable()
export class WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor
export class WorkspaceMigrationGraphqlApiExceptionInterceptor
implements NestInterceptor
{
constructor(private readonly i18nService: I18nService) {}
@@ -31,6 +33,10 @@ export class WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor
workspaceMigrationBuilderExceptionFormatter(error, i18n);
}
if (error instanceof WorkspaceMigrationRunnerException) {
workspaceMigrationRunnerExceptionFormatter(error);
}
throw error;
}),
);
@@ -0,0 +1,47 @@
import {
BaseGraphQLError,
ErrorCode,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
type WorkspaceMigrationRunnerException,
WorkspaceMigrationRunnerExceptionCode,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception';
export const workspaceMigrationRunnerExceptionFormatter = (
error: WorkspaceMigrationRunnerException,
) => {
const isExecutionFailed =
error.code === WorkspaceMigrationRunnerExceptionCode.EXECUTION_FAILED;
throw new BaseGraphQLError(
error.message,
isExecutionFailed
? ErrorCode.APPLICATION_INSTALLATION_FAILED
: ErrorCode.INTERNAL_SERVER_ERROR,
{
code: error.code,
...(isExecutionFailed && {
action: error.action,
errors: {
...(error.errors?.metadata && {
metadata: {
message: error.errors.metadata.message,
code:
(error.errors.metadata as { code?: string })?.code ??
'INTERNAL_SERVER_ERROR',
},
}),
...(error.errors?.workspaceSchema && {
workspaceSchema: {
message: error.errors.workspaceSchema.message,
code:
(error.errors.workspaceSchema as { code?: string })?.code ??
'INTERNAL_SERVER_ERROR',
},
}),
},
}),
userFriendlyMessage: error.userFriendlyMessage,
},
);
};
@@ -1058,13 +1058,6 @@ export class WorkspaceMigrationBuildOrchestratorService {
};
}
const relatedFlatEntityMapsKeys = [
...new Set([
...Object.keys(fromToAllFlatEntityMaps),
...Object.keys(dependencyAllFlatEntityMaps ?? {}),
]),
] as (keyof AllFlatEntityMaps)[];
const { aggregatedOrchestratorActionsReport } =
aggregateOrchestratorActionsReport({
orchestratorActionsReport,
@@ -1075,7 +1068,6 @@ export class WorkspaceMigrationBuildOrchestratorService {
return {
status: 'success',
workspaceMigration: {
relatedFlatEntityMapsKeys,
actions: [
// Object and fields and indexes
...aggregatedOrchestratorActionsReport.index.delete,
@@ -196,15 +196,9 @@ export class WorkspaceMigrationValidateBuildAndRunService {
return;
}
await this.workspaceMigrationRunnerService
.run(validateAndBuildResult.workspaceMigration)
.catch((error) => {
this.logger.error(error);
throw new WorkspaceMigrationV2Exception(
WorkspaceMigrationV2ExceptionCode.RUNNER_INTERNAL_SERVER_ERROR,
error.message,
);
});
await this.workspaceMigrationRunnerService.run(
validateAndBuildResult.workspaceMigration,
);
}
public async validateBuildAndRunWorkspaceMigration({
@@ -0,0 +1,5 @@
export const WORKSPACE_MIGRATION_ACTION_TYPE = {
delete: 'delete',
create: 'create',
update: 'update',
} as const;
@@ -1,9 +1,10 @@
import { type AllMetadataName } from 'twenty-shared/metadata';
import { type MetadataFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity.type';
import { type WORKSPACE_MIGRATION_ACTION_TYPE } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/constants/workspace-migration-action-type.constant';
export type BaseCreateWorkspaceMigrationAction<T extends AllMetadataName> = {
flatEntity: MetadataFlatEntity<T>;
type: 'create';
type: typeof WORKSPACE_MIGRATION_ACTION_TYPE.create;
metadataName: T;
};
@@ -1,7 +1,9 @@
import { type AllMetadataName } from 'twenty-shared/metadata';
import { type WORKSPACE_MIGRATION_ACTION_TYPE } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/constants/workspace-migration-action-type.constant';
export type BaseDeleteWorkspaceMigrationAction<T extends AllMetadataName> = {
universalIdentifier: string;
type: 'delete';
type: typeof WORKSPACE_MIGRATION_ACTION_TYPE.delete;
metadataName: T;
};
@@ -1,9 +1,10 @@
import { type AllMetadataName } from 'twenty-shared/metadata';
import { type FlatEntityPropertiesUpdates } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-properties-updates.type';
import { type WORKSPACE_MIGRATION_ACTION_TYPE } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/constants/workspace-migration-action-type.constant';
export type BaseUpdateWorkspaceMigrationAction<T extends AllMetadataName> = {
type: 'update';
type: typeof WORKSPACE_MIGRATION_ACTION_TYPE.update;
metadataName: T;
entityId: string;
updates: FlatEntityPropertiesUpdates<T>;
@@ -1,4 +1,3 @@
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { type WorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common';
export type WorkspaceMigration<
@@ -6,6 +5,4 @@ export type WorkspaceMigration<
> = {
actions: TActions[];
workspaceId: string;
// TODO remove from workspaceMigration once we've refactored the actions to have metadata and action type grain
relatedFlatEntityMapsKeys?: (keyof AllFlatEntityMaps)[];
};
@@ -27,9 +27,9 @@ import { isPropertyUpdate } from 'src/engine/workspace-manager/workspace-migrati
import { UpdateFieldAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/types/workspace-migration-field-action';
import { serializeDefaultValue } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/utils/serialize-default-value.util';
import {
WorkspaceMigrationRunnerException,
WorkspaceMigrationRunnerExceptionCode,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception';
WorkspaceMigrationActionExecutionException,
WorkspaceMigrationActionExecutionExceptionCode,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-action-execution.exception';
import { type WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type';
import { fieldMetadataTypeToColumnType } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/field-metadata-type-to-column-type.util';
import { fromFlatEntityPropertiesUpdatesToPartialFlatEntity } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/from-flat-entity-properties-updates-to-partial-flat-entity';
@@ -243,10 +243,10 @@ export class UpdateFieldActionHandlerService extends WorkspaceMigrationRunnerAct
);
if (!isDefined(foreignKeyName)) {
throw new WorkspaceMigrationRunnerException(
'Foreign key not found',
WorkspaceMigrationRunnerExceptionCode.NOT_SUPPORTED,
);
throw new WorkspaceMigrationActionExecutionException({
message: 'Foreign key not found',
code: WorkspaceMigrationActionExecutionExceptionCode.NOT_SUPPORTED,
});
}
await this.workspaceSchemaManagerService.foreignKeyManager.dropForeignKey(
@@ -300,10 +300,11 @@ export class UpdateFieldActionHandlerService extends WorkspaceMigrationRunnerAct
for (const property of compositeType.properties) {
if (isMorphOrRelationFieldMetadataType(property.type)) {
throw new WorkspaceMigrationRunnerException(
'Relation field metadata in composite type is not supported yet',
WorkspaceMigrationRunnerExceptionCode.NOT_SUPPORTED,
);
throw new WorkspaceMigrationActionExecutionException({
message:
'Relation field metadata in composite type is not supported yet',
code: WorkspaceMigrationActionExecutionExceptionCode.NOT_SUPPORTED,
});
}
const fromCompositeColumnName = computeCompositeColumnName(
@@ -366,10 +367,11 @@ export class UpdateFieldActionHandlerService extends WorkspaceMigrationRunnerAct
) as ColumnType;
if (isMorphOrRelationFieldMetadataType(property.type)) {
throw new WorkspaceMigrationRunnerException(
'Relation field metadata in composite type is not supported yet',
WorkspaceMigrationRunnerExceptionCode.NOT_SUPPORTED,
);
throw new WorkspaceMigrationActionExecutionException({
message:
'Relation field metadata in composite type is not supported yet',
code: WorkspaceMigrationActionExecutionExceptionCode.NOT_SUPPORTED,
});
}
const compositeColumnName = computeCompositeColumnName(
@@ -2,10 +2,6 @@ import { compositeTypeDefinitions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type QueryRunner } from 'typeorm';
import {
WorkspaceQueryRunnerException,
WorkspaceQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.exception';
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import {
@@ -24,6 +20,10 @@ import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object
import { IndexFieldMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-field-metadata.entity';
import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { type WorkspaceSchemaManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.service';
import {
WorkspaceMigrationActionExecutionException,
WorkspaceMigrationActionExecutionExceptionCode,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-action-execution.exception';
import { getWorkspaceSchemaContextForMigration } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/get-workspace-schema-context-for-migration.util';
export const computeFlatIndexFieldColumnNames = ({
@@ -104,10 +104,10 @@ export const insertIndexMetadata = async ({
);
if (indexInsertResult.identifiers.length !== 1) {
throw new WorkspaceQueryRunnerException(
'Failed to create index metadata',
WorkspaceQueryRunnerExceptionCode.INTERNAL_SERVER_ERROR,
);
throw new WorkspaceMigrationActionExecutionException({
message: 'Failed to create index metadata',
code: WorkspaceMigrationActionExecutionExceptionCode.INTERNAL_SERVER_ERROR,
});
}
const indexMetadataId = indexInsertResult.identifiers[0].id;
@@ -67,7 +67,10 @@ export class DeleteServerlessFunctionActionHandlerService extends WorkspaceMigra
}
async rollbackForMetadata(
context: WorkspaceMigrationActionRunnerArgs<DeleteServerlessFunctionAction>,
context: Omit<
WorkspaceMigrationActionRunnerArgs<DeleteServerlessFunctionAction>,
'queryRunner'
>,
): Promise<void> {
const { action, allFlatEntityMaps } = context;
const { universalIdentifier } = action;
@@ -0,0 +1,64 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable, CustomError } from 'twenty-shared/utils';
export const WorkspaceMigrationActionExecutionExceptionCode = {
FIELD_METADATA_NOT_FOUND: 'FIELD_METADATA_NOT_FOUND',
OBJECT_METADATA_NOT_FOUND: 'OBJECT_METADATA_NOT_FOUND',
ENUM_OPERATION_FAILED: 'ENUM_OPERATION_FAILED',
UNSUPPORTED_COMPOSITE_COLUMN_TYPE: 'UNSUPPORTED_COMPOSITE_COLUMN_TYPE',
NOT_SUPPORTED: 'NOT_SUPPORTED',
INVALID_ACTION_TYPE: 'INVALID_ACTION_TYPE',
FLAT_ENTITY_NOT_FOUND: 'FLAT_ENTITY_NOT_FOUND',
UNSUPPORTED_FIELD_METADATA_TYPE: 'UNSUPPORTED_FIELD_METADATA_TYPE',
INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
} as const;
const getWorkspaceMigrationActionExecutionExceptionUserFriendlyMessage = (
code: keyof typeof WorkspaceMigrationActionExecutionExceptionCode,
) => {
switch (code) {
case WorkspaceMigrationActionExecutionExceptionCode.FIELD_METADATA_NOT_FOUND:
return msg`Field metadata not found.`;
case WorkspaceMigrationActionExecutionExceptionCode.OBJECT_METADATA_NOT_FOUND:
return msg`Object metadata not found.`;
case WorkspaceMigrationActionExecutionExceptionCode.ENUM_OPERATION_FAILED:
return msg`Enum operation failed.`;
case WorkspaceMigrationActionExecutionExceptionCode.UNSUPPORTED_COMPOSITE_COLUMN_TYPE:
return msg`Unsupported composite column type.`;
case WorkspaceMigrationActionExecutionExceptionCode.NOT_SUPPORTED:
return msg`This operation is not supported.`;
case WorkspaceMigrationActionExecutionExceptionCode.INVALID_ACTION_TYPE:
return msg`Invalid action type.`;
case WorkspaceMigrationActionExecutionExceptionCode.FLAT_ENTITY_NOT_FOUND:
return msg`Entity not found.`;
case WorkspaceMigrationActionExecutionExceptionCode.UNSUPPORTED_FIELD_METADATA_TYPE:
return msg`Unsupported field metadata type.`;
case WorkspaceMigrationActionExecutionExceptionCode.INTERNAL_SERVER_ERROR:
return msg`An unexpected error occurred.`;
default:
assertUnreachable(code);
}
};
export class WorkspaceMigrationActionExecutionException extends CustomError {
code: keyof typeof WorkspaceMigrationActionExecutionExceptionCode;
userFriendlyMessage: MessageDescriptor;
constructor({
message,
code,
userFriendlyMessage,
}: {
message: string;
code: keyof typeof WorkspaceMigrationActionExecutionExceptionCode;
userFriendlyMessage?: MessageDescriptor;
}) {
super(message);
this.code = code;
this.userFriendlyMessage =
userFriendlyMessage ??
getWorkspaceMigrationActionExecutionExceptionUserFriendlyMessage(code);
}
}
@@ -1,62 +1,68 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { assertUnreachable, CustomError } from 'twenty-shared/utils';
import {
appendCommonExceptionCode,
CustomException,
} from 'src/utils/custom-exception';
import { type WorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common';
export const WorkspaceMigrationRunnerExceptionCode = appendCommonExceptionCode({
FIELD_METADATA_NOT_FOUND: 'FIELD_METADATA_NOT_FOUND',
OBJECT_METADATA_NOT_FOUND: 'OBJECT_METADATA_NOT_FOUND',
ENUM_OPERATION_FAILED: 'ENUM_OPERATION_FAILED',
UNSUPPORTED_COMPOSITE_COLUMN_TYPE: 'UNSUPPORTED_COMPOSITE_COLUMN_TYPE',
NOT_SUPPORTED: 'NOT_SUPPORTED',
INVALID_ACTION_TYPE: 'INVALID_ACTION_TYPE',
FLAT_ENTITY_NOT_FOUND: 'FLAT_ENTITY_NOT_FOUND',
UNSUPPORTED_FIELD_METADATA_TYPE: 'UNSUPPORTED_FIELD_METADATA_TYPE',
} as const);
export const WorkspaceMigrationRunnerExceptionCode = {
INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
EXECUTION_FAILED: 'EXECUTION_FAILED',
} as const;
const getWorkspaceMigrationRunnerExceptionUserFriendlyMessage = (
code: keyof typeof WorkspaceMigrationRunnerExceptionCode,
) => {
switch (code) {
case WorkspaceMigrationRunnerExceptionCode.FIELD_METADATA_NOT_FOUND:
return msg`Field metadata not found.`;
case WorkspaceMigrationRunnerExceptionCode.OBJECT_METADATA_NOT_FOUND:
return msg`Object metadata not found.`;
case WorkspaceMigrationRunnerExceptionCode.ENUM_OPERATION_FAILED:
return msg`Enum operation failed.`;
case WorkspaceMigrationRunnerExceptionCode.UNSUPPORTED_COMPOSITE_COLUMN_TYPE:
return msg`Unsupported composite column type.`;
case WorkspaceMigrationRunnerExceptionCode.NOT_SUPPORTED:
return msg`This operation is not supported.`;
case WorkspaceMigrationRunnerExceptionCode.INVALID_ACTION_TYPE:
return msg`Invalid action type.`;
case WorkspaceMigrationRunnerExceptionCode.FLAT_ENTITY_NOT_FOUND:
return msg`Entity not found.`;
case WorkspaceMigrationRunnerExceptionCode.INTERNAL_SERVER_ERROR:
return msg`An unexpected error occurred.`;
case WorkspaceMigrationRunnerExceptionCode.UNSUPPORTED_FIELD_METADATA_TYPE:
return msg`Unsupported field metadata type.`;
case WorkspaceMigrationRunnerExceptionCode.EXECUTION_FAILED:
return msg`Migration execution failed.`;
default:
assertUnreachable(code);
}
};
export class WorkspaceMigrationRunnerException extends CustomException<
keyof typeof WorkspaceMigrationRunnerExceptionCode
> {
constructor(
message: string,
code: keyof typeof WorkspaceMigrationRunnerExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getWorkspaceMigrationRunnerExceptionUserFriendlyMessage(code),
});
export type WorkspaceMigrationRunnerExecutionErrors = {
metadata?: Error;
workspaceSchema?: Error;
};
type WorkspaceMigrationRunnerExceptionConstructorArgs =
| {
message: string;
code: typeof WorkspaceMigrationRunnerExceptionCode.INTERNAL_SERVER_ERROR;
userFriendlyMessage?: MessageDescriptor;
}
| {
action: WorkspaceMigrationAction;
errors: WorkspaceMigrationRunnerExecutionErrors;
code: typeof WorkspaceMigrationRunnerExceptionCode.EXECUTION_FAILED;
userFriendlyMessage?: MessageDescriptor;
};
export class WorkspaceMigrationRunnerException extends CustomError {
code: keyof typeof WorkspaceMigrationRunnerExceptionCode;
userFriendlyMessage: MessageDescriptor;
action?: WorkspaceMigrationAction;
errors?: WorkspaceMigrationRunnerExecutionErrors;
constructor(args: WorkspaceMigrationRunnerExceptionConstructorArgs) {
if (args.code === WorkspaceMigrationRunnerExceptionCode.EXECUTION_FAILED) {
super(
`Migration action '${args.action.type}' for '${args.action.metadataName}' failed`,
);
this.code = args.code;
this.action = args.action;
this.errors = args.errors;
} else {
super(args.message);
this.code = args.code;
}
this.userFriendlyMessage =
args.userFriendlyMessage ??
getWorkspaceMigrationRunnerExceptionUserFriendlyMessage(args.code);
}
}
@@ -13,6 +13,10 @@ import {
type WorkspaceMigrationAction,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common';
import { WORKSPACE_MIGRATION_ACTION_HANDLER_METADATA_KEY } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/constants/workspace-migration-action-handler-metadata-key.constant';
import {
WorkspaceMigrationRunnerException,
WorkspaceMigrationRunnerExceptionCode,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception';
import { type WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type';
import { optimisticallyApplyCreateActionOnAllFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-create-action-on-all-flat-entity-maps.util';
import { optimisticallyApplyDeleteActionOnAllFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-delete-action-on-all-flat-entity-maps.util';
@@ -24,7 +28,6 @@ type OptimisticallyApplyActionOnAllFlatEntityMapsArgs<
WorkspaceMigrationActionRunnerArgs<TActionType>,
'allFlatEntityMaps' | 'action'
>;
export abstract class BaseWorkspaceMigrationRunnerActionHandlerService<
TActionType extends WorkspaceMigrationActionType,
TMetadataName extends AllMetadataName,
@@ -80,7 +83,7 @@ export abstract class BaseWorkspaceMigrationRunnerActionHandlerService<
}
rollbackForMetadata(
_context: WorkspaceMigrationActionRunnerArgs<TAction>,
_context: Omit<WorkspaceMigrationActionRunnerArgs<TAction>, 'queryRunner'>,
): Promise<void> {
return Promise.resolve();
}
@@ -94,32 +97,53 @@ export abstract class BaseWorkspaceMigrationRunnerActionHandlerService<
| MetadataToFlatEntityMapsKey<TMetadataName>
>
> {
try {
await Promise.all([
this.asyncMethodPerformanceMetricWrapper({
label: 'executeForMetadata',
method: async () => this.executeForMetadata(context),
}),
this.asyncMethodPerformanceMetricWrapper({
label: 'executeForWorkspaceSchema',
method: async () => this.executeForWorkspaceSchema(context),
}),
]);
const [metadataResult, workspaceSchemaResult] = await Promise.allSettled([
this.asyncMethodPerformanceMetricWrapper({
label: 'executeForMetadata',
method: async () => this.executeForMetadata(context),
}),
this.asyncMethodPerformanceMetricWrapper({
label: 'executeForWorkspaceSchema',
method: async () => this.executeForWorkspaceSchema(context),
}),
]);
return this.optimisticallyApplyActionOnAllFlatEntityMaps({
const hasMetadataError = metadataResult.status === 'rejected';
const hasWorkspaceSchemaError = workspaceSchemaResult.status === 'rejected';
if (hasMetadataError || hasWorkspaceSchemaError) {
throw new WorkspaceMigrationRunnerException({
action: context.action,
errors: {
...(hasMetadataError && { metadata: metadataResult.reason }),
...(hasWorkspaceSchemaError && {
workspaceSchema: workspaceSchemaResult.reason,
}),
},
code: WorkspaceMigrationRunnerExceptionCode.EXECUTION_FAILED,
});
}
const partialOptimisticCache =
this.optimisticallyApplyActionOnAllFlatEntityMaps({
action: context.action,
allFlatEntityMaps: context.allFlatEntityMaps,
});
} catch (error) {
this.logger.error(`${this.actionType} execution failed`, error);
throw error;
}
return partialOptimisticCache;
}
async rollback(
context: WorkspaceMigrationActionRunnerArgs<TAction>,
context: Omit<WorkspaceMigrationActionRunnerArgs<TAction>, 'queryRunner'>,
): Promise<void> {
await this.rollbackForMetadata(context);
try {
await this.rollbackForMetadata(context);
} catch (error) {
this.logger.error(
`Failed to rollback ${context.action.type} action for ${context.action.metadataName}: ${error instanceof Error ? error.message : 'Unknown error'}`,
'BaseWorkspaceMigrationRunnerActionHandlerService',
);
}
}
private async asyncMethodPerformanceMetricWrapper({
@@ -1,22 +1,19 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { DiscoveryService } from '@nestjs/core';
import { isDefined } from 'twenty-shared/utils';
import { BaseWorkspaceMigrationRunnerActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface';
import { AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import {
buildActionHandlerKey,
type WorkspaceMigrationActionHandlerKey,
type WorkspaceMigrationAction,
type WorkspaceMigrationActionHandlerKey,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common';
import { WorkspaceSchemaMigrationRunnerActionHandlersModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module';
import { WORKSPACE_MIGRATION_ACTION_HANDLER_METADATA_KEY } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/constants/workspace-migration-action-handler-metadata-key.constant';
import {
WorkspaceMigrationRunnerException,
WorkspaceMigrationRunnerExceptionCode,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception';
WorkspaceMigrationActionExecutionException,
WorkspaceMigrationActionExecutionExceptionCode,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-action-execution.exception';
import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type';
@Injectable()
@@ -56,15 +53,7 @@ export class WorkspaceMigrationRunnerActionHandlerRegistryService
});
}
async executeActionHandler<T extends WorkspaceMigrationAction>({
action,
context,
rollback,
}: {
action: T;
context: WorkspaceMigrationActionRunnerArgs<T>;
rollback?: boolean;
}): Promise<Partial<AllFlatEntityMaps>> {
private getActionHandler<T extends WorkspaceMigrationAction>(action: T) {
const actionHandlerKey = buildActionHandlerKey(
action.type,
action.metadataName,
@@ -72,18 +61,36 @@ export class WorkspaceMigrationRunnerActionHandlerRegistryService
const handler = this.actionHandlers.get(actionHandlerKey);
if (!handler) {
throw new WorkspaceMigrationRunnerException(
`No migration runner action handler found for action: ${actionHandlerKey}`,
WorkspaceMigrationRunnerExceptionCode.INVALID_ACTION_TYPE,
);
throw new WorkspaceMigrationActionExecutionException({
message: `No migration runner action handler found for action: ${actionHandlerKey}`,
code: WorkspaceMigrationActionExecutionExceptionCode.INVALID_ACTION_TYPE,
});
}
if (isDefined(rollback) && rollback) {
await handler.rollback(context);
return handler;
}
return {};
}
async executeActionHandler<T extends WorkspaceMigrationAction>({
action,
context,
}: {
action: T;
context: WorkspaceMigrationActionRunnerArgs<T>;
}) {
const handler = this.getActionHandler(action);
return await handler.execute(context);
}
async executeActionRollbackHandler<T extends WorkspaceMigrationAction>({
action,
context,
}: {
action: T;
context: Omit<WorkspaceMigrationActionRunnerArgs<T>, 'queryRunner'>;
}) {
const handler = this.getActionHandler(action);
await handler.rollback(context);
}
}
@@ -1,23 +1,24 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { AllMetadataName } from 'twenty-shared/metadata';
import { DataSource } from 'typeorm';
import {
WorkspaceQueryRunnerException,
WorkspaceQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.exception';
import { LoggerService } from 'src/engine/core-modules/logger/logger.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
import { getMetadataNameFromFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-name-from-flat-entity-maps-key.util';
import { getMetadataRelatedMetadataNames } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-related-metadata-names.util';
import { FIND_ALL_CORE_VIEWS_GRAPHQL_OPERATION } from 'src/engine/metadata-modules/view/constants/find-all-core-views-graphql-operation.constant';
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigration } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration';
import { WorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common';
import {
WorkspaceMigrationRunnerException,
WorkspaceMigrationRunnerExceptionCode,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception';
import { WorkspaceMigrationRunnerActionHandlerRegistryService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/registry/workspace-migration-runner-action-handler-registry.service';
@Injectable()
@@ -104,35 +105,93 @@ export class WorkspaceMigrationRunnerService {
return asyncOperations;
}
private async invalidateCachePostExecution({
allFlatEntityMapsKeys,
workspaceId,
actions,
}: {
allFlatEntityMapsKeys: (keyof AllFlatEntityMaps)[];
workspaceId: string;
actions: WorkspaceMigrationAction[];
}): Promise<void> {
this.logger.time(
'Runner',
`Cache invalidation ${allFlatEntityMapsKeys.join()}`,
);
await this.flatEntityMapsCacheService.invalidateFlatEntityMaps({
workspaceId,
flatMapsKeys: allFlatEntityMapsKeys,
});
const invalidationResults = await Promise.allSettled(
this.getLegacyCacheInvalidationPromises({
workspaceMigration: {
actions,
workspaceId,
},
}),
);
const invalidationFailures = invalidationResults.filter(
(result) => result.status === 'rejected',
);
if (invalidationFailures.length > 0) {
invalidationFailures.forEach((err) =>
this.logger.error(
`Failed to invalidate a legacy cache ${err.reason}`,
'Runner',
),
);
throw new Error(
`Failed to invalidate ${invalidationFailures.length} cache operations`,
);
}
this.logger.timeEnd(
'Runner',
`Cache invalidation ${allFlatEntityMapsKeys.join()}`,
);
}
run = async ({
actions,
workspaceId,
relatedFlatEntityMapsKeys,
}: WorkspaceMigration): Promise<AllFlatEntityMaps> => {
this.logger.time('Runner', 'Total execution');
this.logger.time('Runner', 'Initial cache retrieval');
const queryRunner = this.coreDataSource.createQueryRunner();
const actionMetadataNames = [
...new Set(actions.flatMap((action) => action.metadataName)),
];
const actionsMetadataAndRelatedMetadataNames: AllMetadataName[] = [
...new Set([
...actionMetadataNames,
...actionMetadataNames.flatMap(getMetadataRelatedMetadataNames),
]),
];
const allFlatEntityMapsKeys = actionsMetadataAndRelatedMetadataNames.map(
getMetadataFlatEntityMapsKey,
);
let allFlatEntityMaps =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: relatedFlatEntityMapsKeys,
flatMapsKeys: allFlatEntityMapsKeys,
},
);
this.logger.timeEnd('Runner', 'Initial cache retrieval');
this.logger.time('Runner', 'Transaction execution');
await queryRunner.connect();
await queryRunner.startTransaction();
let flatEntityMapsToInvalidate: (keyof AllFlatEntityMaps)[] = [];
try {
await queryRunner.connect();
await queryRunner.startTransaction();
for (const action of actions) {
const partialOptimisticCache =
const result =
await this.workspaceMigrationRunnerActionHandlerRegistry.executeActionHandler(
{
action,
@@ -144,20 +203,10 @@ export class WorkspaceMigrationRunnerService {
},
},
);
const optimisticallyUpdatedFlatEntityMapsKeys = Object.keys(
partialOptimisticCache,
) as (keyof AllFlatEntityMaps)[];
flatEntityMapsToInvalidate = [
...new Set([
...optimisticallyUpdatedFlatEntityMapsKeys,
...flatEntityMapsToInvalidate,
]),
];
allFlatEntityMaps = {
...allFlatEntityMaps,
...partialOptimisticCache,
...result,
};
}
@@ -165,89 +214,46 @@ export class WorkspaceMigrationRunnerService {
this.logger.timeEnd('Runner', 'Transaction execution');
const flatEntitiesCacheToInvalidate = [
...new Set([
...flatEntityMapsToInvalidate,
...flatEntityMapsToInvalidate
.map(getMetadataNameFromFlatEntityMapsKey)
.flatMap(getMetadataRelatedMetadataNames)
.map(getMetadataFlatEntityMapsKey),
]),
];
this.logger.time(
'Runner',
`Cache invalidation ${flatEntitiesCacheToInvalidate.join()}`,
);
await this.flatEntityMapsCacheService.invalidateFlatEntityMaps({
await this.invalidateCachePostExecution({
allFlatEntityMapsKeys,
workspaceId,
flatMapsKeys: flatEntitiesCacheToInvalidate,
actions,
});
const invalidationResults = await Promise.allSettled(
this.getLegacyCacheInvalidationPromises({
workspaceMigration: {
actions,
workspaceId,
},
}),
);
const invalidationFailures = invalidationResults.filter(
(result) => result.status === 'rejected',
);
if (invalidationFailures.length > 0) {
invalidationFailures.forEach((err) =>
this.logger.error(
`Failed to invalidate a legacy cache ${err.reason}`,
'Runner',
),
);
throw new Error(
`Failed to invalidate ${invalidationFailures.length} cache operations`,
);
}
this.logger.timeEnd(
'Runner',
`Cache invalidation ${flatEntitiesCacheToInvalidate.join()}`,
);
this.logger.timeEnd('Runner', 'Total execution');
return allFlatEntityMaps;
} catch (error) {
if (queryRunner.isTransactionActive) {
try {
await queryRunner.rollbackTransaction();
} catch (error) {
await queryRunner.rollbackTransaction().catch((error) =>
// eslint-disable-next-line no-console
console.trace(`Failed to rollback transaction: ${error.message}`);
}
console.trace(`Failed to rollback transaction: ${error.message}`),
);
}
const invertedActions = actions.reverse();
const invertedActions = [...actions].reverse();
for (const invertedAction of invertedActions) {
await this.workspaceMigrationRunnerActionHandlerRegistry.executeActionHandler(
await this.workspaceMigrationRunnerActionHandlerRegistry.executeActionRollbackHandler(
{
action: invertedAction,
context: {
action: invertedAction,
allFlatEntityMaps: allFlatEntityMaps,
queryRunner,
allFlatEntityMaps,
workspaceId,
},
rollback: true,
},
);
}
throw new WorkspaceQueryRunnerException(
error.message,
WorkspaceQueryRunnerExceptionCode.INTERNAL_SERVER_ERROR,
);
if (error instanceof WorkspaceMigrationRunnerException) {
throw error;
}
throw new WorkspaceMigrationRunnerException({
message: error.message,
code: WorkspaceMigrationRunnerExceptionCode.INTERNAL_SERVER_ERROR,
});
} finally {
await queryRunner.release();
}
@@ -3,7 +3,7 @@ import { type QueryRunner } from 'typeorm';
import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock';
import { type WorkspaceSchemaManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.service';
import { WorkspaceMigrationRunnerException } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception';
import { WorkspaceMigrationActionExecutionException } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-action-execution.exception';
import {
collectEnumOperationsForField,
EnumOperation,
@@ -61,7 +61,7 @@ describe('WorkspaceSchemaEnumOperations', () => {
schemaName: 'test_schema',
workspaceSchemaManagerService: mockSchemaManagerService,
}),
).rejects.toThrow(WorkspaceMigrationRunnerException);
).rejects.toThrow(WorkspaceMigrationActionExecutionException);
// All operations should be attempted in parallel despite failure
expect(
@@ -1,9 +1,9 @@
import { FieldMetadataType } from 'twenty-shared/types';
import {
WorkspaceMigrationRunnerException,
WorkspaceMigrationRunnerExceptionCode,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception';
WorkspaceMigrationActionExecutionException,
WorkspaceMigrationActionExecutionExceptionCode,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-action-execution.exception';
import { isTextColumnType } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/is-text-column-type.util';
export const fieldMetadataTypeToColumnType = <Type extends FieldMetadataType>(
@@ -41,9 +41,9 @@ export const fieldMetadataTypeToColumnType = <Type extends FieldMetadataType>(
case FieldMetadataType.TS_VECTOR:
return 'tsvector';
default:
throw new WorkspaceMigrationRunnerException(
`Cannot convert ${fieldMetadataType} to column type.`,
WorkspaceMigrationRunnerExceptionCode.UNSUPPORTED_FIELD_METADATA_TYPE,
);
throw new WorkspaceMigrationActionExecutionException({
message: `Cannot convert ${fieldMetadataType} to column type.`,
code: WorkspaceMigrationActionExecutionExceptionCode.UNSUPPORTED_FIELD_METADATA_TYPE,
});
}
};
@@ -16,9 +16,9 @@ import { type WorkspaceSchemaColumnDefinition } from 'src/engine/twenty-orm/work
import { computePostgresEnumName } from 'src/engine/workspace-manager/workspace-migration/utils/compute-postgres-enum-name.util';
import { serializeDefaultValue } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/utils/serialize-default-value.util';
import {
WorkspaceMigrationRunnerException,
WorkspaceMigrationRunnerExceptionCode,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception';
WorkspaceMigrationActionExecutionException,
WorkspaceMigrationActionExecutionExceptionCode,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-action-execution.exception';
import { fieldMetadataTypeToColumnType } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/field-metadata-type-to-column-type.util';
import { getWorkspaceSchemaContextForMigration } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/get-workspace-schema-context-for-migration.util';
@@ -40,10 +40,10 @@ export const generateCompositeColumnDefinition = ({
compositeProperty.type === FieldMetadataType.RELATION ||
compositeProperty.type === FieldMetadataType.MORPH_RELATION
) {
throw new WorkspaceMigrationRunnerException(
`Relation type not supported for composite columns`,
WorkspaceMigrationRunnerExceptionCode.UNSUPPORTED_COMPOSITE_COLUMN_TYPE,
);
throw new WorkspaceMigrationActionExecutionException({
message: `Relation type not supported for composite columns`,
code: WorkspaceMigrationActionExecutionExceptionCode.UNSUPPORTED_COMPOSITE_COLUMN_TYPE,
});
}
const columnName = computeCompositeColumnName(
@@ -12,9 +12,9 @@ import { isEnumFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-
import { type WorkspaceSchemaManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.service';
import { computePostgresEnumName } from 'src/engine/workspace-manager/workspace-migration/utils/compute-postgres-enum-name.util';
import {
WorkspaceMigrationRunnerException,
WorkspaceMigrationRunnerExceptionCode,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception';
WorkspaceMigrationActionExecutionException,
WorkspaceMigrationActionExecutionExceptionCode,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-action-execution.exception';
export interface CreateEnumOperationSpec {
operation: EnumOperation.CREATE;
@@ -246,9 +246,9 @@ export const executeBatchEnumOperations = async ({
await Promise.all(enumPromises);
} catch (error) {
throw new WorkspaceMigrationRunnerException(
`Failed to execute batch enum operations: ${error instanceof Error ? error.message : 'Unknown error'}`,
WorkspaceMigrationRunnerExceptionCode.ENUM_OPERATION_FAILED,
);
throw new WorkspaceMigrationActionExecutionException({
message: `Failed to execute batch enum operations: ${error instanceof Error ? error.message : 'Unknown error'}`,
code: WorkspaceMigrationActionExecutionExceptionCode.ENUM_OPERATION_FAILED,
});
}
};