Skip to content

Commit 22fb1f4

Browse files
committed
Use asynchronous aggregation queries
1 parent 500e4a7 commit 22fb1f4

3 files changed

Lines changed: 210 additions & 68 deletions

File tree

import-automation/workflow/ingestion-helper/aggregation_utils.py

Lines changed: 145 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@
2121

2222
logging.getLogger().setLevel(logging.INFO)
2323

24+
2425
class 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

67127
class 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

362428
class 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

577649
class 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

import-automation/workflow/ingestion-helper/main.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -282,13 +282,10 @@ def ingestion_helper(request):
282282
is_base_dc=FLAGS.is_base_dc,
283283
)
284284
try:
285-
if aggregation.run_aggregation(import_list):
286-
return ('OK', 200)
287-
else:
288-
return ('Aggregation failed', 500)
285+
job_ids = aggregation.run_aggregation(import_list)
286+
return jsonify({'status': 'SUBMITTED', 'jobIds': job_ids}), 200
289287
except Exception as e:
290288
return (f"Aggregation failed: {str(e)}", 500)
291-
292289
elif action_type == 'clear_redis_cache':
293290
logging.info("Action: clear_redis_cache")
294291
redis_host = os.environ.get("REDIS_HOST")
@@ -306,6 +303,26 @@ def ingestion_helper(request):
306303
else:
307304
logging.warning("REDIS_HOST not set, skipping cache flush.")
308305
return jsonify({'status': 'SKIPPED', 'message': 'REDIS_HOST not set'}), 200
306+
elif action_type == 'check_aggregation_status':
307+
# Checks the status of submitted aggregation BigQuery jobs.
308+
# Input:
309+
# jobIds: list of BigQuery job IDs
310+
job_ids = request_json.get('jobIds', [])
311+
if not job_ids:
312+
return ('Missing or empty jobIds', 400)
313+
314+
aggregation = AggregationUtils(
315+
connection_id=FLAGS.spanner_connection_id,
316+
project_id=FLAGS.spanner_project_id,
317+
instance_id=FLAGS.spanner_instance_id,
318+
database_id=FLAGS.spanner_graph_database_id,
319+
location=FLAGS.location,
320+
)
321+
try:
322+
status = aggregation.check_aggregation_status(job_ids)
323+
return jsonify(status), 200
324+
except Exception as e:
325+
return (f"Aggregation status check failed: {str(e)}", 500)
309326

310327
else:
311328
return (f'Unknown actionType: {action_type}', 400)

0 commit comments

Comments
 (0)