2121
2222logging .getLogger ().setLevel (logging .INFO )
2323
24+
2425class BigQueryExecutor :
2526 """Handles BigQuery client initialization and query execution."""
27+
2628 def __init__ (self ,
2729 connection_id : str ,
2830 project_id : str ,
@@ -35,7 +37,8 @@ def __init__(self,
3537 self .database_id = database_id
3638 self .location = location
3739 try :
38- self .client = bigquery .Client (project = self .project_id , location = self .location )
40+ self .client = bigquery .Client (project = self .project_id ,
41+ location = self .location )
3942 except Exception as e :
4043 logging .warning (f"Failed to initialize BigQuery client: { e } " )
4144 self .client = None
@@ -44,47 +47,110 @@ def get_spanner_destination_uri(self) -> str:
4447 """Returns the Spanner destination URI for EXPORT DATA."""
4548 return f"https://spanner.googleapis.com/projects/{ self .project_id } /instances/{ self .instance_id } /databases/{ self .database_id } "
4649
47- def execute (self , query : str , job_config : Optional [bigquery .QueryJobConfig ] = None ) -> bigquery .table .RowIterator :
50+ def execute (
51+ self ,
52+ query : str ,
53+ job_config : Optional [bigquery .QueryJobConfig ] = None
54+ ) -> bigquery .table .RowIterator :
4855 """Executes a query and returns the result."""
49- if not self .client :
50- logging .error ("BigQuery client not initialized" )
51- raise RuntimeError ("BigQuery client not initialized" )
52-
5356 start_time = time .time ()
54- logging .info (f"Executing query (first 100 chars): { query .strip ()[:100 ]} ..." )
55-
5657 try :
57- query_job = self .client . query (query , job_config = job_config )
58+ query_job = self .execute (query , job_config )
5859 result = query_job .result ()
5960 duration = time .time () - start_time
60- logging .info (f"Query completed in { duration :.2f} s. Job ID: { query_job .job_id } " )
61+ logging .info (
62+ f"Query completed in { duration :.2f} s. Job ID: { query_job .job_id } "
63+ )
6164 return result
6265 except Exception as e :
63- logging .error (f"Query execution failed after { time .time () - start_time :.2f} s: { e } " )
66+ logging .error (
67+ f"Query execution failed after { time .time () - start_time :.2f} s: { e } "
68+ )
69+ raise
70+
71+ def execute (
72+ self ,
73+ query : str ,
74+ job_config : Optional [bigquery .QueryJobConfig ] = None
75+ ) -> bigquery .job .QueryJob :
76+ """Submits a query asynchronously and returns the QueryJob."""
77+ if not self .client :
78+ logging .error ("BigQuery client not initialized" )
79+ raise RuntimeError ("BigQuery client not initialized" )
80+
81+ logging .info (
82+ f"Submitting query (first 100 chars): { query .strip ()[:100 ]} ..." )
83+
84+ try :
85+ query_job = self .client .query (query , job_config = job_config )
86+ logging .info (f"Query submitted. Job ID: { query_job .job_id } " )
87+ return query_job
88+ except Exception as e :
89+ logging .error (f"Failed to submit query: { e } " )
6490 raise
6591
92+ def get_jobs_status (self , job_ids : List [str ]) -> Dict [str , Any ]:
93+ """Returns the overall status of a list of BigQuery jobs."""
94+ if not self .client :
95+ logging .error ("BigQuery client not initialized" )
96+ raise RuntimeError ("BigQuery client not initialized" )
97+
98+ overall_status = "DONE"
99+ failed_jobs = []
100+ error_message = ""
101+
102+ for job_id in job_ids :
103+ try :
104+ job = self .client .get_job (job_id , location = self .location )
105+ if job .error_result :
106+ overall_status = "FAILED"
107+ failed_jobs .append (job_id )
108+ error_message += f"Job { job_id } failed: { job .error_result } . "
109+ elif job .state != "DONE" and overall_status != "FAILED" :
110+ overall_status = "RUNNING"
111+ except Exception as e :
112+ logging .error (f"Failed to get job status for { job_id } : { e } " )
113+ overall_status = "FAILED"
114+ failed_jobs .append (job_id )
115+ error_message += f"Failed to get job { job_id } : { e } . "
116+
117+ if overall_status == "FAILED" :
118+ return {
119+ "status" : overall_status ,
120+ "error" : error_message ,
121+ "failedJobs" : failed_jobs
122+ }
123+ else :
124+ return {"status" : overall_status }
125+
66126
67127class LinkedEdgeGenerator :
68128 """Generates and ingests linked relationship edges (e.g., transitive closures) into Spanner for faster lookup."""
69- def __init__ (self , executor : BigQueryExecutor , is_base_dc : bool = True ) -> None :
129+
130+ def __init__ (self ,
131+ executor : BigQueryExecutor ,
132+ is_base_dc : bool = True ) -> None :
70133 self .executor = executor
71134 self .is_base_dc = is_base_dc
72135
73- def run_all (self , import_names : List [str ] = None ) -> None :
74- """Runs all global aggregations in sequence."""
136+ def run_all (self ,
137+ import_names : List [str ] = None ) -> List [bigquery .job .QueryJob ]:
138+ """Runs all global aggregations asynchronously and returns their jobs."""
75139 if not import_names :
76140 logging .info ("No imports specified. Skipping global aggregations." )
77- return
141+ return []
78142
79143 logging .info (f"Running global aggregations for imports: { import_names } " )
80-
81- # TODO: Run these methods in parallel to speed up execution since they are independent.
82- self .run_linked_contained_in_place (import_names )
83- self .run_linked_member_of (import_names )
84- self .run_linked_member (import_names )
85144
145+ jobs = [
146+ self .run_linked_contained_in_place (import_names ),
147+ self .run_linked_member_of (import_names ),
148+ self .run_linked_member (import_names )
149+ ]
150+ return jobs
86151
87- def run_linked_contained_in_place (self , import_names : List [str ] = None ) -> None :
152+ def run_linked_contained_in_place (self ,
153+ import_names : List [str ] = None ) -> None :
88154 """Expands place containment hierarchies."""
89155 if not import_names :
90156 return
@@ -96,7 +162,7 @@ def run_linked_contained_in_place(self, import_names: List[str] = None) -> None:
96162 provenances = [f"'{ prefix } { name } '" for name in safe_names ]
97163 provenance_filter = f" AND provenance IN ({ ', ' .join (provenances )} )"
98164 gen_graphs_prov = 'dc/base/GeneratedGraphs' if self .is_base_dc else 'GeneratedGraphs'
99-
165+
100166 query = f"""
101167 -- Pull base edges needed for containedInPlace aggregation
102168 CREATE OR REPLACE TEMPORARY TABLE `temp_base_contained_in_place` AS
@@ -171,7 +237,7 @@ def run_linked_contained_in_place(self, import_names: List[str] = None) -> None:
171237 FROM
172238 FilteredEdges
173239 """
174- self .executor .execute (query )
240+ return self .executor .execute (query )
175241
176242 def run_linked_member_of (self , import_names : List [str ] = None ) -> None :
177243 """Expands membership hierarchies using memberOf and specializationOf."""
@@ -263,7 +329,7 @@ def run_linked_member_of(self, import_names: List[str] = None) -> None:
263329 FROM
264330 FilteredEdges
265331 """
266- self .executor .execute (query )
332+ return self .executor .execute (query )
267333
268334 def run_linked_member (self , import_names : List [str ] = None ) -> None :
269335 """Expands topic/SVGP descendants to identify leaf members."""
@@ -356,38 +422,44 @@ def run_linked_member(self, import_names: List[str] = None) -> None:
356422 FROM
357423 FilteredEdges
358424 """
359- self .executor .execute (query )
425+ return self .executor .execute (query )
360426
361427
362428class ProvenanceSummaryGenerator :
363429 """Contains the SQL queries to generate ProvenanceSummary in the Cache table."""
364- def __init__ (self , executor : BigQueryExecutor , is_base_dc : bool = True ) -> None :
430+
431+ def __init__ (self ,
432+ executor : BigQueryExecutor ,
433+ is_base_dc : bool = True ) -> None :
365434 self .executor = executor
366435 self .is_base_dc = is_base_dc
367436
368- def run_all (self , import_names : List [str ]) -> None :
369- """Runs all provenance summary generation in sequence ."""
437+ def run_all (self , import_names : List [str ]) -> List [ bigquery . job . QueryJob ] :
438+ """Runs all provenance summary generation asynchronously and returns their jobs ."""
370439 if not import_names :
371440 logging .info ("No imports specified. Skipping cache aggregations." )
372- return
441+ return []
373442
374- logging .info (f"Running provenance summary generation for imports: { import_names } " )
375- self .run_provenance_summary_aggregation (import_names )
443+ logging .info (
444+ f"Running provenance summary generation for imports: { import_names } "
445+ )
446+ return [self .run_provenance_summary_aggregation (import_names )]
376447
377- def run_provenance_summary_aggregation (self , import_names : List [str ]) -> None :
448+ def run_provenance_summary_aggregation (self ,
449+ import_names : List [str ]) -> None :
378450 """Calculates ProvenanceSummary for all variables and populates the Cache table."""
379451 if not import_names :
380452 return
381453
382454 dest = self .executor .get_spanner_destination_uri ()
383455 connection_id = self .executor .connection_id
384-
456+
385457 # Escape single quotes to prevent SQL injection
386458 safe_names = [name .replace ("'" , "''" ) for name in import_names ]
387459 # Format import names for the SQL IN clause
388460 imports_str = ", " .join ([f"'{ name } '" for name in safe_names ])
389461 provenance_dcid_expr = "CONCAT('dc/base/', raw.import_name)" if self .is_base_dc else "raw.import_name"
390-
462+
391463 query = f"""
392464 -- Step 1: Fetch Observation rows for the specific import
393465 -- We cast 'observations' to STRING to avoid the PROTO error.
@@ -571,31 +643,33 @@ def run_provenance_summary_aggregation(self, import_names: List[str]) -> None:
571643 FROM facet_summaries
572644 GROUP BY variable_measured, provenance_dcid;
573645 """
574- self .executor .execute (query )
646+ return self .executor .execute (query )
575647
576648
577649class AggregationUtils :
578650 """Orchestrates the overall aggregation workflow."""
579- def __init__ (self ,
651+
652+ def __init__ (self ,
580653 connection_id : str ,
581654 project_id : str ,
582655 instance_id : str ,
583656 database_id : str ,
584657 location : Optional [str ] = None ,
585658 is_base_dc : bool = True ) -> None :
586- self .executor = BigQueryExecutor (
587- connection_id = connection_id ,
588- project_id = project_id ,
589- instance_id = instance_id ,
590- database_id = database_id ,
591- location = location
592- )
593- self .linked_edge_generator = LinkedEdgeGenerator ( self . executor , is_base_dc )
594- self . provenance_summary_generator = ProvenanceSummaryGenerator ( self .executor , is_base_dc )
595-
596- def run_aggregation (self , import_list : List [Dict [str , Any ]]) -> bool :
659+ self .executor = BigQueryExecutor (connection_id = connection_id ,
660+ project_id = project_id ,
661+ instance_id = instance_id ,
662+ database_id = database_id ,
663+ location = location )
664+ self . linked_edge_generator = LinkedEdgeGenerator (
665+ self . executor , is_base_dc )
666+ self .provenance_summary_generator = ProvenanceSummaryGenerator (
667+ self .executor , is_base_dc )
668+
669+ def run_aggregation (self , import_list : List [Dict [str , Any ]]) -> List [ str ] :
597670 """
598671 Orchestrates standard per-import aggregations and global aggregations.
672+ Returns a list of BigQuery job IDs for async polling.
599673 """
600674 logging .info (f"Received request for importList: { import_list } " )
601675
@@ -608,17 +682,34 @@ def run_aggregation(self, import_list: List[Dict[str, Any]]) -> bool:
608682 import_names .append (import_name )
609683 query = "SELECT @import_name as import_name, CURRENT_TIMESTAMP() as execution_time"
610684 job_config = bigquery .QueryJobConfig (query_parameters = [
611- bigquery .ScalarQueryParameter ("import_name" , "STRING" , import_name ),
685+ bigquery .ScalarQueryParameter ("import_name" , "STRING" ,
686+ import_name ),
612687 ])
613688 self .executor .execute (query , job_config = job_config )
614689 else :
615- logging .info ('Skipping aggregation logic for empty importName' )
690+ logging .info (
691+ 'Skipping aggregation logic for empty importName' )
616692
617- # 2. Run global aggregations
618- self .linked_edge_generator .run_all (import_names )
619- self .provenance_summary_generator .run_all (import_names )
620-
621- return True
693+ # 2. Run global aggregations asynchronously
694+ jobs = []
695+ jobs .extend (self .linked_edge_generator .run_all (import_names ))
696+ jobs .extend (self .provenance_summary_generator .run_all (import_names ))
697+
698+ job_ids = [job .job_id for job in jobs if job ]
699+ logging .info (f"Submitted async aggregation jobs: { job_ids } " )
700+
701+ return job_ids
622702 except Exception as e :
623703 logging .error (f"Aggregation failed: { e } " )
624704 raise e
705+
706+ def check_aggregation_status (self , job_ids : List [str ]) -> Dict [str , Any ]:
707+ """
708+ Checks the status of the provided BigQuery job IDs.
709+ """
710+ logging .info (f"Checking status for jobs: { job_ids } " )
711+ try :
712+ return self .executor .get_jobs_status (job_ids )
713+ except Exception as e :
714+ logging .error (f"Failed to check aggregation status: { e } " )
715+ raise e
0 commit comments