Repository navigation
fix(dedupe): stop imports failing when findings are deleted under them - #16379
Conversation
Imports, reimports and POST /api/v2/findings/ failed with "insert or update on table ... Key (finding_id)=(N) is not present in table dojo_finding", or lost the findings they had just written, when a finding was deleted while they were still working on it. Django's foreign keys are DEFERRABLE INITIALLY DEFERRED, so a writer takes no lock a delete could wait for; the per-table guards added so far each covered one child table. - The excess-duplicate delete (async_dupe_delete) only removes duplicates that have settled: the finding unsaved, and its test free of import activity, for DD_DUPE_DELETE_GRACE_MINUTES (default 30, 0 restores the old behaviour). A reimport start and each deduplication batch refresh test.updated when duplicate deletion is on, and each delete chunk re-checks under a lock on its findings and their tests, so a reimport that started after the selection is never deleted out from under it. Nothing changes while duplicate deletion is off (the default). - A reimport saving a finding it matched earlier in the run uses save_matched_finding(): force_update, so a finding deleted since is skipped instead of failing the import or being re-created by an INSERT. - An import whose findings or test were deleted mid-run (a test, engagement or product delete, including the API's asynchronous one) fails with a clear message instead of a raw foreign-key error. Reported via internal error diagnostics and a customer support ticket. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…elete_duplicates The deadlock-retry tests build System_Settings as a bare Mock, so the new delete_duplicates check read a truthy Mock attribute and ran the test-activity heartbeat against mocked findings. They exercise the retry, not duplicate deletion, so the mock now states delete_duplicates=False. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review verdict: approve with nits (merge once the re-run unit tests are green)Reviewed at BlockingNothing in the code itself.
What I verified
Nits / worth considering (not blocking)
Not verified here
|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lock The excess-duplicate delete held back every duplicate of a test for 30 minutes after any import activity, so a test reimported more often than that was never cleaned up. Replace the window with a shared PostgreSQL advisory lock that each import and deduplication batch holds on its test while it runs: the delete try-locks each test per chunk and skips only the ones being imported right now. A lock must never outlive its import: a failed release closes the connection at the end of the request or task, a release that finds no lock (a pooler in transaction mode) logs the setting that turns the lock off, and a lock whose holders have all been idle past DD_DUPE_DELETE_ABANDONED_IMPORT_LOCK_MINUTES is treated as abandoned. The finding-level grace drops to 1 minute and covers create requests only. The race tests now delete every row they commit, so later modules in a kept test database see a clean one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adversarial review at 2e63fd2: changes requestedI didn't write this change. I tried to break it against a real PostgreSQL 18 test database, using scratch tests that are not part of the PR. What holds up:
Blockers1. The delete chunk row-locks the duplicates it is about to skip, which can deadlock the import the lock exists to protect.
Suggested fix: take the advisory try-locks first, on the test ids the selection already has, and then 2. A test under import at the head of the selection stops duplicate deletion for every test. Should fix3. The abandoned-lock valve does not do what its docstring says when it cannot see a holder. Nits
Verdict: changes requested, for blockers 1 and 2. Both fall inside the code this PR adds, and both have small fixes. 🤖 Generated with Claude Code |
Review of the import lock found two ways it still hurt the imports it protects. Each chunk row-locked all its findings before checking the import locks and kept those locks on the findings it skipped, so an import saving or tagging its own duplicate waited on the delete or deadlocked against it. And the per-run cap was applied before busy tests were dropped, so a test under import holding the lowest ids starved every other test. The selection now leaves tests under import out before the cap, and each chunk takes its non-waiting import-lock checks first and row-locks only the findings of free tests. The abandoned-lock valve decides per holder in Python, so a holder whose state is hidden counts as live instead of being skipped by bool_and. Locks are taken one test at a time and any failed release closes the connection. Product grading only covers products that lost a duplicate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Re-review at 55dff3c (reply to my verdict)I re-ran every break attempt this push touches against the same PostgreSQL 18 test database. The scratch tests are the same ones as before and are not part of the PR. As a control, I ran them against 2e63fd2's Blocker 1 (skipped rows kept locked, and the tag deadlock): fixed. The chunk now takes the non-blocking import locks first (
Blocker 2 (per-run cap starvation): fixed. Tests whose lock is held are now dropped from the selection before the cap applies ( bool_and NULL visibility in the valve: fixed. The valve now pulls holders row by row and decides in Python (
Fed those rows with a 2 s threshold, Lock-leak nits: fixed.
One remaining nit, not blocking: an asynchronous exception (a Celery soft time limit) that lands after Verdict: changed to no blockers, ready to merge once CI is green. When I checked, 19 jobs at this head were still pending, including the unit-test, UI-test and performance jobs. 🤖 Generated with Claude Code |
Description
Imports, reimports and
POST /api/v2/findings/fail, or silently lose the findings they just wrote, when a finding is deleted while they are still working on it. The usual error isinsert or update on table "<child>" violates foreign key constraint ... Key (finding_id)=(N) is not present in table "dojo_finding". It shows up ondojo_finding_found_by,dojo_finding_cwe,dojo_findingvulnerabilityreference,dojo_finding_tags,dojo_endpoint_status,dojo_locationfindingreference,dojo_finding_groupand the ProEnhancedFindingcompanion. On the background import path the whole run fails after the API has already returned 200, so that run's scan data is lost and the caller never sees an error.Django declares its foreign keys
DEFERRABLE INITIALLY DEFERRED, so a writer inserting a child row takes no lock on the finding that a delete could wait for. The guards added so far each protect one child table, and a row lock on the delete side cannot make a writer wait. Production logs showed two deleters behind the failures:async_dupe_delete, every minute). With a lowmax_dupesit deletes duplicates the moment they are marked, including ones a running import created or matched seconds earlier. A common trigger is a report with several rows for one package (one per CVE) that hash to the same finding: every match saves the finding again, every save re-runs deduplication, and the next cleanup tick deletes it mid-import.DELETE /api/v2/findings/{id}/from a client, or a test, engagement or product delete (the asynchronous one removes the findings first and the test last).This PR fixes both at the delete and import level rather than per child table.
The excess-duplicate delete skips a test only while an import is running on it
dojo/importers/import_lock.py). The lock is taken as soon as the test exists and before the first finding is created or matched.DD_DUPE_DELETE_MAX_PER_RUN) applies, so they never use up a run and every other test is reached.DD_DUPE_DELETE_GRACE_MINUTES(default 1) also waits for a later run. That covers a create request that is still writing the finding's child rows.A lock can never outlive its import
A lock left behind would keep the delete off that test for good, so each way one could be left behind is bounded:
CONN_MAX_AGEwould keep the connection open indefinitely.DD_DUPE_DELETE_SKIP_TESTS_UNDER_IMPORT=False, which turns the lock off.DD_DUPE_DELETE_ABANDONED_IMPORT_LOCK_MINUTES(default 30), no import is running, so the delete treats the lock as abandoned, deletes anyway, and logs a warning with the holding backends. This covers a host that died without closing its connection. A holder whose state the database user cannot see (a backend of another database role) counts as live, even next to an idle one, so the check can only give a test back to the delete. The decision is made per holder in Python (abandoned_tests), not in an SQL aggregate that would skip the unknown state.No resurrection on matched-finding saves
A reimport saving a finding it matched earlier in the run (CVE back-fill, reactivation, mitigation, component fill) now goes through
save_matched_finding(), which saves withforce_update. A finding deleted since is skipped and stays deleted, instead of failing the import withFinding.DoesNotExistor being re-created by Django's UPDATE-then-INSERT fallback.A clear error when the import target is being deleted
process_scanturns a foreign-key violation that names a finding or test which no longer exists into the importer's existing "was deleted while the scan was being processed" styleValidationError, so the caller gets a 400 with a reason instead of a 500. A violation naming a row that still exists is re-raised unchanged. A matched finding that the reimport does not need to save (an unchanged match) and that a client deletes mid-run still fails the reimport, now with this 400.Expect a small rebase against the open #16337 (
async_dupe_deleteas a singleton), which touches the same two files.Test results
New tests in
unittests/test_dedupe_delete_commit_race.pycommit real rows, so the deferred foreign keys fire as in production, and each one deletes every row it committed afterwards so later modules see a clean database. Deleters run on their own connection, as the beat task does in another worker.TestDupeDeleteRacesInFlightWriter: a new duplicate, and a matched duplicate whose test an import is running on, survive while their writer adds afound_byrow. Controls: a settled duplicate is deleted, a grace of 0 deletes at once, and a duplicate of a test that was reimported moments ago, with no import running, is deleted. That last control fails on the previous revision of this PR, which held back a whole test for 30 minutes after any import activity. A deduplication batch holds its test's lock while it runs and releases it, and takes none with duplicate deletion off.TestImportLockCannotOutliveItsImport: a release in an aborted transaction drops the connection and the lock; a release that finds no lock logs the fix; a lock whose holder sat idle past the threshold no longer holds the delete back; and one within the threshold still does. The first three fail before their fix.TestDupeDeleteLeavesImportedTestsAlone: with a per-run cap of one and the test under import holding the lowest id, the run still deletes the other test's duplicate; an import can save its own duplicate, and tag it with a tag the chunk is deleting, while a chunk deletes another test's duplicates, without waiting or being aborted. All three fail on the first revision of the lock, which row-locked the whole chunk before checking the locks and applied the cap first.TestAbandonedImportLockDecision: a holder with hidden state beside an idle one keeps its test; only tests whose every holder is idle past the threshold are abandoned.TestImportRacesDupeDelete: a real import, reimport, and reimport matching existing duplicates, with the delete firing on another connection right after each deduplication batch and the grace period off, so the lock alone protects them. All three fail without the lock (findings lost, or thedojo_endpoint_statusFK violation).TestFindingCreateRacesDupeDelete:POST /api/v2/findings/with the delete firing right after the create's own deduplication (fails ondevwith a 500).TestReimportStartRacesDupeDeleteSelection: a delete run whose selection predates a reimport does not remove the duplicates that reimport has matched since (fails without the lock).TestTaggedImportRacesTestDeleteand its Locations-off subclass: imports with parser and import tags,group_byfinding groups, endpoint and location flushes, and an asynchronous-delete shape (findings gone, test still present). Each must fail with the clear error rather than a raw FK violation. Deleting a different test that shares the import's tags leaves the tag counts consistent.TestReimportRacesMatchedFindingDelete: a finding deleted between two matched saves, and before its reactivation save, is not re-created and does not fail the reimport (fails ondevwithFinding.DoesNotExist).Three
test_duplication_loopstests that cover which duplicates are excess pinDD_DUPE_DELETE_GRACE_MINUTES=0, because they save their findings just before the delete.Run locally in the OSS unit-test container:
test_dedupe_delete_commit_race,test_finding_helperandtest_duplication_loopspass (96 tests). To check the row cleanup, the race module was run first on a fresh test database, and then the import, reimport, tag, JIRA, API v3 import and sample-data modules ran against that same kept database: 1797 tests, all passing.ruff checkis clean.The race was also reproduced against a running stack: 3000-finding reimports, finding creates and test deletes, with the duplicate delete running every 50 ms. Before the change there were HTTP 500s on create and on sync reimport, and thousands of lost import-history rows.
Documentation
docs/content/triage_findings/finding_deduplication/about_deduplication.mdgains a "When duplicates are deleted" section describing when a duplicate waits and the three settings.Checklist
dev.devbranch.🤖 Generated with Claude Code