| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728 |
- from __future__ import annotations
- import copy
- import json
- from datetime import UTC, datetime, timedelta
- 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
- class _Result:
- def __init__(self, row=None, *, rowcount=0):
- self.row = row
- self.rowcount = rowcount
- def mappings(self):
- return self
- def one_or_none(self):
- return self.row
- def all(self):
- if self.row is None:
- return []
- return self.row if isinstance(self.row, list) else [self.row]
- class _HandoffConnection:
- def __init__(self, engine, *, transactional):
- self.engine = engine
- self.transactional = transactional
- self.backup = None
- self.finalized = False
- def __enter__(self):
- self.backup = copy.deepcopy(self.engine.row)
- return self
- def __exit__(self, exc_type, *_args):
- if exc_type is not None:
- self.engine.row = self.backup
- return False
- if self.finalized and self.engine.finalize_mode:
- if self.engine.finalize_mode == "rollback_unknown":
- self.engine.row = self.backup
- self.engine.fail_recheck = True
- raise RuntimeError("database commit acknowledgement lost")
- return False
- def execute(self, statement, parameters):
- sql = str(statement)
- self.engine.events.append(sql)
- if (
- "FROM public.dataflow_dataset_bindings" in sql
- and "JOIN" not in sql
- ):
- if self.engine.fail_reserve:
- raise RuntimeError("database unavailable")
- return _Result(copy.deepcopy(self.engine.binding))
- if "INSERT INTO public.rule_run_artifacts" in sql:
- if self.engine.row is not None:
- return _Result(None)
- self.engine.row = {
- "id": parameters["id"],
- "correlation_id": parameters["correlation_id"],
- "binding_id": parameters["binding_id"],
- "artifact_ref": parameters["artifact_ref"],
- "artifact_digest": parameters["artifact_digest"],
- "row_count": parameters["row_count"],
- "schema_hash": parameters["schema_hash"],
- "schema_fields": json.loads(parameters["schema_fields"]),
- "artifact_kind": parameters["artifact_kind"],
- "binding_hash": parameters["binding_hash"],
- "expires_at": parameters["expires_at"],
- "handoff_status": "pending",
- }
- return _Result(copy.deepcopy(self.engine.row), rowcount=1)
- if (
- "UPDATE public.rule_run_artifacts" in sql
- and "handoff_status = 'ready'" in sql
- ):
- if self.engine.row is not None:
- self.engine.row["handoff_status"] = "ready"
- self.finalized = True
- return _Result(copy.deepcopy(self.engine.row), rowcount=1)
- return _Result(None)
- if (
- "DELETE FROM public.rule_run_artifacts" in sql
- and "handoff_status = 'pending'" in sql
- ):
- deleted = self.engine.row is not None
- self.engine.row = None
- return _Result(rowcount=int(deleted))
- if "FROM public.rule_run_artifacts" in sql:
- if self.engine.fail_recheck:
- raise RuntimeError("database recheck unavailable")
- row = copy.deepcopy(self.engine.row)
- if "JOIN public.dataflow_dataset_bindings" in sql:
- if (
- row is None
- or row["handoff_status"] != "ready"
- or row["binding_hash"] != self.engine.binding[
- "binding_hash"
- ]
- ):
- row = None
- elif row is not None:
- row["current_binding_hash"] = self.engine.binding[
- "binding_hash"
- ]
- return _Result(row)
- raise AssertionError(sql)
- class HandoffEngine:
- def __init__(self, binding):
- self.binding = dict(binding)
- self.row = None
- self.events = []
- self.fail_reserve = False
- self.fail_recheck = False
- self.finalize_mode = None
- def begin(self):
- return _HandoffConnection(self, transactional=True)
- def connect(self):
- return _HandoffConnection(self, transactional=False)
- class EventMinio(FakeMinio):
- def __init__(self, events):
- super().__init__()
- self.events = events
- self.fail_upload = False
- def put_object(self, *args, **kwargs):
- self.events.append("MINIO PUT")
- if self.fail_upload:
- raise RuntimeError("object upload failed")
- return super().put_object(*args, **kwargs)
- def _binding(binding_hash):
- return {
- "binding_hash": binding_hash,
- "access_mode": "read_write",
- "object_kind": "parquet_artifact",
- }
- def _publish_fixture(tmp_path, *, engine=None, client=None):
- from app.runner.artifacts import PostgresArtifactResolver
- binding_id = new_governance_uid()
- binding_hash = "b" * 64
- engine = engine or HandoffEngine(_binding(binding_hash))
- client = client or EventMinio(engine.events)
- store = _store(client)
- path = tmp_path / "handoff.parquet"
- pl.DataFrame({"id": [1]}).write_parquet(path)
- resolver = PostgresArtifactResolver(engine, store)
- return {
- "binding_id": binding_id,
- "binding_hash": binding_hash,
- "client": client,
- "engine": engine,
- "path": path,
- "resolver": resolver,
- "store": store,
- }
- def _publish(fixture):
- return fixture["resolver"].publish_path(
- str(fixture["path"]),
- binding_id=fixture["binding_id"],
- binding_hash=fixture["binding_hash"],
- correlation_id=new_governance_uid(),
- kind="output",
- ttl_seconds=60,
- schema_fields=[
- {"name": "id", "type": "integer", "nullable": False}
- ],
- )
- def test_publish_reserves_pending_before_upload_and_finalizes_ready(tmp_path):
- fixture = _publish_fixture(tmp_path)
- result = _publish(fixture)
- events = fixture["engine"].events
- assert next(
- index
- for index, event in enumerate(events)
- if "INSERT INTO public.rule_run_artifacts" in event
- ) < events.index("MINIO PUT")
- assert events.index("MINIO PUT") < next(
- index
- for index, event in enumerate(events)
- if "handoff_status = 'ready'" in event
- )
- assert fixture["engine"].row["handoff_status"] == "ready"
- assert fixture["engine"].row["binding_hash"] == fixture["binding_hash"]
- assert result["artifact_ref"] == fixture["engine"].row["artifact_ref"]
- def test_reserve_locks_and_rejects_stale_binding_before_upload(tmp_path):
- fixture = _publish_fixture(tmp_path)
- fixture["engine"].binding["binding_hash"] = "c" * 64
- with pytest.raises(ValueError, match="binding"):
- _publish(fixture)
- assert any(
- "FOR SHARE" in event
- for event in fixture["engine"].events
- if "dataflow_dataset_bindings" in event
- )
- assert "MINIO PUT" not in fixture["engine"].events
- assert fixture["engine"].row is None
- def test_reserve_crash_never_uploads_an_object(tmp_path):
- from app.runner.artifacts import ArtifactCommitUnknown
- fixture = _publish_fixture(tmp_path)
- fixture["engine"].fail_reserve = True
- with pytest.raises(ArtifactCommitUnknown):
- _publish(fixture)
- assert "MINIO PUT" not in fixture["engine"].events
- assert fixture["client"].objects == {}
- def test_upload_failure_removes_pending_reservation(tmp_path):
- fixture = _publish_fixture(tmp_path)
- fixture["client"].fail_upload = True
- with pytest.raises(RuntimeError, match="upload"):
- _publish(fixture)
- assert fixture["engine"].row is None
- assert fixture["client"].objects == {}
- def test_finalize_commit_after_client_error_is_rechecked_as_success(tmp_path):
- fixture = _publish_fixture(tmp_path)
- fixture["engine"].finalize_mode = "committed_then_error"
- result = _publish(fixture)
- assert fixture["engine"].row["handoff_status"] == "ready"
- assert result["artifact_ref"] == fixture["engine"].row["artifact_ref"]
- assert fixture["client"].objects
- def test_unconfirmed_finalize_returns_unknown_without_deleting_object(tmp_path):
- from app.runner.artifacts import ArtifactCommitUnknown
- fixture = _publish_fixture(tmp_path)
- fixture["engine"].finalize_mode = "rollback_unknown"
- with pytest.raises(ArtifactCommitUnknown):
- _publish(fixture)
- assert fixture["client"].objects
- class ReconcileMinio(FakeMinio):
- def __init__(self, now):
- super().__init__()
- self.now = now
- self.ages = {}
- def list_objects(self, bucket, *, prefix, recursive):
- assert recursive is True
- return [
- SimpleNamespace(
- object_name=key,
- last_modified=self.ages.get(key, self.now),
- )
- for object_bucket, key in sorted(self.objects)
- if object_bucket == bucket and key.startswith(prefix)
- ]
- 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)
- def connect(self):
- return _ReconcileConnection(self)
- class _ReconcileConnection:
- def __init__(self, engine):
- self.engine = engine
- def __enter__(self):
- return self
- def __exit__(self, *_args):
- return False
- def execute(self, statement, parameters):
- sql = str(statement)
- self.engine.events.append(sql)
- if (
- "SELECT id::text AS id" in sql
- and "handoff_status IN" in sql
- ):
- 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 "ANY(CAST(:artifact_refs AS text[]))" in sql
- ):
- return _Result(
- [
- {"artifact_ref": row["artifact_ref"]}
- for row in self.engine.rows
- if row["artifact_ref"]
- in parameters["artifact_refs"]
- ]
- )
- row = next(
- (
- item
- for item in self.engine.rows
- if item["id"] == parameters.get("id")
- ),
- None,
- )
- if (
- "UPDATE public.rule_run_artifacts" in sql
- and "handoff_status = 'ready'" in sql
- ):
- if self.engine.finalize_race_to_ready and row is not None:
- row["handoff_status"] = "ready"
- 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
- and row["handoff_status"] == parameters["expected_status"]
- ):
- row["handoff_status"] = "failed"
- row["failure_code"] = parameters["failure_code"]
- 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 = [
- item
- for item in self.engine.rows
- if item["id"] != parameters["id"]
- ]
- return _Result(rowcount=before - len(self.engine.rows))
- raise AssertionError(sql)
- def _catalog_row(artifact, *, status, binding_hash="d" * 64):
- return {
- "id": new_governance_uid(),
- "artifact_ref": artifact["artifact_ref"],
- "artifact_digest": artifact["digest"],
- "row_count": artifact["row_count"],
- "schema_hash": artifact["schema_hash"],
- "schema_fields": artifact["schema_fields"],
- "expires_at": artifact["expires_at"],
- "handoff_status": status,
- "binding_hash": binding_hash,
- "binding_id": new_governance_uid(),
- "artifact_kind": "output",
- "correlation_id": artifact["artifact_ref"].split("/")[4],
- }
- def test_reconcile_repairs_both_catalog_and_object_store_safely():
- from app.runner.artifacts import PostgresArtifactResolver
- now = datetime(2026, 7, 23, 12, 0, tzinfo=UTC)
- client = ReconcileMinio(now)
- store = _store(client, clock=lambda: now)
- valid_pending = store.write(
- pl.DataFrame({"id": [1]}),
- new_governance_uid(),
- 600,
- )
- missing_pending = store.write(
- pl.DataFrame({"id": [2]}),
- new_governance_uid(),
- 600,
- )
- store.delete(missing_pending["artifact_ref"])
- missing_ready = store.write(
- pl.DataFrame({"id": [3]}),
- new_governance_uid(),
- 600,
- )
- store.delete(missing_ready["artifact_ref"])
- invalid_pending = store.write(
- pl.DataFrame({"id": [6]}),
- new_governance_uid(),
- 600,
- )
- invalid_key = invalid_pending["artifact_ref"].split("/", 3)[-1]
- client.objects[("dataops-rules", invalid_key)][
- "content_type"
- ] = "text/plain"
- old_orphan = store.write(
- pl.DataFrame({"id": [4]}),
- new_governance_uid(),
- 600,
- )
- fresh_orphan = store.write(
- pl.DataFrame({"id": [5]}),
- new_governance_uid(),
- 600,
- )
- old_key = old_orphan["artifact_ref"].split("/", 3)[-1]
- fresh_key = fresh_orphan["artifact_ref"].split("/", 3)[-1]
- client.ages[old_key] = now - timedelta(minutes=20)
- client.ages[fresh_key] = now - timedelta(seconds=30)
- client.objects[
- ("dataops-rules", "rules/not-a-safe-catalog-key.parquet")
- ] = copy.deepcopy(next(iter(client.objects.values())))
- client.ages["rules/not-a-safe-catalog-key.parquet"] = now - timedelta(
- hours=1
- )
- engine = ReconcileEngine(
- [
- _catalog_row(valid_pending, status="pending"),
- _catalog_row(missing_pending, status="pending"),
- _catalog_row(missing_ready, status="ready"),
- _catalog_row(invalid_pending, status="pending"),
- ]
- )
- result = PostgresArtifactResolver(engine, store).reconcile(
- limit=10,
- grace_seconds=300,
- )
- assert result == {
- "pending_finalized": 1,
- "pending_deleted": 1,
- "ready_failed": 1,
- "orphans_deleted": 1,
- }
- assert ("dataops-rules", old_key) not in client.objects
- assert ("dataops-rules", fresh_key) in client.objects
- assert ("dataops-rules", invalid_key) not in client.objects
- assert (
- "dataops-rules",
- "rules/not-a-safe-catalog-key.parquet",
- ) in client.objects
- assert any(
- row["handoff_status"] == "ready"
- and row["artifact_ref"] == valid_pending["artifact_ref"]
- for row in engine.rows
- )
- assert any(
- row["handoff_status"] == "failed"
- and row["artifact_ref"] == missing_ready["artifact_ref"]
- for row in engine.rows
- )
- assert any(
- row["handoff_status"] == "failed"
- and row["artifact_ref"] == invalid_pending["artifact_ref"]
- 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"
|