Skip to content

Commit 67ac15e

Browse files
committed
perf: parallel BQ queries + TTL cache on clusters/quality/analytics
1 parent f57748b commit 67ac15e

4 files changed

Lines changed: 83 additions & 45 deletions

File tree

api/services/analytics_service.py

Lines changed: 55 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,77 @@
1-
"""Analytics data — queries the 6 looker_* BigQuery views."""
1+
"""Analytics data — 6 BigQuery views queried in parallel with TTL cache."""
22
from __future__ import annotations
3+
from concurrent.futures import ThreadPoolExecutor, as_completed
34
from google.cloud import bigquery
45
from config import PROJECT, DATASET
6+
from services.cache import ttl_cache
57

8+
_ds = f"{PROJECT}.{DATASET}"
69

7-
def get_analytics(bq: bigquery.Client) -> dict:
8-
ds = f"{PROJECT}.{DATASET}"
9-
10-
cluster_dist = list(bq.query(f"""
10+
_QUERIES = {
11+
"cluster_distribution": f"""
1112
SELECT cluster_label, product_count, avg_price
12-
FROM `{ds}.looker_cluster_distribution`
13-
ORDER BY product_count DESC
14-
LIMIT 40
15-
""").result())
16-
17-
pricing = list(bq.query(f"""
13+
FROM `{_ds}.looker_cluster_distribution`
14+
ORDER BY product_count DESC LIMIT 40""",
15+
"pricing": f"""
1816
SELECT cluster_label, avg_price, price_min, price_max, product_count
19-
FROM `{ds}.looker_pricing_per_cluster`
20-
ORDER BY avg_price DESC
21-
LIMIT 20
22-
""").result())
23-
24-
heatmap = list(bq.query(f"""
17+
FROM `{_ds}.looker_pricing_per_cluster`
18+
ORDER BY avg_price DESC LIMIT 20""",
19+
"heatmap": f"""
2520
SELECT category, department, product_count, avg_price
26-
FROM `{ds}.looker_heatmap_cat_dept`
27-
ORDER BY product_count DESC
28-
""").result())
29-
30-
quality = list(bq.query(f"""
21+
FROM `{_ds}.looker_heatmap_cat_dept`
22+
ORDER BY product_count DESC""",
23+
"quality": f"""
3124
SELECT completeness_pct, total_records, valid_records,
3225
field_name_completeness, field_brand_completeness,
3326
field_cat_completeness, field_price_completeness,
3427
price_mean, price_min, price_max
35-
FROM `{ds}.looker_data_quality`
36-
LIMIT 1
37-
""").result())
38-
39-
timeline = list(bq.query(f"""
28+
FROM `{_ds}.looker_data_quality` LIMIT 1""",
29+
"timeline": f"""
4030
SELECT sale_date, cluster_label, sales_count, sales_revenue
41-
FROM `{ds}.looker_sales_timeline`
42-
ORDER BY sale_date ASC
43-
""").result())
44-
45-
brands = list(bq.query(f"""
31+
FROM `{_ds}.looker_sales_timeline`
32+
ORDER BY sale_date ASC""",
33+
"brands": f"""
4634
SELECT cluster_label, brand, product_count
47-
FROM `{ds}.looker_brands_per_cluster`
48-
ORDER BY cluster_label, product_count DESC
49-
LIMIT 100
50-
""").result())
35+
FROM `{_ds}.looker_brands_per_cluster`
36+
ORDER BY cluster_label, product_count DESC LIMIT 100""",
37+
}
38+
39+
40+
def _row(r) -> dict:
41+
return {
42+
k: (float(v) if hasattr(v, "__float__") and not isinstance(v, (int, str, bool)) else v)
43+
for k, v in dict(r).items()
44+
}
45+
46+
47+
def _run(bq: bigquery.Client, key: str) -> tuple[str, list]:
48+
rows = list(bq.query(_QUERIES[key]).result())
49+
return key, rows
50+
51+
52+
@ttl_cache(seconds=600) # 10-minute cache — data only changes after pipeline run
53+
def get_analytics(bq: bigquery.Client) -> dict:
54+
results: dict[str, list] = {}
55+
56+
# Fire all 6 queries in parallel
57+
with ThreadPoolExecutor(max_workers=6) as pool:
58+
futures = {pool.submit(_run, bq, key): key for key in _QUERIES}
59+
for future in as_completed(futures):
60+
key, rows = future.result()
61+
results[key] = rows
5162

52-
def row(r):
53-
return {k: (float(v) if hasattr(v, '__float__') and not isinstance(v, (int, str, bool)) else v)
54-
for k, v in dict(r).items()}
63+
timeline_rows = results["timeline"]
64+
quality_rows = results["quality"]
5565

5666
return {
57-
"cluster_distribution": [row(r) for r in cluster_dist],
58-
"pricing": [row(r) for r in pricing],
59-
"heatmap": [row(r) for r in heatmap],
60-
"quality": row(quality[0]) if quality else {},
67+
"cluster_distribution": [_row(r) for r in results["cluster_distribution"]],
68+
"pricing": [_row(r) for r in results["pricing"]],
69+
"heatmap": [_row(r) for r in results["heatmap"]],
70+
"quality": _row(quality_rows[0]) if quality_rows else {},
6171
"timeline": [
6272
{**{k: v for k, v in dict(r).items() if k != "sale_date"},
6373
"sale_date": str(r.sale_date)}
64-
for r in timeline
74+
for r in timeline_rows
6575
],
66-
"brands": [row(r) for r in brands],
76+
"brands": [_row(r) for r in results["brands"]],
6777
}

api/services/cache.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Simple in-memory TTL cache for expensive BigQuery results."""
2+
from __future__ import annotations
3+
import time
4+
import functools
5+
from typing import Any
6+
7+
_store: dict[str, tuple[float, Any]] = {}
8+
9+
10+
def ttl_cache(seconds: int = 600):
11+
"""Decorator: cache function result for `seconds`. Key = func name + args."""
12+
def decorator(fn):
13+
@functools.wraps(fn)
14+
def wrapper(*args, **kwargs):
15+
key = f"{fn.__qualname__}:{args[1:]}:{kwargs}"
16+
entry = _store.get(key)
17+
if entry and time.monotonic() - entry[0] < seconds:
18+
return entry[1]
19+
result = fn(*args, **kwargs)
20+
_store[key] = (time.monotonic(), result)
21+
return result
22+
return wrapper
23+
return decorator

api/services/cluster_service.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
from google.cloud import bigquery
55
from config import TABLE_CLUSTERED, TABLE_CLEAN, TABLE_ENRICHED
66
from models.schemas import ClusterSummary, ClustersResponse, ClusterProduct, ClusterProductsResponse
7+
from services.cache import ttl_cache
78

89

10+
@ttl_cache(seconds=600)
911
def get_clusters(bq: bigquery.Client) -> ClustersResponse:
1012
sql = f"""
1113
SELECT
@@ -32,6 +34,7 @@ def get_clusters(bq: bigquery.Client) -> ClustersResponse:
3234
return ClustersResponse(clusters=clusters)
3335

3436

37+
@ttl_cache(seconds=600)
3538
def get_cluster_products(bq: bigquery.Client, cluster_id: int) -> ClusterProductsResponse:
3639
sql = f"""
3740
SELECT

api/services/quality_service.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
from google.cloud import bigquery
55
from config import TABLE_QUALITY
66
from models.schemas import QualityReport
7+
from services.cache import ttl_cache
78

89

10+
@ttl_cache(seconds=600)
911
def get_quality_report(bq: bigquery.Client) -> QualityReport:
1012
sql = f"""
1113
SELECT *

0 commit comments

Comments
 (0)