|
|
@@ -7,6 +7,7 @@ from types import SimpleNamespace
|
|
|
|
|
|
import polars as pl
|
|
|
import pytest
|
|
|
+from minio.error import S3Error
|
|
|
|
|
|
from app.core.common.identifiers import new_governance_uid
|
|
|
from tests.runner.test_artifacts import FakeMinio, _store
|
|
|
@@ -295,6 +296,7 @@ class ReconcileEngine:
|
|
|
def __init__(self, rows):
|
|
|
self.rows = [dict(row) for row in rows]
|
|
|
self.events = []
|
|
|
+ self.finalize_race_to_ready = False
|
|
|
|
|
|
def begin(self):
|
|
|
return _ReconcileConnection(self)
|
|
|
@@ -323,6 +325,19 @@ class _ReconcileConnection:
|
|
|
return _Result(
|
|
|
copy.deepcopy(self.engine.rows[: parameters["limit"]])
|
|
|
)
|
|
|
+ if (
|
|
|
+ "SELECT id::text AS id" in sql
|
|
|
+ and "WHERE id = CAST(:id AS uuid)" in sql
|
|
|
+ ):
|
|
|
+ row = next(
|
|
|
+ (
|
|
|
+ item
|
|
|
+ for item in self.engine.rows
|
|
|
+ if item["id"] == parameters["id"]
|
|
|
+ ),
|
|
|
+ None,
|
|
|
+ )
|
|
|
+ return _Result(copy.deepcopy(row))
|
|
|
if "SELECT artifact_ref" in sql and "handoff_status" not in sql:
|
|
|
return _Result(
|
|
|
[{"artifact_ref": row["artifact_ref"]} for row in self.engine.rows]
|
|
|
@@ -339,17 +354,25 @@ class _ReconcileConnection:
|
|
|
"UPDATE public.rule_run_artifacts" in sql
|
|
|
and "handoff_status = 'ready'" in sql
|
|
|
):
|
|
|
- if row is not None:
|
|
|
+ if self.engine.finalize_race_to_ready and row is not None:
|
|
|
row["handoff_status"] = "ready"
|
|
|
- return _Result(copy.deepcopy(row), rowcount=int(row is not None))
|
|
|
+ return _Result(None, rowcount=0)
|
|
|
+ if row is not None and row["handoff_status"] == "pending":
|
|
|
+ row["handoff_status"] = "ready"
|
|
|
+ return _Result(copy.deepcopy(row), rowcount=1)
|
|
|
+ return _Result(None, rowcount=0)
|
|
|
if (
|
|
|
"UPDATE public.rule_run_artifacts" in sql
|
|
|
and "handoff_status = 'failed'" in sql
|
|
|
):
|
|
|
- if row is not None:
|
|
|
+ if (
|
|
|
+ row is not None
|
|
|
+ and row["handoff_status"] == parameters["expected_status"]
|
|
|
+ ):
|
|
|
row["handoff_status"] = "failed"
|
|
|
row["failure_code"] = parameters["failure_code"]
|
|
|
- return _Result(rowcount=int(row is not None))
|
|
|
+ return _Result(rowcount=1)
|
|
|
+ return _Result(rowcount=0)
|
|
|
if "DELETE FROM public.rule_run_artifacts" in sql:
|
|
|
before = len(self.engine.rows)
|
|
|
self.engine.rows = [
|
|
|
@@ -473,3 +496,225 @@ def test_reconcile_repairs_both_catalog_and_object_store_safely():
|
|
|
and row["failure_code"] == "pending_object_invalid"
|
|
|
for row in engine.rows
|
|
|
)
|
|
|
+
|
|
|
+
|
|
|
+def test_reconcile_accepts_concurrent_matching_ready_without_downgrade():
|
|
|
+ from app.runner.artifacts import PostgresArtifactResolver
|
|
|
+
|
|
|
+ now = datetime(2026, 7, 23, 12, 0, tzinfo=UTC)
|
|
|
+ client = ReconcileMinio(now)
|
|
|
+ store = _store(client, clock=lambda: now)
|
|
|
+ artifact = store.write(
|
|
|
+ pl.DataFrame({"id": [1]}),
|
|
|
+ new_governance_uid(),
|
|
|
+ 600,
|
|
|
+ )
|
|
|
+ engine = ReconcileEngine([_catalog_row(artifact, status="pending")])
|
|
|
+ engine.finalize_race_to_ready = True
|
|
|
+
|
|
|
+ result = PostgresArtifactResolver(engine, store).reconcile(
|
|
|
+ limit=1,
|
|
|
+ grace_seconds=300,
|
|
|
+ )
|
|
|
+
|
|
|
+ assert result["pending_finalized"] == 1
|
|
|
+ assert engine.rows[0]["handoff_status"] == "ready"
|
|
|
+ assert engine.rows[0].get("failure_code") is None
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.parametrize(
|
|
|
+ ("status", "storage_error"),
|
|
|
+ [
|
|
|
+ ("pending", TimeoutError("MinIO timed out")),
|
|
|
+ ("ready", TimeoutError("MinIO timed out")),
|
|
|
+ ("pending", ConnectionError("MinIO network unavailable")),
|
|
|
+ ("ready", ConnectionError("MinIO network unavailable")),
|
|
|
+ (
|
|
|
+ "pending",
|
|
|
+ S3Error(
|
|
|
+ "AccessDenied",
|
|
|
+ "authentication unavailable",
|
|
|
+ None,
|
|
|
+ None,
|
|
|
+ None,
|
|
|
+ None,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ (
|
|
|
+ "ready",
|
|
|
+ S3Error(
|
|
|
+ "AccessDenied",
|
|
|
+ "authentication unavailable",
|
|
|
+ None,
|
|
|
+ None,
|
|
|
+ None,
|
|
|
+ None,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+)
|
|
|
+def test_reconcile_preserves_catalog_on_transient_storage_error(
|
|
|
+ status, storage_error
|
|
|
+):
|
|
|
+ from app.runner.artifacts import PostgresArtifactResolver
|
|
|
+
|
|
|
+ now = datetime(2026, 7, 23, 12, 0, tzinfo=UTC)
|
|
|
+ client = ReconcileMinio(now)
|
|
|
+ store = _store(client, clock=lambda: now)
|
|
|
+ artifact = store.write(
|
|
|
+ pl.DataFrame({"id": [1]}),
|
|
|
+ new_governance_uid(),
|
|
|
+ 600,
|
|
|
+ )
|
|
|
+ key = artifact["artifact_ref"].split("/", 3)[-1]
|
|
|
+ original_stat = client.stat_object
|
|
|
+
|
|
|
+ def fail_stat(bucket, object_key):
|
|
|
+ if object_key == key:
|
|
|
+ raise storage_error
|
|
|
+ return original_stat(bucket, object_key)
|
|
|
+
|
|
|
+ client.stat_object = fail_stat
|
|
|
+ engine = ReconcileEngine([_catalog_row(artifact, status=status)])
|
|
|
+
|
|
|
+ result = PostgresArtifactResolver(engine, store).reconcile(
|
|
|
+ limit=1,
|
|
|
+ grace_seconds=300,
|
|
|
+ )
|
|
|
+
|
|
|
+ assert result == {
|
|
|
+ "pending_finalized": 0,
|
|
|
+ "pending_deleted": 0,
|
|
|
+ "ready_failed": 0,
|
|
|
+ "orphans_deleted": 0,
|
|
|
+ }
|
|
|
+ assert engine.rows[0]["handoff_status"] == status
|
|
|
+ assert ("dataops-rules", key) in client.objects
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.parametrize("status", ["pending", "ready"])
|
|
|
+def test_reconcile_preserves_catalog_on_transient_download_error(status):
|
|
|
+ from app.runner.artifacts import PostgresArtifactResolver
|
|
|
+
|
|
|
+ now = datetime(2026, 7, 23, 12, 0, tzinfo=UTC)
|
|
|
+ client = ReconcileMinio(now)
|
|
|
+ store = _store(client, clock=lambda: now)
|
|
|
+ artifact = store.write(
|
|
|
+ pl.DataFrame({"id": [1]}),
|
|
|
+ new_governance_uid(),
|
|
|
+ 600,
|
|
|
+ )
|
|
|
+ key = artifact["artifact_ref"].split("/", 3)[-1]
|
|
|
+
|
|
|
+ def fail_download(_bucket, object_key):
|
|
|
+ assert object_key == key
|
|
|
+ raise TimeoutError("MinIO download timed out")
|
|
|
+
|
|
|
+ client.get_object = fail_download
|
|
|
+ engine = ReconcileEngine([_catalog_row(artifact, status=status)])
|
|
|
+
|
|
|
+ result = PostgresArtifactResolver(engine, store).reconcile(
|
|
|
+ limit=1,
|
|
|
+ grace_seconds=300,
|
|
|
+ )
|
|
|
+
|
|
|
+ assert result["pending_finalized"] == 0
|
|
|
+ assert result["ready_failed"] == 0
|
|
|
+ assert engine.rows[0]["handoff_status"] == status
|
|
|
+ assert ("dataops-rules", key) in client.objects
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.parametrize("status", ["pending", "ready"])
|
|
|
+def test_reconcile_handles_confirmed_not_found_during_stream_validation(status):
|
|
|
+ from app.runner.artifacts import PostgresArtifactResolver
|
|
|
+
|
|
|
+ now = datetime(2026, 7, 23, 12, 0, tzinfo=UTC)
|
|
|
+ client = ReconcileMinio(now)
|
|
|
+ store = _store(client, clock=lambda: now)
|
|
|
+ artifact = store.write(
|
|
|
+ pl.DataFrame({"id": [1]}),
|
|
|
+ new_governance_uid(),
|
|
|
+ 600,
|
|
|
+ )
|
|
|
+
|
|
|
+ def disappear_on_download(bucket, object_key):
|
|
|
+ client.objects.pop((bucket, object_key), None)
|
|
|
+ raise KeyError(object_key)
|
|
|
+
|
|
|
+ client.get_object = disappear_on_download
|
|
|
+ engine = ReconcileEngine([_catalog_row(artifact, status=status)])
|
|
|
+
|
|
|
+ result = PostgresArtifactResolver(engine, store).reconcile(
|
|
|
+ limit=1,
|
|
|
+ grace_seconds=300,
|
|
|
+ )
|
|
|
+
|
|
|
+ if status == "pending":
|
|
|
+ assert result["pending_deleted"] == 1
|
|
|
+ assert engine.rows == []
|
|
|
+ else:
|
|
|
+ assert result["ready_failed"] == 1
|
|
|
+ assert engine.rows[0]["handoff_status"] == "failed"
|
|
|
+ assert engine.rows[0]["failure_code"] == "ready_object_missing"
|
|
|
+
|
|
|
+
|
|
|
+def test_reconcile_streams_pending_content_before_finalize():
|
|
|
+ from app.runner.artifacts import PostgresArtifactResolver
|
|
|
+
|
|
|
+ now = datetime(2026, 7, 23, 12, 0, tzinfo=UTC)
|
|
|
+ client = ReconcileMinio(now)
|
|
|
+ store = _store(client, clock=lambda: now)
|
|
|
+ artifact = store.write(
|
|
|
+ pl.DataFrame({"id": [1, 2, 3]}),
|
|
|
+ new_governance_uid(),
|
|
|
+ 600,
|
|
|
+ )
|
|
|
+ key = artifact["artifact_ref"].split("/", 3)[-1]
|
|
|
+ stored = client.objects[("dataops-rules", key)]
|
|
|
+ payload = stored["payload"]
|
|
|
+ midpoint = len(payload) // 2
|
|
|
+ stored["payload"] = (
|
|
|
+ payload[:midpoint]
|
|
|
+ + bytes([payload[midpoint] ^ 1])
|
|
|
+ + payload[midpoint + 1 :]
|
|
|
+ )
|
|
|
+ client.get_calls.clear()
|
|
|
+ engine = ReconcileEngine([_catalog_row(artifact, status="pending")])
|
|
|
+
|
|
|
+ result = PostgresArtifactResolver(engine, store).reconcile(
|
|
|
+ limit=1,
|
|
|
+ grace_seconds=300,
|
|
|
+ )
|
|
|
+
|
|
|
+ assert result["pending_finalized"] == 0
|
|
|
+ assert engine.rows[0]["handoff_status"] == "failed"
|
|
|
+ assert engine.rows[0]["failure_code"] == "pending_object_invalid"
|
|
|
+ assert ("dataops-rules", key) not in client.objects
|
|
|
+ assert client.get_calls == [("dataops-rules", key)]
|
|
|
+
|
|
|
+
|
|
|
+def test_reconcile_marks_fresh_ready_snapshot_failed_for_invalid_content():
|
|
|
+ from app.runner.artifacts import PostgresArtifactResolver
|
|
|
+
|
|
|
+ now = datetime(2026, 7, 23, 12, 0, tzinfo=UTC)
|
|
|
+ client = ReconcileMinio(now)
|
|
|
+ store = _store(client, clock=lambda: now)
|
|
|
+ artifact = store.write(
|
|
|
+ pl.DataFrame({"id": [1, 2]}),
|
|
|
+ new_governance_uid(),
|
|
|
+ 600,
|
|
|
+ )
|
|
|
+ key = artifact["artifact_ref"].split("/", 3)[-1]
|
|
|
+ stored = client.objects[("dataops-rules", key)]
|
|
|
+ payload = stored["payload"]
|
|
|
+ stored["payload"] = bytes([payload[0] ^ 1]) + payload[1:]
|
|
|
+ engine = ReconcileEngine([_catalog_row(artifact, status="ready")])
|
|
|
+
|
|
|
+ result = PostgresArtifactResolver(engine, store).reconcile(
|
|
|
+ limit=1,
|
|
|
+ grace_seconds=300,
|
|
|
+ )
|
|
|
+
|
|
|
+ assert result["ready_failed"] == 1
|
|
|
+ assert engine.rows[0]["handoff_status"] == "failed"
|
|
|
+ assert engine.rows[0]["failure_code"] == "ready_object_invalid"
|