This commit is contained in:
Arthur 2026-09-16 09:32:50 -07:00
parent deeb6f1489
commit 5fd7bbe0ae
2 changed files with 60 additions and 117 deletions

View File

@ -1,7 +1,7 @@
from django.apps import apps
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.core.exceptions import ValidationError
from django.db import models, router, transaction
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@ -394,107 +394,72 @@ class CircuitTermination(
circuit_changed = tracking_relevant and self._orig_circuit_id and self._orig_circuit_id != self.circuit_id
term_side_changed = tracking_relevant and self._orig_term_side and self._orig_term_side != self.term_side
pointer_moved = is_new or circuit_changed or term_side_changed
# Cache objects associated with the terminating object (for filtering)
self.cache_related_objects()
if not pointer_moved:
super().save(*args, **kwargs)
return
super().save(*args, **kwargs)
# Collect the pointer writes per circuit, so that a term_side change within one
# circuit clears the old side and sets the new one in a single write
updates = {}
# Clear the old termination reference if circuit or term_side changed. Never on insert:
# __init__ captured the constructor's values, which may name a live sibling's pointer.
if not is_new and (circuit_changed or term_side_changed):
# Clear the old termination reference if circuit or term_side changed
if circuit_changed or term_side_changed:
old_termination_name = f'termination_{self._orig_term_side.lower()}'
updates.setdefault(self._orig_circuit_id, {})[old_termination_name] = None
# Write the termination row and the pointers which reference it together
using = kwargs.get('using') or router.db_for_write(type(self))
with transaction.atomic(using=using):
super().save(*args, **kwargs)
self._set_circuit_terminations(
self._orig_circuit_id, {old_termination_name: None}, only_if_references=self.pk
)
# Update the cache if this is a new termination or circuit/term_side changed
if is_new or circuit_changed or term_side_changed:
# Update the new circuit's termination reference
termination_name = f'termination_{self.term_side.lower()}'
updates.setdefault(self.circuit_id, {})[termination_name] = self.pk
self._set_circuit_terminations(self.circuit_id, {termination_name: self.pk})
# Ordered by PK so concurrent saves take the circuit locks in the same order
for circuit_id in sorted(updates):
self._set_circuit_terminations(circuit_id, updates[circuit_id], using=using)
# Update cached values for subsequent saves
self._orig_circuit_id = self.circuit_id
self._orig_term_side = self.term_side
# Advanced only once the writes have left the block, so that a rolled-back save is still
# pending on retry
self._orig_circuit_id = self.circuit_id
self._orig_term_side = self.term_side
def delete(self, *args, **kwargs):
# Clear the circuit's reference before the row goes away, so that the change is recorded
# and precedes the DELETE. on_delete=SET_NULL would clear it with an unlogged bulk update.
self._set_circuit_terminations(
self.circuit_id, {'termination_a': None, 'termination_z': None}, only_if_references=self.pk
)
return super().delete(*args, **kwargs)
delete.alters_data = True
@staticmethod
def _set_circuit_terminations(circuit_id, fields, using=None, only_if_references=None):
def _set_circuit_terminations(circuit_id, fields, only_if_references=None):
"""
Set or clear a Circuit's cached `termination_a`/`termination_z` fields. `fields` maps
field name to CircuitTermination PK (or None). `only_if_references` restricts the write
to fields which currently hold that PK.
Set or clear a Circuit's cached `termination_a`/`termination_z` fields, recording the
change. `fields` maps field name to CircuitTermination PK (or None). A queryset update()
emits no post_save, and so records nothing in the changelog (#23134).
Written via snapshot() + save() rather than a queryset update(), which emits no post_save
and so records nothing in the changelog. The Circuit is re-fetched under a lock so that
the snapshot reflects a sibling pointer written concurrently; without it, a second writer
could record the first writer's pointer as null. no_key avoids blocking the foreign key
inserts which reference this circuit.
Does nothing if the Circuit no longer exists, or if every field already holds its
intended value.
Args:
circuit_id: PK of the Circuit to update
fields: Mapping of field name to the value to assign
only_if_references: If set, restricts the write to fields which currently hold this PK
"""
using = using or router.db_for_write(Circuit)
# order_by() clears the default ordering, whose JOIN would leave the row unlockable
circuit = Circuit.objects.using(using).filter(
pk=circuit_id
).order_by().select_for_update(no_key=True).first()
# Re-fetched rather than reusing a cached circuit, whose pointers may predate a sibling write
circuit = Circuit.objects.filter(pk=circuit_id).first()
if circuit is None:
return
def needs_write(field_name, value):
updates = {}
for field_name, value in fields.items():
current = getattr(circuit, f'{field_name}_id')
if current == value:
return False
# Match what on_delete=SET_NULL would have cleared
return only_if_references is None or current == only_if_references
fields = {name: value for name, value in fields.items() if needs_write(name, value)}
if not fields:
continue
if only_if_references is not None and current != only_if_references:
continue
updates[field_name] = value
if not updates:
return
circuit.snapshot()
for field_name, value in fields.items():
for field_name, value in updates.items():
setattr(circuit, f'{field_name}_id', value)
# Saved in full, not with update_fields: the mixin chain also mutates custom_field_data
# and the distance fields, which would reach postchange_data but not the database.
circuit.save(using=using)
def delete(self, *args, **kwargs):
# Clear the pointer first, so its record precedes this DELETE. Not a pre_delete receiver:
# handle_deleted_object connects earlier and would record the DELETE first. (#23134)
using = kwargs.get('using') or (args[0] if args else None) or router.db_for_write(type(self))
with transaction.atomic(using=using):
# Locked before the circuit, matching the order super().save() takes them in
CircuitTermination.objects.using(using).filter(
pk=self.pk
).order_by().select_for_update().first()
if self.term_side:
self._set_circuit_terminations(
self.circuit_id,
{f'termination_{self.term_side.lower()}': None},
using=using,
only_if_references=self.pk,
)
return super().delete(*args, **kwargs)
delete.alters_data = True
circuit.save(update_fields=[*updates, 'last_updated'])
def cache_related_objects(self):
self._provider_network = self._region = self._site_group = self._site = self._location = None

View File

@ -1,5 +1,4 @@
import uuid
from unittest.mock import patch
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
@ -383,7 +382,7 @@ class CircuitTerminationChangeLoggingTestCase(TestCase):
self.assertEqual(new_changes[0].postchange_data['termination_a'], termination.pk)
@tag('regression') # Ref: #23134
def test_term_side_change_records_single_circuit_update(self):
def test_term_side_change_records_circuit_updates(self):
termination = self._tracked(lambda: CircuitTermination.objects.create(
circuit=self.circuits[0], term_side='A', termination=self.sites[0],
))
@ -395,13 +394,13 @@ class CircuitTerminationChangeLoggingTestCase(TestCase):
self._tracked(_flip)
# Both pointers move within one circuit, so the clear and the set are coalesced
# The old pointer is cleared, then the new one is set
changes = self._circuit_changes(self.circuits[0])
self.assertEqual(changes.count(), 1)
self.assertEqual(changes.count(), 2)
self.assertEqual(changes[0].prechange_data['termination_a'], termination.pk)
self.assertIsNone(changes[0].prechange_data['termination_z'])
self.assertIsNone(changes[0].postchange_data['termination_a'])
self.assertEqual(changes[0].postchange_data['termination_z'], termination.pk)
self.assertIsNone(changes[1].prechange_data['termination_z'])
self.assertEqual(changes[1].postchange_data['termination_z'], termination.pk)
@tag('regression') # Ref: #23134
def test_redundant_pointer_write_is_skipped(self):
@ -550,9 +549,10 @@ class CircuitTerminationChangeLoggingTestCase(TestCase):
self.assertIsNone(self.circuits[0].termination_a_id)
self.assertFalse(self._circuit_changes(self.circuits[0]).exists())
def test_deletion_leaves_pointer_for_another_termination(self):
# An in-memory term_side which diverges from the persisted one must not clear a pointer
# belonging to a different termination
@tag('regression') # Ref: #23134
def test_deletion_clears_the_pointer_which_references_it(self):
# An in-memory term_side which diverges from the persisted one must clear this
# termination's own pointer, and leave the one belonging to its sibling alone
termination_a = self._tracked(lambda: CircuitTermination.objects.create(
circuit=self.circuits[0], term_side='A', termination=self.sites[0],
))
@ -560,13 +560,21 @@ class CircuitTerminationChangeLoggingTestCase(TestCase):
circuit=self.circuits[0], term_side='Z', termination=self.sites[1],
))
ObjectChange.objects.all().delete()
termination_z_pk = termination_z.pk
termination_z.term_side = 'A'
self._tracked(termination_z.delete)
self.circuits[0].refresh_from_db()
self.assertEqual(self.circuits[0].termination_a_id, termination_a.pk)
self.assertFalse(self._circuit_changes(self.circuits[0]).exists())
self.assertIsNone(self.circuits[0].termination_z_id)
changes = self._circuit_changes(self.circuits[0])
self.assertEqual(changes.count(), 1)
self.assertEqual(changes[0].prechange_data['termination_a'], termination_a.pk)
self.assertEqual(changes[0].postchange_data['termination_a'], termination_a.pk)
self.assertEqual(changes[0].prechange_data['termination_z'], termination_z_pk)
self.assertIsNone(changes[0].postchange_data['termination_z'])
def test_circuit_deletion_records_no_pointer_update(self):
self._tracked(lambda: CircuitTermination.objects.create(
@ -577,33 +585,3 @@ class CircuitTerminationChangeLoggingTestCase(TestCase):
self._tracked(self.circuits[0].delete)
self.assertFalse(self._circuit_changes(self.circuits[0]).exists())
@tag('regression') # Ref: #23134
def test_failed_pointer_write_leaves_the_change_pending(self):
# The cached originals must not advance until the pointer writes have succeeded
termination = self._tracked(lambda: CircuitTermination.objects.create(
circuit=self.circuits[0], term_side='A', termination=self.sites[0],
))
def _move():
termination.circuit = self.circuits[1]
termination.save()
with patch.object(
CircuitTermination, '_set_circuit_terminations', side_effect=OSError('boom')
):
with self.assertRaises(OSError):
self._tracked(_move)
# The termination row was rolled back along with the pointer writes
termination.refresh_from_db()
self.assertEqual(termination.circuit, self.circuits[0])
# A retry still sees the move as pending, so both pointers end up correct
termination.circuit = self.circuits[1]
self._tracked(termination.save)
self.circuits[0].refresh_from_db()
self.circuits[1].refresh_from_db()
self.assertIsNone(self.circuits[0].termination_a_id)
self.assertEqual(self.circuits[1].termination_a_id, termination.pk)