Skip to content

Commit cb8a9cd

Browse files
jirhikerclaude
andauthored
Share source assets across products to dedupe unification (#92)
Source assets were keyed per-product ([product_id, "sources", key]), so every product re-ran DIE unification for the same source. The four waterlevels-NM products (summary/timeseries/trends/recency) and the arsenic/nitrate/tds summary + MCL products each pulled identical upstream data 2-4x, amplifying API throttling and memory pressure. Key source assets product-independently by their unification signature: ["sources", parameter, mode, scope, source_key], where mode is summary|timeseries (the only distinction the backend makes) and scope encodes the spatial filter. Each (parameter, mode, scope, source) tuple becomes one shared asset, deduped across all products (124 -> 87 assets, 37 duplicate unifications eliminated). A dedicated sources_job materializes every shared source once on its own schedule (05:00, ahead of the product schedules). Each per-product job now selects only its combine + geoserver assets and loads the source inputs from the GCS IO manager instead of re-unifying them. The IO manager is subclassed to return an empty payload when a source pickle is absent, so a combine never hard-fails on a not-yet-materialized source (e.g. on a fresh deploy, before sources_job has run once). Source assets always write a payload when they run, so this cannot mask a real data loss. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 53e2edf commit cb8a9cd

2 files changed

Lines changed: 264 additions & 120 deletions

File tree

orchestration/assets/products.py

Lines changed: 147 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,43 @@
1-
"""Per-source Dagster asset graph for a product.
2-
3-
``build_product_assets(product)`` expands one products.yaml entry into a small
4-
asset graph:
5-
6-
sources/<key> ─┐
7-
sources/<key> ─┼─▶ <product_id> ─▶ <product_id>/geoserver
8-
sources/<key> ─┘ (combine) (publish)
9-
10-
- **source assets** — keyed ``[product_id, "sources", <source_key>]``, one per
11-
data source that provides the product's parameter. Each runs DIE unification
12-
for just that source and emits its records/sites/timeseries.
13-
- **combine asset** — keyed ``[product_id]``. Merges every source's
14-
contribution, writes the OGC GeoJSON collection, and uploads it to GCS.
1+
"""Shared-source Dagster asset graph for the data products.
2+
3+
The graph has two layers wired through the GCS IO manager:
4+
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.
18+
- **combine asset** — keyed ``[product_id]``. Reads its source inputs back
19+
(``ins``), merges them, writes the OGC GeoJSON collection, uploads to GCS.
1520
- **geoserver asset** — keyed ``[product_id, "geoserver"]``. Downloads the
16-
combined GeoJSON, converts it to a GeoPackage, and publishes it as a layer in
17-
GeoServer.
21+
combined GeoJSON, converts to GeoPackage, publishes it as a GeoServer layer.
22+
23+
Job layout (see ``definitions.py``) makes the sharing pay off: a dedicated
24+
``sources_job`` materializes every shared source asset once; each per-product
25+
job selects only its combine + geoserver assets and loads the (already
26+
materialized) source inputs from the GCS IO manager. So a product run never
27+
re-unifies a source another product already produced.
1828
1929
Design notes:
2030
- Source and geoserver assets never hard-fail. They catch their own errors and
2131
report status via an ``AssetCheckResult`` that goes red (WARN) on error or
2232
empty output, so one dead source — or a GeoServer outage — surfaces in the UI
23-
without blocking the rest of the product graph.
33+
without blocking the rest of the graph.
2434
- Records cross the IO manager as plain ``_payload`` dicts (the record classes
2535
use ``__getattr__`` over ``_payload`` which does not survive pickling). The
2636
combine asset rebuilds record objects before dumping.
2737
"""
2838
import tempfile
2939
import traceback
40+
from collections import namedtuple
3041
from collections.abc import Iterator
3142
from datetime import datetime, timezone
3243
from pathlib import Path
@@ -58,8 +69,9 @@
5869
# truth for the ogc_mcl_exceedance product.
5970
_MCL_KEY = "config/mcl.json"
6071

61-
# Multi-analyte products that gather one summary record per analyte per well.
62-
_MULTI_ANALYTE_OUTPUT_TYPES = ("ogc_major_chemistry", "ogc_mcl_exceedance")
72+
# Output types whose unification runs in summary mode. The backend only
73+
# distinguishes summary vs timeseries; everything else unifies as timeseries.
74+
_SUMMARY_OUTPUT_TYPES = ("ogc_summary", "ogc_major_chemistry", "ogc_mcl_exceedance")
6375

6476
# Classic major-ion suite for the ogc_major_chemistry product. One feature per
6577
# well, with each analyte's latest value/units/date as properties.
@@ -74,6 +86,11 @@
7486
"sulfate",
7587
]
7688

89+
# A single shared source asset's identity. Two products that produce the same
90+
# SourceSpec share one asset (the namedtuple is hashable, so dedup is just set
91+
# membership). ``group`` follows the parameter, not the product.
92+
SourceSpec = namedtuple("SourceSpec", "parameter mode scope source_key group")
93+
7794

7895
def _product_params(product: dict) -> list[str]:
7996
"""The DIE parameter(s) a product unifies. Single-parameter products yield
@@ -88,14 +105,41 @@ def _product_params(product: dict) -> list[str]:
88105
return [product["parameter"]]
89106

90107

91-
def _product_source_keys(product: dict) -> list[str]:
92-
"""Source keys that apply to this product: the union of its parameters'
93-
agencies, filtered by the product's include/exclude list."""
94-
agencies: list = []
95-
for param in _product_params(product):
96-
for a in PARAMETER_SOURCE_MAP[param]["agencies"]:
97-
if a not in agencies:
98-
agencies.append(a)
108+
def _product_mode(product: dict) -> str:
109+
"""``summary`` or ``timeseries`` — the unification mode the product needs."""
110+
return "summary" if product.get("output_type") in _SUMMARY_OUTPUT_TYPES else "timeseries"
111+
112+
113+
def _spatial_scope(product: dict) -> str:
114+
"""A stable string identity for the product's spatial filter. Sources with
115+
different spatial extents unify to different results, so the extent is part
116+
of the shared-source key."""
117+
sf = product.get("spatial_filter", {}) or {}
118+
if sf.get("county"):
119+
return f"county_{sf['county']}"
120+
if sf.get("state"):
121+
return f"state_{sf['state']}"
122+
return "all"
123+
124+
125+
def _scope_to_spatial_filter(scope: str) -> dict:
126+
"""Inverse of :func:`_spatial_scope`, for rebuilding a config from a spec."""
127+
kind, _, value = scope.partition("_")
128+
if kind == "county":
129+
return {"county": value}
130+
if kind == "state":
131+
return {"state": value}
132+
return {}
133+
134+
135+
def _group_for_param(parameter: str) -> str:
136+
return "waterlevels" if parameter == WATERLEVELS else "analytes"
137+
138+
139+
def _param_source_keys(product: dict, parameter: str) -> list[str]:
140+
"""Source keys that apply to *parameter* for this product: the parameter's
141+
agencies filtered by the product's include/exclude list."""
142+
agencies = list(PARAMETER_SOURCE_MAP[parameter]["agencies"])
99143
spec = product.get("sources", {}) or {}
100144
if spec.get("include"):
101145
return [a for a in agencies if a in spec["include"]]
@@ -104,28 +148,52 @@ def _product_source_keys(product: dict) -> list[str]:
104148
return agencies
105149

106150

107-
def _in_name(source_key: str) -> str:
108-
# Combine-asset input kwargs must be valid Python identifiers; source keys
109-
# may contain hyphens, so sanitize and prefix.
110-
return f"src_{source_key.replace('-', '_')}"
111-
112-
113-
def _build_source_asset(
114-
product: dict, source_key: str, group: str
115-
) -> tuple[dg.AssetsDefinition, dg.AssetKey]:
116-
"""Build the asset that unifies a single source for *product*.
117-
118-
Returns ``(asset_def, asset_key)``. The asset never raises: on failure it
119-
records the traceback and fails its ``returned_data`` check (WARN) instead,
120-
so a broken source does not block the combine asset. Output is shipped as
121-
plain ``_payload`` dicts for IO-manager pickling (see module docstring)."""
122-
pid = product["id"]
123-
src_key = dg.AssetKey([pid, "sources", source_key])
124-
params = _product_params(product)
151+
def product_source_specs(product: dict) -> list[SourceSpec]:
152+
"""Every shared source asset this product depends on, one per
153+
(parameter, source) pair. ``mode`` and ``scope`` are constant for a product;
154+
the parameter and source vary. Returned in a stable order."""
155+
mode = _product_mode(product)
156+
scope = _spatial_scope(product)
157+
specs: list[SourceSpec] = []
158+
for param in _product_params(product):
159+
group = _group_for_param(param)
160+
for source_key in _param_source_keys(product, param):
161+
specs.append(SourceSpec(param, mode, scope, source_key, group))
162+
return specs
163+
164+
165+
def shared_source_key(spec: SourceSpec) -> dg.AssetKey:
166+
return dg.AssetKey(["sources", spec.parameter, spec.mode, spec.scope, spec.source_key])
167+
168+
169+
def _in_name(spec: SourceSpec) -> str:
170+
# Combine-asset input kwargs must be valid Python identifiers; parameter and
171+
# source keys may contain hyphens, so sanitize. (parameter, source) is
172+
# unique within a product, so it disambiguates the multi-analyte combines.
173+
raw = f"src_{spec.parameter}_{spec.source_key}"
174+
return raw.replace("-", "_")
175+
176+
177+
def build_shared_source_asset(spec: SourceSpec) -> dg.AssetsDefinition:
178+
"""Build the shared asset that unifies one source for one (parameter, mode,
179+
scope) — keyed product-independently so every product needing it shares it.
180+
181+
The asset never raises: on failure it records the traceback and fails its
182+
``returned_data`` check (WARN) instead, so a broken source does not block any
183+
product's combine asset. Output ships as plain ``_payload`` dicts for
184+
IO-manager pickling (see module docstring)."""
185+
src_key = shared_source_key(spec)
186+
# Synthetic product spec driving config: only parameter, mode, and spatial
187+
# filter affect a single source's unification (sources include/exclude only
188+
# selects which sources a product consumes — irrelevant here).
189+
synth_product = {
190+
"output_type": "ogc_summary" if spec.mode == "summary" else "ogc_timeseries",
191+
"spatial_filter": _scope_to_spatial_filter(spec.scope),
192+
}
125193

126194
@dg.asset(
127195
key=src_key,
128-
group_name=group,
196+
group_name=spec.group,
129197
check_specs=[dg.AssetCheckSpec(name=_CHECK_NAME, asset=src_key)],
130198
)
131199
def _source_asset(
@@ -136,24 +204,20 @@ def _source_asset(
136204
sites: list[dict] = []
137205
timeseries: list[list[dict]] = []
138206
try:
139-
# One unification pass per parameter. Single-parameter products run
140-
# once; the major-chemistry product runs once per analyte and
141-
# accumulates summary records (analyte identity lives in each
142-
# record's parameter_name). A source that doesn't provide a given
143-
# parameter is skipped by unify_source (source_pair → None).
207+
# A source that doesn't provide this parameter is skipped by
208+
# unify_source (source_pair → None).
144209
with forward_die_logs(context):
145-
for param in params:
146-
config = die_config.get_config(product, parameter=param)
147-
persister = unify_source(config, source_key)
148-
# Ship plain dicts across the IO manager; rebuild in combine.
149-
records.extend(r._payload for r in persister.records)
150-
sites.extend(s._payload for s in persister.sites)
151-
timeseries.extend(
152-
[o._payload for o in site_ts] for site_ts in persister.timeseries
153-
)
210+
config = die_config.get_config(synth_product, parameter=spec.parameter)
211+
persister = unify_source(config, spec.source_key)
212+
# Ship plain dicts across the IO manager; rebuild in combine.
213+
records.extend(r._payload for r in persister.records)
214+
sites.extend(s._payload for s in persister.sites)
215+
timeseries.extend(
216+
[o._payload for o in site_ts] for site_ts in persister.timeseries
217+
)
154218
except Exception:
155219
error = traceback.format_exc()
156-
context.log.error(f"Source {source_key} failed:\n{error}")
220+
context.log.error(f"Source {spec.source_key} failed:\n{error}")
157221

158222
obs_count = sum(len(t) for t in timeseries)
159223
payload = {"records": records, "sites": sites, "timeseries": timeseries}
@@ -164,7 +228,10 @@ def _source_asset(
164228
yield dg.Output(
165229
payload,
166230
metadata={
167-
"source": source_key,
231+
"source": spec.source_key,
232+
"parameter": spec.parameter,
233+
"mode": spec.mode,
234+
"scope": spec.scope,
168235
"record_count": len(records),
169236
"site_count": len(sites),
170237
"observation_count": obs_count,
@@ -183,27 +250,22 @@ def _source_asset(
183250
},
184251
)
185252

186-
return _source_asset, src_key
253+
return _source_asset
187254

188255

189256
def _build_combine_asset(
190-
product: dict,
191-
source_keys: list[str],
192-
source_asset_keys: list[dg.AssetKey],
193-
group: str,
257+
product: dict, specs: list[SourceSpec], group: str
194258
) -> dg.AssetsDefinition:
195259
"""Build the combine asset (keyed ``[product_id]``) for *product*.
196260
197-
Depends on every source asset (wired via ``ins``), merges their
198-
records/sites/timeseries, writes the OGC GeoJSON collection — summary,
199-
timeseries, major-chemistry, or waterlevel-trend depending on
200-
``output_type`` — and uploads it to GCS."""
261+
Depends on every shared source asset it needs (wired via ``ins``), merges
262+
their records/sites/timeseries, writes the OGC GeoJSON collection — summary,
263+
timeseries, major-chemistry, or trend depending on ``output_type`` — and
264+
uploads it to GCS. The source inputs are loaded from the GCS IO manager
265+
(materialized by the sources job), so the combine never re-unifies them."""
201266
pid = product["id"]
202267
output_type = product["output_type"]
203-
ins = {
204-
_in_name(k): dg.AssetIn(key=ak)
205-
for k, ak in zip(source_keys, source_asset_keys)
206-
}
268+
ins = {_in_name(spec): dg.AssetIn(key=shared_source_key(spec)) for spec in specs}
207269

208270
@dg.asset(key=dg.AssetKey(pid), group_name=group, ins=ins)
209271
def _combine_asset(
@@ -380,22 +442,15 @@ def _geoserver_asset(
380442
return _geoserver_asset
381443

382444

383-
def build_product_assets(product: dict) -> list[dg.AssetsDefinition]:
384-
"""Return the full asset list for *product*: one source asset per applicable
385-
source, the combine asset, and the geoserver publish asset (see module
386-
docstring for the graph shape). Assets are grouped ``waterlevels`` or
387-
``analytes`` by parameter (major-chemistry products group under
388-
``analytes``)."""
445+
def build_product_pipeline_assets(
446+
product: dict, specs: list[SourceSpec]
447+
) -> list[dg.AssetsDefinition]:
448+
"""Return the product's own assets — the combine asset and the geoserver
449+
publish asset. The shared source assets it consumes (``specs``) are built
450+
once by :func:`build_shared_source_asset` in ``definitions.py``, not here, so
451+
products sharing a source share one asset. The combine's group follows its
452+
parameter family (waterlevels vs analytes)."""
389453
group = "waterlevels" if product.get("parameter") == WATERLEVELS else "analytes"
390-
source_keys = _product_source_keys(product)
391-
392-
source_assets = []
393-
source_asset_keys = []
394-
for sk in source_keys:
395-
asset, key = _build_source_asset(product, sk, group)
396-
source_assets.append(asset)
397-
source_asset_keys.append(key)
398-
399-
combine = _build_combine_asset(product, source_keys, source_asset_keys, group)
454+
combine = _build_combine_asset(product, specs, group)
400455
geoserver = _build_geoserver_asset(product, group)
401-
return source_assets + [combine, geoserver]
456+
return [combine, geoserver]

0 commit comments

Comments
 (0)