Ver Fonte

Closes #22485: Break the search signal wiring import cycle (#22744)

Closes #22485
bctiemann há 1 mês atrás
pai
commit
cd2a43dc5f

+ 1 - 0
netbox/core/apps.py

@@ -32,6 +32,7 @@ class CoreConfig(AppConfig):
         from core.checks import check_duplicate_indexes, check_postgresql_version, check_redis_version  # noqa: F401
         from core.checks import check_duplicate_indexes, check_postgresql_version, check_redis_version  # noqa: F401
         from netbox import context_managers  # noqa: F401
         from netbox import context_managers  # noqa: F401
         from netbox.models.features import register_models
         from netbox.models.features import register_models
+        from netbox.search import signals as search_signals  # noqa: F401
 
 
         from . import data_backends, events, search  # noqa: F401
         from . import data_backends, events, search  # noqa: F401
 
 

+ 15 - 11
netbox/netbox/search/backends.py

@@ -8,7 +8,6 @@ from django.db import DatabaseError, ProgrammingError, transaction
 from django.db.models import F, Q, Window, prefetch_related_objects
 from django.db.models import F, Q, Window, prefetch_related_objects
 from django.db.models.fields.related import ForeignKey
 from django.db.models.fields.related import ForeignKey
 from django.db.models.functions import window
 from django.db.models.functions import window
-from django.db.models.signals import post_delete, post_save
 from django.utils.module_loading import import_string
 from django.utils.module_loading import import_string
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 from netaddr.core import AddrFormatError
 from netaddr.core import AddrFormatError
@@ -21,7 +20,6 @@ from utilities.querysets import RestrictedPrefetch
 from utilities.string import title
 from utilities.string import title
 
 
 from . import FieldTypes, LookupTypes, get_indexer
 from . import FieldTypes, LookupTypes, get_indexer
-from .deferred import OP_CACHE, OP_REMOVE, mark_for_deferred_indexing
 
 
 DEFAULT_LOOKUP_TYPE = LookupTypes.PARTIAL
 DEFAULT_LOOKUP_TYPE = LookupTypes.PARTIAL
 MAX_RESULTS = 1000
 MAX_RESULTS = 1000
@@ -64,11 +62,11 @@ class SearchBackend:
         """
         """
         raise NotImplementedError
         raise NotImplementedError
 
 
-    # caching_handler() and removal_handler() are the default, synchronous signal receivers connected
-    # to post_save/post_delete at module load. They are internal plumbing for signal dispatch, not a
-    # documented extension point: the public backend contract is cache()/remove()/clear(). A backend
-    # that needs to do something other than index inline (e.g. defer the work) overrides these in its
-    # subclass; see CachedValueSearchBackend.
+    # caching_handler() and removal_handler() are the default, synchronous signal receivers; they are
+    # connected to post_save/post_delete from netbox.search.signals (wired from CoreConfig.ready()).
+    # They are internal plumbing for signal dispatch, not a documented extension point: the public
+    # backend contract is cache()/remove()/clear(). A backend that needs to do something other than
+    # index inline (e.g. defer the work) overrides these in its subclass; see CachedValueSearchBackend.
     def caching_handler(self, sender, instance, created, **kwargs):
     def caching_handler(self, sender, instance, created, **kwargs):
         """
         """
         Receiver for the post_save signal, responsible for caching object creation/changes.
         Receiver for the post_save signal, responsible for caching object creation/changes.
@@ -127,10 +125,18 @@ class CachedValueSearchBackend(SearchBackend):
     # the originating routing context is gone, so the alias must be captured here and replayed on the
     # the originating routing context is gone, so the alias must be captured here and replayed on the
     # deferred write to keep cache entries in the originating schema (e.g. a branch schema under
     # deferred write to keep cache entries in the originating schema (e.g. a branch schema under
     # netbox-branching). Deferral is internal to this backend; the public contract is unchanged.
     # netbox-branching). Deferral is internal to this backend; the public contract is unchanged.
+    #
+    # mark_for_deferred_indexing() etc. are imported inside each method rather than at module level:
+    # this module's own top would import deferred.py *before* search_backend is defined further down
+    # this same file, and deferred.py (plus jobs.py) need that singleton at their own module level.
+    # A module-level import here would close that loop into a backends -> deferred -> backends
+    # cycle. See #22485.
     def caching_handler(self, sender, instance, created, using=None, **kwargs):
     def caching_handler(self, sender, instance, created, using=None, **kwargs):
         """
         """
         Receiver for the post_save signal, responsible for caching object creation/changes.
         Receiver for the post_save signal, responsible for caching object creation/changes.
         """
         """
+        from .deferred import OP_CACHE, mark_for_deferred_indexing
+
         # Skip non-cacheable objects without scheduling any deferred work.
         # Skip non-cacheable objects without scheduling any deferred work.
         try:
         try:
             indexer = get_indexer(instance)
             indexer = get_indexer(instance)
@@ -150,6 +156,8 @@ class CachedValueSearchBackend(SearchBackend):
         """
         """
         Receiver for the post_delete signal, responsible for caching object deletion.
         Receiver for the post_delete signal, responsible for caching object deletion.
         """
         """
+        from .deferred import OP_REMOVE, mark_for_deferred_indexing
+
         # Skip non-cacheable objects without scheduling any deferred work.
         # Skip non-cacheable objects without scheduling any deferred work.
         try:
         try:
             indexer = get_indexer(instance)
             indexer = get_indexer(instance)
@@ -440,7 +448,3 @@ def get_backend():
 
 
 
 
 search_backend = get_backend()
 search_backend = get_backend()
-
-# Connect handlers to the appropriate model signals
-post_save.connect(search_backend.caching_handler)
-post_delete.connect(search_backend.removal_handler)

+ 2 - 8
netbox/netbox/search/deferred.py

@@ -4,6 +4,8 @@ from django.db import DEFAULT_DB_ALIAS, connections, transaction
 from redis.exceptions import RedisError
 from redis.exceptions import RedisError
 
 
 from netbox.constants import RQ_QUEUE_DEFAULT
 from netbox.constants import RQ_QUEUE_DEFAULT
+from netbox.search.backends import search_backend
+from netbox.search.jobs import SearchCacheJob
 from utilities.rqworker import any_workers_for_queue
 from utilities.rqworker import any_workers_for_queue
 
 
 # This module is internal plumbing for the search signal handlers; nothing here
 # This module is internal plumbing for the search signal handlers; nothing here
@@ -157,14 +159,6 @@ def _flush(batch, using):
         groups = remove_groups if op == OP_REMOVE else cache_groups
         groups = remove_groups if op == OP_REMOVE else cache_groups
         groups.setdefault(object_type_id, []).append(pk)
         groups.setdefault(object_type_id, []).append(pk)
 
 
-    # Imported here, not at module load, to avoid an import cycle: backends.py
-    # imports this module at module level (for the signal handlers), and
-    # netbox.search.jobs imports the search_backend singleton from backends.py,
-    # which is bound at the bottom of that module. A proper fix is tracked in
-    # #22485.
-    from netbox.search.backends import search_backend
-    from netbox.search.jobs import SearchCacheJob
-
     try:
     try:
         # Both the worker-availability check and the job enqueue talk to Redis,
         # Both the worker-availability check and the job enqueue talk to Redis,
         # and a worker can die between the two. Treat any Redis failure across the
         # and a worker can die between the two. Treat any Redis failure across the

+ 31 - 0
netbox/netbox/search/signals.py

@@ -0,0 +1,31 @@
+"""
+Connects the global search cache's post_save/post_delete receivers.
+
+Wired explicitly from CoreConfig.ready() (core/apps.py) rather than as a side effect of this
+module being imported, so connection happens deterministically at startup instead of depending
+on which of this subsystem's several consumers (netbox.forms.search, netbox.views.misc,
+extras.management.commands.reindex, netbox.search.jobs, core.jobs, dcim.signals) happens to
+import netbox.search first.
+"""
+from django.db.models.signals import post_delete, post_save
+from django.dispatch import receiver
+
+from .backends import search_backend
+
+
+@receiver(post_save)
+def caching_handler(sender, instance, created, **kwargs):
+    """
+    Update the search cache when an object is created or modified. Delegates to whichever
+    backend is configured; see SearchBackend.caching_handler().
+    """
+    search_backend.caching_handler(sender, instance, created=created, **kwargs)
+
+
+@receiver(post_delete)
+def removal_handler(sender, instance, **kwargs):
+    """
+    Remove an object's cached representation when it is deleted. Delegates to whichever
+    backend is configured; see SearchBackend.removal_handler().
+    """
+    search_backend.removal_handler(sender, instance, **kwargs)

+ 6 - 4
netbox/netbox/tests/test_search.py

@@ -741,7 +741,8 @@ class CustomBackendContractTestCase(TransactionTestCase):
         backend = _MinimalSearchBackend()
         backend = _MinimalSearchBackend()
 
 
         # Connect the custom backend's (inherited, synchronous) handlers, exactly as
         # Connect the custom backend's (inherited, synchronous) handlers, exactly as
-        # backends.py connects the configured backend at import. Same call site, no type-check.
+        # netbox.search.signals connects the configured backend from CoreConfig.ready(). Same
+        # call site, no type-check.
         post_save.connect(backend.caching_handler, sender=Site)
         post_save.connect(backend.caching_handler, sender=Site)
         post_delete.connect(backend.removal_handler, sender=Site)
         post_delete.connect(backend.removal_handler, sender=Site)
         self.addCleanup(post_save.disconnect, backend.caching_handler, sender=Site)
         self.addCleanup(post_save.disconnect, backend.caching_handler, sender=Site)
@@ -757,9 +758,10 @@ class CustomBackendContractTestCase(TransactionTestCase):
         self.assertEqual(len(backend.removed), 1)
         self.assertEqual(len(backend.removed), 1)
 
 
     def test_default_backend_defers_via_same_call_path(self):
     def test_default_backend_defers_via_same_call_path(self):
-        # The default backend (CachedValueSearchBackend) IS connected at import, and reaches the
-        # SAME caching_handler call path -- but its override defers instead of indexing inline.
-        # This contrasts with the custom backend above: identical dispatch, polymorphic behavior.
+        # The default backend (CachedValueSearchBackend) IS connected via netbox.search.signals, and
+        # reaches the SAME caching_handler call path -- but its override defers instead of indexing
+        # inline. This contrasts with the custom backend above: identical dispatch, polymorphic
+        # behavior.
         with transaction.atomic():
         with transaction.atomic():
             Site.objects.create(name='Default Defers', slug='default-defers')
             Site.objects.create(name='Default Defers', slug='default-defers')
             scheduled = scheduled_search_flushes()
             scheduled = scheduled_search_flushes()