Skip to content

Commit 9e94a21

Browse files
committed
Improved dependency merge
1 parent f80fed9 commit 9e94a21

File tree

1 file changed

+126
-129
lines changed

1 file changed

+126
-129
lines changed

netbox_branching/models/branches.py

Lines changed: 126 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -532,8 +532,18 @@ def _collapse_changes_for_object(changes, logger):
532532
Collapse a list of ObjectChanges for a single object.
533533
Returns: (final_action, merged_data, last_change)
534534
535-
Simplified DELETE logic: If DELETE appears anywhere in the changes,
536-
ignore all other changes and keep only the DELETE.
535+
A key point is that we only care about the final state of the object. Also
536+
each ChangeObject needs to be correct so the final state is correct, i.e.
537+
if we delete an object, there aren't going to be other objects still referencing it.
538+
539+
ChangeObject can have CREATE, UPDATE, and DELETE actions.
540+
We need to collapse these changes into a single action.
541+
We can have the following cases:
542+
- CREATE + (any updates) + DELETE = skip entirely
543+
- (anything other than CREATE) + DELETE = DELETE
544+
- CREATE + UPDATEs = CREATE
545+
- multiple UPDATEs = UPDATE
546+
537547
"""
538548
if not changes:
539549
return None, None, None
@@ -616,106 +626,6 @@ def _get_fk_references(model_class, data, changed_objects):
616626

617627
return references
618628

619-
@staticmethod
620-
def _removed_reference_to(collapsed_change, target_key, logger):
621-
"""
622-
Check if this collapsed change removed an FK reference to target_key.
623-
Returns True if:
624-
- The object previously referenced target_key (in its initial state)
625-
- The final state does NOT reference target_key (or object is deleted)
626-
"""
627-
if not collapsed_change.changes:
628-
return False
629-
630-
target_ct_id, target_obj_id = target_key
631-
first_change = collapsed_change.changes[0]
632-
633-
# If this object is being deleted, check if it referenced target initially
634-
if collapsed_change.final_action == 'delete':
635-
initial_refs = Branch._get_fk_references(
636-
collapsed_change.model_class,
637-
first_change.prechange_data or {},
638-
{target_key}
639-
)
640-
return target_key in initial_refs
641-
642-
# If created in branch, it couldn't have had an initial reference
643-
if first_change.action == 'create':
644-
return False
645-
646-
# It's an update - check if FK reference changed
647-
initial_state = first_change.prechange_data or {}
648-
final_state = collapsed_change.merged_data or {}
649-
650-
# Check each FK field
651-
for field in collapsed_change.model_class._meta.get_fields():
652-
if isinstance(field, models.ForeignKey):
653-
related_model = field.related_model
654-
related_ct = ContentType.objects.get_for_model(related_model)
655-
656-
# Only check if this FK could point to our target
657-
if related_ct.id != target_ct_id:
658-
continue
659-
660-
fk_field_name = field.attname # e.g., 'device_id'
661-
initial_value = initial_state.get(fk_field_name)
662-
final_value = final_state.get(fk_field_name)
663-
664-
# Reference was removed or changed from target
665-
if initial_value == target_obj_id and initial_value != final_value:
666-
logger.debug(f" Found removed reference: {field.name} was {initial_value}, now {final_value}")
667-
return True
668-
669-
return False
670-
671-
@staticmethod
672-
def _build_dependency_graph(collapsed_changes, logger):
673-
"""
674-
Build dependency graph between collapsed changes.
675-
Modifies collapsed_changes in place to set depends_on/depended_by.
676-
"""
677-
logger.info("Building dependency graph...")
678-
679-
# 1. FK dependencies for creates/updates
680-
# If we CREATE/UPDATE object A with FK to object B,
681-
# and B is being created, then B must be created first
682-
logger.debug(" Analyzing FK dependencies for creates/updates...")
683-
for key, collapsed in collapsed_changes.items():
684-
if collapsed.final_action in ('create', 'update'):
685-
fk_refs = Branch._get_fk_references(
686-
collapsed.model_class,
687-
collapsed.merged_data,
688-
collapsed_changes.keys()
689-
)
690-
691-
for ref_key in fk_refs:
692-
ref_collapsed = collapsed_changes[ref_key]
693-
# Only add dependency if the referenced object is being created
694-
if ref_collapsed.final_action == 'create':
695-
collapsed.depends_on.add(ref_key)
696-
ref_collapsed.depended_by.add(key)
697-
logger.debug(f" {collapsed} depends on {ref_collapsed} (FK reference)")
698-
699-
# 2. Delete dependencies
700-
# If we DELETE object A, and object B removes its reference to A,
701-
# then B's change must happen before A's delete
702-
logger.debug(" Analyzing dependencies for deletes...")
703-
for key, collapsed in collapsed_changes.items():
704-
if collapsed.final_action == 'delete':
705-
# Find all changes that removed references to this object
706-
for other_key, other_collapsed in collapsed_changes.items():
707-
if other_key == key:
708-
continue
709-
710-
if Branch._removed_reference_to(other_collapsed, key, logger):
711-
# other_collapsed must happen before collapsed (the delete)
712-
collapsed.depends_on.add(other_key)
713-
other_collapsed.depended_by.add(key)
714-
logger.debug(f" {collapsed} depends on {other_collapsed} (removed reference)")
715-
716-
total_deps = sum(len(c.depends_on) for c in collapsed_changes.values())
717-
logger.info(f" Dependency graph built: {total_deps} dependencies")
718-
719629
@staticmethod
720630
def _topological_sort_with_cycle_detection(collapsed_changes, logger):
721631
"""
@@ -767,7 +677,24 @@ def _topological_sort_with_cycle_detection(collapsed_changes, logger):
767677
@staticmethod
768678
def _order_collapsed_changes(collapsed_changes, logger):
769679
"""
770-
Order collapsed changes respecting dependencies and constraints.
680+
Order collapsed changes respecting dependencies and time.
681+
682+
Algorithm:
683+
1. Initial ordering by time: DELETEs, UPDATEs, CREATEs (each group sorted by time)
684+
2. Build dependency graph:
685+
- If UPDATE references deleted object in prechange_data → UPDATE must come before DELETE
686+
(UPDATE removes the FK reference, allowing the DELETE to proceed)
687+
- If UPDATE references created object in postchange_data → CREATE must come before UPDATE
688+
(CREATE must exist before UPDATE can reference it)
689+
- If CREATE references another created object in postchange_data → referenced CREATE must come first
690+
(Referenced object must exist before referencing object is created)
691+
3. Topological sort respecting dependencies
692+
693+
This ensures:
694+
- DELETEs generally happen first to free unique constraints (time order within group)
695+
- UPDATEs that remove FK references happen before their associated DELETEs
696+
- CREATEs happen before UPDATEs/CREATEs that reference them
697+
771698
Returns: ordered list of CollapsedChange objects
772699
"""
773700
logger.info(f"Ordering {len(collapsed_changes)} collapsed changes...")
@@ -782,36 +709,106 @@ def _order_collapsed_changes(collapsed_changes, logger):
782709
if not to_process:
783710
return []
784711

785-
# Build dependency graph
786-
Branch._build_dependency_graph(to_process, logger)
712+
# Group by action and sort each group by time
713+
deletes = sorted(
714+
[v for v in to_process.values() if v.final_action == 'delete'],
715+
key=lambda c: c.last_change.time
716+
)
717+
updates = sorted(
718+
[v for v in to_process.values() if v.final_action == 'update'],
719+
key=lambda c: c.last_change.time
720+
)
721+
creates = sorted(
722+
[v for v in to_process.values() if v.final_action == 'create'],
723+
key=lambda c: c.last_change.time
724+
)
787725

788-
# Topological sort
789-
ordered_keys = Branch._topological_sort_with_cycle_detection(to_process, logger)
726+
logger.info(
727+
f" Initial time-based groups: {len(deletes)} deletes, "
728+
f"{len(updates)} updates, {len(creates)} creates"
729+
)
730+
731+
# Reset dependencies
732+
for collapsed in to_process.values():
733+
collapsed.depends_on = set()
734+
collapsed.depended_by = set()
735+
736+
# Build lookup maps for efficient dependency checking
737+
deletes_map = {c.key: c for c in deletes}
738+
creates_map = {c.key: c for c in creates}
790739

791-
# Group by model and refine order within each model
792-
logger.info("Refining order within models (deletes before creates to free unique constraints)...")
793-
by_model = defaultdict(list)
794-
for key in ordered_keys:
795-
collapsed = to_process[key]
796-
by_model[collapsed.model_class].append(collapsed)
797-
798-
# Within each model: updates, then deletes, then creates
799-
# This ensures deletes free up unique constraints (like slugs) before creates claim them
800-
result = []
801-
for model_class, changes in by_model.items():
802-
updates = [c for c in changes if c.final_action == 'update']
803-
deletes = [c for c in changes if c.final_action == 'delete']
804-
creates = [c for c in changes if c.final_action == 'create']
805-
806-
if updates or creates or deletes:
807-
logger.debug(
808-
f" {model_class.__name__}: {len(updates)} updates, "
809-
f"{len(deletes)} deletes, {len(creates)} creates"
740+
logger.info("Building dependency graph...")
741+
742+
# 1. Check UPDATEs for dependencies
743+
logger.debug(" Analyzing UPDATE dependencies...")
744+
for update in updates:
745+
# Check if UPDATE references deleted object in prechange_data
746+
# This means the UPDATE had a reference that it's removing
747+
# The UPDATE must happen BEFORE the DELETE so the FK reference is removed first
748+
if update.changes[0].prechange_data:
749+
prechange_refs = Branch._get_fk_references(
750+
update.model_class,
751+
update.changes[0].prechange_data,
752+
deletes_map.keys()
810753
)
754+
for ref_key in prechange_refs:
755+
# DELETE depends on UPDATE (UPDATE removes reference, then DELETE can proceed)
756+
delete_collapsed = deletes_map[ref_key]
757+
delete_collapsed.depends_on.add(update.key)
758+
update.depended_by.add(ref_key)
759+
logger.debug(
760+
f" {delete_collapsed} depends on {update} "
761+
f"(UPDATE removes FK reference before DELETE)"
762+
)
763+
764+
# Check if UPDATE references created object in postchange_data
765+
# This means the UPDATE needs the CREATE to exist first
766+
# The CREATE must happen BEFORE the UPDATE
767+
if update.merged_data:
768+
postchange_refs = Branch._get_fk_references(
769+
update.model_class,
770+
update.merged_data,
771+
creates_map.keys()
772+
)
773+
for ref_key in postchange_refs:
774+
# UPDATE depends on CREATE
775+
create_collapsed = creates_map[ref_key]
776+
update.depends_on.add(ref_key)
777+
create_collapsed.depended_by.add(update.key)
778+
logger.debug(
779+
f" {update} depends on {create_collapsed} "
780+
f"(UPDATE references created object)"
781+
)
782+
783+
# 2. Check CREATEs for dependencies on other CREATEs
784+
logger.debug(" Analyzing CREATE dependencies...")
785+
for create in creates:
786+
if create.merged_data:
787+
# Check if this CREATE references other created objects
788+
refs = Branch._get_fk_references(
789+
create.model_class,
790+
create.merged_data,
791+
creates_map.keys()
792+
)
793+
for ref_key in refs:
794+
if ref_key != create.key: # Don't self-reference
795+
# CREATE depends on another CREATE
796+
ref_create = creates_map[ref_key]
797+
create.depends_on.add(ref_key)
798+
ref_create.depended_by.add(create.key)
799+
logger.debug(
800+
f" {create} depends on {ref_create} "
801+
f"(CREATE references another created object)"
802+
)
803+
804+
total_deps = sum(len(c.depends_on) for c in to_process.values())
805+
logger.info(f" Dependency graph built: {total_deps} dependencies")
806+
807+
# Topological sort to respect dependencies
808+
ordered_keys = Branch._topological_sort_with_cycle_detection(to_process, logger)
811809

812-
result.extend(updates)
813-
result.extend(deletes)
814-
result.extend(creates)
810+
# Convert keys back to collapsed changes
811+
result = [to_process[key] for key in ordered_keys]
815812

816813
logger.info(f"Ordering complete: {len(result)} changes to apply")
817814
return result

0 commit comments

Comments
 (0)