Arthur 11 timmar sedan
förälder
incheckning
8ec91108f2
1 ändrade filer med 23 tillägg och 16 borttagningar
  1. 23 16
      netbox/circuits/models/circuits.py

+ 23 - 16
netbox/circuits/models/circuits.py

@@ -399,6 +399,10 @@ class CircuitTermination(
         # Cache objects associated with the terminating object (for filtering)
         self.cache_related_objects()
 
+        if not pointer_moved:
+            super().save(*args, **kwargs)
+            return
+
         # 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 = {}
@@ -412,23 +416,24 @@ class CircuitTermination(
         with transaction.atomic(using=router.db_for_write(type(self))):
             super().save(*args, **kwargs)
 
-            # Update the cache if this is a new termination or circuit/term_side changed
-            if pointer_moved:
-                # Update the new circuit's termination reference
-                termination_name = f'termination_{self.term_side.lower()}'
-                updates.setdefault(self.circuit_id, {})[termination_name] = self.pk
-
-            for circuit_id, fields in updates.items():
-                circuit = self._set_circuit_terminations(circuit_id, fields)
-                # Keep the instance's cached Circuit in step with the write
+            # Update the new circuit's termination reference
+            termination_name = f'termination_{self.term_side.lower()}'
+            updates.setdefault(self.circuit_id, {})[termination_name] = self.pk
+
+            # Ordered by PK, so that two terminations moving between the same pair of circuits
+            # take the two locks in the same order and cannot deadlock
+            for circuit_id in sorted(updates, key=lambda pk: pk or 0):
+                circuit = self._set_circuit_terminations(circuit_id, updates[circuit_id])
+                # Adopt the fetched Circuit, so that to_objectchange() and the caller see the
+                # new pointers. Note this replaces the instance's cached parent, discarding any
+                # prefetch or annotation set up on it.
                 if circuit is not None and circuit.pk == self.circuit_id:
                     self.circuit = circuit
 
             # Update cached values for subsequent saves, only once the pointer writes have
             # succeeded, so that a failed save is still pending on retry
-            if pointer_moved:
-                self._orig_circuit_id = self.circuit_id
-                self._orig_term_side = self.term_side
+            self._orig_circuit_id = self.circuit_id
+            self._orig_term_side = self.term_side
 
     @staticmethod
     def _set_circuit_terminations(circuit_id, fields):
@@ -437,13 +442,15 @@ class CircuitTermination(
         field name to CircuitTermination PK (or None).
 
         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 so that sequential A/Z
-        saves snapshot current state; concurrent writers under READ COMMITTED still cannot see
-        each other's uncommitted writes.
+        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.
 
         Returns the Circuit, or None if no such row exists.
         """
-        circuit = Circuit.objects.filter(pk=circuit_id).first()
+        # order_by() clears the default ordering, whose JOIN would leave the row unlockable
+        circuit = Circuit.objects.filter(pk=circuit_id).order_by().select_for_update(no_key=True).first()
         if circuit is None:
             return None