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
1929Design 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"""
2838import tempfile
2939import traceback
40+ from collections import namedtuple
3041from collections .abc import Iterator
3142from datetime import datetime , timezone
3243from pathlib import Path
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.
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
7895def _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
189256def _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