fix(importers): stop reimport recreating a test deleted mid-scan - #15424
fix(importers): stop reimport recreating a test deleted mid-scan#15424Maffooch wants to merge 2 commits into
Conversation
The importers hold the test in memory for the whole run and write the changes collected on it back at the end. Model.save() on an instance whose row has been deleted in the meantime does not fail: the UPDATE matches no row and Django falls through to an INSERT, recreating the row that was just deleted. Deleting a product, engagement or test while one of its scans is being processed reaches that, and the INSERT then either violates the foreign key to a parent the same delete removed (reported from production as a foreign key violation on dojo_test.engagement_id during reimport-scan) or silently resurrects a test whose findings are gone. Both write-back points now confirm the test row is still there first and raise Test.DoesNotExist naming the deleted test instead. The engagement needs no check of its own: the test row could not still exist if the engagement it points at had been deleted. The check is a primary key existence query, so the pinned import/reimport query counts move by two. Co-Authored-By: Claude <noreply@anthropic.com>
|
Merging this needs a paired Pro change — flagging rather than guessing at it. The two existence checks add two queries to every import and reimport, and Pro pins the same counts independently in Affected expectations in that file (current values):
The delta should be If that Pro PR is raised before this one merges, it needs Nothing else on the Pro side needs a change: Pro overrides neither Generated by Claude Code |
The tag-inheritance import baselines pin the import and reimport query counts independently of test_importers_performance, so they carry the same two extra primary key existence checks. All six ZAP import/reimport baselines move by two; the tag add/remove and finding-creation baselines in the same class do not run an import and are unchanged. Co-Authored-By: Claude <noreply@anthropic.com>
|
CI was red on the first commit: 6 failures out of 4212, all of them mine, all the same kind of thing I'd already flagged — and one I should have caught before pushing.
Every one is Rather than fix just the reported six, I went back and ran every file in No change to the fix itself; this commit is test expectations only. Generated by Claude Code |
|
CI is green on Closing out the independent check I said was running: the full So: the 6 failures CI caught were real and mine, they're fixed, and the only remaining local failure is an artifact of where I ran it. Generated by Claude Code |
|
This pull request has conflicts, please resolve those before we can evaluate the pull request. |
1 similar comment
|
This pull request has conflicts, please resolve those before we can evaluate the pull request. |
|
Closing this as superseded — not resolving the conflict, because resolving it would only push a duplicate. #15427 landed on It is also the better version of the fix, in three ways I'd rather have than my own:
Its Both perf modules are already updated there at For the record, since these came out of a production error sweep: the three reports that motivated this are the same ones behind #15427, and I've pointed the internal threads at that PR instead of this one so the merged fix is what gets found next time. The one report from that sweep that is not covered by either PR is the finding-level form of this defect — Nothing about the fix is lost by closing this. Generated by Claude Code |
Description
POST /api/v2/reimport-scan/fails with a500and a foreign key violation ondojo_test.engagement_idwhen the object it is importing into is deleted while the scan is being processed. Reported repeatedly from production instances.The importers resolve the test up front and hold it — and through it its engagement — in memory for the whole run, then write the changes collected on it back at the end (
update_timestamps,update_test_meta,update_test_tags, thenself.test.save()/self.test.engagement.save(), and finallyupdate_test_progress()).Model.save()on an instance whose row has been deleted in the meantime does not fail. The pk is set, so Django issues anUPDATE; it matches no row, andModel._save_table()falls through to anINSERT. The row that was just deleted is recreated. Deleting a product, engagement or test in the UI while one of its scans is being processed is enough to get there, and the recreated row then goes one of two ways:INSERTviolates a foreign key — this is the reported failure,insert or update on table "dojo_test" violates foreign key constraint "dojo_test_engagement_id_..._fk_dojo_engagement_id"with the engagement id absent fromdojo_engagement;INSERTsucceeds and silently resurrects a test whose findings are gone.The second outcome is the quieter half of the same defect and is why suppressing the error would not be enough: the write must not happen at all.
The change
BaseImporter.verify_test_still_exists()confirms the test row is still present and otherwise raisesTest.DoesNotExistnaming the deleted test. It is called from the two points that write the test back: the newsave_test_and_engagement()(used by bothDefaultImporter.process_scanandDefaultReImporter.process_scanin place of the inline save pair) andupdate_test_progress(), which is the only write-back on the post-processing path.The engagement needs no check of its own: the test row could not still be there if the engagement it points at had been deleted, so verifying the test covers both saves.
An import that loses its test still fails — there is nothing left to report on and no honest success to return — but it now fails with an error that says what happened, and without writing a resurrected row first.
Two approaches were rejected on the way:
save(force_update=True), which turns theINSERTfallback into aDatabaseErrorat no query cost, marks the surrounding transaction for rollback (Model.save_basewraps the save intransaction.mark_for_rollback_on_error). That leaves the caller unable to run any further query, which would break failure-path cleanup that has to run after the error.QuerySet.update(), which returns a row count instead of falling through to anINSERT, bypassessave()and with it the change tracking onTest.Test results
New test module
unittests/test_importers_deleted_object.py, classTestImportObjectDeletedDuringScan(5 tests):update_test_progress()refuses to recreate the test, parameterized over the test row being deleted directly and over the engagement being deleted (which cascades to it);save_test_and_engagement()refuses to recreate the test, and neither the test nor the engagement row comes back — the reported scenario;On unmodified
bugfixthe parameterized test fails withAssertionError: DoesNotExist not raisedand the deleted row present again afterwards, and the engagement variant additionally surfaces the reported constraint by name at the end of the transaction:Verified against PostgreSQL 16 on Python 3.13:
unittests.test_importers_deleted_object— 5 tests, OK (2 failures + 3 errors before the change)unittests.test_import_reimport,unittests.test_importers_importer,unittests.test_importers_closeold,unittests.test_reimport_prefetch,unittests.test_importers_deleted_object— 192 tests, OK (1 skipped)unittests.test_importers_performance— 11 tests, OKruff check .cleanQuery counts
The existence check is one primary key lookup at each of the two write-back points, so every import and reimport costs two extra queries against roughly 100–200. All 34 pinned expectations in
unittests/test_importers_performance.pymoved by exactly+2; no other count changed, which also confirms the two checks are the whole cost. Paying two indexed lookups per scan to stop resurrecting deleted objects is the trade this makes deliberately.Documentation
No documentation change: the
500was never documented behaviour, and this adds no setting or API surface.Downstream / Pro consideration
update_test_progressnor the reimporter's write-back, so the fix reaches the Pro import paths with no Pro-side edit, andsave_test_and_engagement()is new API onBaseImporterthat nothing overrides.Test.DoesNotExistis a plain Python raise before any write, so it leaves the transaction usable. Pro's importer failure handler runs several queries of its own after catching an import error (recording the failed status on its companion row, moving the report, writing a usage record); those still work. This is the property that ruled outforce_updateabove.self.test.save()/self.test.engagement.save()pair and is unaffected by this change; switching it to the inheritedsave_test_and_engagement()would extend the same protection there and is worth a follow-up in that repo.Finding.save()recreating a finding deleted mid-reimport — is a separate call site. Its close-old-candidate case was fixed in fix(reimport): skip close-old candidates whose finding row was deleted mid-reimport #15408; a report of it during finding creation is not addressed here.Checklist
bugfixbranch — based on and targetingbugfix.Reported internally
Generated by Claude Code