make mergeMany atomic and optimize relation/field-map handling (#21885)
Closes [core-team-issue#2333](https://github.com/twentyhq/core-team-issues/issues/2333) ## Summary Hardens and optimizes `CommonMergeManyQueryRunnerService`: - **Atomicity**: wrap relation migration + duplicate deletion + survivor update in a single transaction so a mid-merge failure rolls back fully (previously failures were swallowed and could leave orphaned/half-merged data). - **Perf**: drop the redundant `find`-before-`update` in relation migration (2N → N queries, no row hydration) and hoist `buildFieldMapsFromFlatObjectMetadata` out of the per-field loops. ### Why a transaction (not parallelization) The relation migrations could be parallelized with `Promise.all`, but merge is a destructive operation: a partial failure leaves orphaned or half-merged records. We prioritize correctness, so the steps run inside one transaction. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21885?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Félix Malfait <felix@twenty.com> Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
+85
-63
@@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import {
|
||||
@@ -15,7 +15,6 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
import { FindOptionsRelations, In, ObjectLiteral } from 'typeorm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
|
||||
import { CommonBaseQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-base-query-runner.service';
|
||||
import {
|
||||
CommonQueryRunnerException,
|
||||
@@ -35,6 +34,7 @@ import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runne
|
||||
import { hasRecordFieldValue } from 'src/engine/api/graphql/graphql-query-runner/utils/has-record-field-value.util';
|
||||
import { mergeFieldValues } from 'src/engine/api/graphql/graphql-query-runner/utils/merge-field-values.util';
|
||||
import { WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
|
||||
import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
|
||||
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
@@ -42,6 +42,8 @@ import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-module
|
||||
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
|
||||
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
|
||||
import { WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
|
||||
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
|
||||
|
||||
@Injectable()
|
||||
export class CommonMergeManyQueryRunnerService extends CommonBaseQueryRunnerService<
|
||||
@@ -50,16 +52,11 @@ export class CommonMergeManyQueryRunnerService extends CommonBaseQueryRunnerServ
|
||||
> {
|
||||
protected readonly operationName = CommonQueryNames.MERGE_MANY;
|
||||
|
||||
private readonly logger = new Logger(CommonMergeManyQueryRunnerService.name);
|
||||
async run(
|
||||
args: CommonExtendedInput<MergeManyQueryArgs>,
|
||||
queryRunnerContext: CommonExtendedQueryRunnerContext,
|
||||
): Promise<ObjectRecord> {
|
||||
const {
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadata,
|
||||
} = queryRunnerContext;
|
||||
const { flatFieldMetadataMaps, flatObjectMetadata } = queryRunnerContext;
|
||||
|
||||
const recordsToMerge = await this.fetchRecordsToMerge(
|
||||
queryRunnerContext,
|
||||
@@ -86,15 +83,48 @@ export class CommonMergeManyQueryRunnerService extends CommonBaseQueryRunnerServ
|
||||
|
||||
const idsToDelete = args.ids.filter((id) => id !== priorityRecord.id);
|
||||
|
||||
await this.migrateRelatedRecords(
|
||||
const updatedRecord =
|
||||
await queryRunnerContext.workspaceDataSource.transaction(
|
||||
(transactionManager: WorkspaceEntityManager) =>
|
||||
this.executeMergeWithinTransaction(transactionManager, {
|
||||
args,
|
||||
queryRunnerContext,
|
||||
idsToDelete,
|
||||
priorityRecordId: priorityRecord.id,
|
||||
mergedData,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.processNestedRelations({
|
||||
args,
|
||||
queryRunnerContext,
|
||||
updatedRecords: [updatedRecord],
|
||||
});
|
||||
|
||||
return updatedRecord;
|
||||
}
|
||||
|
||||
private async executeMergeWithinTransaction(
|
||||
transactionManager: WorkspaceEntityManager,
|
||||
{
|
||||
args,
|
||||
queryRunnerContext,
|
||||
idsToDelete,
|
||||
priorityRecord.id,
|
||||
);
|
||||
|
||||
const queryBuilder = queryRunnerContext.repository.createQueryBuilder(
|
||||
flatObjectMetadata.nameSingular,
|
||||
);
|
||||
priorityRecordId,
|
||||
mergedData,
|
||||
}: {
|
||||
args: CommonExtendedInput<MergeManyQueryArgs>;
|
||||
queryRunnerContext: CommonExtendedQueryRunnerContext;
|
||||
idsToDelete: string[];
|
||||
priorityRecordId: string;
|
||||
mergedData: Partial<ObjectRecord>;
|
||||
},
|
||||
): Promise<ObjectRecord> {
|
||||
const {
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
} = queryRunnerContext;
|
||||
|
||||
const columnsToReturn = buildColumnsToReturn({
|
||||
select: args.selectedFieldsResult.select,
|
||||
@@ -104,26 +134,33 @@ export class CommonMergeManyQueryRunnerService extends CommonBaseQueryRunnerServ
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
await queryBuilder
|
||||
const transactionRepository = transactionManager.getRepository(
|
||||
flatObjectMetadata.nameSingular,
|
||||
queryRunnerContext.rolePermissionConfig,
|
||||
queryRunnerContext.authContext,
|
||||
);
|
||||
|
||||
await this.migrateRelatedRecords(
|
||||
transactionManager,
|
||||
queryRunnerContext,
|
||||
idsToDelete,
|
||||
priorityRecordId,
|
||||
);
|
||||
|
||||
await transactionRepository
|
||||
.createQueryBuilder(flatObjectMetadata.nameSingular)
|
||||
.delete()
|
||||
.whereInIds(idsToDelete)
|
||||
.returning(columnsToReturn)
|
||||
.execute();
|
||||
|
||||
const updatedRecord = await this.updatePriorityRecord(
|
||||
return this.updatePriorityRecord(
|
||||
args,
|
||||
queryRunnerContext,
|
||||
priorityRecord.id,
|
||||
transactionRepository,
|
||||
priorityRecordId,
|
||||
mergedData,
|
||||
);
|
||||
|
||||
await this.processNestedRelations({
|
||||
args,
|
||||
queryRunnerContext,
|
||||
updatedRecords: [updatedRecord],
|
||||
});
|
||||
|
||||
return updatedRecord;
|
||||
}
|
||||
|
||||
private async fetchRecordsToMerge(
|
||||
@@ -212,6 +249,11 @@ export class CommonMergeManyQueryRunnerService extends CommonBaseQueryRunnerServ
|
||||
): Partial<ObjectRecord> {
|
||||
const mergedResult: Partial<ObjectRecord> = {};
|
||||
|
||||
const { fieldIdByName } = buildFieldMapsFromFlatObjectMetadata(
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadata,
|
||||
);
|
||||
|
||||
const allFieldNames = new Set<string>();
|
||||
|
||||
recordsToMerge.forEach((record) => {
|
||||
@@ -219,7 +261,7 @@ export class CommonMergeManyQueryRunnerService extends CommonBaseQueryRunnerServ
|
||||
if (
|
||||
!this.shouldExcludeFieldFromMerge(
|
||||
fieldName,
|
||||
flatObjectMetadata,
|
||||
fieldIdByName,
|
||||
flatFieldMetadataMaps,
|
||||
)
|
||||
) {
|
||||
@@ -244,10 +286,6 @@ export class CommonMergeManyQueryRunnerService extends CommonBaseQueryRunnerServ
|
||||
} else if (recordsWithValues.length === 1) {
|
||||
mergedResult[fieldName] = recordsWithValues[0].value;
|
||||
} else {
|
||||
const { fieldIdByName } = buildFieldMapsFromFlatObjectMetadata(
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadata,
|
||||
);
|
||||
const fieldMetadata = findFlatEntityByIdInFlatEntityMaps({
|
||||
flatEntityId: fieldIdByName[fieldName],
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
@@ -279,13 +317,9 @@ export class CommonMergeManyQueryRunnerService extends CommonBaseQueryRunnerServ
|
||||
|
||||
private shouldExcludeFieldFromMerge(
|
||||
fieldName: string,
|
||||
flatObjectMetadata: FlatObjectMetadata,
|
||||
fieldIdByName: Record<string, string>,
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
|
||||
): boolean {
|
||||
const { fieldIdByName } = buildFieldMapsFromFlatObjectMetadata(
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadata,
|
||||
);
|
||||
const fieldMetadata = findFlatEntityByIdInFlatEntityMaps({
|
||||
flatEntityId: fieldIdByName[fieldName],
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
@@ -311,6 +345,7 @@ export class CommonMergeManyQueryRunnerService extends CommonBaseQueryRunnerServ
|
||||
private async updatePriorityRecord(
|
||||
args: CommonExtendedInput<MergeManyQueryArgs>,
|
||||
queryRunnerContext: CommonExtendedQueryRunnerContext,
|
||||
repository: WorkspaceRepository<ObjectLiteral>,
|
||||
priorityRecordId: string,
|
||||
mergedData: Partial<ObjectRecord>,
|
||||
): Promise<ObjectRecord> {
|
||||
@@ -318,7 +353,6 @@ export class CommonMergeManyQueryRunnerService extends CommonBaseQueryRunnerServ
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
repository,
|
||||
} = queryRunnerContext;
|
||||
|
||||
const queryBuilder = repository.createQueryBuilder(
|
||||
@@ -354,6 +388,7 @@ export class CommonMergeManyQueryRunnerService extends CommonBaseQueryRunnerServ
|
||||
}
|
||||
|
||||
private async migrateRelatedRecords(
|
||||
transactionManager: WorkspaceEntityManager,
|
||||
context: CommonExtendedQueryRunnerContext,
|
||||
fromIds: string[],
|
||||
toId: string,
|
||||
@@ -366,8 +401,6 @@ export class CommonMergeManyQueryRunnerService extends CommonBaseQueryRunnerServ
|
||||
|
||||
const relationFieldsPointingToCurrentObject: Array<{
|
||||
objectMetadata: FlatObjectMetadata;
|
||||
fieldName: string;
|
||||
fieldId: string;
|
||||
joinColumnName: string | undefined;
|
||||
}> = [];
|
||||
|
||||
@@ -401,8 +434,6 @@ export class CommonMergeManyQueryRunnerService extends CommonBaseQueryRunnerServ
|
||||
|
||||
relationFieldsPointingToCurrentObject.push({
|
||||
objectMetadata: objMetadata,
|
||||
fieldName: field.name,
|
||||
fieldId: field.id,
|
||||
joinColumnName: computeMorphOrRelationFieldJoinColumnName({
|
||||
name: field.name,
|
||||
}),
|
||||
@@ -414,29 +445,20 @@ export class CommonMergeManyQueryRunnerService extends CommonBaseQueryRunnerServ
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const repository = context.workspaceDataSource.getRepository(
|
||||
relationField.objectMetadata.nameSingular,
|
||||
context.rolePermissionConfig,
|
||||
);
|
||||
const repository = transactionManager.getRepository(
|
||||
relationField.objectMetadata.nameSingular,
|
||||
context.rolePermissionConfig,
|
||||
context.authContext,
|
||||
);
|
||||
|
||||
const whereCondition = { [relationField.joinColumnName]: In(fromIds) };
|
||||
|
||||
const existingRecords = await repository.find({
|
||||
where: whereCondition,
|
||||
});
|
||||
|
||||
if (existingRecords.length > 0) {
|
||||
await repository.update(whereCondition, {
|
||||
[relationField.joinColumnName]: toId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to migrate relation field "${relationField.fieldName}" (${relationField.joinColumnName}) in object "${relationField.objectMetadata.nameSingular}":`,
|
||||
error.message,
|
||||
);
|
||||
}
|
||||
// repository.update() runs outside the transaction; build from the transaction-scoped repository so the migration rolls back with the merge.
|
||||
await repository
|
||||
.createQueryBuilder(relationField.objectMetadata.nameSingular)
|
||||
.update()
|
||||
.set({ [relationField.joinColumnName]: toId })
|
||||
.where({ [relationField.joinColumnName]: In(fromIds) })
|
||||
.returning('*')
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user