Repository: iterative/dvc

Issue:
repro: failing on frozen stage in dag
Reproduction script:

```
#!/bin/bash

rm -rf wspace
mkdir wspace
pushd wspace
set -ex

mkdir repo
pushd repo

git init --quiet
dvc init --quiet

git add -A
git commit -am "init"

echo data >> data
dvc add data
dvc run -n s1 -d data -o o1 "cat data >> o1 && echo 's1' >> o1"
dvc run -n s2 -d o1 -o o2 "cat o1 >> o2 && echo 's2' >> o2"
dvc run -n s3 -d o2 -o o3 "cat o2 >> o3 && echo 's3' >> o3"

dvc freeze s2

echo whaddup >> data
dvc repro -v --dry
```

```
+----------+ 
| data.dvc | 
+----------+ 
      *      
      *      
      *      
   +----+    
   | s1 |    
   +----+    
      *      
      *      
      *      
   +----+    
   | s2 |    
   +----+    
      *      
      *      
      *      
   +----+    
   | s3 |    
   +----+    
```

Freezing s2 or s3 and running `dvc repro` will result in errors during dfs search for nodes.

```
Traceback (most recent call last):
  File "/home/prd/development/projects/dvc/dvc/main.py", line 90, in main
    ret = cmd.run()
  File "/home/prd/development/projects/dvc/dvc/command/repro.py", line 25, in run
    stages = self.repo.reproduce(target, **self._repro_kwargs)
  File "/home/prd/development/projects/dvc/dvc/repo/__init__.py", line 53, in wrapper
    return f(repo, *args, **kwargs)
  File "/home/prd/development/projects/dvc/dvc/repo/scm_context.py", line 4, in run
    result = method(repo, *args, **kw)
  File "/home/prd/development/projects/dvc/dvc/repo/reproduce.py", line 125, in reproduce
    return _reproduce_stages(active_graph, targets, **kwargs)
  File "/home/prd/development/projects/dvc/dvc/repo/reproduce.py", line 166, in _reproduce_stages
    pipeline = _get_pipeline(G, stages, downstream, single_item)
  File "/home/prd/development/projects/dvc/dvc/repo/reproduce.py", line 230, in _get_pipeline
    all_pipelines += nx.dfs_postorder_nodes(G, stage)
  File "/home/prd/development/venvs/dvc3.7/lib/python3.7/site-packages/networkx/algorithms/traversal/depth_first_search.py", line 296, in <genexpr>
    return (v for u, v, d in edges if d == 'reverse')
  File "/home/prd/development/venvs/dvc3.7/lib/python3.7/site-packages/networkx/algorithms/traversal/depth_first_search.py", line 429, in dfs_labeled_edges
    stack = [(start, depth_limit, iter(G[start]))]
  File "/home/prd/development/venvs/dvc3.7/lib/python3.7/site-packages/networkx/classes/graph.py", line 475, in __getitem__
    return self.adj[n]
  File "/home/prd/development/venvs/dvc3.7/lib/python3.7/site-packages/networkx/classes/coreviews.py", line 81, in __getitem__
    return AtlasView(self._atlas[name])
KeyError: Stage: 's1'
```

Might be related to https://github.com/iterative/dvc/issues/4893

Expected behaviour:
Info that nothing till `s2/s3` was reproduced (because they are frozen) and reproduction info about following stages.

I suspect that the culprit might be the fact that we are just skipping frozen stages during active dag collection, while we should have some placeholder nodes not reproducing their content. That seems to be the case also in #4893.


Discussion:

Resolving patch:
diff --git a/dvc/repo/reproduce.py b/dvc/repo/reproduce.py
--- a/dvc/repo/reproduce.py
+++ b/dvc/repo/reproduce.py
@@ -54,28 +54,6 @@ def _dump_stage(stage):
     dvcfile.dump(stage, update_pipeline=False)
 
 
-def _get_active_graph(G):
-    import networkx as nx
-
-    active = G.copy()
-    for stage in G:
-        if not stage.frozen:
-            continue
-        active.remove_edges_from(G.out_edges(stage))
-        for edge in G.out_edges(stage):
-            _, to_stage = edge
-            for node in nx.dfs_preorder_nodes(G, to_stage):
-                # NOTE: `in_degree` will return InDegreeView({}) if stage
-                # no longer exists in the `active` DAG.
-                if not active.in_degree(node):
-                    # NOTE: if some edge no longer exists `remove_edges_from`
-                    # will ignore it without error.
-                    active.remove_edges_from(G.out_edges(node))
-                    active.remove_node(node)
-
-    return active
-
-
 @locked
 @scm_context
 def reproduce(
@@ -92,7 +70,7 @@ def reproduce(
     if isinstance(targets, str):
         targets = [targets]
 
-    if not all_pipelines and targets is None:
+    if not all_pipelines and not targets:
         from dvc.dvcfile import PIPELINE_FILE
 
         targets = [PIPELINE_FILE]
@@ -101,22 +79,20 @@ def reproduce(
     if not interactive:
         kwargs["interactive"] = self.config["core"].get("interactive", False)
 
-    active_graph = _get_active_graph(self.graph)
-    active_pipelines = get_pipelines(active_graph)
-
     stages = set()
     if pipeline or all_pipelines:
+        pipelines = get_pipelines(self.graph)
         if all_pipelines:
-            pipelines = active_pipelines
+            used_pipelines = pipelines
         else:
-            pipelines = []
+            used_pipelines = []
             for target in targets:
                 stage = self.stage.get_target(target)
-                pipelines.append(get_pipeline(active_pipelines, stage))
+                used_pipelines.append(get_pipeline(pipelines, stage))
 
-        for pipeline in pipelines:
-            for stage in pipeline:
-                if pipeline.in_degree(stage) == 0:
+        for pline in used_pipelines:
+            for stage in pline:
+                if pline.in_degree(stage) == 0:
                     stages.add(stage)
     else:
         for target in targets:
@@ -124,13 +100,12 @@ def reproduce(
                 self.stage.collect(
                     target,
                     recursive=recursive,
-                    graph=active_graph,
                     accept_group=accept_group,
                     glob=glob,
                 )
             )
 
-    return _reproduce_stages(active_graph, list(stages), **kwargs)
+    return _reproduce_stages(self.graph, list(stages), **kwargs)
 
 
 def _reproduce_stages(
@@ -171,7 +146,7 @@ def _reproduce_stages(
 
     The derived evaluation of _downstream_ B would be: [B, D, E]
     """
-    pipeline = _get_pipeline(G, stages, downstream, single_item)
+    steps = _get_steps(G, stages, downstream, single_item)
 
     force_downstream = kwargs.pop("force_downstream", False)
     result = []
@@ -179,7 +154,7 @@ def _reproduce_stages(
     # `ret` is used to add a cosmetic newline.
     ret = []
     checkpoint_func = kwargs.pop("checkpoint_func", None)
-    for stage in pipeline:
+    for stage in steps:
         if ret:
             logger.info("")
 
@@ -213,9 +188,17 @@ def _reproduce_stages(
     return result
 
 
-def _get_pipeline(G, stages, downstream, single_item):
+def _get_steps(G, stages, downstream, single_item):
     import networkx as nx
 
+    active = G.copy()
+    if not single_item:
+        # NOTE: frozen stages don't matter for single_item
+        for stage in G:
+            if stage.frozen:
+                # NOTE: disconnect frozen stage from its dependencies
+                active.remove_edges_from(G.out_edges(stage))
+
     all_pipelines = []
     for stage in stages:
         if downstream:
@@ -227,20 +210,21 @@ def _get_pipeline(G, stages, downstream, single_item):
             # itself, and then reverse it, instead of using
             # graph.reverse() directly because it calls `deepcopy`
             # underneath -- unless copy=False is specified.
-            nodes = nx.dfs_postorder_nodes(G.copy().reverse(copy=False), stage)
+            nodes = nx.dfs_postorder_nodes(active.reverse(copy=False), stage)
             all_pipelines += reversed(list(nodes))
         else:
-            all_pipelines += nx.dfs_postorder_nodes(G, stage)
+            all_pipelines += nx.dfs_postorder_nodes(active, stage)
 
-    pipeline = []
+    steps = []
     for stage in all_pipelines:
-        if stage not in pipeline:
+        if stage not in steps:
+            # NOTE: order of steps still matters for single_item
             if single_item and stage not in stages:
                 continue
 
-            pipeline.append(stage)
+            steps.append(stage)
 
-    return pipeline
+    return steps
 
 
 def _repro_callback(experiments_callback, unchanged, stages):


Test patch:
diff --git a/tests/func/test_repro_multistage.py b/tests/func/test_repro_multistage.py
--- a/tests/func/test_repro_multistage.py
+++ b/tests/func/test_repro_multistage.py
@@ -170,6 +170,19 @@ def test_non_existing_stage_name(tmp_dir, dvc, run_copy):
     assert main(["freeze", ":copy-file1-file3"]) != 0
 
 
+def test_repro_frozen(tmp_dir, dvc, run_copy):
+    (data_stage,) = tmp_dir.dvc_gen("data", "foo")
+    stage0 = run_copy("data", "stage0", name="copy-data-stage0")
+    run_copy("stage0", "stage1", name="copy-data-stage1")
+    run_copy("stage1", "stage2", name="copy-data-stage2")
+
+    dvc.freeze("copy-data-stage1")
+
+    tmp_dir.gen("data", "bar")
+    stages = dvc.reproduce()
+    assert stages == [data_stage, stage0]
+
+
 def test_downstream(tmp_dir, dvc):
     # The dependency graph should look like this:
     #
diff --git a/tests/unit/repo/test_reproduce.py b/tests/unit/repo/test_reproduce.py
--- a/tests/unit/repo/test_reproduce.py
+++ b/tests/unit/repo/test_reproduce.py
@@ -1,37 +1,5 @@
 import mock
 
-from dvc.repo.reproduce import _get_active_graph
-
-
-def test_get_active_graph(tmp_dir, dvc):
-    (pre_foo_stage,) = tmp_dir.dvc_gen({"pre-foo": "pre-foo"})
-    foo_stage = dvc.run(
-        single_stage=True, deps=["pre-foo"], outs=["foo"], cmd="echo foo > foo"
-    )
-    bar_stage = dvc.run(
-        single_stage=True, deps=["foo"], outs=["bar"], cmd="echo bar > bar"
-    )
-    baz_stage = dvc.run(
-        single_stage=True, deps=["foo"], outs=["baz"], cmd="echo baz > baz"
-    )
-
-    dvc.freeze("bar.dvc")
-
-    graph = dvc.graph
-    active_graph = _get_active_graph(graph)
-    assert active_graph.nodes == graph.nodes
-    assert set(active_graph.edges) == {
-        (foo_stage, pre_foo_stage),
-        (baz_stage, foo_stage),
-    }
-
-    dvc.freeze("baz.dvc")
-
-    graph = dvc.graph
-    active_graph = _get_active_graph(graph)
-    assert set(active_graph.nodes) == {bar_stage, baz_stage}
-    assert not active_graph.edges
-
 
 @mock.patch("dvc.repo.reproduce._reproduce_stage", returns=[])
 def test_number_reproduces(reproduce_stage_mock, tmp_dir, dvc):