fix: batch viewGroup mutations sequentially to prevent race conditions (#19027)

## Bug Description

When reordering stages in the Kanban board, the frontend fires all
viewGroup update mutations concurrently via Promise.all, causing race
conditions in the workspace migration runner's cache invalidation,
database contention, and a thundering herd effect that stalls the
server.

## Changes

Changed `usePerformViewGroupAPIPersist` to execute viewGroup update
mutations sequentially instead of concurrently. The `Promise.all`
pattern fired all N mutations simultaneously, each triggering a full
workspace migration runner pipeline (transaction + cache invalidation).
The sequential `for...of` loop ensures each mutation completes
(including its cache invalidation) before the next begins, eliminating
the race condition.

## Related Issue

Fixes #18865

## Testing

This fix addresses the root cause identified in the Sonarly analysis on
the issue. The concurrent mutation pattern was causing:
- PostgreSQL row-level lock contention on viewGroup rows
- Cache thundering herd from repeated invalidation/recomputation cycles
- Server stalls requiring container restarts

The sequential approach ensures proper ordering and prevents these race
conditions.

---------

Co-authored-by: Rayan <rayan@example.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Rayan Salhab
2026-03-27 10:13:51 +02:00
committed by GitHub
parent 17424320e3
commit 6f0ac88e20
12 changed files with 427 additions and 45 deletions
@@ -84,6 +84,19 @@ export class ViewGroupResolver {
});
}
@Mutation(() => [ViewGroupDTO])
@UseGuards(UpdateViewGroupPermissionGuard)
async updateManyViewGroups(
@Args('inputs', { type: () => [UpdateViewGroupInput] })
updateViewGroupInputs: UpdateViewGroupInput[],
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<ViewGroupDTO[]> {
return await this.viewGroupService.updateMany({
updateViewGroupInputs,
workspaceId,
});
}
@Mutation(() => ViewGroupDTO)
@UseGuards(DeleteViewGroupPermissionGuard)
async deleteViewGroup(
@@ -7,6 +7,7 @@ import { IsNull, Repository } from 'typeorm';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util';
import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { fromCreateViewGroupInputToFlatViewGroupToCreate } from 'src/engine/metadata-modules/flat-view-group/utils/from-create-view-group-input-to-flat-view-group-to-create.util';
@@ -152,6 +153,32 @@ export class ViewGroupService {
workspaceId: string;
updateViewGroupInput: UpdateViewGroupInput;
}): Promise<ViewGroupDTO> {
const [updatedViewGroup] = await this.updateMany({
updateViewGroupInputs: [updateViewGroupInput],
workspaceId,
});
if (!isDefined(updatedViewGroup)) {
throw new ViewGroupException(
'Failed to update view group',
ViewGroupExceptionCode.INVALID_VIEW_GROUP_DATA,
);
}
return updatedViewGroup;
}
async updateMany({
updateViewGroupInputs,
workspaceId,
}: {
updateViewGroupInputs: UpdateViewGroupInput[];
workspaceId: string;
}): Promise<ViewGroupDTO[]> {
if (updateViewGroupInputs.length === 0) {
return [];
}
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{
@@ -167,11 +194,13 @@ export class ViewGroupService {
},
);
const optimisticallyUpdatedFlatViewGroup =
fromUpdateViewGroupInputToFlatViewGroupToUpdateOrThrow({
flatViewGroupMaps: existingFlatViewGroupMaps,
updateViewGroupInput,
});
const flatViewGroupsToUpdate = updateViewGroupInputs.map(
(updateViewGroupInput) =>
fromUpdateViewGroupInputToFlatViewGroupToUpdateOrThrow({
flatViewGroupMaps: existingFlatViewGroupMaps,
updateViewGroupInput,
}),
);
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
@@ -180,7 +209,7 @@ export class ViewGroupService {
viewGroup: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [optimisticallyUpdatedFlatViewGroup],
flatEntityToUpdate: flatViewGroupsToUpdate,
},
},
workspaceId,
@@ -193,7 +222,7 @@ export class ViewGroupService {
if (validateAndBuildResult.status === 'fail') {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while updating view group',
'Multiple validation errors occurred while updating view groups',
);
}
@@ -205,12 +234,13 @@ export class ViewGroupService {
},
);
return fromFlatViewGroupToViewGroupDto(
findFlatEntityByUniversalIdentifierOrThrow({
universalIdentifier:
optimisticallyUpdatedFlatViewGroup.universalIdentifier,
flatEntityMaps: recomputedExistingFlatViewGroupMaps,
}),
return updateViewGroupInputs.map(({ id }) =>
fromFlatViewGroupToViewGroupDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: recomputedExistingFlatViewGroupMaps,
}),
),
);
}