@@ -22,10 +22,11 @@ class _TolerantGCSPickleIOManager(GCSPickleIOManager):
2222
2323 Combine assets load their shared source inputs via ``ins``, and that load
2424 happens *before* the asset body runs — so a missing pickle can't be caught
25- inside the combine. The source assets are materialized by ``sources_job``;
26- a product job loads their output from GCS without re-running them. On first
27- deploy — or if a newly added shared source has never been materialized —
28- the blob is absent and the stock manager would fail the combine hard.
25+ inside the combine. A cohort job materializes a combine's sources in the
26+ same run, so the blob is normally present; but an **ad-hoc** materialization
27+ of just a combine (e.g. re-publish from the Assets UI without re-running the
28+ upstream sources), or a brand-new shared source never yet materialized,
29+ leaves the blob absent and the stock manager would fail the combine hard.
2930
3031 Returning an empty payload instead degrades that product to an empty
3132 collection (the geoserver asset already soft-fails on 0 features) rather
@@ -104,82 +105,111 @@ def _build_graph(products_config: dict):
104105 return source_assets , pipeline_assets , specs_by_pid , all_specs
105106
106107
107- def _product_selection (pid : str ) -> dg .AssetSelection :
108- # Only the product's own assets — combine + geoserver. The shared source
109- # inputs are NOT selected: the sources job materializes them, and the
110- # combine loads them from the GCS IO manager. This is what keeps a product
111- # run from re-unifying a source another product already produced.
112- return dg .AssetSelection .keys (dg .AssetKey (pid ), dg .AssetKey ([pid , "geoserver" ]))
108+ def _cohort_key (specs ) -> tuple [str , str , str ]:
109+ """A cohort bundles the products that *can* share source assets — same
110+ parameter group, unification mode, and spatial scope. Materializing a cohort
111+ in one run is what lets each shared source unify once while every member's
112+ full lineage (sources → combine → geoserver) stays visible in that run.
113113
114+ These three fields are constant within a product (all of a product's specs
115+ carry the same mode/scope, and its parameters are all one group), so any
116+ spec is representative."""
117+ s = specs [0 ]
118+ return (s .group , s .mode , s .scope )
114119
115- def _build_product_jobs (
116- products_config : dict ,
117- ) -> dict [str , "UnresolvedAssetJobDefinition" ]:
118- """One asset job per product, selecting only that product's combine +
119- geoserver assets. Returns {product_id: job} so schedules can target it. The
120- shared source inputs are loaded from the GCS IO manager, not re-materialized
121- (see :func:`_product_selection`)."""
122- jobs = {}
120+
121+ def _cohort_name (key : tuple [str , str , str ]) -> str :
122+ group , mode , scope = key
123+ return f"{ group } _{ mode } _{ scope } "
124+
125+
126+ def _cron_sort_key (cron : str ) -> tuple [int , int ]:
127+ # "min hour * * *" -> (hour, minute) for picking a cohort's earliest member.
128+ parts = cron .split ()
129+ try :
130+ return (int (parts [1 ]), int (parts [0 ]))
131+ except (IndexError , ValueError ):
132+ return (6 , 0 )
133+
134+
135+ def _build_cohorts (products_config : dict , specs_by_pid : dict ) -> dict :
136+ """Group products into cohorts keyed by (group, mode, scope). Returns
137+ ``{cohort_name: {"members": [pid, ...], "cron": str}}``; the cohort cron is
138+ the earliest member schedule (members run together, so they share one)."""
139+ cohorts : dict = {}
123140 for product in _products (products_config ):
124141 pid = product ["id" ]
125- jobs [pid ] = dg .define_asset_job (
126- name = f"{ pid } _job" ,
127- selection = _product_selection (pid ),
128- description = f"Publish the { pid } data product (combine → geoserver) from materialized sources." ,
142+ specs = specs_by_pid [pid ]
143+ if not specs :
144+ continue
145+ name = _cohort_name (_cohort_key (specs ))
146+ cohort = cohorts .setdefault (name , {"members" : [], "cron" : None })
147+ cohort ["members" ].append (pid )
148+ cron = product .get ("schedule" , "0 6 * * *" )
149+ if cohort ["cron" ] is None or _cron_sort_key (cron ) < _cron_sort_key (cohort ["cron" ]):
150+ cohort ["cron" ] = cron
151+ return cohorts
152+
153+
154+ def _cohort_selection (members : list [str ], specs_by_pid : dict ) -> dg .AssetSelection :
155+ """The full graph for a cohort: every member's shared source assets (deduped
156+ across members — a shared source resolves to one key, so it is selected once
157+ and materialized once per run), plus every member's combine and geoserver
158+ asset. The result is a complete sources → combine → geoserver lineage with no
159+ duplicated source fetch."""
160+ keys : set = set ()
161+ for pid in members :
162+ for spec in specs_by_pid [pid ]:
163+ keys .add (shared_source_key (spec ))
164+ keys .add (dg .AssetKey (pid ))
165+ keys .add (dg .AssetKey ([pid , "geoserver" ]))
166+ return dg .AssetSelection .keys (* sorted (keys , key = lambda k : k .to_user_string ()))
167+
168+
169+ def _build_cohort_jobs (
170+ cohorts : dict , specs_by_pid : dict
171+ ) -> dict [str , "UnresolvedAssetJobDefinition" ]:
172+ """One job per cohort, selecting that cohort's full graph (see
173+ :func:`_cohort_selection`). Returns ``{cohort_name: job}``."""
174+ jobs = {}
175+ for name , cohort in cohorts .items ():
176+ members = cohort ["members" ]
177+ jobs [name ] = dg .define_asset_job (
178+ name = f"{ name } _job" ,
179+ selection = _cohort_selection (members , specs_by_pid ),
180+ description = (
181+ f"Materialize the { name } cohort in one run — shared sources "
182+ f"(each unified once) → combines → geoserver for: "
183+ f"{ ', ' .join (members )} ."
184+ ),
129185 )
130186 return jobs
131187
132188
133- def _build_sources_job (all_specs ) -> "UnresolvedAssetJobDefinition" :
134- """A single job that materializes every shared source asset once. Product
135- jobs read these results from the IO manager rather than re-unifying."""
136- keys = [shared_source_key (spec ) for spec in all_specs ]
137- return dg .define_asset_job (
138- name = "sources_job" ,
139- selection = dg .AssetSelection .keys (* keys ),
140- description = "Unify every shared source once; product jobs consume the cached results." ,
141- )
142-
143-
144189def _build_schedules (
145- products_config : dict ,
146- product_jobs : dict [str , "UnresolvedAssetJobDefinition" ],
147- sources_job : "UnresolvedAssetJobDefinition" ,
190+ cohorts : dict , cohort_jobs : dict [str , "UnresolvedAssetJobDefinition" ]
148191) -> list [dg .ScheduleDefinition ]:
149- # Sources run first (default 05:00), ahead of the product schedules (06:00+),
150- # so each product publishes from same-day source data. A product that runs
151- # before the sources job simply reads the prior run's cached source IO.
152- schedules = [
192+ return [
153193 dg .ScheduleDefinition (
154- name = "schedule_sources " ,
155- job = sources_job ,
156- cron_schedule = products_config . get ( "sources_schedule" , "0 5 * * *" ) ,
194+ name = f"schedule_ { name } " ,
195+ job = cohort_jobs [ name ] ,
196+ cron_schedule = cohort [ "cron" ] ,
157197 execution_timezone = "America/Denver" ,
158198 )
199+ for name , cohort in cohorts .items ()
159200 ]
160- for product in _products (products_config ):
161- pid = product ["id" ]
162- schedules .append (
163- dg .ScheduleDefinition (
164- name = f"schedule_{ pid } " ,
165- job = product_jobs [pid ],
166- cron_schedule = product .get ("schedule" , "0 6 * * *" ),
167- execution_timezone = "America/Denver" ,
168- )
169- )
170- return schedules
171201
172202
173203_products_config = _load_products ()
174204_source_assets , _pipeline_assets , _specs_by_pid , _all_specs = _build_graph (_products_config )
175205_assets = _source_assets + _pipeline_assets
176- _product_jobs = _build_product_jobs (_products_config )
177- _sources_job = _build_sources_job ( _all_specs )
178- _schedules = _build_schedules (_products_config , _product_jobs , _sources_job )
206+ _cohorts = _build_cohorts (_products_config , _specs_by_pid )
207+ _cohort_jobs = _build_cohort_jobs ( _cohorts , _specs_by_pid )
208+ _schedules = _build_schedules (_cohorts , _cohort_jobs )
179209
180210defs = dg .Definitions (
181211 assets = _assets ,
182- jobs = [ _sources_job , * _product_jobs .values ()] ,
212+ jobs = list ( _cohort_jobs .values ()) ,
183213 schedules = _schedules ,
184214 resources = {
185215 # USGS_API_KEY is a Dagster+ secret; EnvVar resolves it at run time and
@@ -193,11 +223,12 @@ def _build_schedules(
193223 ),
194224 "geoserver" : GeoServerResource (),
195225 # Persist asset I/O to GCS instead of the serverless run's ephemeral
196- # /tmp. This is what lets a product job load its shared source inputs
197- # (materialized by sources_job) without re-running them. The tolerant
198- # subclass returns an empty payload when a source's blob is absent, so a
199- # combine never hard-fails on a not-yet-materialized source (e.g. on a
200- # fresh deploy — run sources_job once before the product jobs).
226+ # /tmp. A cohort run materializes a shared source once and every member
227+ # combine reads it back through this manager (so the source unifies once
228+ # even though multiple combines consume it). The tolerant subclass
229+ # returns an empty payload when a source's blob is absent, so an ad-hoc
230+ # combine-only materialization (or a never-yet-run new source) degrades
231+ # to an empty collection instead of hard-failing.
201232 "io_manager" : _TolerantGCSPickleIOManager (
202233 gcs = AuthedGCSResource (),
203234 gcs_bucket = _products_config .get ("gcs_bucket" , "dataservices-die-products" ),
0 commit comments