Skip to content

Commit 707cbf9

Browse files
perf(tags): replace process-global signal disconnect with thread-local 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.
1 parent 64b0287 commit 707cbf9

4 files changed

Lines changed: 78 additions & 10 deletions

File tree

dojo/importers/location_manager.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,13 @@
77

88
from django.core.exceptions import ValidationError
99
from django.db import transaction
10-
from django.db.models import signals
1110
from django.utils import timezone
1211

12+
from dojo import tag_inheritance
1313
from dojo.importers.base_location_manager import BaseLocationManager
1414
from dojo.location.models import AbstractLocation, Location, LocationFindingReference, LocationProductReference
1515
from dojo.location.status import FindingLocationStatus, ProductLocationStatus
1616
from dojo.models import Product, _manage_inherited_tags
17-
from dojo.tags_signals import make_inherited_tags_sticky
1817
from dojo.tools.locations import LocationData
1918
from dojo.url.models import URL
2019
from dojo.utils import get_system_setting
@@ -551,10 +550,17 @@ def _get_tags(tags_field: TagField) -> dict[int, set[str]]:
551550
existing_inherited_by_location: dict[int, set[str]] = _get_tags(Location.inherited_tags)
552551
existing_tags_by_location: dict[int, set[str]] = _get_tags(Location.tags)
553552

554-
# Perform the bulk updates. First, though, disconnect the make_inherited_tags_sticky signal on Location.tags
555-
# while updating, otherwise each (inherited_)tags.set() will trigger, defeating the purpose of this bulk update.
556-
disconnected = signals.m2m_changed.disconnect(make_inherited_tags_sticky, sender=Location.tags.through)
557-
try:
553+
# Perform the bulk updates inside a `tag_inheritance.batch()` context.
554+
# While the batch is active, signal handlers in `dojo/tags_signals.py`
555+
# short-circuit per-row inheritance work that would otherwise fire on
556+
# every `(inherited_)tags.set()` and defeat the bulk update.
557+
#
558+
# This replaces a previous `signals.m2m_changed.disconnect(...)` /
559+
# `connect(...)` dance which was process-global and therefore unsafe
560+
# under threaded gunicorn / Celery thread pools / ASGI threadpools:
561+
# while disconnected, every thread in the process lost sticky
562+
# enforcement. Thread-local batch state avoids that hazard.
563+
with tag_inheritance.batch():
558564
for location in locations:
559565
target_tag_names: set[str] = set()
560566
for pid in product_ids_by_location[location.id]:
@@ -573,6 +579,3 @@ def _get_tags(tags_field: TagField) -> dict[int, set[str]]:
573579
list(target_tag_names),
574580
potentially_existing_tags=existing_tags_by_location[location.id],
575581
)
576-
finally:
577-
if disconnected:
578-
signals.m2m_changed.connect(make_inherited_tags_sticky, sender=Location.tags.through)

dojo/tag_inheritance.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""
2+
Tag inheritance — central coordination module.
3+
4+
Provides a thread-local ``batch()`` context manager that suppresses
5+
per-instance inheritance work driven by ``m2m_changed`` and ``post_save``
6+
signals. While inside a batch, the signal handlers in
7+
``dojo/tags_signals.py`` early-return; the calling code is responsible for
8+
applying inheritance in bulk (e.g. via the importer's existing
9+
``_bulk_inherit_tags`` path or ``propagate_tags_on_product_sync``).
10+
11+
This replaces the previous pattern of ``signals.m2m_changed.disconnect(...)``
12+
in importer hot loops, which was process-global and unsafe under threaded
13+
gunicorn / Celery thread pools / ASGI threadpools (see PR description for
14+
the full rationale).
15+
"""
16+
from __future__ import annotations
17+
18+
import contextlib
19+
import threading
20+
from contextlib import contextmanager
21+
22+
_state = threading.local()
23+
24+
25+
def is_in_batch() -> bool:
26+
"""Return True when the current thread is inside an active ``batch()``."""
27+
return bool(getattr(_state, "depth", 0))
28+
29+
30+
@contextmanager
31+
def batch():
32+
"""
33+
Suppress per-instance inheritance signals for the calling thread.
34+
35+
Usage:
36+
with tag_inheritance.batch():
37+
# Bulk operations that would otherwise fire `make_inherited_tags_sticky`
38+
# or `inherit_tags_on_instance` per row.
39+
...
40+
41+
The context is reentrant; nested ``with`` blocks share the suppression
42+
until the outermost block exits. State lives in ``threading.local()``,
43+
so concurrent threads (and Celery workers in non-prefork pools) are
44+
unaffected by other threads' batches.
45+
"""
46+
_state.depth = getattr(_state, "depth", 0) + 1
47+
try:
48+
yield
49+
finally:
50+
_state.depth -= 1
51+
if _state.depth <= 0:
52+
# Clean up the attribute so leak-free thread reuse stays simple.
53+
with contextlib.suppress(AttributeError):
54+
del _state.depth

dojo/tags_signals.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from django.db.models import signals
55
from django.dispatch import receiver
66

7+
from dojo import tag_inheritance
78
from dojo.celery_dispatch import dojo_dispatch_task
89
from dojo.location.models import Location, LocationFindingReference, LocationProductReference
910
from dojo.models import Endpoint, Engagement, Finding, Product, Test
@@ -32,6 +33,12 @@ def product_tags_post_add_remove(sender, instance, action, **kwargs):
3233
@receiver(signals.m2m_changed, sender=Location.tags.through)
3334
def make_inherited_tags_sticky(sender, instance, action, **kwargs):
3435
"""Make sure inherited tags are added back in if they are removed"""
36+
# Inside a `tag_inheritance.batch()` block the caller takes responsibility
37+
# for applying inheritance in bulk; per-row signal work would defeat the
38+
# purpose. This replaces the old `signals.m2m_changed.disconnect(...)`
39+
# pattern, which was process-global and unsafe under threaded workers.
40+
if tag_inheritance.is_in_batch():
41+
return
3542
if action in {"post_add", "post_remove"}:
3643
if inherit_product_tags(instance):
3744
tag_list = [tag.name for tag in instance.tags.all()]

unittests/test_tag_inheritance_perf.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,11 @@ def test_baseline_zap_scan_reimport_no_change_v3(self):
493493
# Phase A nudges these slightly downward (post_save gated on created=True
494494
# avoids re-running inheritance on no-op finding updates during reimport).
495495
# Pre-Phase-A: 1461/1319 import, 77/95 reimport.
496+
# Phase B Stage 1 (thread-safe batch context) adds ~20 queries on the V3
497+
# import path because the previous process-global signal-disconnect was
498+
# narrower in scope (Location.tags.through only). Net-positive trade for
499+
# eliminating the threading bug; full Phase B reductions land in Stage 2.
496500
EXPECTED_ZAP_IMPORT_V2 = 1385
497-
EXPECTED_ZAP_IMPORT_V3 = 1243
501+
EXPECTED_ZAP_IMPORT_V3 = 1263
498502
EXPECTED_ZAP_REIMPORT_NO_CHANGE_V2 = 69
499503
EXPECTED_ZAP_REIMPORT_NO_CHANGE_V3 = 87

0 commit comments

Comments
 (0)