This is an automated email from the ASF dual-hosted git repository.

shunping pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git


The following commit(s) were added to refs/heads/master by this push:
     new ec7004f6f57 [Interactive Beam] Fix caching deadlock, wait race 
conditions, and stale graph in notebooks (#39161)
ec7004f6f57 is described below

commit ec7004f6f57c9d2ce957ff257ffb6051329fa8a3
Author: Ian Liao <[email protected]>
AuthorDate: Wed Jul 29 12:59:12 2026 -0700

    [Interactive Beam] Fix caching deadlock, wait race conditions, and stale 
graph in notebooks (#39161)
    
    * Fix Interactive Beam caching deadlock, race conditions, and stale graph 
in Colab.
    - Resolve self-deadlock in background thread: Updated 
`_wait_for_dependencies` to exclude target PCollections from the wait list when 
called from the background thread (i.e. when `async_result` is provided), 
allowing it to only wait on upstream dependencies.
    - Fix duplicate execution race condition: Replaced waiting on 
`future.result()` with a `threading.Event` (`_completed_event`) set at the very 
end of `_on_done`. This ensures `collect()` only resumes after the background 
job has fully marked the PCollection as computed.
    - Recalculate uncomputed PCollections: Added a check in `record()` to 
re-evaluate computed PCollections after waiting, preventing the launch of 
duplicate pipeline fragments for PCollections that completed during the wait.
    - Fix stale pipeline graph: Removed caching of the `PipelineGraph` in 
`RecordingManager`. Re-creating the graph dynamically ensures that new 
transforms added in subsequent Colab cells are correctly detected and computed.
    
    * add unit tests
    
    * fix broken unit tests
    
    * Apply thread lock following gemini-code-assist suggestions
    
    * Address reviewer's comments
    
    * Address reviewer's comment
    
    * Addressed reviewer's comments
    
    * fix broken unit tests and checks
    
    * fix formatting issue
---
 .../runners/interactive/interactive_beam_test.py   |   8 +-
 .../runners/interactive/recording_manager.py       | 197 ++++++++++++---------
 .../runners/interactive/recording_manager_test.py  | 188 ++++++++++++++++++--
 3 files changed, 296 insertions(+), 97 deletions(-)

diff --git 
a/sdks/python/apache_beam/runners/interactive/interactive_beam_test.py 
b/sdks/python/apache_beam/runners/interactive/interactive_beam_test.py
index 21163fc121c..af3f6638fc9 100644
--- a/sdks/python/apache_beam/runners/interactive/interactive_beam_test.py
+++ b/sdks/python/apache_beam/runners/interactive/interactive_beam_test.py
@@ -828,7 +828,7 @@ class InteractiveBeamComputeTest(unittest.TestCase):
     ])
 
     mock_clear_output.assert_called_once()
-    async_result.result(timeout=60)  # Let it finish
+    async_result.wait_for_completion(timeout=60)
 
   def test_compute_dependency_wait_true(self):
     p = beam.Pipeline(ir.InteractiveRunner())
@@ -853,12 +853,12 @@ class InteractiveBeamComputeTest(unittest.TestCase):
       spy_wait.assert_called_with({pcoll2}, async_res2)
 
       # Let pcoll1 finish
-      async_res1.result(timeout=60)
+      async_res1.wait_for_completion(timeout=60)
       self.assertTrue(pcoll1 in self.env.computed_pcollections)
       self.assertFalse(self.env.is_pcollection_computing(pcoll1))
 
       # pcoll2 should now run and complete
-      async_res2.result(timeout=60)
+      async_res2.wait_for_completion(timeout=60)
       self.assertTrue(pcoll2 in self.env.computed_pcollections)
 
   @patch.object(ie.InteractiveEnvironment, 'is_pcollection_computing')
@@ -878,7 +878,7 @@ class InteractiveBeamComputeTest(unittest.TestCase):
                       '_execute_pipeline_fragment',
                       wraps=rm._execute_pipeline_fragment) as spy_execute:
       async_res2 = ib.compute(pcoll2, blocking=False, wait_for_inputs=False)
-      async_res2.result(timeout=60)
+      async_res2.wait_for_completion(timeout=60)
 
       # Assert that execute was called for pcoll2 without waiting
       spy_execute.assert_called_with({pcoll2}, async_res2, ANY, ANY)
diff --git a/sdks/python/apache_beam/runners/interactive/recording_manager.py 
b/sdks/python/apache_beam/runners/interactive/recording_manager.py
index cabcca558dc..bd861d9904e 100644
--- a/sdks/python/apache_beam/runners/interactive/recording_manager.py
+++ b/sdks/python/apache_beam/runners/interactive/recording_manager.py
@@ -25,6 +25,7 @@ import warnings
 from concurrent.futures import Future
 from concurrent.futures import ThreadPoolExecutor
 from typing import Any
+from typing import Iterable
 from typing import Optional
 from typing import Union
 
@@ -86,6 +87,7 @@ class AsyncComputationResult:
             bar_style='info',
         ) if IS_IPYTHON else None)
     self._cancel_requested = False
+    self._completed_event = threading.Event()
 
     if IS_IPYTHON:
       self._cancel_button.on_click(self._cancel_clicked)
@@ -151,27 +153,40 @@ class AsyncComputationResult:
     except TimeoutError:
       return None
 
-  def _on_done(self, future: Future):
-    self._env.unmark_pcollection_computing(self._pcolls)
-    self._recording_manager._async_computations.pop(self._display_id, None)
-
-    if future.cancelled():
-      self.update_display('Computation Cancelled.', 1.0)
-      return
-
-    exc = future.exception()
+  def wait_for_completion(self, timeout=None):
+    if not self._completed_event.wait(timeout=timeout):
+      raise TimeoutError(
+          'Timeout waiting for asynchronous computation completion.')
+    if self._future.cancelled():
+      raise RuntimeError('Asynchronous computation was cancelled.')
+    exc = self.exception()
     if exc:
-      self.update_display(f'Error: {exc}', 1.0)
-      _LOGGER.error('Asynchronous computation failed: %s', exc, exc_info=exc)
-    else:
-      self.update_display('Computation Finished Successfully.', 1.0)
-      res = future.result()
-      if res and res.state == PipelineState.DONE:
-        self._env.mark_pcollection_computed(self._pcolls)
+      raise exc
+
+  def _on_done(self, future: Future):
+    try:
+      if future.cancelled():
+        self.update_display('Computation Cancelled.', 1.0)
+        return
+
+      exc = future.exception()
+      if exc:
+        self.update_display(f'Error: {exc}', 1.0)
+        _LOGGER.error('Asynchronous computation failed: %s', exc, exc_info=exc)
       else:
-        _LOGGER.warning(
-            'Async computation finished but state is not DONE: %s',
-            res.state if res else 'Unknown')
+        self.update_display('Computation Finished Successfully.', 1.0)
+        res = future.result()
+        if res and res.state == PipelineState.DONE:
+          self._env.mark_pcollection_computed(self._pcolls)
+        else:
+          _LOGGER.warning(
+              'Async computation finished but state is not DONE: %s',
+              res.state if res else 'Unknown')
+    finally:
+      self._env.unmark_pcollection_computing(self._pcolls)
+      with self._recording_manager._lock:
+        self._recording_manager._async_computations.pop(self._display_id, None)
+      self._completed_event.set()
 
   def cancel(self):
     if self._future.done():
@@ -438,7 +453,9 @@ class RecordingManager:
     self._executor = ThreadPoolExecutor(max_workers=os.cpu_count())
     self._env = ie.current_env()
     self._async_computations: dict[str, AsyncComputationResult] = {}
+    self._lock = threading.Lock()
     self._pipeline_graph = None
+    self._applied_labels_snapshot = set()
 
   def _execute_pipeline_fragment(
       self,
@@ -499,24 +516,10 @@ class RecordingManager:
       pipeline_result = self._execute_pipeline_fragment(
           pcolls_to_compute, async_result, runner, options)
 
-      # if pipeline_result.state == PipelineState.DONE:
-      #   self._env.mark_pcollection_computed(pcolls_to_compute)
-      #   _LOGGER.info(
-      #       'Asynchronous computation finished successfully for'
-      #       f' {len(pcolls_to_compute)} PCollections.'
-      #   )
-      # else:
-      #   _LOGGER.error(
-      #       'Asynchronous computation failed for'
-      #       f' {len(pcolls_to_compute)} PCollections. State:'
-      #       f' {pipeline_result.state}'
-      #   )
       return pipeline_result
     except Exception as e:
       _LOGGER.exception('Exception during asynchronous computation: %s', e)
       raise
-    # finally:
-    #   self._env.unmark_pcollection_computing(pcolls_to_compute)
 
   def _watch(self, pcolls: list[beam.pvalue.PCollection]) -> None:
     """Watch any pcollections not being watched.
@@ -575,7 +578,7 @@ class RecordingManager:
     if cache_manager:
       cache_manager.cleanup()
 
-  def cancel(self: None) -> None:
+  def cancel(self) -> None:
     """Cancels the current background recording job."""
 
     bcj.attempt_to_cancel_background_caching_job(self.user_pipeline)
@@ -694,7 +697,8 @@ class RecordingManager:
       future = Future()
       async_result = AsyncComputationResult(
           future, pcolls_to_compute, self.user_pipeline, self)
-      self._async_computations[async_result._display_id] = async_result
+      with self._lock:
+        self._async_computations[async_result._display_id] = async_result
       self._env.mark_pcollection_computing(pcolls_to_compute)
 
       def task():
@@ -710,18 +714,22 @@ class RecordingManager:
       return async_result
 
   def _get_pipeline_graph(self):
-    """Lazily initializes and returns the PipelineGraph."""
-    if self._pipeline_graph is None:
+    """Lazily initializes and returns the PipelineGraph, rebuilding it
+    only if the pipeline transforms have changed.
+    """
+    if (self._pipeline_graph is None or
+        self._applied_labels_snapshot != self.user_pipeline.applied_labels):
       try:
         # Try to create the graph.
         self._pipeline_graph = PipelineGraph(self.user_pipeline)
+        self._applied_labels_snapshot = set(self.user_pipeline.applied_labels)
       except (ImportError, NameError, AttributeError):
         # If pydot is missing, PipelineGraph() might crash.
         _LOGGER.warning(
-            "Could not create PipelineGraph (pydot missing?). " \
-            "Async features disabled."
-        )
+            "Could not create PipelineGraph (pydot missing?). "
+            "Async features disabled.")
         self._pipeline_graph = None
+        self._applied_labels_snapshot = set()
     return self._pipeline_graph
 
   def _get_pcoll_id_map(self):
@@ -800,11 +808,19 @@ class RecordingManager:
     """Waits for any dependencies of the given
     PCollections that are currently being computed."""
     dependencies = self._get_all_dependencies(pcolls)
+    if async_result is None:
+      pcolls_to_check = dependencies.union(pcolls)
+    else:
+      pcolls_to_check = dependencies
     computing_deps: dict[beam.pvalue.PCollection, AsyncComputationResult] = {}
 
-    for dep in dependencies:
-      if self._env.is_pcollection_computing(dep):
-        for comp in self._async_computations.values():
+    with self._lock:
+      async_computations_copy = list(self._async_computations.values())
+
+    for dep in pcolls_to_check:
+      is_computing = self._env.is_pcollection_computing(dep)
+      if is_computing:
+        for comp in async_computations_copy:
           if dep in comp._pcolls:
             computing_deps[dep] = comp
             break
@@ -820,17 +836,16 @@ class RecordingManager:
         len(computing_deps),
         computing_deps.keys())
 
-    futures_to_wait = list(
-        set(comp._future for comp in computing_deps.values()))
+    results_to_wait = list(set(comp for comp in computing_deps.values()))
 
     try:
-      for i, future in enumerate(futures_to_wait):
+      for i, comp in enumerate(results_to_wait):
         if async_result:
           async_result.update_display(
-              f'Waiting for dependency {i + 1}/{len(futures_to_wait)}...',
-              progress=0.05 + 0.05 * (i / len(futures_to_wait)),
+              f'Waiting for dependency {i + 1}/{len(results_to_wait)}...',
+              progress=0.05 + 0.05 * (i / len(results_to_wait)),
           )
-        future.result()
+        comp.wait_for_completion()
       if async_result:
         async_result.update_display('Dependencies finished.', progress=0.1)
       _LOGGER.info('Dependencies finished successfully.')
@@ -841,6 +856,18 @@ class RecordingManager:
       _LOGGER.error('Dependency computation failed: %s', e, exc_info=e)
       return False
 
+  def _get_uncomputed_pcolls(
+      self,
+      pcolls: Iterable[beam.pvalue.PCollection],
+  ) -> set[beam.pvalue.PCollection]:
+    """Filter out already computed PCollections from the environment."""
+    current_env = ie.current_env()
+    computed = {
+        pcoll
+        for pcoll in pcolls if pcoll in current_env.computed_pcollections
+    }
+    return set(pcolls).difference(computed)
+
   def record(
       self,
       pcolls: list[beam.pvalue.PCollection],
@@ -878,38 +905,46 @@ class RecordingManager:
     self._watch(pcolls)
     self.record_pipeline()
 
-    # Get the subset of computed PCollections. These do not to be recomputed.
-    computed_pcolls = set(
-        pcoll for pcoll in pcolls
-        if pcoll in ie.current_env().computed_pcollections)
-
-    # Start a pipeline fragment to start computing the PCollections.
-    uncomputed_pcolls = set(pcolls).difference(computed_pcolls)
-    if uncomputed_pcolls:
-      if not self._wait_for_dependencies(uncomputed_pcolls):
-        raise RuntimeError(
-            'Cannot record because a dependency failed to compute'
-            ' asynchronously.')
-
-      self._clear()
-
-      merged_options = pipeline_options.PipelineOptions(
-          **{
-              **self.user_pipeline.options.get_all_options(
-                  drop_default=True, retain_unknown_options=True),
-              **options.get_all_options(
-                  drop_default=True, retain_unknown_options=True)
-          }) if options else self.user_pipeline.options
-
-      cache_path = ie.current_env().options.cache_root
-      is_remote_run = cache_path and ie.current_env(
-      ).options.cache_root.startswith('gs://')
-      pf.PipelineFragment(
-          list(uncomputed_pcolls), merged_options,
-          runner=runner).run(blocking=is_remote_run)
-      result = ie.current_env().pipeline_result(self.user_pipeline)
-    else:
-      result = None
+    # Early check and return if everything is already computed
+    uncomputed_pcolls = self._get_uncomputed_pcolls(pcolls)
+    if not uncomputed_pcolls:
+      recording = Recording(
+          self.user_pipeline, pcolls, None, max_n, max_duration_secs)
+      self._recordings.add(recording)
+      return recording
+
+    # Wait for dependencies if there are uncomputed PCollections
+    if not self._wait_for_dependencies(uncomputed_pcolls):
+      raise RuntimeError(
+          'Cannot record because a dependency failed to compute'
+          ' asynchronously.')
+
+    # Re-evaluate uncomputed PCollections
+    uncomputed_pcolls = self._get_uncomputed_pcolls(pcolls)
+    if not uncomputed_pcolls:
+      recording = Recording(
+          self.user_pipeline, pcolls, None, max_n, max_duration_secs)
+      self._recordings.add(recording)
+      return recording
+
+    # Flattened execution path (no indentation needed)
+    self._clear()
+
+    merged_options = pipeline_options.PipelineOptions(
+        **{
+            **self.user_pipeline.options.get_all_options(
+                drop_default=True, retain_unknown_options=True),
+            **options.get_all_options(
+                drop_default=True, retain_unknown_options=True)
+        }) if options else self.user_pipeline.options
+
+    cache_path = ie.current_env().options.cache_root
+    is_remote_run = cache_path and ie.current_env(
+    ).options.cache_root.startswith('gs://')
+    pf.PipelineFragment(
+        list(uncomputed_pcolls), merged_options,
+        runner=runner).run(blocking=is_remote_run)
+    result = ie.current_env().pipeline_result(self.user_pipeline)
 
     recording = Recording(
         self.user_pipeline, pcolls, result, max_n, max_duration_secs)
diff --git 
a/sdks/python/apache_beam/runners/interactive/recording_manager_test.py 
b/sdks/python/apache_beam/runners/interactive/recording_manager_test.py
index d2038719f67..55c3bd91cfd 100644
--- a/sdks/python/apache_beam/runners/interactive/recording_manager_test.py
+++ b/sdks/python/apache_beam/runners/interactive/recording_manager_test.py
@@ -937,20 +937,22 @@ class RecordingManagerTest(unittest.TestCase):
     p = beam.Pipeline(InteractiveRunner())
     numbers = p | 'numbers' >> beam.Create([0, 1, 2])
 
-    # Set the cache directory for Interactive Beam to be in a GCS bucket.
-    ib.options.cache_root = 'gs://test-bucket/'
+    original_cache_root = ib.options.cache_root
+    try:
+      # Set the cache directory for Interactive Beam to be in a GCS bucket.
+      ib.options.cache_root = 'gs://test-bucket/'
 
-    # Create the recording objects. By calling `record` a new PipelineFragment
-    # is started to compute the given PCollections and cache to disk.
-    rm = RecordingManager(p)
+      # Create the recording objects. By calling `record` a new 
PipelineFragment
+      # is started to compute the given PCollections and cache to disk.
+      rm = RecordingManager(p)
 
-    # Run record() and check if the PipelineFragment.run had blocking set to
-    # True due to the GCS cache_root value.
-    rm.record([numbers], max_n=3, max_duration=500)
-    mock_pipeline_fragment.assert_called_with(blocking=True)
+      # Run record() and check if the PipelineFragment.run had blocking set to
+      # True due to the GCS cache_root value.
+      rm.record([numbers], max_n=3, max_duration=500)
+      mock_pipeline_fragment.assert_called_with(blocking=True)
 
-    # Reset cache_root value.
-    ib.options.cache_root = None
+    finally:
+      ib.options.cache_root = original_cache_root
 
   def test_compute_async_blocking(self):
     p = beam.Pipeline(InteractiveRunner())
@@ -1062,9 +1064,171 @@ class RecordingManagerTest(unittest.TestCase):
     ie.current_env().mark_pcollection_computing({p1})
 
     self.assertTrue(rm._wait_for_dependencies({p2}))
-    mock_future.result.assert_called_once()
+    mock_async_res.wait_for_completion.assert_called_once()
     ie.current_env().unmark_pcollection_computing({p1})
 
+  def test_wait_for_dependencies_async_result(self):
+    p = beam.Pipeline(InteractiveRunner())
+    pcoll = p | beam.Create([1])
+    rm = RecordingManager(p)
+
+    # Mock a background computation for pcoll
+    async_res = MagicMock(spec=AsyncComputationResult)
+    async_res._pcolls = {pcoll}
+    rm._async_computations['id1'] = async_res
+    ie.current_env().mark_pcollection_computing({pcoll})
+
+    # Case 1: Called from main thread (async_result=None)
+    # It should wait for pcoll's background job completion event
+    with patch.object(async_res, 'wait_for_completion') as mock_wait:
+      with patch.object(rm, '_get_all_dependencies', return_value=set()):
+        rm._wait_for_dependencies({pcoll}, async_result=None)
+        mock_wait.assert_called_once()
+
+    # Case 2: Called from background thread (async_result=async_res)
+    # It should NOT wait (prevents self-deadlock)
+    with patch.object(async_res, 'wait_for_completion') as mock_wait:
+      with patch.object(rm, '_get_all_dependencies', return_value=set()):
+        rm._wait_for_dependencies({pcoll}, async_result=async_res)
+        mock_wait.assert_not_called()
+
+  def test_record_recalculates_uncomputed_pcolls_after_wait(self):
+    from apache_beam.runners.interactive import pipeline_fragment as pf
+    p = beam.Pipeline(InteractiveRunner())
+    pcoll = p | beam.Create([1])
+    rm = RecordingManager(p)
+
+    # Mock wait_for_dependencies to simulate pcoll completing during the wait
+    def mock_wait(pcolls, async_result=None):
+      # Simulate pcoll completing by marking it computed
+      ie.current_env().mark_pcollection_computed({pcoll})
+      return True
+
+    with patch.object(rm, '_wait_for_dependencies', side_effect=mock_wait), \
+         patch.object(rm, '_clear') as mock_clear, \
+         patch.object(pf, 'PipelineFragment') as mock_fragment:
+
+      rm.record([pcoll], max_n=10, max_duration='inf')
+
+      # Since pcoll was marked computed during the wait,
+      # uncomputed_pcolls becomes empty.
+      # So _clear() and PipelineFragment should NOT be called.
+      mock_clear.assert_not_called()
+      mock_fragment.assert_not_called()
+
+  def test_get_pipeline_graph_not_cached(self):
+    p = beam.Pipeline(InteractiveRunner())
+    pcoll1 = p | 'Create1' >> beam.Create([1])
+    rm = RecordingManager(p)
+
+    # First call: graph is created
+    graph1 = rm._get_pipeline_graph()
+    self.assertIsNotNone(graph1)
+    self.assertEqual(graph1._pipeline_instrument.user_pipeline, p)
+
+    # Add a new transform to the pipeline
+    pcoll2 = pcoll1 | 'Map1' >> beam.Map(lambda x: x + 1)
+
+    # Second call: graph should be updated and contain the new Map1 transform
+    graph2 = rm._get_pipeline_graph()
+    self.assertIsNotNone(graph2)
+    self.assertIsNot(graph1, graph2)
+
+    # Verify that the new graph contains the newly added transform
+    transform_names = [
+        t.unique_name
+        for t in graph2._pipeline_proto.components.transforms.values()
+    ]
+    self.assertIn('Map1', transform_names)
+
+  def test_wait_for_completion_raises_exception_on_failure(self):
+    future = Future()
+    async_res = AsyncComputationResult(
+        future, set(), beam.Pipeline(InteractiveRunner()), MagicMock())
+    # Set the future exception
+    exc = ValueError("computation error")
+    future.set_exception(exc)
+    async_res._completed_event.set()  # Simulate completion
+    with self.assertRaises(ValueError) as context:
+      async_res.wait_for_completion(timeout=60)
+    self.assertEqual(context.exception, exc)
+
+  def test_wait_for_completion_raises_error_on_cancellation(self):
+    future = Future()
+    async_res = AsyncComputationResult(
+        future, set(), beam.Pipeline(InteractiveRunner()), MagicMock())
+    future.cancel()
+    async_res._completed_event.set()
+    with self.assertRaises(RuntimeError) as context:
+      async_res.wait_for_completion(timeout=60)
+    self.assertIn('computation was cancelled', str(context.exception))
+
+  def test_wait_for_completion_raises_timeout_error(self):
+    future = Future()
+    async_res = AsyncComputationResult(
+        future, set(), beam.Pipeline(InteractiveRunner()), MagicMock())
+    # completed_event is NOT set, so wait will timeout
+    with self.assertRaises(TimeoutError):
+      async_res.wait_for_completion(timeout=0.01)
+
+  def test_get_uncomputed_pcolls(self):
+    p = beam.Pipeline(InteractiveRunner())
+    p1 = p | 'C1' >> beam.Create([1])
+    p2 = p | 'C2' >> beam.Create([2])
+    ib.watch(locals())
+    ie.current_env().track_user_pipelines()
+
+    rm = RecordingManager(p)
+
+    # Initially both are uncomputed
+    self.assertEqual(rm._get_uncomputed_pcolls([p1, p2]), {p1, p2})
+
+    # Mark p1 as computed
+    ie.current_env().mark_pcollection_computed([p1])
+    self.assertEqual(rm._get_uncomputed_pcolls([p1, p2]), {p2})
+
+    # Cleanup
+    ie.current_env().evict_computed_pcollections()
+
+  def test_get_pipeline_graph_caching(self):
+    p = beam.Pipeline(InteractiveRunner())
+    p1 = p | 'C1' >> beam.Create([1])
+    ib.watch(locals())
+    ie.current_env().track_user_pipelines()
+
+    rm = RecordingManager(p)
+    graph1 = rm._get_pipeline_graph()
+    graph2 = rm._get_pipeline_graph()
+
+    # Graph instance is cached and reused when pipeline transforms have not 
changed
+    self.assertIs(graph1, graph2)
+
+    # Applying a new transform updates user_pipeline.applied_labels
+    _ = p1 | 'M1' >> beam.Map(lambda x: x)
+    graph3 = rm._get_pipeline_graph()
+
+    # Graph is rebuilt because pipeline applied_labels changed
+    self.assertIsNot(graph1, graph3)
+
+  def test_record_early_return_when_all_computed(self):
+    p = beam.Pipeline(InteractiveRunner())
+    p1 = p | 'C1' >> beam.Create([1])
+    ib.watch(locals())
+    ie.current_env().track_user_pipelines()
+
+    rm = RecordingManager(p)
+    ie.current_env().mark_pcollection_computed([p1])
+
+    with patch.object(rm, '_execute_pipeline_fragment') as mock_exec:
+      recording = rm.record([p1], max_n=10, max_duration=100)
+      # Fragment execution must NOT be called
+      mock_exec.assert_not_called()
+      self.assertIsNone(recording._result)
+      self.assertEqual(
+          recording.wait_until_finish(), 
beam.runners.runner.PipelineState.DONE)
+
+    ie.current_env().evict_computed_pcollections()
+
 
 if __name__ == '__main__':
   unittest.main()

Reply via email to