From f6af4eeabd323b8491f7f770f654d09e69631ab8 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Fri, 27 Mar 2026 11:04:37 -0400 Subject: [PATCH] fix(merge): Handle unique constraint conflicts in merge_users_for_model_in_org Before updating user references during a user merge, delete from_user rows that would violate unique constraints by conflicting with existing to_user rows. Uses Exists/OuterRef subqueries to generically detect conflicts for any model, including conditional UniqueConstraints. Fixes SENTRY-5HVP --- src/sentry/backup/dependencies.py | 43 +++++++++++++++++++++- tests/sentry/users/models/test_user.py | 50 ++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/src/sentry/backup/dependencies.py b/src/sentry/backup/dependencies.py index e7325c0d554778..c4933e33b97bea 100644 --- a/src/sentry/backup/dependencies.py +++ b/src/sentry/backup/dependencies.py @@ -7,7 +7,7 @@ from typing import NamedTuple from django.db import models -from django.db.models import Q, UniqueConstraint +from django.db.models import Exists, OuterRef, Q, UniqueConstraint from django.db.models.fields.related import ForeignKey, OneToOneField from sentry.backup.helpers import EXCLUDED_APPS @@ -704,6 +704,26 @@ def dedupe_and_reassign_groupsubscription_in_org( GroupSubscription.objects.filter(scoped, user_id=from_user_id).update(user_id=to_user_id) +def _get_unique_constraints_for_field( + model: type[models.base.Model], field_name: str +) -> list[tuple[frozenset[str], Q | None]]: + """ + Return all unique constraints on ``model`` that include ``field_name``. + Each entry is ``(frozenset_of_field_names, optional_condition_Q)``. + """ + results: list[tuple[frozenset[str], Q | None]] = [] + for combo in model._meta.unique_together: + fields = frozenset(combo) + if field_name in fields: + results.append((fields, None)) + for constraint in model._meta.constraints: + if isinstance(constraint, UniqueConstraint): + fields = frozenset(constraint.fields) + if field_name in fields: + results.append((fields, getattr(constraint, "condition", None))) + return results + + def merge_users_for_model_in_org( model: type[models.base.Model], *, organization_id: int, from_user_id: int, to_user_id: int ) -> None: @@ -739,5 +759,26 @@ def merge_users_for_model_in_org( for_this_org = Q(**{field_name: organization_id for field_name in org_refs}) for user_ref in user_refs: + # Delete from_user rows that would violate unique constraints when updated. + for unique_fields, condition in _get_unique_constraints_for_field(model, user_ref): + other_fields = unique_fields - {user_ref} + if not other_fields: + continue + + # Build Exists subquery: find to_user rows matching on all non-user fields. + subquery_kwargs: dict[str, object] = {user_ref: to_user_id} + for field in other_fields: + subquery_kwargs[field] = OuterRef(field) + + subquery = model.objects.filter(**subquery_kwargs) + if condition is not None: + subquery = subquery.filter(condition) + + qs = model.objects.filter(for_this_org, **{user_ref: from_user_id}) + if condition is not None: + qs = qs.filter(condition) + qs.filter(Exists(subquery)).delete() + + # Now safe to update remaining rows. q = for_this_org & Q(**{user_ref: from_user_id}) model.objects.filter(q).update(**{user_ref: to_user_id}) diff --git a/tests/sentry/users/models/test_user.py b/tests/sentry/users/models/test_user.py index a34ab72712ae1b..a44e93f4c4ef14 100644 --- a/tests/sentry/users/models/test_user.py +++ b/tests/sentry/users/models/test_user.py @@ -307,6 +307,56 @@ def test_merge_handles_groupsubscription_conflicts(self) -> None: assert not GroupSubscription.objects.filter(group=group, user_id=from_user.id).exists() assert GroupSubscription.objects.filter(group=group, user_id=to_user.id).count() == 1 + def test_merge_handles_recentsearch_conflicts(self) -> None: + from sentry.utils.hashlib import md5_text + + from_user = self.create_user("from-user@example.com") + to_user = self.create_user("to-user@example.com") + org = self.create_organization(name="recentsearch-conflict-org") + + with outbox_runner(): + with assume_test_silo_mode(SiloMode.CELL): + self.create_member(user=from_user, organization=org) + + with assume_test_silo_mode(SiloMode.CELL): + query = "is:unresolved" + query_hash = md5_text(query).hexdigest() + RecentSearch.objects.create( + organization=org, user_id=from_user.id, type=0, query=query, query_hash=query_hash + ) + RecentSearch.objects.create( + organization=org, user_id=to_user.id, type=0, query=query, query_hash=query_hash + ) + + with outbox_runner(): + from_user.merge_to(to_user) + + with assume_test_silo_mode(SiloMode.CELL): + assert not RecentSearch.objects.filter(organization=org, user_id=from_user.id).exists() + assert RecentSearch.objects.filter(organization=org, user_id=to_user.id).count() == 1 + + def test_merge_handles_groupbookmark_conflicts(self) -> None: + from_user = self.create_user("from-user@example.com") + to_user = self.create_user("to-user@example.com") + org = self.create_organization(name="bookmark-conflict-org") + + with outbox_runner(): + with assume_test_silo_mode(SiloMode.CELL): + self.create_member(user=from_user, organization=org) + + with assume_test_silo_mode(SiloMode.CELL): + project = self.create_project(organization=org) + group = self.create_group(project=project) + GroupBookmark.objects.create(project=project, group=group, user_id=from_user.id) + GroupBookmark.objects.create(project=project, group=group, user_id=to_user.id) + + with outbox_runner(): + from_user.merge_to(to_user) + + with assume_test_silo_mode(SiloMode.CELL): + assert not GroupBookmark.objects.filter(group=group, user_id=from_user.id).exists() + assert GroupBookmark.objects.filter(group=group, user_id=to_user.id).count() == 1 + @expect_models( ORG_MEMBER_MERGE_TESTED, OrgAuthToken,