Skip to content

perf(tags): centralize tag inheritance + replace signal disconnect with batch context manager (Phase B)#14827

Draft
valentijnscholten wants to merge 2 commits intoDefectDojo:devfrom
valentijnscholten:perf/tag-inheritance-phase-b
Draft

perf(tags): centralize tag inheritance + replace signal disconnect with batch context manager (Phase B)#14827
valentijnscholten wants to merge 2 commits intoDefectDojo:devfrom
valentijnscholten:perf/tag-inheritance-phase-b

Conversation

@valentijnscholten
Copy link
Copy Markdown
Member

@valentijnscholten valentijnscholten commented May 6, 2026

Important

Depends on:

  • PR #14812 (Phase A: bulk product-side propagation)

Both must merge first; this branch is stacked on top.

This PR lands the thread-safety fix that could be caused by the current signal disconnect code. It also batches tag updates triggered by a change in tags on a Product. It uses a context manager to disable inheritance during batching, and applies the inherited tags efficiently afterwards in one batch.

Stage 1 (this commit) — thread-local batch context

Adds dojo/tag_inheritance.py with:

  • batch() context manager (thread-local depth counter)
  • is_in_batch() predicate

While inside a batch, make_inherited_tags_sticky (m2m_changed) early-returns. Calling code is responsible for applying inheritance in bulk.

Replaces the previous pattern in dojo/importers/location_manager.py:556:

disconnected = signals.m2m_changed.disconnect(
    make_inherited_tags_sticky, sender=Location.tags.through,
)
try:
    ...
finally:
    if disconnected:
        signals.m2m_changed.connect(...)

Signal.disconnect mutates Django's process-global receiver list. While disconnected, every thread/greenlet in the same process loses sticky enforcement. Safe only under Celery --pool=prefork + single-threaded gunicorn; broken under --pool=threads|gevent|eventlet, gunicorn --threads >1, or ASGI threadpools. Also fragile on hard process exit mid-import (handler stays disconnected for the rest of the process lifetime).

The new context lives in threading.local(): each thread has its own depth counter, the signal handler stays globally connected, and the suppression decision is per-thread. No global mutation, no reconnect hazard.

Verification

Stage 1:

  • unittests.test_tag_inheritance — 36 tests pass (5 skipped for v3 feature flag).
  • unittests.test_tag_utils_bulk — 29 tests pass.
  • unittests.test_tag_inheritance_perf — 20 tests pass.

Pinned perf-test note: V3 zap_scan_import baseline rises 1243 → 1263 (~1.6%). The previous process-global disconnect was narrower in scope (Location.tags.through only); the batch context covers all child-tag through-tables. Net trade is positive given the thread-safety fix; full Phase B reductions arrive in Stages 3-5.

@valentijnscholten valentijnscholten changed the title perf(tags): centralize tag inheritance + replace signal disconnect with thread-local batch (Phase B) perf(tags): centralize tag inheritance + replace signal disconnect with batch context manager (Phase B) May 6, 2026
@valentijnscholten valentijnscholten force-pushed the perf/tag-inheritance-phase-b branch from 707cbf9 to e04bf83 Compare May 6, 2026 22:06
@Maffooch Maffooch requested review from Jino-T, blakeaowens and dogboat May 7, 2026 03:58
@Maffooch Maffooch added this to the 2.59.0 milestone May 7, 2026
…reate

Replaces the per-row `.save()` loop in `propagate_tags_on_product_sync`
with bulk SQL through the existing tag-utils helpers. For every child
model (Engagement/Test/Finding/Endpoint/Location), reads current
inherited_tags in one query, computes the per-child diff against the
Product's tags, and applies adds/removes via `bulk_add_tag_mapping` and
the new `bulk_remove_tags_from_instances` helper. Both `tags` and
`inherited_tags` fields are kept in sync.

Also gates the per-child `inherit_tags_on_instance` post_save handler on
`created=True`. The previous behavior fired on every save (create OR
update), repeatedly re-applying inherited tags to children whose tag
state had not changed. Sticky enforcement on user-driven tag edits is
unchanged (still handled by `make_inherited_tags_sticky` on m2m_changed).

Pinned query-count baselines from PR DefectDojo#14811 drop accordingly:

  product_tag_add  -> 100 findings : 4758 -> 91   (~52x fewer queries)
  product_tag_remove -> 100 findings : 4540 -> 53 (~85x fewer queries)

Sticky and child-creation paths are unchanged in this PR. Phase B
targets those (centralized inheritance module + drop the duplicate
`inherited_tags` TagField).
…l batch context

Adds dojo/tag_inheritance.py with a thread-local batch() context manager
and is_in_batch() predicate. While the calling thread is inside a batch,
make_inherited_tags_sticky (m2m_changed) early-returns; the calling code
takes responsibility for applying inheritance in bulk.

Replaces the previous pattern in dojo/importers/location_manager.py:556:

    disconnected = signals.m2m_changed.disconnect(
        make_inherited_tags_sticky, sender=Location.tags.through,
    )
    try:
        ...
    finally:
        if disconnected:
            signals.m2m_changed.connect(...)

Signal.disconnect mutates Django's process-global receiver list and is
not thread-safe. While disconnected, every thread/greenlet in the same
process loses sticky enforcement. Safe only under Celery --pool=prefork
and single-threaded gunicorn; broken under --pool=threads|gevent|eventlet,
gunicorn --threads >1, or ASGI threadpools. Also fragile on hard process
exit mid-import (handler stays disconnected for the rest of the process
lifetime).

The new batch context lives in threading.local(): each thread has its
own depth counter, the signal handler stays globally connected, and the
suppression decision is per-thread. No global mutation, no reconnect
hazard.

This is Phase B Stage 1. Subsequent stages will wrap the broader importer
orchestration in batch(), replace the duplicate inherited_tags TagField
with a JSON column, and drop _manage_inherited_tags / per-model
inherit_tags().

Pinned perf-test note: V3 zap_scan_import baseline rises 1243 -> 1263
(~1.6%). The previous process-global disconnect was narrower in scope
(Location.tags.through only); the batch context covers all child-tag
through-tables. Net trade is positive given the threading bug fix; full
Phase B reductions arrive in later stages.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 8, 2026

This pull request has conflicts, please resolve those before we can evaluate the pull request.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants