Skip to content

Commit dc3efc7

Browse files
jirhikerclaude
andcommitted
Fix Parquet IO-manager crash on non-scalar payload keys
The Dagster PayloadParquetIOManager crashed serializing source outputs: pyarrow.lib.ArrowInvalid: Could not convert <SiteRecord ...> ... for column "location" A record's _payload can carry incidental non-scalar keys beyond its declared schema — the FROST connectors leave the raw {location: SiteRecord, thing, datastream, observation} in each observation's payload (ParameterTransformer does rec.update(record)). The old pickle IO manager tolerated these; Parquet can't. The combine only ever reads a record's declared keys anyway. - orchestration/assets/products.py: the source asset now ships _schema_dict(r) = {k: r._payload.get(k) for k in r.keys} for records/sites/timeseries — only the declared scalar fields, dropping the junk. - usgs/source.py: the USGS qualifier field is a list in the API; join it to a scalar string so it stays consistent with the other sources and serializes cleanly through both Parquet and the GeoJSON dump (it otherwise round-tripped as a numpy array). Reproduced and fixed locally end-to-end: ebid (SiteRecord junk) and nwis (qualifier list) both now round-trip records+sites+timeseries through the Parquet handoff. Full suite green (233 passed); products.py compiles. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e7414b1 commit dc3efc7

3 files changed

Lines changed: 25 additions & 9 deletions

File tree

backend/connectors/usgs/source.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,9 +236,11 @@ def _standardize_record(self, record: dict) -> dict:
236236
"datetime_measured": props["time"],
237237
"source_parameter_units": props["unit_of_measure"],
238238
# provisional vs approved, and provider qualifier flags — carried
239-
# through to the timeseries product instead of dropped
239+
# through to the timeseries product instead of dropped. qualifier is
240+
# a list in the API; join to a scalar string so it stays consistent
241+
# with the other sources and serializes cleanly (Parquet/GeoJSON).
240242
"approval_status": props.get("approval_status"),
241-
"qualifier": props.get("qualifier"),
243+
"qualifier": ", ".join(q) if isinstance((q := props.get("qualifier")), list) else q,
242244
}
243245

244246
def _extract_site_records(self, records, site_record):

orchestration/assets/products.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,13 @@
139139
SourceSpec = namedtuple("SourceSpec", "parameter scope source_key group")
140140

141141

142+
def _schema_dict(record) -> dict:
143+
"""A record's declared-schema fields only, as a plain scalar dict for the
144+
Parquet IO-manager handoff. Drops incidental non-scalar payload keys that
145+
would fail Arrow serialization (see the source asset)."""
146+
return {k: record._payload.get(k) for k in record.keys}
147+
148+
142149
def _product_params(product: dict) -> list[str]:
143150
"""The DIE parameter(s) a product unifies. Single-parameter products yield
144151
one; multi-analyte products yield their analyte list."""
@@ -298,11 +305,17 @@ def _source_asset(
298305
summary_persister, timeseries_persister = unify_source_both(
299306
config, spec.source_key
300307
)
301-
# Ship plain dicts across the IO manager; rebuild in combine.
302-
records.extend(r._payload for r in summary_persister.records)
303-
sites.extend(s._payload for s in timeseries_persister.sites)
308+
# Ship plain scalar dicts across the IO manager; rebuild in
309+
# combine. Emit only each record's declared schema keys — a
310+
# record's _payload can also carry incidental non-scalar junk
311+
# (e.g. the FROST connectors leave a SiteRecord in "location" and
312+
# nested thing/datastream/observation dicts), which the pickle IO
313+
# manager tolerated but the Parquet one cannot serialize. The
314+
# combine only ever reads the declared keys anyway.
315+
records.extend(_schema_dict(r) for r in summary_persister.records)
316+
sites.extend(_schema_dict(s) for s in timeseries_persister.sites)
304317
timeseries.extend(
305-
[o._payload for o in site_ts]
318+
[_schema_dict(o) for o in site_ts]
306319
for site_ts in timeseries_persister.timeseries
307320
)
308321
except Exception:

tests/test_usgs_metadata.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ def _raw_feature(approval="Provisional", qualifier=None):
1919

2020
def test_standardize_record_keeps_approval_and_qualifier():
2121
s = NWISWaterLevelSource()
22-
rec = s._standardize_record(_raw_feature("Approved", ["Static"]))
22+
# API qualifier is a list; joined to a scalar string
23+
rec = s._standardize_record(_raw_feature("Approved", ["Static", "Ice"]))
2324
assert rec["approval_status"] == "Approved"
24-
assert rec["qualifier"] == ["Static"]
25+
assert rec["qualifier"] == "Static, Ice"
2526

2627

2728
def test_standardize_record_missing_metadata_is_none():
@@ -46,7 +47,7 @@ def test_extract_parameter_record_populates_metadata():
4647
std = s._standardize_record(_raw_feature("Provisional", ["Ice"]))
4748
out = s._extract_parameter_record(std)
4849
assert out["approval_status"] == "Provisional"
49-
assert out["qualifier"] == ["Ice"]
50+
assert out["qualifier"] == "Ice"
5051

5152

5253
def test_parameter_record_schema_includes_metadata_keys():

0 commit comments

Comments
 (0)