2525from concurrent .futures import Future
2626from concurrent .futures import ThreadPoolExecutor
2727from typing import Any
28+ from typing import Iterable
2829from typing import Optional
2930from 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