Skip to content

Commit 3a4edad

Browse files
MaffoochdevGregAclaude
authored
fix(dedupe): re-point chained duplicates before deleting excess ones (backport of #15364) (#15383)
* fix(dedupe): re-point chained duplicates before deleting excess ones async_dupe_delete could pick an excess duplicate that a SURVIVING finding still references via duplicate_finding (a chained duplicate A -> B -> C, which arises from past bugs or high parallel load - the case fix_loop_duplicates exists for). That self-FK is ON DELETE DO_NOTHING, so the delete raises IntegrityError, the chunk rolls back, and the periodic task retries the same finding forever without ever making progress. Observed in production: the same finding id failing every run, and no excess duplicates deleted on that instance at all. Before deleting, inbound references from survivors are now re-pointed at their chain's root outside the delete set; survivors whose chain dead-ends or loops inside it are promoted to originals, mirroring how fix_loop_duplicates handles parentless duplicates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit a83aca3) * test: assert the FK invariant the dedupe fix protects Django defers FK checks to COMMIT, so inside a TestCase transaction the missing re-point surfaces as a reference to a deleted row rather than the IntegrityError production sees at commit time. Assert both the dangling reference and connection.check_constraints() so the test fails for the right reason without the fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 04b855a) --------- Co-authored-by: Greg Anderson <greg.anderson@owasp.org> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 2e841bf commit 3a4edad

2 files changed

Lines changed: 132 additions & 0 deletions

File tree

dojo/tasks.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
from collections import defaultdict
23

34
import pghistory
45
from celery import Task
@@ -41,6 +42,74 @@ def async_dupe_delete(*args, **kwargs):
4142
_async_dupe_delete_impl()
4243

4344

45+
def _repoint_chained_duplicates(ids_to_delete):
46+
"""
47+
Break inbound duplicate_finding references from findings that survive this run.
48+
49+
Chained duplicates (A -> B -> C, from past bugs or high parallel load - see
50+
fix_loop_duplicates) can leave a SURVIVING finding pointing at an excess duplicate
51+
that is about to be deleted. The duplicate_finding self-FK is ON DELETE DO_NOTHING,
52+
so deleting while such references exist raises IntegrityError, rolls the chunk
53+
back, and the task retries forever without ever making progress (observed in
54+
production: the same finding id failing every run).
55+
56+
Survivors are re-pointed at their chain's root outside the delete set; when the
57+
chain dead-ends or loops inside the delete set they are promoted to originals,
58+
mirroring how fix_loop_duplicates handles parentless duplicates.
59+
"""
60+
delete_set = set(ids_to_delete)
61+
62+
# Each deleted finding's own parent lets us walk chains that pass through the
63+
# delete set: id -> duplicate_finding_id.
64+
parent_of_deleted = dict(
65+
Finding.objects.filter(id__in=delete_set).values_list("id", "duplicate_finding_id"),
66+
)
67+
68+
def resolve_surviving_root(start_id):
69+
seen = set()
70+
current = start_id
71+
while current in parent_of_deleted:
72+
if current in seen:
73+
# Defensive: a reference loop entirely inside the delete set.
74+
return None
75+
seen.add(current)
76+
current = parent_of_deleted[current]
77+
return current # A surviving finding id, or None when the chain dead-ends.
78+
79+
survivors = (
80+
Finding.objects
81+
.filter(duplicate_finding_id__in=delete_set)
82+
.exclude(id__in=delete_set)
83+
.values_list("id", "duplicate_finding_id")
84+
)
85+
86+
repoint_groups = defaultdict(list) # surviving root id -> [survivor ids]
87+
promote_ids = []
88+
89+
for survivor_id, parent_id in survivors:
90+
root_id = resolve_surviving_root(parent_id)
91+
if root_id is None or root_id == survivor_id:
92+
# No surviving root (or the chain circles back to the survivor
93+
# itself): promote to original rather than fabricate a self-loop.
94+
promote_ids.append(survivor_id)
95+
else:
96+
repoint_groups[root_id].append(survivor_id)
97+
98+
if not repoint_groups and not promote_ids:
99+
return
100+
101+
deduplicationLogger.warning(
102+
"dupe delete: re-pointing %d chained duplicate reference(s) (promoting %d to original) before deleting %d findings",
103+
sum(len(ids) for ids in repoint_groups.values()), len(promote_ids), len(delete_set),
104+
)
105+
106+
for root_id, survivor_ids in repoint_groups.items():
107+
Finding.objects.filter(id__in=survivor_ids).update(duplicate_finding_id=root_id)
108+
109+
if promote_ids:
110+
Finding.objects.filter(id__in=promote_ids).update(duplicate_finding=None, duplicate=False)
111+
112+
44113
def _async_dupe_delete_impl():
45114
"""Internal implementation of async_dupe_delete within pghistory context."""
46115
try:
@@ -115,6 +184,8 @@ def _async_dupe_delete_impl():
115184
logger.info("total number of excess duplicates to delete: %s", len(ids_to_delete))
116185

117186
if ids_to_delete:
187+
_repoint_chained_duplicates(ids_to_delete)
188+
118189
# order_desc=True deletes higher ids before lower ids, consistent with how
119190
# finding_delete handles duplicate clusters (duplicate_cluster.order_by("-id").delete()).
120191
bulk_delete_findings(Finding.objects.filter(id__in=ids_to_delete), order_desc=True)

unittests/test_duplication_loops.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
22

33
from crum import impersonate
4+
from django.db import connection
45
from django.test.utils import override_settings
56

67
from dojo.finding.deduplication import set_duplicate
@@ -407,6 +408,66 @@ def test_delete_duplicate_order(self):
407408
self.finding_a.refresh_from_db()
408409
self.assertEqual(self.finding_a.duplicate_finding_set().count(), 1)
409410

411+
def test_delete_duplicate_excess_with_chained_reference(self):
412+
"""
413+
A surviving finding that points at an excess duplicate (a chained duplicate, from past
414+
bugs or high parallel load) must not be left dangling. The duplicate_finding self-FK is
415+
ON DELETE DO_NOTHING, so without re-pointing the survivor first the delete leaves a
416+
reference to a deleted row. Django defers FK checks to COMMIT, so in production that
417+
surfaces as an IntegrityError that rolls the chunk back and makes the periodic task
418+
retry the same finding forever; inside a TestCase transaction it surfaces as the
419+
dangling reference and failed constraint check asserted below.
420+
"""
421+
system_settings = System_Settings.objects.get(no_cache=True)
422+
system_settings.delete_duplicates = True
423+
system_settings.max_dupes = 1
424+
system_settings.save()
425+
426+
self.finding_b.date = "2024-01-01"
427+
self.finding_c.date = "2023-01-01"
428+
set_duplicate(self.finding_b, self.finding_a)
429+
set_duplicate(self.finding_c, self.finding_a)
430+
431+
# A fourth finding chained onto the excess duplicate C. set_duplicate would
432+
# normalize the chain to the root, so wire the pathological state directly,
433+
# the way it exists in the wild.
434+
finding_x = copy_model_util(Finding.objects.get(id=4), exclude_fields=["duplicate_finding"])
435+
finding_x.title = "X: " + finding_x.title
436+
finding_x.hash_code = None
437+
finding_x.duplicate = True
438+
finding_x.duplicate_finding = self.finding_c
439+
finding_x.save()
440+
441+
try:
442+
_async_dupe_delete_impl()
443+
444+
self.assertFalse(
445+
Finding.objects.filter(id=self.finding_c.id).exists(),
446+
"The excess duplicate (Finding C) should have been deleted despite the chained reference.",
447+
)
448+
self.assertFalse(
449+
Finding.objects.filter(duplicate_finding_id=self.finding_c.id).exists(),
450+
"No finding may still reference the deleted duplicate.",
451+
)
452+
# The check Postgres would run at COMMIT in production, where a leftover
453+
# reference is the IntegrityError that wedges the periodic task.
454+
connection.check_constraints()
455+
self.assertTrue(
456+
Finding.objects.filter(id=self.finding_b.id).exists(),
457+
"The newest duplicate (Finding B) should still exist.",
458+
)
459+
460+
finding_x.refresh_from_db()
461+
self.assertEqual(
462+
finding_x.duplicate_finding_id,
463+
self.finding_a.id,
464+
"The chained survivor should be re-pointed at the cluster's surviving root.",
465+
)
466+
self.assertTrue(finding_x.duplicate, "The chained survivor stays a duplicate, now of the root.")
467+
finally:
468+
if finding_x.id and Finding.objects.filter(id=finding_x.id).exists():
469+
finding_x.delete()
470+
410471
def test_delete_duplicate_order_same_date_tiebreak_by_id(self):
411472
"""When duplicate findings share the same date, excess deletes use id as tie-break (oldest id first)."""
412473
system_settings = System_Settings.objects.get()

0 commit comments

Comments
 (0)