Skip to content

Commit 5b4e409

Browse files
brawerclaude
andauthored
fix(validate): handle a genuinely missing conflated.parquet cleanly (#766)
#722's own verification checklist asked to "confirm validate correctly fails on a deliberately-bad case (e.g. an artificially tiny --mem-limit that should trip the OOM check)" -- never actually done until now. Ran it for real: a 32m --mem-limit against v0.8.2 on Hetzner, which OOM-killed the container (confirmed twice in dmesg) before it ever got past import_atp, so no conflated.parquet was ever uploaded. That crashed `validate` outright: check_nonempty (and check_match_rate in the advisory checks) call read_parquet() unconditionally, and DuckDB raises duckdb.IOException (an HTTP 404 against the S3 test bucket) rather than returning an empty result for a URL with nothing at it -- an unhandled traceback instead of a clean failure report, even though the underlying script still exited non-zero either way. Fixes both: - check_nonempty now catches duckdb.IOException and reports a clean failed CheckResult instead of propagating the exception. - run_hard_checks uses that result to skip (not attempt) the five other hard checks that also read the same file -- each would fail with an identical, redundant "could not read" result for the exact same reason. The four log/dmesg-based checks after those don't depend on the parquet file at all, so they still run -- and are exactly what explains *why* there's no output (here: check_no_oom, which correctly reported the real kill). - check_match_rate (advisory) gets the same try/except, reporting "skipped" -- same semantic as its existing regional-extract skip right above it, not a failure (advisory checks never fail validate). Verified against the real broken run's downloaded logs: validate now prints a clean [FAIL]/[SKIP] report (non-empty output, run completed, cgroup signal, no OOM, and ATP geometry floor correctly FAIL; the five parquet-dependent checks correctly SKIP; match rate correctly SKIPs too) and exits 1, instead of crashing with a traceback. Verified: uv run pytest from scripts/ (81 passed, 3 new: missing-output cases for check_nonempty, run_hard_checks, and check_match_rate). Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 3cc4108 commit 5b4e409

2 files changed

Lines changed: 115 additions & 13 deletions

File tree

scripts/test-on-hetzner/tests/test_validate.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,19 @@ def test_check_nonempty(tmp_path, select_suffix, expected):
123123
assert v.check_nonempty(con, url).passed is expected
124124

125125

126+
def test_check_nonempty_reports_clean_failure_when_output_is_missing(tmp_path):
127+
# A run that crashed/was OOM-killed before conflate ever finished
128+
# never uploads conflated.parquet at all -- confirmed against a real
129+
# Hetzner run during #722's own verification (a deliberately tiny
130+
# --mem-limit). Without the try/except in check_nonempty, DuckDB's
131+
# duckdb.IOException would propagate straight out of this call as an
132+
# unhandled traceback instead of a reportable CheckResult.
133+
con = duckdb.connect()
134+
result = v.check_nonempty(con, str(tmp_path / "does-not-exist.parquet"))
135+
assert result.passed is False
136+
assert "could not read" in result.message
137+
138+
126139
@pytest.mark.parametrize(
127140
"sql,expected",
128141
[
@@ -323,6 +336,40 @@ def test_run_hard_checks_returns_all_checks_in_order(tmp_path, monkeypatch):
323336
assert all(r.passed for r in results)
324337

325338

339+
def test_run_hard_checks_skips_parquet_checks_when_output_is_missing(tmp_path):
340+
# Mirrors a real crashed/OOM-killed run: no conflated.parquet was
341+
# ever uploaded. The five checks that also read that file should be
342+
# skipped (not attempted and failed one by one for the same
343+
# underlying reason) -- but the log/dmesg-based checks don't need
344+
# the file at all, and should still run and report the real signal
345+
# (here: the OOM that's exactly why there's no output).
346+
con = duckdb.connect()
347+
url = str(tmp_path / "does-not-exist.parquet")
348+
records = [
349+
{"level": "INFO", "message": "conflate: start", "fields": {"step": "conflate", "phase": "start"}},
350+
]
351+
dmesg_text = "Memory cgroup out of memory: Killed process 2699 (osm-diffs)"
352+
results = v.run_hard_checks(
353+
con, url, records, dmesg_text=dmesg_text, mem_limit="4g", expect_pipeline_version=None, min_atp_features=None
354+
)
355+
by_name = {r.name: r for r in results}
356+
357+
assert by_name["non-empty output"].passed is False
358+
for name in [
359+
"schema",
360+
"atp/atp_geometry null-consistency",
361+
"osm/osm_geometry null-consistency",
362+
"osm_geometry validity",
363+
"provenance BOM",
364+
]:
365+
assert by_name[name].passed is None, f"{name} should be skipped, not attempted"
366+
367+
# Not parquet-dependent -- still actually run, and still report the
368+
# real failure (the OOM), not silently skipped alongside the rest.
369+
assert by_name["no OOM"].passed is False
370+
assert "killed process" in by_name["no OOM"].message.lower()
371+
372+
326373
# ── advisory checks ─────────────────────────────────────────────────
327374
#
328375
# Leaner than the hard-check tests above: one test per piece of actual
@@ -338,6 +385,13 @@ def test_check_match_rate_skips_for_regional_extract(tmp_path):
338385
assert "skipped" in result.message
339386

340387

388+
def test_check_match_rate_skips_when_output_is_missing(tmp_path):
389+
con = duckdb.connect()
390+
result = v.check_match_rate(con, str(tmp_path / "does-not-exist.parquet"), regional_extract=None)
391+
assert result.passed is None
392+
assert "skipped" in result.message
393+
394+
341395
def test_check_match_rate_computes_ratio(tmp_path):
342396
# One matched row (VALID_ROW_SQL) plus one ATP-only (unmatched) row,
343397
# built by nulling out osm/osm_geometry on a second copy of it.

scripts/test-on-hetzner/validate.py

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,21 @@ def list_element_type(duckdb_type):
143143

144144

145145
def check_nonempty(con, url):
146-
count = con.execute("SELECT count(*) FROM read_parquet(?)", [url]).fetchone()[0]
146+
# A run that crashed (or was OOM-killed -- see check_no_oom) before
147+
# conflate ever finished never uploads a conflated.parquet at all,
148+
# so `url` isn't just empty, it's unreadable -- confirmed the hard
149+
# way (#722's own verification checklist item, "confirm validate
150+
# correctly fails on a deliberately-bad case": an artificially tiny
151+
# --mem-limit produced exactly this). Without this try/except,
152+
# DuckDB raises duckdb.IOException (an HTTP 404 against the S3
153+
# test bucket) straight out of this function, and every other hard
154+
# check that also reads `url` would do the same -- an unhandled
155+
# traceback instead of a clean failure report, even though the
156+
# underlying `validate` script still exits non-zero either way.
157+
try:
158+
count = con.execute("SELECT count(*) FROM read_parquet(?)", [url]).fetchone()[0]
159+
except duckdb.IOException as e:
160+
return CheckResult("non-empty output", False, f"could not read {url}: {e}")
147161
return CheckResult("non-empty output", count > 0, f"{count} rows")
148162

149163

@@ -350,11 +364,22 @@ def check_match_rate(con, url, regional_extract):
350364
# AllThePlaces is always worldwide -- a regional OSM extract
351365
# shows ~0% match outside its region by design, not by defect.
352366
return CheckResult("match rate", None, "skipped (regional extract)")
353-
total, matched = con.execute(
354-
"SELECT count(*), count(*) FILTER (WHERE atp IS NOT NULL AND osm IS NOT NULL) "
355-
"FROM read_parquet(?)",
356-
[url],
357-
).fetchone()
367+
# Same missing-output case check_nonempty's own doc comment
368+
# explains (a crashed/OOM-killed run never uploads a
369+
# conflated.parquet at all) -- there's genuinely no match rate to
370+
# report here, same as the regional-extract case just above, so
371+
# this is a skip too, not a failure (advisory checks never fail
372+
# validate regardless -- see the module docstring -- but without
373+
# this, DuckDB's duckdb.IOException would still surface as an
374+
# unhandled traceback rather than a clean skip).
375+
try:
376+
total, matched = con.execute(
377+
"SELECT count(*), count(*) FILTER (WHERE atp IS NOT NULL AND osm IS NOT NULL) "
378+
"FROM read_parquet(?)",
379+
[url],
380+
).fetchone()
381+
except duckdb.IOException as e:
382+
return CheckResult("match rate", None, f"skipped: could not read {url}: {e}")
358383
rate = matched / total if total else 0.0
359384
return CheckResult("match rate", True, f"{matched}/{total} rows matched ({rate:.1%})")
360385

@@ -458,14 +483,37 @@ def run_hard_checks(con, url, records, dmesg_text, mem_limit, expect_pipeline_ve
458483
"""Runs every hard check and returns the list of `CheckResult`s, in
459484
the order printed. Doesn't short-circuit on the first failure --
460485
`cmd_validate` wants the full picture in one pass, not a
461-
fix-one-rerun-find-the-next loop."""
486+
fix-one-rerun-find-the-next loop.
487+
488+
One exception: if `check_nonempty` itself can't even read `url`
489+
(see its own doc comment), the five checks below it that also read
490+
that same file are skipped rather than attempted -- each would fail
491+
with an identical, redundant "could not read" result for the exact
492+
same reason. The four log/dmesg-based checks after those don't
493+
depend on the parquet file at all, so they still run -- and are
494+
exactly what can explain *why* there's no output (e.g. an
495+
OOM-kill, via check_no_oom)."""
496+
nonempty = check_nonempty(con, url)
497+
if not nonempty.passed:
498+
skip = 'skipped: no usable output (see "non-empty output" above)'
499+
parquet_checks = [
500+
CheckResult("schema", None, skip),
501+
CheckResult("atp/atp_geometry null-consistency", None, skip),
502+
CheckResult("osm/osm_geometry null-consistency", None, skip),
503+
CheckResult("osm_geometry validity", None, skip),
504+
CheckResult("provenance BOM", None, skip),
505+
]
506+
else:
507+
parquet_checks = [
508+
check_schema(con, url),
509+
check_null_consistency(con, url, "atp", "atp_geometry"),
510+
check_null_consistency(con, url, "osm", "osm_geometry"),
511+
check_geometry_validity(con, url),
512+
check_provenance_bom(con, url, expect_pipeline_version),
513+
]
462514
return [
463-
check_nonempty(con, url),
464-
check_schema(con, url),
465-
check_null_consistency(con, url, "atp", "atp_geometry"),
466-
check_null_consistency(con, url, "osm", "osm_geometry"),
467-
check_geometry_validity(con, url),
468-
check_provenance_bom(con, url, expect_pipeline_version),
515+
nonempty,
516+
*parquet_checks,
469517
check_run_completed(records),
470518
check_cgroup_signal(records, mem_limit),
471519
check_no_oom(dmesg_text),

0 commit comments

Comments
 (0)