Merge pull request #22331 from netbox-community/22270-delete

#22270 - Skip cascade-deleted objects when clearing reverse SET_NUL relations
This commit is contained in:
bctiemann 2026-05-28 20:05:30 -04:00 committed by GitHub
commit cc78ebf347
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 59 additions and 1 deletions

View File

@ -225,7 +225,23 @@ def handle_deleted_object(sender, instance, **kwargs):
# We only care about triggering the m2m_changed signal for models which support
# change logging
continue
related_object_type = ContentType.objects.get_for_model(related_model)
for obj in related_model.objects.filter(**{related_field_name: instance.pk}):
# Skip any related object that is itself being deleted as part of this same
# operation (e.g. a sibling caught up in the same cascade). Its deletion has
# already been recorded, so nulling the FK and saving here would record an
# UPDATE ObjectChange *after* the object's DELETE, corrupting the changelog and
# breaking branch replay. (Ref: #22270)
#
# Note this is order-dependent: it only fires once the related object's own
# pre_delete has run (adding it to the set). If the cascade happens to delete
# this instance *before* the related object, the guard won't trigger and the
# related object still gets an UPDATE — but in the harmless UPDATE-then-DELETE
# order, not the corrupting DELETE-then-UPDATE order. Fully suppressing it in
# every ordering would require the complete deletion set, which isn't available
# from a pre_delete signal.
if (related_object_type, obj.pk) in _signals_received.pre_delete:
continue
obj.snapshot() # Ensure the change record includes the "before" state
if type(relation) is ManyToManyRel:
getattr(obj, related_field_name).remove(instance)

View File

@ -12,7 +12,8 @@ from core import signals
from core.choices import DataSourceStatusChoices, JobStatusChoices, ObjectChangeActionChoices
from core.models import ConfigRevision, DataSource, ObjectChange, ObjectType
from core.signals import _signals_received, clear_events, post_sync
from dcim.models import Site, SiteGroup
from dcim.choices import InterfaceTypeChoices
from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site, SiteGroup
from extras.models import Tag
from extras.validators import CustomValidator
from netbox.context import events_queue
@ -204,6 +205,47 @@ class HandleDeletedObjectSignalTestCase(TestCase):
self.assertEqual(oc.prechange_data['group'], group_pk)
self.assertIsNone(oc.postchange_data['group'])
def test_cascade_delete_does_not_record_update_after_delete(self):
# Regression test for #22270. Deleting a device cascades to all of its interfaces.
# When the LAG interface's pre_delete fires, it clears the `lag` FK (SET_NULL) on its
# member interfaces and records a change. If a member is itself being deleted in the
# same cascade, that update must not be written *after* the member's delete record —
# an UPDATE-after-DELETE corrupts the changelog and breaks branch replay.
manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type', slug='device-type')
role = DeviceRole.objects.create(name='Role', slug='role')
site = Site.objects.create(name='Site', slug='site')
device = Device.objects.create(name='Device', site=site, device_type=device_type, role=role)
# Members are created before the LAG so their PKs are lower; the cascade fires their
# pre_delete (and records their DELETE) before the LAG's pre_delete runs.
member1 = Interface.objects.create(device=device, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
member2 = Interface.objects.create(device=device, name='eth1', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
lag = Interface.objects.create(device=device, name='lag0', type=InterfaceTypeChoices.TYPE_LAG)
member1.lag = lag
member1.save()
member2.lag = lag
member2.save()
member_pks = (member1.pk, member2.pk)
interface_type = ContentType.objects.get_for_model(Interface)
request = _build_request(self.user)
with event_tracking(request):
device.delete()
for member_pk in member_pks:
actions = list(
ObjectChange.objects.filter(
request_id=request.id,
changed_object_type=interface_type,
changed_object_id=member_pk,
).order_by('time', 'pk').values_list('action', flat=True)
)
# In this ordering the member's pre_delete fires before the LAG's, so the LAG
# skips it: the member's only change record for the request is its own deletion.
# Pre-fix, a spurious UPDATE was appended *after* this DELETE — assert the exact
# sequence so any trailing (or leading) update fails the test.
self.assertEqual(actions, [ObjectChangeActionChoices.ACTION_DELETE])
class ClearSignalHistorySignalTestCase(TestCase):
"""