Compare commits

...

2 Commits

Author SHA1 Message Date
Charles Bochet 3c2fed8232 feat: add batch updateManyViewGroups endpoint to fix Kanban reorder race conditions
Replace N sequential updateViewGroup mutations with a single
updateManyViewGroups batch mutation. The server processes all updates
in one validateBuildAndRunWorkspaceMigration call, eliminating
PostgreSQL lock contention and cache thundering herd.

- Add updateMany to ViewGroupService, refactor updateOne to delegate
- Add updateManyViewGroups mutation to ViewGroupResolver
- Create frontend GraphQL mutation and update persist hooks
- Add integration test for batch update operations

Fixes #18865

Made-with: Cursor
2026-03-27 08:32:53 +01:00
Rayan 14048f7253 fix: execute viewGroup mutations sequentially to prevent race conditions
Changes Promise.all concurrent mutations to sequential for...of loop.
This prevents race conditions in the workspace migration runner when
reordering stages in Kanban board.

Fixes #18865
2026-03-27 04:37:42 +00:00
9 changed files with 415 additions and 45 deletions
File diff suppressed because one or more lines are too long
@@ -0,0 +1,11 @@
import { VIEW_GROUP_FRAGMENT } from '@/views/graphql/fragments/viewGroupFragment';
import { gql } from '@apollo/client';
export const UPDATE_MANY_VIEW_GROUPS = gql`
${VIEW_GROUP_FRAGMENT}
mutation UpdateManyViewGroups($inputs: [UpdateViewGroupInput!]!) {
updateManyViewGroups(inputs: $inputs) {
...ViewGroupFragment
}
}
`;
@@ -8,43 +8,44 @@ import { t } from '@lingui/core/macro';
import { CrudOperationType } from 'twenty-shared/types';
import { useMutation } from '@apollo/client/react';
import {
type UpdateViewGroupMutationVariables,
UpdateViewGroupDocument,
type UpdateManyViewGroupsMutationVariables,
UpdateManyViewGroupsDocument,
} from '~/generated-metadata/graphql';
export const usePerformViewGroupAPIPersist = () => {
const [updateViewGroupMutation] = useMutation(UpdateViewGroupDocument);
const [updateManyViewGroupsMutation] = useMutation(
UpdateManyViewGroupsDocument,
);
const { handleMetadataError } = useMetadataErrorHandler();
const { enqueueErrorSnackBar } = useSnackBar();
const performViewGroupAPIUpdate = useCallback(
async (
updateViewGroupInputs: UpdateViewGroupMutationVariables[],
updateViewGroupInputs: UpdateManyViewGroupsMutationVariables,
): Promise<
MetadataRequestResult<
Awaited<ReturnType<typeof updateViewGroupMutation>>[]
>
MetadataRequestResult<Awaited<
ReturnType<typeof updateManyViewGroupsMutation>
> | null>
> => {
if (updateViewGroupInputs.length === 0) {
if (
!Array.isArray(updateViewGroupInputs.inputs) ||
updateViewGroupInputs.inputs.length === 0
) {
return {
status: 'successful',
response: [],
response: null,
};
}
try {
const results = await Promise.all(
updateViewGroupInputs.map((variables) =>
updateViewGroupMutation({
variables,
}),
),
);
const result = await updateManyViewGroupsMutation({
variables: updateViewGroupInputs,
});
return {
status: 'successful',
response: results,
response: result,
};
} catch (error) {
if (CombinedGraphQLErrors.is(error)) {
@@ -62,7 +63,7 @@ export const usePerformViewGroupAPIPersist = () => {
};
}
},
[updateViewGroupMutation, handleMetadataError, enqueueErrorSnackBar],
[updateManyViewGroupsMutation, handleMetadataError, enqueueErrorSnackBar],
);
return {
@@ -67,9 +67,9 @@ export const useSaveCurrentViewGroups = () => {
return;
}
await performViewGroupAPIUpdate([
{
input: {
await performViewGroupAPIUpdate({
inputs: [
{
id: existingField.id,
update: {
isVisible: viewGroupToSave.isVisible,
@@ -77,8 +77,8 @@ export const useSaveCurrentViewGroups = () => {
fieldValue: viewGroupToSave.fieldValue,
},
},
},
]);
],
});
},
[
store,
@@ -109,7 +109,7 @@ export const useSaveCurrentViewGroups = () => {
const currentViewGroups = view.viewGroups;
const viewGroupsToUpdate = viewGroupsToSave
const viewGroupInputsToUpdate = viewGroupsToSave
.map((viewGroupToSave) => {
const existingField = currentViewGroups.find(
(currentViewGroup) =>
@@ -136,13 +136,11 @@ export const useSaveCurrentViewGroups = () => {
}
return {
input: {
id: existingField.id,
update: {
isVisible: viewGroupToSave.isVisible,
position: viewGroupToSave.position,
fieldValue: viewGroupToSave.fieldValue,
},
id: existingField.id,
update: {
isVisible: viewGroupToSave.isVisible,
position: viewGroupToSave.position,
fieldValue: viewGroupToSave.fieldValue,
},
};
})
@@ -152,7 +150,7 @@ export const useSaveCurrentViewGroups = () => {
throw new Error('mainGroupByFieldMetadataId is required');
}
await performViewGroupAPIUpdate(viewGroupsToUpdate);
await performViewGroupAPIUpdate({ inputs: viewGroupInputsToUpdate });
},
[
store,
@@ -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,
}),
),
);
}
@@ -0,0 +1,235 @@
import { createOneSelectFieldMetadataForIntegrationTests } from 'test/integration/metadata/suites/field-metadata/utils/create-one-select-field-metadata-for-integration-tests.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { createManyViewGroups } from 'test/integration/metadata/suites/view-group/utils/create-many-view-groups.util';
import { deleteOneViewGroup } from 'test/integration/metadata/suites/view-group/utils/delete-one-view-group.util';
import { destroyOneViewGroup } from 'test/integration/metadata/suites/view-group/utils/destroy-one-view-group.util';
import { updateManyViewGroups } from 'test/integration/metadata/suites/view-group/utils/update-many-view-groups.util';
import { createOneView } from 'test/integration/metadata/suites/view/utils/create-one-view.util';
import { isDefined } from 'twenty-shared/utils';
import { type CreateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/create-view-group.input';
describe('View Group Resolver - Successful Update Many Operations - v2', () => {
let testSetup: {
testViewId: string;
testObjectMetadataId: string;
};
let createdViewGroupIds: string[] = [];
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'myUpdateManyGroupTestObjectV2',
namePlural: 'myUpdateManyGroupTestObjectsV2',
labelSingular: 'My Update Many Group Test Object v2',
labelPlural: 'My Update Many Group Test Objects v2',
icon: 'Icon123',
},
});
const { selectFieldMetadataId } =
await createOneSelectFieldMetadataForIntegrationTests({
input: {
objectMetadataId,
},
});
const {
data: {
createView: { id: testViewId },
},
} = await createOneView({
input: {
icon: 'icon123',
objectMetadataId,
name: 'TestViewForUpdateManyGroups',
mainGroupByFieldMetadataId: selectFieldMetadataId,
},
expectToFail: false,
});
testSetup = {
testViewId,
testObjectMetadataId: objectMetadataId,
};
});
afterAll(async () => {
await updateOneObjectMetadata({
input: {
idToUpdate: testSetup.testObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: testSetup.testObjectMetadataId },
});
});
afterEach(async () => {
for (const viewGroupId of createdViewGroupIds) {
if (isDefined(viewGroupId)) {
const {
data: { deleteViewGroup },
} = await deleteOneViewGroup({
expectToFail: false,
input: {
id: viewGroupId,
},
});
expect(deleteViewGroup.deletedAt).not.toBeNull();
await destroyOneViewGroup({
expectToFail: false,
input: {
id: viewGroupId,
},
});
}
}
createdViewGroupIds = [];
});
it('should batch-update positions of multiple view groups at once', async () => {
const createInputs: CreateViewGroupInput[] = [
{
viewId: testSetup.testViewId,
position: 0,
isVisible: true,
fieldValue: 'Group A',
},
{
viewId: testSetup.testViewId,
position: 1,
isVisible: true,
fieldValue: 'Group B',
},
{
viewId: testSetup.testViewId,
position: 2,
isVisible: true,
fieldValue: 'Group C',
},
];
const {
data: { createManyViewGroups: createdGroups },
} = await createManyViewGroups({
inputs: createInputs,
expectToFail: false,
});
createdViewGroupIds = createdGroups.map(
(viewGroup: { id: string }) => viewGroup.id,
);
const {
data: { updateManyViewGroups: updatedGroups },
errors,
} = await updateManyViewGroups({
inputs: [
{ id: createdGroups[0].id, update: { position: 2 } },
{ id: createdGroups[1].id, update: { position: 0 } },
{ id: createdGroups[2].id, update: { position: 1 } },
],
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(updatedGroups).toBeDefined();
expect(updatedGroups).toHaveLength(3);
expect(updatedGroups[0]).toMatchObject({
id: createdGroups[0].id,
position: 2,
});
expect(updatedGroups[1]).toMatchObject({
id: createdGroups[1].id,
position: 0,
});
expect(updatedGroups[2]).toMatchObject({
id: createdGroups[2].id,
position: 1,
});
});
it('should batch-update visibility of multiple view groups at once', async () => {
const createInputs: CreateViewGroupInput[] = [
{
viewId: testSetup.testViewId,
position: 0,
isVisible: true,
fieldValue: 'Visible Group',
},
{
viewId: testSetup.testViewId,
position: 1,
isVisible: true,
fieldValue: 'To Be Hidden Group',
},
];
const {
data: { createManyViewGroups: createdGroups },
} = await createManyViewGroups({
inputs: createInputs,
expectToFail: false,
});
createdViewGroupIds = createdGroups.map(
(viewGroup: { id: string }) => viewGroup.id,
);
const {
data: { updateManyViewGroups: updatedGroups },
errors,
} = await updateManyViewGroups({
inputs: [
{ id: createdGroups[0].id, update: { isVisible: false } },
{
id: createdGroups[1].id,
update: { isVisible: false, position: 5 },
},
],
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(updatedGroups).toBeDefined();
expect(updatedGroups).toHaveLength(2);
expect(updatedGroups[0]).toMatchObject({
id: createdGroups[0].id,
isVisible: false,
});
expect(updatedGroups[1]).toMatchObject({
id: createdGroups[1].id,
isVisible: false,
position: 5,
});
});
it('should return empty array for empty inputs', async () => {
const {
data: { updateManyViewGroups: updatedGroups },
errors,
} = await updateManyViewGroups({
inputs: [],
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(updatedGroups).toBeDefined();
expect(updatedGroups).toHaveLength(0);
});
});
@@ -0,0 +1,23 @@
import gql from 'graphql-tag';
import { VIEW_GROUP_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants';
import { type UpdateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/update-view-group.input';
export const updateManyViewGroupsQueryFactory = ({
gqlFields = VIEW_GROUP_GQL_FIELDS,
inputs,
}: {
gqlFields?: string;
inputs: UpdateViewGroupInput[];
}) => ({
query: gql`
mutation UpdateManyViewGroups($inputs: [UpdateViewGroupInput!]!) {
updateManyViewGroups(inputs: $inputs) {
${gqlFields}
}
}
`,
variables: {
inputs,
},
});
@@ -0,0 +1,45 @@
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { updateManyViewGroupsQueryFactory } from 'test/integration/metadata/suites/view-group/utils/update-many-view-groups-query-factory.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { type UpdateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/update-view-group.input';
import { type ViewGroupEntity } from 'src/engine/metadata-modules/view-group/entities/view-group.entity';
export const updateManyViewGroups = async ({
inputs,
gqlFields,
expectToFail,
}: {
inputs: UpdateViewGroupInput[];
gqlFields?: string;
expectToFail?: boolean;
}): CommonResponseBody<{
updateManyViewGroups: ViewGroupEntity[];
}> => {
const graphqlOperation = updateManyViewGroupsQueryFactory({
inputs,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage:
'View Groups batch update should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage:
'View Groups batch update has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};