From 6b55a6b51cfd1aa5a99fc136bffb3b119f59c13d Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:23:26 +0200 Subject: [PATCH] Fix duplicate searchFieldMetadata inserts in the 2.16 backfill upgrade command (#23060) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context A self-hosted instance upgrading from 2.0.3 to v2.22.0 got stuck with one workspace failing at `2.16.0_BackfillSearchFieldMetadataCommand`: ``` [QueryFailedError] duplicate key value violates unique constraint "IDX_SEARCH_FIELD_METADATA_OBJECT_FIELD_UNIQUE" Detail: Key ("objectMetadataId", "fieldMetadataId")=(...) already exists. ``` The failure happened on a retry after a previous partial run, and reproduced even though the command already recomputes `flatSearchFieldMetadataMaps` before deriving the create-set (#22884). ## Root cause The idempotency dedupe compares `(objectMetadataId, fieldMetadataId)` pairs across two differently-fresh caches: - The **existing rows** side comes from `flatSearchFieldMetadataMaps`, which is recomputed from the database (real current ids). - The **candidate** side resolves ids through `flatObjectMetadataMaps` / `flatFieldMetadataMaps`, which are **not** invalidated. During a cross-version upgrade these can be stale, since the migration runner only invalidates the cache keys a migration touched. When a stale map resolves a candidate to an outdated id, the dedupe key doesn't match the existing row and the row is re-emitted. The migration runner then re-resolves the universal identifiers against fresh maps at execution time and inserts with the real current ids — exactly the pair already committed by the earlier partial run (each per-application migration commits independently) — tripping the unique constraint and failing the upgrade. ## Fix Two independent layers, either of which would have prevented the failure: 1. **Consistent snapshot for the build phase**: the command now invalidates and recomputes all three maps the dedupe depends on (`flatObjectMetadataMaps`, `flatFieldMetadataMaps`, `flatSearchFieldMetadataMaps`), so candidate resolution, existing-row keys, and the runner all see the same database state. 2. **Id-churn-proof dedupe**: every row this command creates carries a deterministic universal identifier (`getSearchFieldUniversalIdentifier`, derived from application + field universal identifiers, no database ids involved) and `(workspaceId, universalIdentifier)` is unique. The build util now also skips any candidate whose deterministic universal identifier already exists, catching leftovers from a previous partial run even if objects/fields were recreated under new ids in between. Deliberately **not** done: `ON CONFLICT DO NOTHING` in the create action handler — it is shared by all runtime `searchFieldMetadata` creation, and swallowing a conflict would leave the flat-entity cache holding an entity id that differs from the row actually in the database. ## Test Added a regression test reproducing the failure shape: an existing row with the same deterministic universal identifier but stale metadata ids must not be re-emitted by the backfill. Note: `ReconcileSearchFieldMetadataCommand` (2.20) has the same stale-cache exposure; hardening it is left to a follow-up. Review in cubic --- ...-backfill-search-field-metadata.command.ts | 13 +++-- ...-metadata-backfill-operations.util.spec.ts | 53 ++++++++++++++++++- ...field-metadata-backfill-operations.util.ts | 26 +++++++-- 3 files changed, 82 insertions(+), 10 deletions(-) diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/2-16-workspace-command-1799100000000-backfill-search-field-metadata.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/2-16-workspace-command-1799100000000-backfill-search-field-metadata.command.ts index e414063e7b..9ed24c1465 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/2-16-workspace-command-1799100000000-backfill-search-field-metadata.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/2-16-workspace-command-1799100000000-backfill-search-field-metadata.command.ts @@ -34,11 +34,16 @@ export class BackfillSearchFieldMetadataCommand extends ProvisionedWorkspaceComm const isDryRun = options.dryRun ?? false; // The migration runner only invalidates the flat-maps keys a migration touched, - // so during a cross-version upgrade earlier commands can leave this map stale. - // A stale map breaks the existing-rows dedupe below and re-inserts rows, - // tripping IDX_SEARCH_FIELD_METADATA_OBJECT_FIELD_UNIQUE. Recompute from the - // database before deriving the create-set. + // so during a cross-version upgrade earlier commands can leave these maps stale. + // The existing-rows dedupe below compares (objectMetadataId, fieldMetadataId) + // pairs across maps: candidate ids resolved from a stale object/field map won't + // match the fresh search map's ids, so already-created rows are re-emitted and + // the runner (which re-resolves universal identifiers against fresh maps) trips + // IDX_SEARCH_FIELD_METADATA_OBJECT_FIELD_UNIQUE. Recompute every map the dedupe + // depends on from the database before deriving the create-set. await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [ + 'flatObjectMetadataMaps', + 'flatFieldMetadataMaps', 'flatSearchFieldMetadataMaps', ]); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/utils/__tests__/build-search-field-metadata-backfill-operations.util.spec.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/utils/__tests__/build-search-field-metadata-backfill-operations.util.spec.ts index 4955883268..ff6e684014 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/utils/__tests__/build-search-field-metadata-backfill-operations.util.spec.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/utils/__tests__/build-search-field-metadata-backfill-operations.util.spec.ts @@ -1,4 +1,7 @@ -import { TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER } from 'twenty-shared/application'; +import { + getSearchFieldUniversalIdentifier, + TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER, +} from 'twenty-shared/application'; import { FieldMetadataType } from 'twenty-shared/types'; import { buildSearchFieldMetadataBackfillOperations } from 'src/database/commands/upgrade-version-command/2-16/utils/build-search-field-metadata-backfill-operations.util'; @@ -622,6 +625,54 @@ describe('buildSearchFieldMetadataBackfillOperations', () => { ).toHaveLength(0); }); + it('skips a row whose deterministic universal identifier already exists even when the existing row carries stale metadata ids', () => { + const { customObject, nameField, nameDescriptionField, searchVectorField } = + buildCustomObjectFixture(); + + // Simulates a retry after a partial run during a cross-version upgrade: the + // previously committed row still points at the ids the metadata had at insert + // time, while the object/field maps now expose new ids (id churn from earlier + // upgrade commands). The (objectMetadataId, fieldMetadataId) dedupe misses the + // pair, but the deterministic universal identifier is unchanged and must + // prevent re-emitting the row. + const existingSearchFieldMetadata = buildSearchFieldMetadata({ + id: 'existing-search-field-id', + universalIdentifier: getSearchFieldUniversalIdentifier({ + applicationUniversalIdentifier: + CUSTOM_APPLICATION_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: nameField.universalIdentifier, + }), + objectMetadataId: 'stale-object-metadata-id', + fieldMetadataId: 'stale-field-metadata-id', + objectMetadataUniversalIdentifier: customObject.universalIdentifier, + fieldMetadataUniversalIdentifier: nameField.universalIdentifier, + applicationUniversalIdentifier: CUSTOM_APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + const { flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier } = + buildSearchFieldMetadataBackfillOperations({ + flatObjectMetadataMaps: buildFlatObjectMetadataMaps([customObject]), + flatFieldMetadataMaps: buildFlatFieldMetadataMaps([ + nameField, + nameDescriptionField, + searchVectorField, + ]), + flatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps([ + existingSearchFieldMetadata, + ]), + standardFlatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps( + [], + ), + customApplicationId: CUSTOM_APPLICATION_ID, + }); + + expect( + Object.keys( + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier, + ), + ).toHaveLength(0); + }); + it('skips a custom object whose name field is not a searchable type', () => { const customObjectId = 'relation-name-object-id'; const customObjectUniversalIdentifier = 'relation-name-object-uid'; diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/utils/build-search-field-metadata-backfill-operations.util.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/utils/build-search-field-metadata-backfill-operations.util.ts index 41220b32e6..5631940e82 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/utils/build-search-field-metadata-backfill-operations.util.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/utils/build-search-field-metadata-backfill-operations.util.ts @@ -94,16 +94,32 @@ export const buildSearchFieldMetadataBackfillOperations = ({ return; } - candidateSearchFieldMetadataKeys.add(searchFieldMetadataKey); - - flatSearchFieldMetadatasToCreate.push( - buildFlatSearchFieldMetadataForField({ + const universalFlatSearchFieldMetadata = buildFlatSearchFieldMetadataForField( + { flatObjectMetadata, flatFieldMetadata, tsVectorFlatFieldMetadata, position, - }), + }, ); + + // Second dedupe layer, immune to metadata-id churn: rows created by a previous + // partial run of this command carry the same deterministic universal identifier + // (unique per workspace). The id-based check above can miss them when earlier + // upgrade commands recreated objects/fields under new ids. + if ( + isDefined( + flatSearchFieldMetadataMaps.byUniversalIdentifier[ + universalFlatSearchFieldMetadata.universalIdentifier + ], + ) + ) { + return; + } + + candidateSearchFieldMetadataKeys.add(searchFieldMetadataKey); + + flatSearchFieldMetadatasToCreate.push(universalFlatSearchFieldMetadata); }; // Standard objects: mirror exactly what provisioning/standard-sync creates.