perf(migration): drop O(n^2) inheritance query from endpoint-to-locations migration, make re-runs converge - #15425
Merged
Conversation
…-runs converge The endpoint-to-locations migration crawled on real data: 962 endpoints took 32 minutes, decaying from 79.9 to 0.5 endpoints/sec. Every Location created by the migration fired `inherit_tags_on_instance`, which calls `Location.all_related_products()`. That query ORs two multi-join paths (LocationProductReference, and Finding -> Test -> Engagement -> Product via LocationFindingReference), so Postgres materialises the entire join and filters it afterwards. Since the migration is itself filling LocationFindingReference, every endpoint made the next one slower. Measured on a small dataset, `Location.objects.create()` cost 132ms, of which 105ms was that query returning zero products every single time. The work was always redundant: a Location has no reference rows yet when the signal fires, so there is nothing to inherit from. Inheritance is applied in bulk by `_run_tag_inheritance()` once the references exist, which is exactly why that pass was added in #15311. The main loop now runs inside `suppress_tag_inheritance()`, with the bulk pass still running outside it. Writes are batched per chunk rather than per endpoint. `URL.bulk_get_or_create` resolves a whole chunk in ~3 queries in place of a savepoint pair, two SELECTs and two INSERTs per endpoint, and meta/reference/tag rows go out as one bulk write per kind. Per-endpoint resilience is preserved: rows are retried individually after a failed batch, and a failed bulk location resolve falls back to per-endpoint get_or_create. Measured on the same dataset and machine: 962 endpoints: 32s -> 2.6s 3,962 endpoints: 25+ min -> 25s Per-500-endpoint windows before: 27s, 63s, 99s, 130s, 186s, 271s. After, the rate rises over the run instead of collapsing. Output is byte-identical to the previous implementation across every location, reference, meta and tag row. Re-runs now converge on the state the source data describes: * LocationFindingReference upserts on (location, finding), so status, auditor and audit_time re-sync when the source Endpoint_Status has moved on, while the original `created` timestamp is preserved. * LocationProductReference status is recomputed from the finding references after each chunk, using the rule Location.status_from_product already applies. This repairs statuses drifted by an earlier run, makes the outcome independent of the order endpoints are visited in, and fixes the documented first-write-wins bug where a Mitigated status stuck even when later Active findings existed for the same product. * DojoMeta rows and tag copies stay insert-only, so re-runs neither duplicate them nor overwrite edits made after the first migration. Also restores full_clean() per URL when V3_FEATURE_LOCATIONS is enabled. `bulk_get_or_create` only calls clean(), so without this an endpoint with an empty host would be written as an invalid Location instead of being reported against its own id. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WqDAY5ktvcpa1JioMwsiPa
blakeaowens
approved these changes
Jul 30, 2026
devGregA
approved these changes
Jul 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Shortcut: sc-14059
Problem
A migration run of 962 endpoints took 32 minutes, with throughput decaying from 79.9 to 0.5 endpoints/sec:
The decay is the tell. Every
Locationcreated by the migration firesinherit_tags_on_instance, which callsLocation.all_related_products(). That query ORs two multi-join paths, and Postgres cannot index anORspanning separate join branches, so it materialises the whole join and filters afterwards:Because the migration is itself filling
locationfindingreference, that query grows with every endpoint migrated. Measured:Location.objects.create()cost 132 ms, of which 105 ms was this query, returning zero products every time.Fix
The signal work is structurally incapable of doing anything here: the
Locationis created insideURL.pre_save_logic(), before any reference row exists, soall_related_products()returns empty andauto_inherit_product_tagsearly-returns. Inheritance is applied in bulk by_run_tag_inheritance()once the references exist, which is why that pass was added in #15311. The main loop now runs inside the purpose-builtsuppress_tag_inheritance(), with the bulk pass still running outside it.Writes are also batched per chunk instead of per endpoint:
URL.bulk_get_or_createresolves a chunk in ~3 queries, replacing a savepoint pair + 2 SELECTs + 2 INSERTs per endpoint.DojoMeta,LocationFindingReference,LocationProductReferenceand tag rows each go out as one bulk write per chunk, deduplicated on their unique constraint.Per-endpoint resilience is preserved. A failed batch write retries row by row and attributes the failure to the endpoint that produced it; a failed bulk location resolve falls back to per-endpoint
get_or_create.Idempotency
The command now converges on the state the source Endpoints/Endpoint_Statuses describe:
LocationFindingReferenceupserts on(location, finding), sostatus,auditorandaudit_timere-sync when the sourceEndpoint_Statushas changed.createdis preserved.LocationProductReferencestatus is recomputed from the finding references after each chunk, using the ruleLocation.status_from_productalready applies. This repairs drifted statuses, makes the outcome independent of endpoint visit order, and fixes the first-write-wins bug the previous code documented, where aMitigatedstatus stuck even when laterActivefindings existed for the same product.DojoMetarows and tag copies stay insert-only, so re-runs neither duplicate them nor overwrite post-migration edits.Results
Same dataset, same machine:
Per-500-endpoint windows before: 27s, 63s, 99s, 130s, 186s, 271s. After, the cumulative rate rises over the run (95 to 158 endpoints/sec).
Verification
Endpoint_Statuspropagates, that a genuine Active-to-Mitigated downgrade propagates, and that an externally corrupted product-reference status is repaired._run_tag_inheritance()stubbed out, both the previous and the new implementation produceinherited_tags = [], and a spy shows the pre-change signal fired once for aLocationand found 0 products. perf(migration): batch endpoint tags and restore inheritance #15311 replacedtags.add()withbulk_add_tag_mapping, which writes through-rows viabulk_createand emits nom2m_changed, so the explicit bulk pass was already the only mechanism. That PR's own regression test passes unchanged.bulk_get_or_createonly callsclean(), whereas thesave()path it replaces ranfull_clean(). Without restoring it, an endpoint with an empty host would be silently written as an invalid Location.Tests
7 new tests covering re-run row counts and
createdpreservation, finding-reference status sync, product-reference reconciliation and downgrade, order independence for a location shared across chunks, invalid-endpoint reporting, the bulk-resolve fallback, and that the inheritance signal is suppressed while inherited tags still land. The 3 existing tests pass unchanged.🤖 Generated with Claude Code
https://claude.ai/code/session_01WqDAY5ktvcpa1JioMwsiPa