Skip to content

Commit 5011cae

Browse files
jirhikerclaude
andcommitted
Fetch each source once for both summary and timeseries unification
A DIE source was unified separately per mode (summary vs timeseries), but every connector's get_records is mode-agnostic: both modes pull the same raw observations and differ only in how they are transformed. So a source needed by both a summary and a timeseries product was fetched from the API twice for identical data. Backend: add unify_source_both(config, source_key), which fetches a source once and unifies it for both modes. It enables an opt-in shared-fetch cache on the source (BaseSource._fetch_records / _sites_cache, off by default so the CLI/API path is byte-identical) and runs _site_wrapper twice with config.output_summary toggled — the second pass reuses the first pass's cached site list and observations instead of re-querying. Output is identical to running unify_source twice; only the underlying fetch is shared. Orchestration: drop `mode` from the shared source key and the cohort key. A source asset now calls unify_source_both and carries records (summary) + sites/timeseries together, so a summary product and a timeseries product over the same (parameter, scope, source) share one asset and one fetch. Summary and timeseries products consequently share a cohort (cohorts keyed by group+scope), which is what lets them run together and dedupe the fetch. Effect: shared source assets 86 -> 68 (-18 redundant fetches: waterlevels 9, arsenic 4, nitrate 5); cohort jobs 4 -> 2 (waterlevels_state_NM, analytes_state_NM). Per-analyte fetch multiplication is unchanged and remains the documented next-step backend optimization. Adds tests/test_unify_dual.py: proves unify_source_both fetches each source once and yields output identical to two separate unify_source runs, plus the fetch-cache invariants. dg check defs clean; 284 offline tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9f2cc8d commit 5011cae

5 files changed

Lines changed: 330 additions & 85 deletions

File tree

backend/source.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,9 @@ def get_analyte_search_param(parameter: str, mapping: dict) -> str:
188188
# Base source classes
189189
# =============================================================================
190190

191+
_FETCH_UNSET = object() # sentinel: site fetch not yet cached
192+
193+
191194
class BaseSource:
192195
transformer_klass = BaseTransformer # deprecated: pass transformer= to __init__
193196

@@ -198,6 +201,25 @@ def __init__(self, transformer: Optional[BaseTransformer] = None, http_client: h
198201
self.log = _l.log
199202
self.warn = _l.warn
200203
self.debug = _l.debug
204+
# Opt-in shared-fetch cache. Off by default, so CLI/API behavior is
205+
# unchanged. unify_source_both turns it on so a source unified for both
206+
# summary and timeseries pulls the API only once (see
207+
# backend/unifier.py:unify_source_both). The two passes issue identical
208+
# fetches (same parameter/scope/dates), so the second reuses the first.
209+
self._fetch_cache_enabled = False
210+
self._records_cache: dict = {} # site-id key -> get_records() result
211+
self._sites_cache = _FETCH_UNSET # BaseSiteSource.read() result
212+
213+
def _fetch_records(self, site_record):
214+
"""get_records() with optional caching (see _fetch_cache_enabled). Keyed
215+
by the site ids requested so repeated chunks reuse the same fetch."""
216+
if not self._fetch_cache_enabled:
217+
return self.get_records(site_record)
218+
sites = site_record if isinstance(site_record, list) else [site_record]
219+
key = tuple(sorted(str(getattr(s, "id", s)) for s in sites))
220+
if key not in self._records_cache:
221+
self._records_cache[key] = self.get_records(site_record)
222+
return self._records_cache[key]
201223

202224
@property
203225
def tag(self):
@@ -296,13 +318,19 @@ def intersects(self, wkt: str) -> bool:
296318
return True
297319

298320
def read(self, *args, **kw) -> List[SiteRecord] | None:
321+
if self._fetch_cache_enabled and self._sites_cache is not _FETCH_UNSET:
322+
return self._sites_cache
299323
self.log("Gathering site records")
300324
records = self.get_records()
301325
if records:
302326
self.log(f"total records={len(records)}")
303-
return self._transform_sites(records)
304-
self.warn("No site records returned")
305-
return None
327+
result: List[SiteRecord] | None = self._transform_sites(records)
328+
else:
329+
self.warn("No site records returned")
330+
result = None
331+
if self._fetch_cache_enabled:
332+
self._sites_cache = result
333+
return result
306334

307335
def _transform_sites(self, records: list) -> List[SiteRecord]:
308336
transformed_records: List[SiteRecord] = []
@@ -350,7 +378,7 @@ def read_summary(self, site_record: SiteRecord | list, start_ind: int, end_ind:
350378
else:
351379
self.log(f"{site_record.id}: Gathering {self.name} data")
352380

353-
all_records = self.get_records(site_record)
381+
all_records = self._fetch_records(site_record)
354382
if not all_records:
355383
names = [str(r.id) for r in site_record] if isinstance(site_record, list) else [str(site_record.id)]
356384
self.warn(f"{','.join(names)}: No records found")
@@ -380,7 +408,7 @@ def read_timeseries(self, site_record: SiteRecord | list) -> List[ParameterRecor
380408
else:
381409
self.log(f"{site_record.id}: Gathering {self.name} data")
382410

383-
all_records = self.get_records(site_record)
411+
all_records = self._fetch_records(site_record)
384412
if not all_records:
385413
names = [str(r.id) for r in site_record] if isinstance(site_record, list) else [str(site_record.id)]
386414
self.warn(f"{','.join(names)}: No records found")

backend/unifier.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,59 @@ def unify_source(config, source_key):
301301
return persister
302302

303303

304+
def unify_source_both(config, source_key):
305+
"""Unify a single source for BOTH summary and timeseries outputs while
306+
fetching the source only once.
307+
308+
Each connector's ``get_records`` is mode-agnostic — summary and timeseries
309+
both pull the same raw observations and differ only in how they are
310+
transformed (see backend/source.py). Running ``unify_source`` twice would
311+
therefore hit the API twice for identical data. This driver instead enables
312+
the source's shared-fetch cache and runs the two transform passes over one
313+
fetch, so a source needed by both a summary and a timeseries product is
314+
pulled once.
315+
316+
Output is identical to calling ``unify_source`` twice (once per mode); only
317+
the underlying fetch is shared. Returns ``(summary_persister,
318+
timeseries_persister)``. Used by the orchestration shared source asset.
319+
"""
320+
config.validate()
321+
322+
pair = config.source_pair(source_key)
323+
if pair is None:
324+
config.warn(
325+
f"Source {source_key!r} does not provide parameter {config.parameter!r}"
326+
)
327+
return make_persister(config), make_persister(config)
328+
329+
site_source, parameter_source = pair
330+
# Share the site list and observation fetch across the two passes. The
331+
# passes issue identical requests (same parameter/scope/dates), so the
332+
# second reuses the first's cached fetch instead of re-querying.
333+
site_source._fetch_cache_enabled = True
334+
parameter_source._fetch_cache_enabled = True
335+
336+
# Timeseries pass first so its fetch primes the cache; the summary pass then
337+
# transforms the same cached observations. output_summary is read live by
338+
# the transformer, so toggling it here switches the record klass/fields per
339+
# pass without rebuilding the source.
340+
config.output_summary = False
341+
timeseries_persister = make_persister(config)
342+
config._persister = timeseries_persister
343+
_site_wrapper(
344+
site_source, parameter_source, timeseries_persister, config, raise_errors=True
345+
)
346+
347+
config.output_summary = True
348+
summary_persister = make_persister(config)
349+
config._persister = summary_persister
350+
_site_wrapper(
351+
site_source, parameter_source, summary_persister, config, raise_errors=True
352+
)
353+
354+
return summary_persister, timeseries_persister
355+
356+
304357
def get_county_bounds(county):
305358
config = Config()
306359
config.county = county

orchestration/assets/products.py

Lines changed: 75 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,39 @@
22
33
The graph has two layers wired through the GCS IO manager:
44
5-
shared source assets per-product pipeline
6-
["sources", param, mode, scope, k] ──▶ <product_id> ──▶ <product_id>/geoserver
7-
(combine) (publish)
8-
9-
- **shared source assets** — keyed ``["sources", <parameter>, <mode>, <scope>,
10-
<source_key>]``. One per *distinct* (parameter, mode, scope, source) tuple
11-
across **all** products. ``mode`` is ``summary`` or ``timeseries`` (the
12-
backend only distinguishes these two unification modes); ``scope`` encodes the
13-
spatial filter (``state_NM`` / ``county_Bernalillo`` / ``all``). Because the
14-
key is product-independent, every product that needs the same source under the
15-
same parameter/mode/scope shares one asset — the unification runs **once** per
16-
run instead of once per product. Each asset unifies a single parameter for a
17-
single source and emits its records/sites/timeseries.
5+
shared source assets per-product pipeline
6+
["sources", param, scope, k] ──▶ <product_id> ──▶ <product_id>/geoserver
7+
(combine) (publish)
8+
9+
- **shared source assets** — keyed ``["sources", <parameter>, <scope>,
10+
<source_key>]``. One per *distinct* (parameter, scope, source) tuple across
11+
**all** products. ``scope`` encodes the spatial filter (``state_NM`` /
12+
``county_Bernalillo`` / ``all``). The source is fetched **once** and unified
13+
for *both* summary and timeseries (see ``unify_source_both`` — every
14+
connector's fetch is mode-agnostic, so summary and timeseries differ only in
15+
how the same observations are transformed), so the asset carries records
16+
(summary) and sites/timeseries together. Because the key is
17+
product-independent *and* mode-independent, every product that needs the same
18+
source under the same parameter/scope — whether a summary or a timeseries
19+
product — shares one asset and one fetch.
1820
- **combine asset** — keyed ``[product_id]``. Reads its source inputs back
19-
(``ins``), merges them, writes the OGC GeoJSON collection, uploads to GCS.
21+
(``ins``), takes the slice it needs (records for summary-type products,
22+
sites/timeseries for timeseries-type), writes the OGC GeoJSON collection,
23+
uploads to GCS.
2024
- **geoserver asset** — keyed ``[product_id, "geoserver"]``. Downloads the
2125
combined GeoJSON, converts to GeoPackage, publishes it as a GeoServer layer.
2226
2327
Job layout (see ``definitions.py``) makes the sharing pay off **and** keeps each
24-
run's lineage complete. Products are grouped into *cohorts* by
25-
(group, mode, scope) — the products that can share source assets. One job per
26-
cohort materializes that cohort's whole graph in a single run: each shared
27-
source unifies once (it is one asset key, selected once), then every member
28-
combine reads it back through the GCS IO manager and publishes. So a source is
29-
never fetched twice in a run, while the full sources → combine → geoserver
30-
lineage stays visible for every product. (Cross-product dedup requires the
31-
sharing products to run together; that is exactly what a cohort is.)
28+
run's lineage complete. Products are grouped into *cohorts* by (group, scope) —
29+
the products that can share source assets. One job per cohort materializes that
30+
cohort's whole graph in a single run: each shared source is fetched once (it is
31+
one asset key, selected once), then every member combine reads it back through
32+
the GCS IO manager and publishes. So a source is never fetched twice in a run —
33+
not across products and not across summary/timeseries — while the full
34+
sources → combine → geoserver lineage stays visible for every product.
35+
(Cross-product dedup requires the sharing products to run together; that is
36+
exactly what a cohort is, and is why summary + timeseries products now share a
37+
cohort.)
3238
3339
Design notes:
3440
- Source and geoserver assets never hard-fail. They catch their own errors and
@@ -40,21 +46,21 @@
4046
combine asset rebuilds record objects before dumping.
4147
4248
Known limitation — per-analyte source fetches (potential future optimization):
43-
Sharing is deduped at the ``(parameter, mode, scope, source)`` grain, which
44-
collapses duplication *across products* (e.g. ``sulfate/summary/state_NM/wqp``
45-
is one asset shared by nm_major_chemistry and nm_mcl_exceedance). It does NOT
49+
Sharing is deduped at the ``(parameter, scope, source)`` grain, which
50+
collapses duplication *across products* (e.g. ``sulfate/state_NM/wqp`` is one
51+
asset shared by nm_major_chemistry and nm_mcl_exceedance) and *across modes*
52+
(one ``wqp`` asset serves both summary and timeseries products). It does NOT
4653
collapse *across analytes*: a source appears once per analyte (e.g. ``wqp``
47-
has ~13 summary source assets, one per analyte). This is because the backend
48-
unifies a single parameter per pass (``unify_source`` uses one
49-
``config.parameter``), so each analyte is a separate sweep of the same wells
50-
even though one provider query (WQP/AMP/...) typically returns all analytes at
51-
once. Collapsing this would need a **backend** change — multi-analyte
52-
unification that fetches a source once and emits per-analyte records — after
53-
which the source key could drop ``parameter`` (e.g.
54-
``["sources", "analytes", mode, scope, source]``) and the analyte combines
55-
would each filter the shared multi-analyte payload. That is the bulk of the
56-
remaining redundant API pulls for analyte products; it touches DIE core, not
57-
this asset graph, so it is intentionally out of scope here.
54+
has ~13 source assets, one per analyte). This is because the backend unifies a
55+
single parameter per pass (``unify_source_both`` uses one ``config.parameter``),
56+
so each analyte is a separate sweep of the same wells even though one provider
57+
query (WQP/AMP/...) typically returns all analytes at once. Collapsing this
58+
would need a **backend** change — multi-analyte unification that fetches a
59+
source once and emits per-analyte records — after which the source key could
60+
drop ``parameter`` (e.g. ``["sources", "analytes", scope, source]``) and the
61+
analyte combines would each filter the shared multi-analyte payload. That is
62+
the bulk of the remaining redundant API pulls for analyte products; it touches
63+
DIE core, not this asset graph, so it is intentionally out of scope here.
5864
"""
5965
import tempfile
6066
import traceback
@@ -77,7 +83,7 @@
7783
dump_trend_collection,
7884
)
7985
from backend.record import ParameterRecord, SiteRecord, SummaryRecord
80-
from backend.unifier import unify_source
86+
from backend.unifier import unify_source_both
8187
from orchestration.logging_bridge import forward_die_logs
8288
from orchestration.resources.die_config import DIEConfigResource
8389
from orchestration.resources.gcs import GCSResource
@@ -90,10 +96,6 @@
9096
# truth for the ogc_mcl_exceedance product.
9197
_MCL_KEY = "config/mcl.json"
9298

93-
# Output types whose unification runs in summary mode. The backend only
94-
# distinguishes summary vs timeseries; everything else unifies as timeseries.
95-
_SUMMARY_OUTPUT_TYPES = ("ogc_summary", "ogc_major_chemistry", "ogc_mcl_exceedance")
96-
9799
# Classic major-ion suite for the ogc_major_chemistry product. One feature per
98100
# well, with each analyte's latest value/units/date as properties.
99101
_MAJOR_CHEMISTRY = [
@@ -109,8 +111,12 @@
109111

110112
# A single shared source asset's identity. Two products that produce the same
111113
# SourceSpec share one asset (the namedtuple is hashable, so dedup is just set
112-
# membership). ``group`` follows the parameter, not the product.
113-
SourceSpec = namedtuple("SourceSpec", "parameter mode scope source_key group")
114+
# membership). ``group`` follows the parameter, not the product. There is no
115+
# ``mode`` field: a source is fetched once and unified for *both* summary and
116+
# timeseries (see build_shared_source_asset / unify_source_both), so a summary
117+
# product and a timeseries product over the same (parameter, scope, source)
118+
# share one asset and one fetch.
119+
SourceSpec = namedtuple("SourceSpec", "parameter scope source_key group")
114120

115121

116122
def _product_params(product: dict) -> list[str]:
@@ -126,11 +132,6 @@ def _product_params(product: dict) -> list[str]:
126132
return [product["parameter"]]
127133

128134

129-
def _product_mode(product: dict) -> str:
130-
"""``summary`` or ``timeseries`` — the unification mode the product needs."""
131-
return "summary" if product.get("output_type") in _SUMMARY_OUTPUT_TYPES else "timeseries"
132-
133-
134135
def _spatial_scope(product: dict) -> str:
135136
"""A stable string identity for the product's spatial filter. Sources with
136137
different spatial extents unify to different results, so the extent is part
@@ -171,20 +172,19 @@ def _param_source_keys(product: dict, parameter: str) -> list[str]:
171172

172173
def product_source_specs(product: dict) -> list[SourceSpec]:
173174
"""Every shared source asset this product depends on, one per
174-
(parameter, source) pair. ``mode`` and ``scope`` are constant for a product;
175-
the parameter and source vary. Returned in a stable order."""
176-
mode = _product_mode(product)
175+
(parameter, source) pair. ``scope`` is constant for a product; the parameter
176+
and source vary. Returned in a stable order."""
177177
scope = _spatial_scope(product)
178178
specs: list[SourceSpec] = []
179179
for param in _product_params(product):
180180
group = _group_for_param(param)
181181
for source_key in _param_source_keys(product, param):
182-
specs.append(SourceSpec(param, mode, scope, source_key, group))
182+
specs.append(SourceSpec(param, scope, source_key, group))
183183
return specs
184184

185185

186186
def shared_source_key(spec: SourceSpec) -> dg.AssetKey:
187-
return dg.AssetKey(["sources", spec.parameter, spec.mode, spec.scope, spec.source_key])
187+
return dg.AssetKey(["sources", spec.parameter, spec.scope, spec.source_key])
188188

189189

190190
def _in_name(spec: SourceSpec) -> str:
@@ -196,19 +196,26 @@ def _in_name(spec: SourceSpec) -> str:
196196

197197

198198
def build_shared_source_asset(spec: SourceSpec) -> dg.AssetsDefinition:
199-
"""Build the shared asset that unifies one source for one (parameter, mode,
200-
scope) — keyed product-independently so every product needing it shares it.
199+
"""Build the shared asset that unifies one source for one (parameter, scope)
200+
— keyed product-independently so every product needing it shares it.
201+
202+
The source is fetched once and unified for *both* summary and timeseries
203+
(unify_source_both), so the asset carries records (summary) and
204+
sites/timeseries together; summary and timeseries products over the same
205+
(parameter, scope, source) share this one asset and one fetch.
201206
202207
The asset never raises: on failure it records the traceback and fails its
203208
``returned_data`` check (WARN) instead, so a broken source does not block any
204209
product's combine asset. Output ships as plain ``_payload`` dicts for
205210
IO-manager pickling (see module docstring)."""
206211
src_key = shared_source_key(spec)
207-
# Synthetic product spec driving config: only parameter, mode, and spatial
208-
# filter affect a single source's unification (sources include/exclude only
209-
# selects which sources a product consumes — irrelevant here).
212+
# Synthetic product spec driving config: only parameter and spatial filter
213+
# affect a single source's unification (sources include/exclude only selects
214+
# which sources a product consumes — irrelevant here; mode is handled by
215+
# unify_source_both, which produces both). output_type is nominal — the
216+
# driver toggles summary/timeseries itself.
210217
synth_product = {
211-
"output_type": "ogc_summary" if spec.mode == "summary" else "ogc_timeseries",
218+
"output_type": "ogc_timeseries",
212219
"spatial_filter": _scope_to_spatial_filter(spec.scope),
213220
}
214221

@@ -226,15 +233,19 @@ def _source_asset(
226233
timeseries: list[list[dict]] = []
227234
try:
228235
# A source that doesn't provide this parameter is skipped by
229-
# unify_source (source_pair → None).
236+
# unify_source_both (source_pair → None).
230237
with forward_die_logs(context):
231238
config = die_config.get_config(synth_product, parameter=spec.parameter)
232-
persister = unify_source(config, spec.source_key)
239+
# One fetch, both modes: summary records + timeseries sites/obs.
240+
summary_persister, timeseries_persister = unify_source_both(
241+
config, spec.source_key
242+
)
233243
# Ship plain dicts across the IO manager; rebuild in combine.
234-
records.extend(r._payload for r in persister.records)
235-
sites.extend(s._payload for s in persister.sites)
244+
records.extend(r._payload for r in summary_persister.records)
245+
sites.extend(s._payload for s in timeseries_persister.sites)
236246
timeseries.extend(
237-
[o._payload for o in site_ts] for site_ts in persister.timeseries
247+
[o._payload for o in site_ts]
248+
for site_ts in timeseries_persister.timeseries
238249
)
239250
except Exception:
240251
error = traceback.format_exc()
@@ -251,7 +262,6 @@ def _source_asset(
251262
metadata={
252263
"source": spec.source_key,
253264
"parameter": spec.parameter,
254-
"mode": spec.mode,
255265
"scope": spec.scope,
256266
"record_count": len(records),
257267
"site_count": len(sites),

0 commit comments

Comments
 (0)