Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions soda-core/src/soda_core/check_collections/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ def execute_check_collections(
constructed.append((impl, impl_class, None, yaml_source))
except Exception as exc:
if abort_on_first_error:
_report_runner_scan_failed_before_reraise(soda_cloud_impl, publish_results, logs, exc)
raise
constructed.append((None, impl_class, exc, yaml_source))

Expand Down Expand Up @@ -238,6 +239,7 @@ def execute_check_collections(
results.append(impl.verify())
except Exception as exc:
if abort_on_first_error:
_report_runner_scan_failed_before_reraise(soda_cloud_impl, publish_results, logs, exc)
raise
results.append(impl_class.build_error_result(yaml_source, exc))

Expand Down Expand Up @@ -398,6 +400,46 @@ def execute_check_collections(
return CheckCollectionSessionResult(results)


def _report_runner_scan_failed_before_reraise(
soda_cloud_impl: Optional[SodaCloud],
publish_results: bool,
logs: Optional[Logs],
exc: BaseException,
) -> None:
"""Best-effort: mark the runner's (still-PENDING) Cloud scan FAILED with the captured logs
just before an ``abort_on_first_error`` re-raise.

A single-contract (runner) scan aborts on the first construction/verify exception, which
re-raises out of ``execute_check_collections`` before phase 3's combined upload runs — the only
place that otherwise ships logs / marks the scan FAILED. Without this the exception propagates to
the CLI (exit 3) and the launcher only writes it to pod logs, so the Cloud scan record shows
FAILED with no logs and the failure is undiagnosable (SAS-13001). Reporting here keeps the
historical re-raise contract intact while making the failure visible in Cloud.

No-op when there is no cloud client, no publish opt-in, or no runner scan id (ad-hoc runs).
Never masks the original exception: any error while reporting is swallowed so the caller still
sees the real failure. The shared ``logs`` gatherer holds every construction/verify record (each
impl is built with ``logs=logs``), so its records are exactly what should reach Cloud.
"""
if soda_cloud_impl is None or not publish_results:
return
soda_scan_id: Optional[str] = EnvConfigHelper().soda_scan_id
if not soda_scan_id:
return
try:
log_records = logs.get_log_records() if logs is not None else None
soda_cloud_impl.mark_scan_as_failed(
scan_id=soda_scan_id,
logs=log_records,
exc=exc if isinstance(exc, Exception) else None,
)
except Exception:
# Warning, not debug: if this best-effort report fails the scan stays FAILED-with-no-logs in
# Cloud — the exact state this helper exists to avoid — so it must be visible in the pod logs
# / Datadog rather than silently swallowed. (The original exception is still re-raised.)
logger.warning("Could not report runner scan as failed before re-raising", exc_info=True)


def _parse_data_timestamp(value: Optional[Union[str, datetime]]) -> Optional[datetime]:
"""Coerce a ``data_timestamp`` value to ``Optional[datetime]``.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@

from unittest.mock import patch

import pytest
from helpers.mock_soda_cloud import MockSodaCloud
from soda_core.common.data_source_impl import DataSourceImpl
from soda_core.common.logging_constants import soda_logger
from soda_core.common.yaml import ContractYamlSource, DataSourceYamlSource
from soda_core.contracts.contract_verification import ContractVerificationSession
from soda_core.contracts.impl.contract_verification_impl import ContractImpl
Expand Down Expand Up @@ -145,3 +147,83 @@
"Ad-hoc run has no scan id to mark failed; mark_scan_as_failed must not be used. "
f"Requests seen: {request_types}"
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it seems there are two failure pathways here, construction and verification, but only verification is tested, did you think about testing construction as well?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — added test_uncaught_exception_during_construction_marks_scan_failed_with_logs in 6030689 (raises inside ContractImpl.__init__ so the exception escapes phase-1 construction rather than verify). Both abort points call the same helper, so this locks in that the construction pathway reports FAILED-with-logs too. 979 unit tests green.


def test_uncaught_exception_during_verify_marks_scan_failed_with_logs(monkeypatch):
"""A single-contract (runner) scan that raises an *uncaught* exception during verify aborts
and re-raises before phase 3's combined upload runs. The engine must still report the runner
scan as FAILED with the captured logs, otherwise the Cloud scan record shows no logs and the
failure is undiagnosable (SAS-13001)."""
monkeypatch.setenv("SODA_SCAN_ID", "scan-under-test")

def _boom(self, *args, **kwargs):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love the name 💥

soda_logger.error("Boom: could not build check collection")
raise RuntimeError("verify exploded")

monkeypatch.setattr(ContractImpl, "verify", _boom)

data_source_impl = DataSourceImpl.from_yaml_source(DataSourceYamlSource.from_str(_DATA_SOURCE_YAML))
mock_cloud = MockSodaCloud()
mock_cloud._upload_contract_yaml_file = lambda *args, **kwargs: "contract-file-id"

# The abort-on-first-error contract path re-raises the exception verbatim.
with pytest.raises(RuntimeError, match="verify exploded"):

Check warning on line 170 in soda-tests/tests/unit/test_contract_marks_scan_failed_on_connection_error.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this exception test to have only one invocation possibly throwing an exception.

See more on https://sonarcloud.io/project/issues?id=sodadata_soda-sql&issues=AZ8i2KY4XVMKMQHFBYFN&open=AZ8i2KY4XVMKMQHFBYFN&pullRequest=2779
ContractVerificationSession.execute(
contract_yaml_sources=[ContractYamlSource.from_str(_CONTRACT_YAML)],
data_source_impls=[data_source_impl],
soda_cloud_impl=mock_cloud,
soda_cloud_publish_results=True,
)

request_types = [r.json.get("type") for r in mock_cloud.requests if isinstance(r.json, dict)]
mark_requests = [
r.json
for r in mock_cloud.requests
if isinstance(r.json, dict) and r.json.get("type") == "sodaCoreMarkScanFailed"
]
assert mark_requests, (
"A scan that raised an uncaught exception during verify must be marked FAILED so the "
f"failure is visible in Cloud. Requests seen: {request_types}"
)
assert mark_requests[0].get("scanId") == "scan-under-test"
# The captured engine logs must be shipped, not an empty payload — that is the whole point.
assert mark_requests[0].get("logs"), "mark-scan-failed must carry the captured engine logs, not an empty payload"


def test_uncaught_exception_during_construction_marks_scan_failed_with_logs(monkeypatch):
"""The other abort pathway: an uncaught exception during contract *construction* (phase 1, before
verify) must likewise mark the runner scan FAILED with the captured logs before re-raising. Both
the construction and verify abort points call the same reporting helper (SAS-13001)."""
monkeypatch.setenv("SODA_SCAN_ID", "scan-under-test")

def _boom_init(self, *args, **kwargs):
soda_logger.error("Boom: could not construct contract")
raise RuntimeError("construction exploded")

# Fail inside ContractImpl.__init__ so the exception escapes phase-1 construction, not verify().
monkeypatch.setattr(ContractImpl, "__init__", _boom_init)

data_source_impl = DataSourceImpl.from_yaml_source(DataSourceYamlSource.from_str(_DATA_SOURCE_YAML))
mock_cloud = MockSodaCloud()
mock_cloud._upload_contract_yaml_file = lambda *args, **kwargs: "contract-file-id"

with pytest.raises(RuntimeError, match="construction exploded"):

Check warning on line 210 in soda-tests/tests/unit/test_contract_marks_scan_failed_on_connection_error.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this exception test to have only one invocation possibly throwing an exception.

See more on https://sonarcloud.io/project/issues?id=sodadata_soda-sql&issues=AZ83wMICQZzfwDg96aq4&open=AZ83wMICQZzfwDg96aq4&pullRequest=2779
ContractVerificationSession.execute(
contract_yaml_sources=[ContractYamlSource.from_str(_CONTRACT_YAML)],
data_source_impls=[data_source_impl],
soda_cloud_impl=mock_cloud,
soda_cloud_publish_results=True,
)

request_types = [r.json.get("type") for r in mock_cloud.requests if isinstance(r.json, dict)]
mark_requests = [
r.json
for r in mock_cloud.requests
if isinstance(r.json, dict) and r.json.get("type") == "sodaCoreMarkScanFailed"
]
assert mark_requests, (
"A scan that raised an uncaught exception during construction must be marked FAILED. "
f"Requests seen: {request_types}"
)
assert mark_requests[0].get("scanId") == "scan-under-test"
assert mark_requests[0].get("logs"), "mark-scan-failed must carry the captured engine logs, not an empty payload"
Loading