Skip to content

Commit ec7004f

Browse files
authored
[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
1 parent eda08d8 commit ec7004f

3 files changed

Lines changed: 296 additions & 97 deletions

File tree

sdks/python/apache_beam/runners/interactive/interactive_beam_test.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -828,7 +828,7 @@ def test_compute_non_blocking_ipython_widgets(
828828
])
829829

830830
mock_clear_output.assert_called_once()
831-
async_result.result(timeout=60) # Let it finish
831+
async_result.wait_for_completion(timeout=60)
832832

833833
def test_compute_dependency_wait_true(self):
834834
p = beam.Pipeline(ir.InteractiveRunner())
@@ -853,12 +853,12 @@ def test_compute_dependency_wait_true(self):
853853
spy_wait.assert_called_with({pcoll2}, async_res2)
854854

855855
# Let pcoll1 finish
856-
async_res1.result(timeout=60)
856+
async_res1.wait_for_completion(timeout=60)
857857
self.assertTrue(pcoll1 in self.env.computed_pcollections)
858858
self.assertFalse(self.env.is_pcollection_computing(pcoll1))
859859

860860
# pcoll2 should now run and complete
861-
async_res2.result(timeout=60)
861+
async_res2.wait_for_completion(timeout=60)
862862
self.assertTrue(pcoll2 in self.env.computed_pcollections)
863863

864864
@patch.object(ie.InteractiveEnvironment, 'is_pcollection_computing')
@@ -878,7 +878,7 @@ def test_compute_dependency_wait_false(self, mock_is_computing):
878878
'_execute_pipeline_fragment',
879879
wraps=rm._execute_pipeline_fragment) as spy_execute:
880880
async_res2 = ib.compute(pcoll2, blocking=False, wait_for_inputs=False)
881-
async_res2.result(timeout=60)
881+
async_res2.wait_for_completion(timeout=60)
882882

883883
# Assert that execute was called for pcoll2 without waiting
884884
spy_execute.assert_called_with({pcoll2}, async_res2, ANY, ANY)

sdks/python/apache_beam/runners/interactive/recording_manager.py

Lines changed: 116 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from concurrent.futures import Future
2626
from concurrent.futures import ThreadPoolExecutor
2727
from typing import Any
28+
from typing import Iterable
2829
from typing import Optional
2930
from typing import Union
3031

@@ -86,6 +87,7 @@ def __init__(
8687
bar_style='info',
8788
) if IS_IPYTHON else None)
8889
self._cancel_requested = False
90+
self._completed_event = threading.Event()
8991

9092
if IS_IPYTHON:
9193
self._cancel_button.on_click(self._cancel_clicked)
@@ -151,27 +153,40 @@ def exception(self, timeout=None):
151153
except TimeoutError:
152154
return None
153155

154-
def _on_done(self, future: Future):
155-
self._env.unmark_pcollection_computing(self._pcolls)
156-
self._recording_manager._async_computations.pop(self._display_id, None)
157-
158-
if future.cancelled():
159-
self.update_display('Computation Cancelled.', 1.0)
160-
return
161-
162-
exc = future.exception()
156+
def wait_for_completion(self, timeout=None):
157+
if not self._completed_event.wait(timeout=timeout):
158+
raise TimeoutError(
159+
'Timeout waiting for asynchronous computation completion.')
160+
if self._future.cancelled():
161+
raise RuntimeError('Asynchronous computation was cancelled.')
162+
exc = self.exception()
163163
if exc:
164-
self.update_display(f'Error: {exc}', 1.0)
165-
_LOGGER.error('Asynchronous computation failed: %s', exc, exc_info=exc)
166-
else:
167-
self.update_display('Computation Finished Successfully.', 1.0)
168-
res = future.result()
169-
if res and res.state == PipelineState.DONE:
170-
self._env.mark_pcollection_computed(self._pcolls)
164+
raise exc
165+
166+
def _on_done(self, future: Future):
167+
try:
168+
if future.cancelled():
169+
self.update_display('Computation Cancelled.', 1.0)
170+
return
171+
172+
exc = future.exception()
173+
if exc:
174+
self.update_display(f'Error: {exc}', 1.0)
175+
_LOGGER.error('Asynchronous computation failed: %s', exc, exc_info=exc)
171176
else:
172-
_LOGGER.warning(
173-
'Async computation finished but state is not DONE: %s',
174-
res.state if res else 'Unknown')
177+
self.update_display('Computation Finished Successfully.', 1.0)
178+
res = future.result()
179+
if res and res.state == PipelineState.DONE:
180+
self._env.mark_pcollection_computed(self._pcolls)
181+
else:
182+
_LOGGER.warning(
183+
'Async computation finished but state is not DONE: %s',
184+
res.state if res else 'Unknown')
185+
finally:
186+
self._env.unmark_pcollection_computing(self._pcolls)
187+
with self._recording_manager._lock:
188+
self._recording_manager._async_computations.pop(self._display_id, None)
189+
self._completed_event.set()
175190

176191
def cancel(self):
177192
if self._future.done():
@@ -438,7 +453,9 @@ def __init__(
438453
self._executor = ThreadPoolExecutor(max_workers=os.cpu_count())
439454
self._env = ie.current_env()
440455
self._async_computations: dict[str, AsyncComputationResult] = {}
456+
self._lock = threading.Lock()
441457
self._pipeline_graph = None
458+
self._applied_labels_snapshot = set()
442459

443460
def _execute_pipeline_fragment(
444461
self,
@@ -499,24 +516,10 @@ def _run_async_computation(
499516
pipeline_result = self._execute_pipeline_fragment(
500517
pcolls_to_compute, async_result, runner, options)
501518

502-
# if pipeline_result.state == PipelineState.DONE:
503-
# self._env.mark_pcollection_computed(pcolls_to_compute)
504-
# _LOGGER.info(
505-
# 'Asynchronous computation finished successfully for'
506-
# f' {len(pcolls_to_compute)} PCollections.'
507-
# )
508-
# else:
509-
# _LOGGER.error(
510-
# 'Asynchronous computation failed for'
511-
# f' {len(pcolls_to_compute)} PCollections. State:'
512-
# f' {pipeline_result.state}'
513-
# )
514519
return pipeline_result
515520
except Exception as e:
516521
_LOGGER.exception('Exception during asynchronous computation: %s', e)
517522
raise
518-
# finally:
519-
# self._env.unmark_pcollection_computing(pcolls_to_compute)
520523

521524
def _watch(self, pcolls: list[beam.pvalue.PCollection]) -> None:
522525
"""Watch any pcollections not being watched.
@@ -575,7 +578,7 @@ def clear(self) -> None:
575578
if cache_manager:
576579
cache_manager.cleanup()
577580

578-
def cancel(self: None) -> None:
581+
def cancel(self) -> None:
579582
"""Cancels the current background recording job."""
580583

581584
bcj.attempt_to_cancel_background_caching_job(self.user_pipeline)
@@ -694,7 +697,8 @@ def compute_async(
694697
future = Future()
695698
async_result = AsyncComputationResult(
696699
future, pcolls_to_compute, self.user_pipeline, self)
697-
self._async_computations[async_result._display_id] = async_result
700+
with self._lock:
701+
self._async_computations[async_result._display_id] = async_result
698702
self._env.mark_pcollection_computing(pcolls_to_compute)
699703

700704
def task():
@@ -710,18 +714,22 @@ def task():
710714
return async_result
711715

712716
def _get_pipeline_graph(self):
713-
"""Lazily initializes and returns the PipelineGraph."""
714-
if self._pipeline_graph is None:
717+
"""Lazily initializes and returns the PipelineGraph, rebuilding it
718+
only if the pipeline transforms have changed.
719+
"""
720+
if (self._pipeline_graph is None or
721+
self._applied_labels_snapshot != self.user_pipeline.applied_labels):
715722
try:
716723
# Try to create the graph.
717724
self._pipeline_graph = PipelineGraph(self.user_pipeline)
725+
self._applied_labels_snapshot = set(self.user_pipeline.applied_labels)
718726
except (ImportError, NameError, AttributeError):
719727
# If pydot is missing, PipelineGraph() might crash.
720728
_LOGGER.warning(
721-
"Could not create PipelineGraph (pydot missing?). " \
722-
"Async features disabled."
723-
)
729+
"Could not create PipelineGraph (pydot missing?). "
730+
"Async features disabled.")
724731
self._pipeline_graph = None
732+
self._applied_labels_snapshot = set()
725733
return self._pipeline_graph
726734

727735
def _get_pcoll_id_map(self):
@@ -800,11 +808,19 @@ def _wait_for_dependencies(
800808
"""Waits for any dependencies of the given
801809
PCollections that are currently being computed."""
802810
dependencies = self._get_all_dependencies(pcolls)
811+
if async_result is None:
812+
pcolls_to_check = dependencies.union(pcolls)
813+
else:
814+
pcolls_to_check = dependencies
803815
computing_deps: dict[beam.pvalue.PCollection, AsyncComputationResult] = {}
804816

805-
for dep in dependencies:
806-
if self._env.is_pcollection_computing(dep):
807-
for comp in self._async_computations.values():
817+
with self._lock:
818+
async_computations_copy = list(self._async_computations.values())
819+
820+
for dep in pcolls_to_check:
821+
is_computing = self._env.is_pcollection_computing(dep)
822+
if is_computing:
823+
for comp in async_computations_copy:
808824
if dep in comp._pcolls:
809825
computing_deps[dep] = comp
810826
break
@@ -820,17 +836,16 @@ def _wait_for_dependencies(
820836
len(computing_deps),
821837
computing_deps.keys())
822838

823-
futures_to_wait = list(
824-
set(comp._future for comp in computing_deps.values()))
839+
results_to_wait = list(set(comp for comp in computing_deps.values()))
825840

826841
try:
827-
for i, future in enumerate(futures_to_wait):
842+
for i, comp in enumerate(results_to_wait):
828843
if async_result:
829844
async_result.update_display(
830-
f'Waiting for dependency {i + 1}/{len(futures_to_wait)}...',
831-
progress=0.05 + 0.05 * (i / len(futures_to_wait)),
845+
f'Waiting for dependency {i + 1}/{len(results_to_wait)}...',
846+
progress=0.05 + 0.05 * (i / len(results_to_wait)),
832847
)
833-
future.result()
848+
comp.wait_for_completion()
834849
if async_result:
835850
async_result.update_display('Dependencies finished.', progress=0.1)
836851
_LOGGER.info('Dependencies finished successfully.')
@@ -841,6 +856,18 @@ def _wait_for_dependencies(
841856
_LOGGER.error('Dependency computation failed: %s', e, exc_info=e)
842857
return False
843858

859+
def _get_uncomputed_pcolls(
860+
self,
861+
pcolls: Iterable[beam.pvalue.PCollection],
862+
) -> set[beam.pvalue.PCollection]:
863+
"""Filter out already computed PCollections from the environment."""
864+
current_env = ie.current_env()
865+
computed = {
866+
pcoll
867+
for pcoll in pcolls if pcoll in current_env.computed_pcollections
868+
}
869+
return set(pcolls).difference(computed)
870+
844871
def record(
845872
self,
846873
pcolls: list[beam.pvalue.PCollection],
@@ -878,38 +905,46 @@ def record(
878905
self._watch(pcolls)
879906
self.record_pipeline()
880907

881-
# Get the subset of computed PCollections. These do not to be recomputed.
882-
computed_pcolls = set(
883-
pcoll for pcoll in pcolls
884-
if pcoll in ie.current_env().computed_pcollections)
885-
886-
# Start a pipeline fragment to start computing the PCollections.
887-
uncomputed_pcolls = set(pcolls).difference(computed_pcolls)
888-
if uncomputed_pcolls:
889-
if not self._wait_for_dependencies(uncomputed_pcolls):
890-
raise RuntimeError(
891-
'Cannot record because a dependency failed to compute'
892-
' asynchronously.')
893-
894-
self._clear()
895-
896-
merged_options = pipeline_options.PipelineOptions(
897-
**{
898-
**self.user_pipeline.options.get_all_options(
899-
drop_default=True, retain_unknown_options=True),
900-
**options.get_all_options(
901-
drop_default=True, retain_unknown_options=True)
902-
}) if options else self.user_pipeline.options
903-
904-
cache_path = ie.current_env().options.cache_root
905-
is_remote_run = cache_path and ie.current_env(
906-
).options.cache_root.startswith('gs://')
907-
pf.PipelineFragment(
908-
list(uncomputed_pcolls), merged_options,
909-
runner=runner).run(blocking=is_remote_run)
910-
result = ie.current_env().pipeline_result(self.user_pipeline)
911-
else:
912-
result = None
908+
# Early check and return if everything is already computed
909+
uncomputed_pcolls = self._get_uncomputed_pcolls(pcolls)
910+
if not uncomputed_pcolls:
911+
recording = Recording(
912+
self.user_pipeline, pcolls, None, max_n, max_duration_secs)
913+
self._recordings.add(recording)
914+
return recording
915+
916+
# Wait for dependencies if there are uncomputed PCollections
917+
if not self._wait_for_dependencies(uncomputed_pcolls):
918+
raise RuntimeError(
919+
'Cannot record because a dependency failed to compute'
920+
' asynchronously.')
921+
922+
# Re-evaluate uncomputed PCollections
923+
uncomputed_pcolls = self._get_uncomputed_pcolls(pcolls)
924+
if not uncomputed_pcolls:
925+
recording = Recording(
926+
self.user_pipeline, pcolls, None, max_n, max_duration_secs)
927+
self._recordings.add(recording)
928+
return recording
929+
930+
# Flattened execution path (no indentation needed)
931+
self._clear()
932+
933+
merged_options = pipeline_options.PipelineOptions(
934+
**{
935+
**self.user_pipeline.options.get_all_options(
936+
drop_default=True, retain_unknown_options=True),
937+
**options.get_all_options(
938+
drop_default=True, retain_unknown_options=True)
939+
}) if options else self.user_pipeline.options
940+
941+
cache_path = ie.current_env().options.cache_root
942+
is_remote_run = cache_path and ie.current_env(
943+
).options.cache_root.startswith('gs://')
944+
pf.PipelineFragment(
945+
list(uncomputed_pcolls), merged_options,
946+
runner=runner).run(blocking=is_remote_run)
947+
result = ie.current_env().pipeline_result(self.user_pipeline)
913948

914949
recording = Recording(
915950
self.user_pipeline, pcolls, result, max_n, max_duration_secs)

0 commit comments

Comments
 (0)