Skip to content

Commit ff21497

Browse files
jskladanclaude
andcommitted
fix(dependency_graph): remove orphaned children in remove_dependency
When a node is removed, child nodes that have no remaining parents are now removed as well, preventing orphaned nodes from lingering in the graph. Closes: #1041 Signed-off-by: Josef Skladanka <jskladan@redhat.com> Co-Authored-By: Claude <claude@anthropic.com>
1 parent f82c1e6 commit ff21497

2 files changed

Lines changed: 285 additions & 28 deletions

File tree

src/fromager/dependency_graph.py

Lines changed: 48 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import collections
34
import dataclasses
45
import graphlib
56
import json
@@ -346,12 +347,10 @@ def remove_dependency(
346347
req_name: NormalizedName,
347348
req_version: Version,
348349
) -> None:
349-
"""Remove a dependency node from the graph.
350+
"""Remove a dependency node and any orphaned descendants from the graph.
350351
351-
Removes the node and all edges pointing to it. This includes:
352-
- Back-references in child nodes' parents lists
353-
- Forward edges in parent nodes' children lists
354-
Child nodes of the removed node are kept if referenced elsewhere.
352+
Removes the node and all edges pointing to it. Child nodes that have
353+
no remaining parents after removal are iteratively removed as well.
355354
356355
Args:
357356
req_name: Canonical name of the package
@@ -362,29 +361,50 @@ def remove_dependency(
362361
logger.debug(f"Cannot remove {key} - not in graph")
363362
return
364363

365-
logger.debug(f"Removing failed dependency {key} from graph")
366-
367-
deleted_node = self.nodes[key]
368-
369-
# Clean up back-references (parents) in nodes that were children of the removed node
370-
for child_edge in deleted_node.children:
371-
child_node = child_edge.destination_node
372-
filtered_parents = [
373-
edge for edge in child_node.parents if edge.destination_node.key != key
374-
]
375-
child_node.parents.clear()
376-
child_node.parents.extend(filtered_parents)
377-
378-
# Remove the node itself
379-
del self.nodes[key]
380-
381-
# Remove forward edges from any node whose children pointed to the removed node
382-
for node in self.nodes.values():
383-
filtered_children = [
384-
edge for edge in node.children if edge.destination_node.key != key
385-
]
386-
node.children.clear()
387-
node.children.extend(filtered_children)
364+
queue: collections.deque[str] = collections.deque([key])
365+
366+
# BFS over orphaned descendants
367+
while queue:
368+
key = queue.popleft()
369+
# This is a defensive check, should not happen in normal operation
370+
if key not in self.nodes:
371+
logger.debug(f"Cannot remove {key} - not in graph")
372+
continue
373+
374+
logger.debug(f"Removing failed dependency {key} from graph")
375+
376+
deleted_node = self.nodes[key]
377+
378+
# Remove references to this node from its direct children
379+
children = []
380+
for child_edge in deleted_node.children:
381+
child_node = child_edge.destination_node
382+
filtered_parents = [
383+
edge
384+
for edge in child_node.parents
385+
if edge.destination_node.key != key
386+
]
387+
child_node.parents.clear()
388+
child_node.parents.extend(filtered_parents)
389+
children.append(child_node)
390+
391+
# Remove references to this node from its direct parents
392+
for parent_edge in deleted_node.parents:
393+
parent_node = parent_edge.destination_node
394+
filtered_children = [
395+
edge
396+
for edge in parent_node.children
397+
if edge.destination_node.key != key
398+
]
399+
parent_node.children.clear()
400+
parent_node.children.extend(filtered_children)
401+
402+
del self.nodes[key]
403+
404+
# Enqueue children that have become orphans
405+
for child in children:
406+
if child.key != ROOT and child.key in self.nodes and not child.parents:
407+
queue.append(child.key)
388408

389409
def get_dependency_edges(
390410
self, match_dep_types: list[RequirementType] | None = None

tests/test_dependency_graph.py

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,3 +468,240 @@ def test_tracking_topology_sorter_empty_graph() -> None:
468468
with pytest.raises(ValueError) as excinfo:
469469
topo.get_available()
470470
assert "topology is not active" in str(excinfo.value)
471+
472+
473+
def _build_graph(*edges: tuple[str, str, str]) -> DependencyGraph:
474+
"""Build a DependencyGraph from (parent, child, req_type) triples.
475+
476+
Use "ROOT" as parent name for top-level dependencies.
477+
"""
478+
graph = DependencyGraph()
479+
for parent_name, child_name, req_type_str in edges:
480+
parent_n = None if parent_name == "ROOT" else canonicalize_name(parent_name)
481+
parent_v = None if parent_name == "ROOT" else Version("1.0")
482+
graph.add_dependency(
483+
parent_name=parent_n,
484+
parent_version=parent_v,
485+
req_type=RequirementType(req_type_str),
486+
req=Requirement(f"{child_name}==1.0"),
487+
req_version=Version("1.0"),
488+
)
489+
return graph
490+
491+
492+
def test_remove_dependency_basic() -> None:
493+
"""Removing a leaf node cleans it from nodes and parent's children."""
494+
graph = _build_graph(
495+
("ROOT", "a", "toplevel"),
496+
("a", "b", "install"),
497+
)
498+
assert "b==1.0" in graph.nodes
499+
assert len(graph.nodes["a==1.0"].children) == 1
500+
501+
graph.remove_dependency(canonicalize_name("b"), Version("1.0"))
502+
503+
assert "b==1.0" not in graph.nodes
504+
assert len(graph.nodes["a==1.0"].children) == 0
505+
506+
507+
def test_remove_dependency_cascades_orphans() -> None:
508+
"""Removing a node recursively removes its orphaned descendants."""
509+
# ROOT -> a -> b -> c (linear chain)
510+
graph = _build_graph(
511+
("ROOT", "a", "toplevel"),
512+
("a", "b", "install"),
513+
("b", "c", "install"),
514+
)
515+
assert "a==1.0" in graph.nodes
516+
assert "b==1.0" in graph.nodes
517+
assert "c==1.0" in graph.nodes
518+
519+
graph.remove_dependency(canonicalize_name("a"), Version("1.0"))
520+
521+
assert "a==1.0" not in graph.nodes
522+
assert "b==1.0" not in graph.nodes
523+
assert "c==1.0" not in graph.nodes
524+
assert len(graph) == 0
525+
526+
527+
def test_remove_dependency_keeps_shared_children() -> None:
528+
"""Children with other parents are kept; only the stale parent edge is removed."""
529+
# ROOT -> a -> shared
530+
# ROOT -> b -> shared
531+
graph = _build_graph(
532+
("ROOT", "a", "toplevel"),
533+
("ROOT", "b", "toplevel"),
534+
("a", "shared", "install"),
535+
("b", "shared", "install"),
536+
)
537+
shared_node = graph.nodes["shared==1.0"]
538+
assert len(shared_node.parents) == 2
539+
540+
graph.remove_dependency(canonicalize_name("a"), Version("1.0"))
541+
542+
assert "a==1.0" not in graph.nodes
543+
assert "shared==1.0" in graph.nodes
544+
assert len(shared_node.parents) == 1
545+
assert shared_node.parents[0].destination_node.key == "b==1.0"
546+
547+
548+
def test_remove_dependency_diamond_sequential() -> None:
549+
"""Diamond: shared child survives first removal, cleaned up by second."""
550+
# ROOT -> a -> c
551+
# ROOT -> b -> c
552+
graph = _build_graph(
553+
("ROOT", "a", "toplevel"),
554+
("ROOT", "b", "toplevel"),
555+
("a", "c", "install"),
556+
("b", "c", "install"),
557+
)
558+
559+
graph.remove_dependency(canonicalize_name("a"), Version("1.0"))
560+
561+
assert "a==1.0" not in graph.nodes
562+
assert "c==1.0" in graph.nodes
563+
assert len(graph.nodes["c==1.0"].parents) == 1
564+
565+
graph.remove_dependency(canonicalize_name("b"), Version("1.0"))
566+
567+
assert "b==1.0" not in graph.nodes
568+
assert "c==1.0" not in graph.nodes
569+
assert len(graph) == 0
570+
571+
572+
def test_remove_dependency_already_removed_child() -> None:
573+
"""Removing a node whose child was already removed is safe."""
574+
# ROOT -> a -> b -> c
575+
graph = _build_graph(
576+
("ROOT", "a", "toplevel"),
577+
("a", "b", "install"),
578+
("b", "c", "install"),
579+
)
580+
581+
graph.remove_dependency(canonicalize_name("c"), Version("1.0"))
582+
graph.remove_dependency(canonicalize_name("b"), Version("1.0"))
583+
584+
assert "b==1.0" not in graph.nodes
585+
assert "c==1.0" not in graph.nodes
586+
assert "a==1.0" in graph.nodes
587+
assert len(graph.nodes["a==1.0"].children) == 0
588+
589+
590+
def test_remove_dependency_mid_graph_cascades() -> None:
591+
"""Removing a mid-graph node cascades to its exclusive subtree.
592+
593+
Verifies both that orphaned nodes are gone and that the surviving
594+
tree structure (edges in both directions) is fully intact.
595+
"""
596+
# ROOT -> a -> b -> d
597+
# b -> e
598+
# a -> c
599+
graph = _build_graph(
600+
("ROOT", "a", "toplevel"),
601+
("a", "b", "install"),
602+
("a", "c", "install"),
603+
("b", "d", "install"),
604+
("b", "e", "install"),
605+
)
606+
607+
graph.remove_dependency(canonicalize_name("b"), Version("1.0"))
608+
609+
# Removed subtree is gone
610+
assert "b==1.0" not in graph.nodes
611+
assert "d==1.0" not in graph.nodes
612+
assert "e==1.0" not in graph.nodes
613+
614+
# Exactly ROOT, a, c remain
615+
assert set(graph.nodes.keys()) == {"", "a==1.0", "c==1.0"}
616+
617+
# ROOT -> a edge intact
618+
root = graph.get_root_node()
619+
assert len(root.children) == 1
620+
assert root.children[0].destination_node.key == "a==1.0"
621+
622+
# a -> c is the only surviving child edge, and a's parent is ROOT
623+
node_a = graph.nodes["a==1.0"]
624+
assert len(node_a.children) == 1
625+
assert node_a.children[0].destination_node.key == "c==1.0"
626+
assert len(node_a.parents) == 1
627+
assert node_a.parents[0].destination_node.key == ""
628+
629+
# c has a as parent, no children
630+
node_c = graph.nodes["c==1.0"]
631+
assert len(node_c.children) == 0
632+
assert len(node_c.parents) == 1
633+
assert node_c.parents[0].destination_node.key == "a==1.0"
634+
635+
636+
def test_remove_dependency_shared_child_kept_by_other_subtree() -> None:
637+
"""Shared child and its descendants survive when kept alive by another subtree.
638+
639+
ROOT -> a -> b -> d
640+
a -> c -> d
641+
ROOT -> e -> c
642+
643+
Removing a should remove a, b; c and d survive because e still parents c.
644+
"""
645+
graph = _build_graph(
646+
("ROOT", "a", "toplevel"),
647+
("ROOT", "e", "toplevel"),
648+
("a", "b", "install"),
649+
("a", "c", "install"),
650+
("b", "d", "install"),
651+
("c", "d", "install"),
652+
("e", "c", "install"),
653+
)
654+
655+
graph.remove_dependency(canonicalize_name("a"), Version("1.0"))
656+
657+
assert "a==1.0" not in graph.nodes
658+
assert "b==1.0" not in graph.nodes
659+
assert "c==1.0" in graph.nodes
660+
assert "d==1.0" in graph.nodes
661+
assert "e==1.0" in graph.nodes
662+
663+
node_c = graph.nodes["c==1.0"]
664+
assert len(node_c.parents) == 1
665+
assert node_c.parents[0].destination_node.key == "e==1.0"
666+
assert len(node_c.children) == 1
667+
assert node_c.children[0].destination_node.key == "d==1.0"
668+
669+
node_d = graph.nodes["d==1.0"]
670+
assert len(node_d.parents) == 1
671+
assert node_d.parents[0].destination_node.key == "c==1.0"
672+
673+
674+
def test_remove_dependency_cascades_through_diamond() -> None:
675+
"""Single removal cascades through a diamond, removing all orphans.
676+
677+
ROOT -> a -> b -> d
678+
a -> c -> d
679+
680+
Removing a orphans b and c; as both are removed, d loses all parents
681+
and is also removed.
682+
"""
683+
graph = _build_graph(
684+
("ROOT", "a", "toplevel"),
685+
("a", "b", "install"),
686+
("a", "c", "install"),
687+
("b", "d", "install"),
688+
("c", "d", "install"),
689+
)
690+
691+
graph.remove_dependency(canonicalize_name("a"), Version("1.0"))
692+
693+
assert "a==1.0" not in graph.nodes
694+
assert "b==1.0" not in graph.nodes
695+
assert "c==1.0" not in graph.nodes
696+
assert "d==1.0" not in graph.nodes
697+
assert len(graph) == 0
698+
699+
700+
def test_remove_dependency_nonexistent() -> None:
701+
"""Removing a node not in the graph is a no-op."""
702+
graph = _build_graph(("ROOT", "a", "toplevel"))
703+
node_count = len(graph.nodes)
704+
705+
graph.remove_dependency(canonicalize_name("nonexistent"), Version("1.0"))
706+
707+
assert len(graph.nodes) == node_count

0 commit comments

Comments
 (0)