Automated fix for collections-collectiq-sync-v2 — fix_class code_fix, scope tier draft.
Resolves https://github.com/AI-Builder-Team/Surtr/issues/1579
> Ready for review — verification is green; HEIMDALL_READY_PRS opens verified tier-draft fixes ready for review. A human still merges — auto-merge never applies outside tier auto.
## What's broken
Run 25df35a6-04e7-4321-86d3-bb2be692d349 of collections-collectiq-sync-v2 failed with StructuralError: Unparseable money value: '#VALUE!' raised at pipelines/runners/collections-collectiq-sync-v2/src/parsers.py:97 (parse_money), reached via parse_collectiq at parsers.py:211 while reading the x_forecast cell of the CollectIQ tab. The CloudWatch tail shows the identical '#VALUE!' on all 8 read+parse attempts across ~120s of backoff (08:15:22 → 08:16:53), so this is a persistent Google Sheets formula-error literal in the source tab, not the transient recalculation blip the retry loop assumes; the run wrote zero rows because parse_collectiq raises before RedshiftLoader.full_replace runs, leaving staging_finance_gsheets.collections_collectiq_snapshot intact but stale. This is the 2nd occurrence of this exact signature, and prior PRs #1334 and #1376 already added and then widened the #VALUE! retry budget (3→8 attempts), which cannot help a value that stays broken across the entire window.
Root cause. A cell feeding one BU's money column on the source CollectIQ tab (Google Sheet 1C2BI7sWAjPoXPxN6fF7ZwSt64JrvGotuIdM0YB8LTDA) contains a persistent #VALUE! spreadsheet formula error, and parse_money at parsers.py:85-97 correctly refuses to coerce a present-but-non-numeric value to NULL, raising StructuralError to fail loud. The broken formula lives in the source sheet and no in-repo change makes those numbers parseable; however, the raised error is unactionable because parse_money carries only the offending value ('#VALUE!') and parse_collectiq at parsers.py:200-214 does NOT add the surrounding context, even though errors.py:6-8 explicitly states the caller that knows the sheet/tab/BU is responsible for adding it. As a result nobody can tell WHICH BU column or WHICH metric row (Collections Forecast / QTD / This Week) holds the broken cell, which is why this pipeline has accumulated open, unresolved other triage issues (#1329, #883, #1110).
## What this PR changes
Do NOT widen the retry budget again — PR #1376 already proved that path futile for a persistent #VALUE!, and burning 122s of Lambda time on a value that never clears only delays the same failure. Instead make the failure pinpoint the source cell: in parse_collectiq (pipelines/runners/collections-collectiq-sync-v2/src/parsers.py, the loop at lines 200-214), wrap each parse_money call so a StructuralError names the exact BU column (header[col_idx]) and metric row (Collections Forecast / QTD Collections / This Week's Collections) and re-raises with that context prepended, honoring the contract errors.py:6-8 already documents. Add a test in pipelines/runners/collections-collectiq-sync-v2/tests/test_parsers.py asserting that a #VALUE! in a given money column raises a StructuralError whose message contains both the BU name and the metric label, so triage issues become instantly actionable and the sheet owner can fix the one broken cell in seconds.
Why this fixes it. The change is confined to the pipeline's own directory (Tier A: src/parsers.py plus a test under tests/), matches the code_fix catalogue entry for missing structured failure reporting, and preserves the pipeline's fail-loud, never-load-wrong-data behavior — it does not paper over the failure, it makes the recurring failure diagnosable. It is the right blast radius because the defect this repo CAN fix is the missing context on the raised error (errors.py:6-8 assigns that responsibility to parse_collectiq, which currently neglects it), not the source spreadsheet and not the retry loop that two prior PRs already exhausted. Naming the BU and metric turns an opaque Unparseable money value: '#VALUE!' into a one-line pointer to the exact source cell, which is precisely what the unresolved other issues for this pipeline have lacked.
### Files changed
.../collections-collectiq-sync-v2/src/parsers.py | 27 +++++++++++++++--.../tests/test_parsers.py | 34 ++++++++++++++++++++++
2 files changed, 58 insertions(+), 3 deletions(-)
## Verification
### pytest (pipelines/runners/collections-collectiq-sync-v2/tests) — exit 0
``
ests/test_handler.py::TestTransientValueErrorRetry::test_persistent_value_error_still_fails_after_retries PASSED [ 43%]
tests/test_handler.py::TestTransientValueErrorRetry::test_recovers_after_five_transient_reads PASSED [ 45%]
tests/test_handler.py::TestReadWithRetry::test_succeeds_first_try PASSED [ 47%]
tests/test_handler.py::TestReadWithRetry::test_retries_transient_then_succeeds PASSED [ 49%]
tests/test_handler.py::TestReadWithRetry::test_nonretryable_raises_immediately PASSED [ 50%]
tests/test_handler.py::TestReadWithRetry::test_gives_up_after_max_retries PASSED [ 52%]
tests/test_handler.py::TestReadWithRetry::test_retries_429_rate_limit PASSED [ 54%]
tests/test_handler.py::TestReadWithRetry::test_status_message_fallback_when_response_has_no_int_status PASSED [ 56%]
tests/test_handler.py::TestReadWithRetry::test_nonpositive_max_retries_raises PASSED [ 58%]
tests/test_parsers.py::TestParseMoney::test_plain PASSED [ 60%]
tests/test_parsers.py::TestParseMoney::test_parentheses_negative PASSED [ 61%]
tests/test_parsers.py::TestParseMoney::test_accounting_dash_is_zero PASSED [ 63%]
tests/test_parsers.py::TestParseMoney::test_blank_is_none PASSED [ 65%]
tests/test_parsers.py::TestParseMoney::test_two_decimal_rounding PASSED [ 67%]
tests/test_parsers.py::TestParseMoney::test_unparseable_nonblank_raises PASSED [ 69%]
tests/test_parsers.py::TestParseDate::test_day_mon_year PASSED [ 70%]
tests/test_parsers.py::TestParseDate::test_blank_is_none PASSED [ 72%]
tests/test_parsers.py::TestParseDate::test_unparseable_nonblank_raises PASSED [ 74%]
tests/test_parsers.py::TestFindColumn::test_exact_and_prefix PASSED [ 76%]
tests/test_parsers.py::TestMakeHeadersUnique::test_dedup PASSED [ 78%]
tests/test_parsers.py::TestParseCollectIQ::test_columns_constant PASSED [ 80%]
tests/test_parsers.py::TestParseCollectIQ::test_all_bus_emitted_cloudfix_skipped PASSED [ 81%]
tests/test_parsers.py::TestParseCollectIQ::test_vanished_canonical_bu_column_raises PASSED [ 83%]
tests/test_parsers.py::TestParseCollectIQ::test_quarter_suffix_mismatch_raises PASSED [ 85%]
tests/test_parsers.py::TestParseCollectIQ::test_missing_bu_header_raises PASSED [ 87%]
tests/test_parsers.py::TestParseCollectIQ::test_missing_metric_row_raises PASSED [ 89%]
tests/test_parsers.py::TestParseCollectIQ::test_unparseable_money_cell_names_bu_and_metric PASSED [ 90%]
tests/ …_(truncated)_
<details>
<summary>Run metadata</summary>
| Field | Value |
| --- | --- |
| Pipeline | collections-collectiq-sync-v2 |
| Failing run | 25df35a6-04e7-4321-86d3-bb2be692d349 |
| Occurrence | 2 (times this exact failure signature has been seen) |
| Signature | 0f1de19fa283672d036ee45e6ef1f1569c12bcb70166101210f68535d7364a4b |
| Verify | green |
</details>
---
🤖 Opened by heimdall. mercy reviews this PR automatically; heimdall revises on REQUEST_CHANGES (bounded rounds). Tier-auto PRs may auto-merge on mercy approval when the consumer enables it; everything else waits for a human. Mention heimdall in a comment to direct it, or add the manual-dev` label to take the PR over and stop it entirely.