Skip to content

Commit 6d665b1

Browse files
shreyastelkarclaude
andcommitted
fix: [AI-8671] accept dbt Fusion's capitalized freshness statuses in sources v1-v3
The dbt Fusion engine serializes source-freshness `status` with its Rust variant names -- `"Pass"` / `"Warn"` / `"Error"` -- while dbt-core and the published `sources/v3.json` schema Fusion itself stamps into the artifact both use the lowercase forms. Every result row therefore failed both members of the `results` union, the whole `sources.json` raised a `ValidationError`, and the ingestion worker silently dropped it. Nine of harvestgroup's ten production environments have zero source-freshness rows as a result. This is the residual of the AI-7675 work: PR #106/#108 added the `_missing_` forward-compat shim to the `run_results` status enums but explicitly left the freshness enums, and the `sources` parsers entirely, untouched. - Make `Status1` a `str, Enum` whose `_missing_` case-folds to the canonical lowercase member first, then falls back to the same forward-compat pseudo-member used by the `run_results` shim for unknown statuses. - Case-folding rather than adding PascalCase members is deliberate: the extractor persists `status.value`, and every dbt-core-backed tenant already writes lowercase into the same table. - Leave the runtime-error-only `Status` enum strict. Fusion has no runtime-error variant, and loosening it would let a row missing a required field fall silently into the field-less branch instead of erroring. - Apply to v1-v3 rather than v3 alone, mirroring how #108 had to follow #106 across the older schemas. Verified against 432 real production artifacts spanning all nine Fusion environments and 13 Fusion builds (preview.190 -> .210), run through parse AND the worker's own `extract_sources`: 432/432 parsed, 10,893 freshness rows extracted, every capitalized input landing as its lowercase counterpart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 10f67a9 commit 6d665b1

4 files changed

Lines changed: 193 additions & 3 deletions

File tree

src/vendor/dbt_artifacts_parser/parsers/sources/sources_v1.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,33 @@ class SourceFreshnessRuntimeError(BaseParserModel):
3838
status: Status
3939

4040

41-
class Status1(Enum):
41+
class Status1(str, Enum):
4242
pass_ = "pass"
4343
warn = "warn"
4444
error = "error"
4545
runtime_error = "runtime error"
4646

47+
@classmethod
48+
def _missing_(cls, value):
49+
# The dbt Fusion engine serializes freshness statuses with its Rust
50+
# variant names -- "Pass" / "Warn" / "Error" -- while the published
51+
# sources schema it stamps into the artifact, and every dbt-core
52+
# release, use the lowercase forms. Fold case first so a Fusion
53+
# artifact resolves to the canonical lowercase member and `.value`
54+
# stays stable for downstream storage.
55+
if isinstance(value, str):
56+
folded = value.casefold()
57+
for member in cls:
58+
if member.value.casefold() == folded:
59+
return member
60+
# Forward-compatibility: surface any other unknown status as a real
61+
# member so downstream `.value` access keeps working instead of failing
62+
# validation and silently dropping the entire sources.json.
63+
member = str.__new__(cls, value)
64+
member._name_ = str(value)
65+
member._value_ = value
66+
return member
67+
4768

4869
class Period(Enum):
4970
minute = "minute"

src/vendor/dbt_artifacts_parser/parsers/sources/sources_v2.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,33 @@ class SourceFreshnessRuntimeError(BaseParserModel):
3838
status: Status
3939

4040

41-
class Status1(Enum):
41+
class Status1(str, Enum):
4242
pass_ = "pass"
4343
warn = "warn"
4444
error = "error"
4545
runtime_error = "runtime error"
4646

47+
@classmethod
48+
def _missing_(cls, value):
49+
# The dbt Fusion engine serializes freshness statuses with its Rust
50+
# variant names -- "Pass" / "Warn" / "Error" -- while the published
51+
# sources schema it stamps into the artifact, and every dbt-core
52+
# release, use the lowercase forms. Fold case first so a Fusion
53+
# artifact resolves to the canonical lowercase member and `.value`
54+
# stays stable for downstream storage.
55+
if isinstance(value, str):
56+
folded = value.casefold()
57+
for member in cls:
58+
if member.value.casefold() == folded:
59+
return member
60+
# Forward-compatibility: surface any other unknown status as a real
61+
# member so downstream `.value` access keeps working instead of failing
62+
# validation and silently dropping the entire sources.json.
63+
member = str.__new__(cls, value)
64+
member._name_ = str(value)
65+
member._value_ = value
66+
return member
67+
4768

4869
class Period(Enum):
4970
minute = "minute"

src/vendor/dbt_artifacts_parser/parsers/sources/sources_v3.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,33 @@ class Results(BaseParserModel):
3838
status: Status
3939

4040

41-
class Status1(Enum):
41+
class Status1(str, Enum):
4242
pass_ = "pass"
4343
warn = "warn"
4444
error = "error"
4545
runtime_error = "runtime error"
4646

47+
@classmethod
48+
def _missing_(cls, value):
49+
# The dbt Fusion engine serializes freshness statuses with its Rust
50+
# variant names -- "Pass" / "Warn" / "Error" -- while the published
51+
# sources schema it stamps into the artifact, and every dbt-core
52+
# release, use the lowercase forms. Fold case first so a Fusion
53+
# artifact resolves to the canonical lowercase member and `.value`
54+
# stays stable for downstream storage.
55+
if isinstance(value, str):
56+
folded = value.casefold()
57+
for member in cls:
58+
if member.value.casefold() == folded:
59+
return member
60+
# Forward-compatibility: surface any other unknown status as a real
61+
# member so downstream `.value` access keeps working instead of failing
62+
# validation and silently dropping the entire sources.json.
63+
member = str.__new__(cls, value)
64+
member._name_ = str(value)
65+
member._value_ = value
66+
return member
67+
4768

4869
class Period(Enum):
4970
minute = "minute"
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""Tests for the sources v1-v3 parsers, specifically the resilient freshness `Status1` enum.
2+
3+
Regression coverage for the dbt Fusion engine emitting capitalized freshness
4+
statuses (``"Pass"`` / ``"Warn"`` / ``"Error"``) in sources.json. Every result row
5+
failed both members of the ``results`` union, so the ENTIRE sources.json raised a
6+
``ValidationError`` and was silently dropped during ingestion -- no source freshness
7+
ever reached Postgres for a Fusion-backed environment.
8+
9+
The statuses must fold to their canonical lowercase members, because the extractor
10+
persists ``result.status.value`` and the rest of the platform (including every
11+
dbt-core-backed tenant already in the same table) uses the lowercase vocabulary.
12+
"""
13+
import pytest
14+
15+
from vendor.dbt_artifacts_parser.parser import parse_sources
16+
from vendor.dbt_artifacts_parser.parsers.sources.sources_v1 import SourceFreshnessOutput as OutputV1
17+
from vendor.dbt_artifacts_parser.parsers.sources.sources_v1 import Status1 as StatusV1
18+
from vendor.dbt_artifacts_parser.parsers.sources.sources_v2 import SourceFreshnessOutput as OutputV2
19+
from vendor.dbt_artifacts_parser.parsers.sources.sources_v2 import Status1 as StatusV2
20+
from vendor.dbt_artifacts_parser.parsers.sources.sources_v3 import Results as RuntimeErrorV3
21+
from vendor.dbt_artifacts_parser.parsers.sources.sources_v3 import Results1 as OutputV3
22+
from vendor.dbt_artifacts_parser.parsers.sources.sources_v3 import Status1 as StatusV3
23+
24+
V3_SCHEMA = "https://schemas.getdbt.com/dbt/sources/v3.json"
25+
26+
# (output model, status enum) per schema version -- the enum is identical in all three.
27+
VERSIONS = [
28+
pytest.param(OutputV1, StatusV1, id="v1"),
29+
pytest.param(OutputV2, StatusV2, id="v2"),
30+
pytest.param(OutputV3, StatusV3, id="v3"),
31+
]
32+
33+
# Fusion's Rust variant name -> the canonical lowercase status dbt-core emits.
34+
FUSION_CASINGS = [("Pass", "pass"), ("Warn", "warn"), ("Error", "error")]
35+
36+
37+
def _output(status: str, unique_id: str = "source.proj.schema.tbl") -> dict:
38+
"""A complete freshness result -- the shape Fusion always emits."""
39+
return {
40+
"unique_id": unique_id,
41+
"max_loaded_at": "2026-08-19T08:33:50.855920Z",
42+
"snapshotted_at": "2026-08-19T17:00:33.833000Z",
43+
"max_loaded_at_time_ago_in_s": 30402.0,
44+
"status": status,
45+
"criteria": {
46+
"warn_after": {"count": 24, "period": "hour"},
47+
"error_after": {"count": 48, "period": "hour"},
48+
},
49+
"adapter_response": {},
50+
"timing": [],
51+
"thread_id": "Thread-20",
52+
"execution_time": 0.0,
53+
}
54+
55+
56+
def _sources_v3(*statuses: str) -> dict:
57+
return {
58+
"metadata": {
59+
"dbt_schema_version": V3_SCHEMA,
60+
"dbt_version": "2.0.0-preview.210",
61+
"invocation_id": "test-invocation-123",
62+
},
63+
"elapsed_time": 1.5,
64+
"results": [_output(s, f"source.proj.sch.t{i}") for i, s in enumerate(statuses)],
65+
}
66+
67+
68+
class TestFusionStatusCasing:
69+
"""Fusion's capitalized statuses must parse AND normalize to lowercase."""
70+
71+
@pytest.mark.parametrize(("model", "status_enum"), VERSIONS)
72+
@pytest.mark.parametrize(("fusion", "canonical"), FUSION_CASINGS)
73+
def test_capitalized_status_folds_to_canonical_member(self, model, status_enum, fusion, canonical):
74+
result = model(**_output(fusion))
75+
assert result.status is status_enum(canonical)
76+
assert result.status.value == canonical
77+
78+
@pytest.mark.parametrize(("model", "status_enum"), VERSIONS)
79+
def test_lowercase_statuses_unchanged(self, model, status_enum):
80+
"""dbt-core's existing lowercase vocabulary must keep resolving as before."""
81+
for status in ("pass", "warn", "error", "runtime error"):
82+
assert model(**_output(status)).status.value == status
83+
84+
@pytest.mark.parametrize(("model", "status_enum"), VERSIONS)
85+
def test_unknown_future_status_parses(self, model, status_enum):
86+
"""Forward-compat: a status dbt has not shipped yet must not drop the file."""
87+
assert model(**_output("some_future_status")).status.value == "some_future_status"
88+
89+
90+
class TestUnionResolutionIsNotLossy:
91+
"""A complete freshness row must NEVER resolve to the runtime-error branch.
92+
93+
``SourcesV3.results`` is ``list[Union[Results, Results1]]`` and ``Results`` (the
94+
runtime-error shape) requires only ``unique_id`` + ``status`` with ``extra="allow"``.
95+
If a full row resolved there, every freshness field would be dropped -- turning a
96+
loud parse failure into silent data loss, which is strictly worse.
97+
"""
98+
99+
@pytest.mark.parametrize(("fusion", "canonical"), FUSION_CASINGS)
100+
def test_full_row_resolves_to_output_branch(self, fusion, canonical):
101+
parsed = parse_sources(_sources_v3(fusion))
102+
(result,) = parsed.results
103+
assert isinstance(result, OutputV3)
104+
assert result.status.value == canonical
105+
assert result.max_loaded_at == "2026-08-19T08:33:50.855920Z"
106+
assert result.criteria.error_after.count == 48
107+
108+
def test_runtime_error_row_still_resolves_to_runtime_error_branch(self):
109+
"""dbt-core emits a distinct, field-less shape for a failed freshness check."""
110+
artifact = _sources_v3()
111+
artifact["results"] = [
112+
{
113+
"unique_id": "source.proj.sch.broken",
114+
"error": "Database Error: permission denied",
115+
"status": "runtime error",
116+
}
117+
]
118+
(result,) = parse_sources(artifact).results
119+
assert isinstance(result, RuntimeErrorV3)
120+
assert result.status.value == "runtime error"
121+
122+
123+
class TestFullArtifactParse:
124+
def test_fusion_artifact_parses_end_to_end(self):
125+
"""The whole-file failure this regression is about: mixed Fusion statuses."""
126+
parsed = parse_sources(_sources_v3("Pass", "Error", "Warn", "Pass"))
127+
assert [r.status.value for r in parsed.results] == ["pass", "error", "warn", "pass"]

0 commit comments

Comments
 (0)