Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/sentry/utils/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,10 +333,16 @@ def bulk_delete_objects(
logger: logging.Logger | None = None,
**filters: Any,
) -> bool:
qs = model.objects.filter(**filters).values_list("id")[:limit]
# Materialize IDs with ORDER BY before issuing the DELETE to break the self-join subquery
# pattern that causes deadlocks under concurrent workers. Pushing ORDER BY into the SELECT
# means PostgreSQL returns IDs in a consistent order; while the DELETE's internal scan order
# is still up to the planner, eliminating the two-phase self-join lock is the primary fix.
ids = list(model.objects.filter(**filters).order_by("id").values_list("id", flat=True)[:limit])
if not ids:
return False

delete_query = DeleteQuery(model)
delete_query.add_q(Q(id__in=qs))
delete_query.add_q(Q(id__in=ids))
n = delete_query.get_compiler(router.db_for_write(model)).execute_sql(ROW_COUNT)

# `n` will be `None` if `qs` is an empty queryset
Expand Down
5 changes: 3 additions & 2 deletions tests/sentry/utils/test_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -448,14 +448,15 @@ def test_limiting(self) -> None:
def test_bulk_delete_single_query(self) -> None:
repo = self.create_repo()
# Commit is chosen because there are foreign keys and a naive delete
# will attempt to cascade
# will attempt to cascade. We now issue two queries: a SELECT to
# materialize IDs (for consistent lock ordering) and the raw DELETE.
Commit.objects.create(organization_id=repo.organization_id, repository_id=repo.id)

assert len(Commit.objects.all()) == 1
before = len(connections[Commit.objects.db].queries_log)
assert bulk_delete_objects(Commit)
after = len(connections[Commit.objects.db].queries_log)
assert after == before + 1
assert after == before + 2
assert len(Commit.objects.all()) == 0

def test_bulk_delete_empty_queryset(self) -> None:
Expand Down
Loading