From b1412514f1eb7cda94eddcb6839159d90de093b7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 13 May 2026 09:50:32 -0400 Subject: [PATCH] Closes #22104: Avoid retracing paths when deleting Cables (#22167) --- netbox/dcim/models/cables.py | 26 ++++++++++++++++++++++++++ netbox/dcim/signals.py | 6 ++++++ netbox/dcim/tests/test_cablepaths.py | 12 ++++++++++++ 3 files changed, 44 insertions(+) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index fb0d87067..dd067ee2c 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -1,5 +1,6 @@ import itertools import logging +import threading from collections import Counter from django.contrib.contenttypes.fields import GenericForeignKey @@ -76,6 +77,11 @@ class Cable(PrimaryModel): """ A physical connection between two endpoints. """ + # Per-thread tracking of Cable PKs currently in delete(); referenced by + # dcim.signals.nullify_connected_endpoints to skip per-CableTermination + # cable path retracing during cascade (retrace_cable_paths handles it once). + _deletion_tracking = threading.local() + type = models.CharField( verbose_name=_('type'), max_length=50, @@ -343,6 +349,26 @@ class Cable(PrimaryModel): except UnsupportedCablePath as e: raise AbortRequest(e) + def delete(self, *args, **kwargs): + # Track this Cable as being deleted so the post_delete signal handler + # for cascaded CableTerminations can skip redundant path retracing; + # retrace_cable_paths() will retrace each affected path once after the + # Cable itself is deleted. Cache the PK locally because super().delete() + # clears self.pk before the finally block runs. The tracking set lives + # on a threading.local() to isolate concurrent deletions across threads. + if not hasattr(Cable._deletion_tracking, 'pks'): + Cable._deletion_tracking.pks = set() + pk = self.pk + Cable._deletion_tracking.pks.add(pk) + try: + return super().delete(*args, **kwargs) + finally: + Cable._deletion_tracking.pks.discard(pk) + + @classmethod + def _is_being_deleted(cls, pk): + return pk in getattr(cls._deletion_tracking, 'pks', ()) + def clone(self): """ Return attributes suitable for cloning this cable. diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index dd37817d1..8d5baf6d1 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -185,6 +185,12 @@ def nullify_connected_endpoints(instance, **kwargs): model = instance.termination_type.model_class() model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='') + # If the parent Cable is being deleted in this same operation, skip the + # per-termination retrace; retrace_cable_paths() will retrace each affected + # path once after the Cable is deleted. + if Cable._is_being_deleted(instance.cable_id): + return + for cablepath in CablePath.objects.filter(_nodes__contains=instance.cable): # Remove the deleted CableTermination if it's one of the path's originating nodes if instance.termination in cablepath.origins: diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 9d7be50ef..9221d00ce 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -55,6 +55,18 @@ class LegacyCablePathTestCase(CablePathTestCase): # Check that all CablePaths have been deleted self.assertEqual(CablePath.objects.count(), 0) + # Check that connected interfaces are fully cleaned up + interface1.refresh_from_db() + interface2.refresh_from_db() + + self.assertIsNone(interface1.cable_id) + self.assertEqual(interface1.cable_end, '') + self.assertPathIsNotSet(interface1) + + self.assertIsNone(interface2.cable_id) + self.assertEqual(interface2.cable_end, '') + self.assertPathIsNotSet(interface2) + def test_102_consoleport_to_consoleserverport(self): """ [CP1] --C1-- [CSP1]