Skip to content

fix(core): Prevent duplicate translations from concurrent saves - #5007

Open
brmk wants to merge 3 commits into
vendurehq:masterfrom
Uplab:fix/concurrent-translation-duplicates
Open

fix(core): Prevent duplicate translations from concurrent saves#5007
brmk wants to merge 3 commits into
vendurehq:masterfrom
Uplab:fix/concurrent-translation-duplicates

Conversation

@brmk

@brmk brmk commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Description

Translatable entities (Product, Collection, ProductVariant, etc.) could end up with two translation rows for the same (baseId, languageCode) pair. TranslatableSaver.update() loads existing translations, then TranslationDiffer inserts any language not already present — with no locking and no unique constraint. Two concurrent updates that both add the same new language (e.g. a double-click on Update in the Admin UI) can both read "no en row yet" before either commits, and both insert one. Once that happens, the entity becomes uneditable through the Admin UI.

This PR:

  • Adds @Unique(['languageCode', 'base']) to all 13 translation entities, so the database itself now rejects a second row for the same (baseId, languageCode) pair.
  • Adds isUniqueConstraintViolationError() to db-errors.ts (Postgres/MySQL/SQLite), mirroring the existing isForeignKeyViolationError().
  • Updates TranslationDiffer.applyDiff() so that when an insert hits this constraint (a concurrent request already inserted the row), it re-fetches that row and updates it instead of throwing a 500 — so a losing concurrent save converges instead of leaving the entity stuck.
  • Adds deduplicateTranslations() to migration-utils, a helper that accepts a single table name or an array of table names, which users can call from their own migration's up() (before the new unique constraint is created) to remove any duplicate rows that already exist in their database. It also works for any custom translatable entity a plugin defines, not just the 13 core ones.
  • Adds an e2e regression test reproducing the race (two concurrent updateProduct calls adding the same new language) and asserting only one translation row results.

Fixes #4884

Breaking changes

Adds a unique index to the 13 *_translation tables. This only affects databases where duplicate rows already exist for the same (baseId, languageCode) — in that case, running vendure migrate generate will produce a migration that fails on up() until the duplicates are removed. No changes to any public API surface otherwise (deduplicateTranslations is a new, additive export).

Migration guide for users

Since existing production databases may already contain the duplicate rows this PR guards against, upgrading needs one extra manual step — this should be called out in the release notes:

  1. Upgrade @vendure/core and run vendure migrate generate <name> as usual. If your database has no duplicates, the generated migration only adds the new constraints and needs no further changes.
  2. If the generated migration fails when applied (unique constraint violation on up()), open the generated migration file and add a deduplicateTranslations(queryRunner, [...]) call at the very top of up(), listing every *_translation table touched by that migration (see the JSDoc example on deduplicateTranslations in migration-utils/translation-deduplication.ts for a full worked Postgres example with the exact, deterministic constraint names TypeORM will generate). If you have your own plugins with custom translatable entities, include their translation tables in that same array.
  3. Re-run the migration. deduplicateTranslations keeps the most recently updated row per (baseId, languageCode) and removes the rest, so no translation content is lost — only the redundant duplicate rows are dropped.

Worth adding to the upgrade guide / release notes so users hitting the failed-migration case aren't stuck guessing what to do.

Checklist

📌 Always:

  • I have set a clear title
  • My PR is small and contains a single feature
  • I have checked my own PR

👍 Most of the time:

  • I have added or updated test cases
  • I have updated the README if needed

View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.

@vercel

vercel Bot commented Jul 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
vendure-storybook Ready Ready Preview, Comment Jul 20, 2026 12:30pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b2171964-ef40-43ef-a941-1f563cf81411

📥 Commits

Reviewing files that changed from the base of the PR and between 9510965 and 4ad3786.

📒 Files selected for processing (1)
  • packages/core/src/service/helpers/translatable-saver/translation-differ.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/core/src/service/helpers/translatable-saver/translation-differ.ts

📝 Walkthrough

Walkthrough

Translation entities now enforce uniqueness for each base entity and language. A migration utility removes pre-existing duplicate rows while retaining the newest record. Translation save logic detects unique-constraint conflicts caused by concurrent inserts, reloads the existing translation, and continues saving. A product end-to-end test covers simultaneous updates adding the same translation language and verifies that only one row remains.

Suggested reviewers: michaelbromley

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main fix: preventing duplicate translations from concurrent saves.
Description check ✅ Passed The PR description covers the summary, breaking changes, migration guidance, and checklist items required by the template.
Linked Issues check ✅ Passed The changes address #4884 by adding unique constraints, conflict handling, deduplication, and a regression test for concurrent saves.
Out of Scope Changes check ✅ Passed The added entities, helper, retry logic, and test all support the duplicate-translation fix and are in scope.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/core/src/service/helpers/translatable-saver/translation-differ.ts (1)

71-78: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Prevent PostgreSQL from aborting the parent transaction on constraint violation.

In PostgreSQL, when a query throws an error (such as a unique constraint violation), the entire transaction enters an aborted state. Any subsequent queries within that same transaction block (like the findOne and save calls in this catch block) will automatically fail with the error: current transaction is aborted, commands ignored until end of transaction block.

Because mutations in Vendure are typically wrapped in an active transaction (and the query runner is attached to ctx), this concurrency fallback logic will always fail on PostgreSQL.

To handle this safely without aborting the parent transaction, you should wrap the initial insert attempt in a savepoint (nested transaction). If the constraint violation occurs, only the savepoint is rolled back, leaving the parent transaction healthy to execute the fallback queries in the catch block. Based on learnings, Vendure natively supports nested savepoint transactions via connection.withTransaction().

🔒️ Proposed fix to use a savepoint for the insert
-                try {
-                    newTranslation = await this.connection
-                        .getRepository(ctx, this.translationCtor)
-                        .save(translation as any);
-                } catch (err: any) {
+                try {
+                    // Wrap in a savepoint to prevent Postgres from aborting the parent transaction
+                    // on a unique constraint violation.
+                    newTranslation = await this.connection.withTransaction(ctx, async ctxWithSavepoint => {
+                        return await this.connection
+                            .getRepository(ctxWithSavepoint, this.translationCtor)
+                            .save(translation as any);
+                    });
+                } catch (err: any) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/service/helpers/translatable-saver/translation-differ.ts`
around lines 71 - 78, Wrap the initial translation save attempt in the nested
savepoint transaction provided by connection.withTransaction(), using the
existing ctx so only the savepoint rolls back on a unique constraint violation.
Keep the fallback logic in the surrounding catch block, allowing its findOne and
save operations to run against the still-healthy parent transaction; preserve
rethrowing non-unique errors as InternalServerError.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/core/src/service/helpers/translatable-saver/translation-differ.ts`:
- Around line 71-78: Wrap the initial translation save attempt in the nested
savepoint transaction provided by connection.withTransaction(), using the
existing ctx so only the savepoint rolls back on a unique constraint violation.
Keep the fallback logic in the surrounding catch block, allowing its findOne and
save operations to run against the still-healthy parent transaction; preserve
rethrowing non-unique errors as InternalServerError.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d07c1e79-7c3b-4bac-9f8f-2fea83e0cd4e

📥 Commits

Reviewing files that changed from the base of the PR and between 284722f and 9c891ee.

📒 Files selected for processing (18)
  • packages/core/e2e/product.e2e-spec.ts
  • packages/core/src/entity/api-key/api-key-translation.entity.ts
  • packages/core/src/entity/asset/asset-translation.entity.ts
  • packages/core/src/entity/collection/collection-translation.entity.ts
  • packages/core/src/entity/facet-value/facet-value-translation.entity.ts
  • packages/core/src/entity/facet/facet-translation.entity.ts
  • packages/core/src/entity/payment-method/payment-method-translation.entity.ts
  • packages/core/src/entity/product-option-group/product-option-group-translation.entity.ts
  • packages/core/src/entity/product-option/product-option-translation.entity.ts
  • packages/core/src/entity/product-variant/product-variant-translation.entity.ts
  • packages/core/src/entity/product/product-translation.entity.ts
  • packages/core/src/entity/promotion/promotion-translation.entity.ts
  • packages/core/src/entity/region/region-translation.entity.ts
  • packages/core/src/entity/shipping-method/shipping-method-translation.entity.ts
  • packages/core/src/migration-utils/index.ts
  • packages/core/src/migration-utils/translation-deduplication.ts
  • packages/core/src/service/helpers/translatable-saver/translation-differ.ts
  • packages/core/src/service/helpers/utils/db-errors.ts

@michaelbromley michaelbromley added the T3: Systemic Involves a systemic decision. Decide before implementing. label Jul 20, 2026
@michaelbromley

michaelbromley commented Jul 20, 2026

Copy link
Copy Markdown
Member

T3: Systemic — resolves an open question by picking one interpretation (pattern 7).

  • New @Unique(['languageCode', 'base']) constraint; stores with existing duplicate rows hit a failing migration
  • Recovery is a manual deduplicateTranslations() call in the generated migration, discoverable only from a JSDoc comment
  • Decision needed on the upgrade path: manual step / auto-inject into generated migration / bundled automatic core migration

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T3: Systemic Involves a systemic decision. Decide before implementing.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Duplicate translations (same entity + languageCode) created by concurrent/double-click Admin save; entity then becomes uneditable

2 participants