Skip to content

Commit 7befdff

Browse files
jirhikerclaude
andcommitted
Group products into cohort jobs for full lineage + source dedup
The sources_job + publish-only product jobs preserved dedup but hid each product's source lineage (the product job showed only combine → geoserver). Replace that with one job per cohort, keyed (group, mode, scope): the products that can share source assets. A cohort job materializes its whole graph in one run — each shared source unifies once (one asset key, selected once), every member combine reads it back through the GCS IO manager, then geoserver publishes. Full sources → combine → geoserver lineage per run, no duplicated source fetch. 5 cohorts (waterlevels/analytes × summary/timeseries × scope); the 87 source assets partition across them so no source is materialized by two jobs. Cohort cron = earliest member schedule. The tolerant IO manager now only matters for ad-hoc combine-only materializations from the UI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent dca35b0 commit 7befdff

2 files changed

Lines changed: 103 additions & 68 deletions

File tree

orchestration/assets/products.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,15 @@
2020
- **geoserver asset** — keyed ``[product_id, "geoserver"]``. Downloads the
2121
combined GeoJSON, converts to GeoPackage, publishes it as a GeoServer layer.
2222
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.
23+
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.)
2832
2933
Design notes:
3034
- Source and geoserver assets never hard-fail. They catch their own errors and

orchestration/definitions.py

Lines changed: 94 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -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-
144189
def _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

180210
defs = 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

Comments
 (0)