test_artifact_handoff.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720
  1. from __future__ import annotations
  2. import copy
  3. import json
  4. from datetime import UTC, datetime, timedelta
  5. from types import SimpleNamespace
  6. import polars as pl
  7. import pytest
  8. from minio.error import S3Error
  9. from app.core.common.identifiers import new_governance_uid
  10. from tests.runner.test_artifacts import FakeMinio, _store
  11. class _Result:
  12. def __init__(self, row=None, *, rowcount=0):
  13. self.row = row
  14. self.rowcount = rowcount
  15. def mappings(self):
  16. return self
  17. def one_or_none(self):
  18. return self.row
  19. def all(self):
  20. if self.row is None:
  21. return []
  22. return self.row if isinstance(self.row, list) else [self.row]
  23. class _HandoffConnection:
  24. def __init__(self, engine, *, transactional):
  25. self.engine = engine
  26. self.transactional = transactional
  27. self.backup = None
  28. self.finalized = False
  29. def __enter__(self):
  30. self.backup = copy.deepcopy(self.engine.row)
  31. return self
  32. def __exit__(self, exc_type, *_args):
  33. if exc_type is not None:
  34. self.engine.row = self.backup
  35. return False
  36. if self.finalized and self.engine.finalize_mode:
  37. if self.engine.finalize_mode == "rollback_unknown":
  38. self.engine.row = self.backup
  39. self.engine.fail_recheck = True
  40. raise RuntimeError("database commit acknowledgement lost")
  41. return False
  42. def execute(self, statement, parameters):
  43. sql = str(statement)
  44. self.engine.events.append(sql)
  45. if (
  46. "FROM public.dataflow_dataset_bindings" in sql
  47. and "JOIN" not in sql
  48. ):
  49. if self.engine.fail_reserve:
  50. raise RuntimeError("database unavailable")
  51. return _Result(copy.deepcopy(self.engine.binding))
  52. if "INSERT INTO public.rule_run_artifacts" in sql:
  53. if self.engine.row is not None:
  54. return _Result(None)
  55. self.engine.row = {
  56. "id": parameters["id"],
  57. "correlation_id": parameters["correlation_id"],
  58. "binding_id": parameters["binding_id"],
  59. "artifact_ref": parameters["artifact_ref"],
  60. "artifact_digest": parameters["artifact_digest"],
  61. "row_count": parameters["row_count"],
  62. "schema_hash": parameters["schema_hash"],
  63. "schema_fields": json.loads(parameters["schema_fields"]),
  64. "artifact_kind": parameters["artifact_kind"],
  65. "binding_hash": parameters["binding_hash"],
  66. "expires_at": parameters["expires_at"],
  67. "handoff_status": "pending",
  68. }
  69. return _Result(copy.deepcopy(self.engine.row), rowcount=1)
  70. if (
  71. "UPDATE public.rule_run_artifacts" in sql
  72. and "handoff_status = 'ready'" in sql
  73. ):
  74. if self.engine.row is not None:
  75. self.engine.row["handoff_status"] = "ready"
  76. self.finalized = True
  77. return _Result(copy.deepcopy(self.engine.row), rowcount=1)
  78. return _Result(None)
  79. if (
  80. "DELETE FROM public.rule_run_artifacts" in sql
  81. and "handoff_status = 'pending'" in sql
  82. ):
  83. deleted = self.engine.row is not None
  84. self.engine.row = None
  85. return _Result(rowcount=int(deleted))
  86. if "FROM public.rule_run_artifacts" in sql:
  87. if self.engine.fail_recheck:
  88. raise RuntimeError("database recheck unavailable")
  89. row = copy.deepcopy(self.engine.row)
  90. if "JOIN public.dataflow_dataset_bindings" in sql:
  91. if (
  92. row is None
  93. or row["handoff_status"] != "ready"
  94. or row["binding_hash"] != self.engine.binding[
  95. "binding_hash"
  96. ]
  97. ):
  98. row = None
  99. elif row is not None:
  100. row["current_binding_hash"] = self.engine.binding[
  101. "binding_hash"
  102. ]
  103. return _Result(row)
  104. raise AssertionError(sql)
  105. class HandoffEngine:
  106. def __init__(self, binding):
  107. self.binding = dict(binding)
  108. self.row = None
  109. self.events = []
  110. self.fail_reserve = False
  111. self.fail_recheck = False
  112. self.finalize_mode = None
  113. def begin(self):
  114. return _HandoffConnection(self, transactional=True)
  115. def connect(self):
  116. return _HandoffConnection(self, transactional=False)
  117. class EventMinio(FakeMinio):
  118. def __init__(self, events):
  119. super().__init__()
  120. self.events = events
  121. self.fail_upload = False
  122. def put_object(self, *args, **kwargs):
  123. self.events.append("MINIO PUT")
  124. if self.fail_upload:
  125. raise RuntimeError("object upload failed")
  126. return super().put_object(*args, **kwargs)
  127. def _binding(binding_hash):
  128. return {
  129. "binding_hash": binding_hash,
  130. "access_mode": "read_write",
  131. "object_kind": "parquet_artifact",
  132. }
  133. def _publish_fixture(tmp_path, *, engine=None, client=None):
  134. from app.runner.artifacts import PostgresArtifactResolver
  135. binding_id = new_governance_uid()
  136. binding_hash = "b" * 64
  137. engine = engine or HandoffEngine(_binding(binding_hash))
  138. client = client or EventMinio(engine.events)
  139. store = _store(client)
  140. path = tmp_path / "handoff.parquet"
  141. pl.DataFrame({"id": [1]}).write_parquet(path)
  142. resolver = PostgresArtifactResolver(engine, store)
  143. return {
  144. "binding_id": binding_id,
  145. "binding_hash": binding_hash,
  146. "client": client,
  147. "engine": engine,
  148. "path": path,
  149. "resolver": resolver,
  150. "store": store,
  151. }
  152. def _publish(fixture):
  153. return fixture["resolver"].publish_path(
  154. str(fixture["path"]),
  155. binding_id=fixture["binding_id"],
  156. binding_hash=fixture["binding_hash"],
  157. correlation_id=new_governance_uid(),
  158. kind="output",
  159. ttl_seconds=60,
  160. schema_fields=[
  161. {"name": "id", "type": "integer", "nullable": False}
  162. ],
  163. )
  164. def test_publish_reserves_pending_before_upload_and_finalizes_ready(tmp_path):
  165. fixture = _publish_fixture(tmp_path)
  166. result = _publish(fixture)
  167. events = fixture["engine"].events
  168. assert next(
  169. index
  170. for index, event in enumerate(events)
  171. if "INSERT INTO public.rule_run_artifacts" in event
  172. ) < events.index("MINIO PUT")
  173. assert events.index("MINIO PUT") < next(
  174. index
  175. for index, event in enumerate(events)
  176. if "handoff_status = 'ready'" in event
  177. )
  178. assert fixture["engine"].row["handoff_status"] == "ready"
  179. assert fixture["engine"].row["binding_hash"] == fixture["binding_hash"]
  180. assert result["artifact_ref"] == fixture["engine"].row["artifact_ref"]
  181. def test_reserve_locks_and_rejects_stale_binding_before_upload(tmp_path):
  182. fixture = _publish_fixture(tmp_path)
  183. fixture["engine"].binding["binding_hash"] = "c" * 64
  184. with pytest.raises(ValueError, match="binding"):
  185. _publish(fixture)
  186. assert any(
  187. "FOR SHARE" in event
  188. for event in fixture["engine"].events
  189. if "dataflow_dataset_bindings" in event
  190. )
  191. assert "MINIO PUT" not in fixture["engine"].events
  192. assert fixture["engine"].row is None
  193. def test_reserve_crash_never_uploads_an_object(tmp_path):
  194. from app.runner.artifacts import ArtifactCommitUnknown
  195. fixture = _publish_fixture(tmp_path)
  196. fixture["engine"].fail_reserve = True
  197. with pytest.raises(ArtifactCommitUnknown):
  198. _publish(fixture)
  199. assert "MINIO PUT" not in fixture["engine"].events
  200. assert fixture["client"].objects == {}
  201. def test_upload_failure_removes_pending_reservation(tmp_path):
  202. fixture = _publish_fixture(tmp_path)
  203. fixture["client"].fail_upload = True
  204. with pytest.raises(RuntimeError, match="upload"):
  205. _publish(fixture)
  206. assert fixture["engine"].row is None
  207. assert fixture["client"].objects == {}
  208. def test_finalize_commit_after_client_error_is_rechecked_as_success(tmp_path):
  209. fixture = _publish_fixture(tmp_path)
  210. fixture["engine"].finalize_mode = "committed_then_error"
  211. result = _publish(fixture)
  212. assert fixture["engine"].row["handoff_status"] == "ready"
  213. assert result["artifact_ref"] == fixture["engine"].row["artifact_ref"]
  214. assert fixture["client"].objects
  215. def test_unconfirmed_finalize_returns_unknown_without_deleting_object(tmp_path):
  216. from app.runner.artifacts import ArtifactCommitUnknown
  217. fixture = _publish_fixture(tmp_path)
  218. fixture["engine"].finalize_mode = "rollback_unknown"
  219. with pytest.raises(ArtifactCommitUnknown):
  220. _publish(fixture)
  221. assert fixture["client"].objects
  222. class ReconcileMinio(FakeMinio):
  223. def __init__(self, now):
  224. super().__init__()
  225. self.now = now
  226. self.ages = {}
  227. def list_objects(self, bucket, *, prefix, recursive):
  228. assert recursive is True
  229. return [
  230. SimpleNamespace(
  231. object_name=key,
  232. last_modified=self.ages.get(key, self.now),
  233. )
  234. for object_bucket, key in sorted(self.objects)
  235. if object_bucket == bucket and key.startswith(prefix)
  236. ]
  237. class ReconcileEngine:
  238. def __init__(self, rows):
  239. self.rows = [dict(row) for row in rows]
  240. self.events = []
  241. self.finalize_race_to_ready = False
  242. def begin(self):
  243. return _ReconcileConnection(self)
  244. def connect(self):
  245. return _ReconcileConnection(self)
  246. class _ReconcileConnection:
  247. def __init__(self, engine):
  248. self.engine = engine
  249. def __enter__(self):
  250. return self
  251. def __exit__(self, *_args):
  252. return False
  253. def execute(self, statement, parameters):
  254. sql = str(statement)
  255. self.engine.events.append(sql)
  256. if (
  257. "SELECT id::text AS id" in sql
  258. and "handoff_status IN" in sql
  259. ):
  260. return _Result(
  261. copy.deepcopy(self.engine.rows[: parameters["limit"]])
  262. )
  263. if (
  264. "SELECT id::text AS id" in sql
  265. and "WHERE id = CAST(:id AS uuid)" in sql
  266. ):
  267. row = next(
  268. (
  269. item
  270. for item in self.engine.rows
  271. if item["id"] == parameters["id"]
  272. ),
  273. None,
  274. )
  275. return _Result(copy.deepcopy(row))
  276. if "SELECT artifact_ref" in sql and "handoff_status" not in sql:
  277. return _Result(
  278. [{"artifact_ref": row["artifact_ref"]} for row in self.engine.rows]
  279. )
  280. row = next(
  281. (
  282. item
  283. for item in self.engine.rows
  284. if item["id"] == parameters.get("id")
  285. ),
  286. None,
  287. )
  288. if (
  289. "UPDATE public.rule_run_artifacts" in sql
  290. and "handoff_status = 'ready'" in sql
  291. ):
  292. if self.engine.finalize_race_to_ready and row is not None:
  293. row["handoff_status"] = "ready"
  294. return _Result(None, rowcount=0)
  295. if row is not None and row["handoff_status"] == "pending":
  296. row["handoff_status"] = "ready"
  297. return _Result(copy.deepcopy(row), rowcount=1)
  298. return _Result(None, rowcount=0)
  299. if (
  300. "UPDATE public.rule_run_artifacts" in sql
  301. and "handoff_status = 'failed'" in sql
  302. ):
  303. if (
  304. row is not None
  305. and row["handoff_status"] == parameters["expected_status"]
  306. ):
  307. row["handoff_status"] = "failed"
  308. row["failure_code"] = parameters["failure_code"]
  309. return _Result(rowcount=1)
  310. return _Result(rowcount=0)
  311. if "DELETE FROM public.rule_run_artifacts" in sql:
  312. before = len(self.engine.rows)
  313. self.engine.rows = [
  314. item
  315. for item in self.engine.rows
  316. if item["id"] != parameters["id"]
  317. ]
  318. return _Result(rowcount=before - len(self.engine.rows))
  319. raise AssertionError(sql)
  320. def _catalog_row(artifact, *, status, binding_hash="d" * 64):
  321. return {
  322. "id": new_governance_uid(),
  323. "artifact_ref": artifact["artifact_ref"],
  324. "artifact_digest": artifact["digest"],
  325. "row_count": artifact["row_count"],
  326. "schema_hash": artifact["schema_hash"],
  327. "schema_fields": artifact["schema_fields"],
  328. "expires_at": artifact["expires_at"],
  329. "handoff_status": status,
  330. "binding_hash": binding_hash,
  331. "binding_id": new_governance_uid(),
  332. "artifact_kind": "output",
  333. "correlation_id": artifact["artifact_ref"].split("/")[4],
  334. }
  335. def test_reconcile_repairs_both_catalog_and_object_store_safely():
  336. from app.runner.artifacts import PostgresArtifactResolver
  337. now = datetime(2026, 7, 23, 12, 0, tzinfo=UTC)
  338. client = ReconcileMinio(now)
  339. store = _store(client, clock=lambda: now)
  340. valid_pending = store.write(
  341. pl.DataFrame({"id": [1]}),
  342. new_governance_uid(),
  343. 600,
  344. )
  345. missing_pending = store.write(
  346. pl.DataFrame({"id": [2]}),
  347. new_governance_uid(),
  348. 600,
  349. )
  350. store.delete(missing_pending["artifact_ref"])
  351. missing_ready = store.write(
  352. pl.DataFrame({"id": [3]}),
  353. new_governance_uid(),
  354. 600,
  355. )
  356. store.delete(missing_ready["artifact_ref"])
  357. invalid_pending = store.write(
  358. pl.DataFrame({"id": [6]}),
  359. new_governance_uid(),
  360. 600,
  361. )
  362. invalid_key = invalid_pending["artifact_ref"].split("/", 3)[-1]
  363. client.objects[("dataops-rules", invalid_key)][
  364. "content_type"
  365. ] = "text/plain"
  366. old_orphan = store.write(
  367. pl.DataFrame({"id": [4]}),
  368. new_governance_uid(),
  369. 600,
  370. )
  371. fresh_orphan = store.write(
  372. pl.DataFrame({"id": [5]}),
  373. new_governance_uid(),
  374. 600,
  375. )
  376. old_key = old_orphan["artifact_ref"].split("/", 3)[-1]
  377. fresh_key = fresh_orphan["artifact_ref"].split("/", 3)[-1]
  378. client.ages[old_key] = now - timedelta(minutes=20)
  379. client.ages[fresh_key] = now - timedelta(seconds=30)
  380. client.objects[
  381. ("dataops-rules", "rules/not-a-safe-catalog-key.parquet")
  382. ] = copy.deepcopy(next(iter(client.objects.values())))
  383. client.ages["rules/not-a-safe-catalog-key.parquet"] = now - timedelta(
  384. hours=1
  385. )
  386. engine = ReconcileEngine(
  387. [
  388. _catalog_row(valid_pending, status="pending"),
  389. _catalog_row(missing_pending, status="pending"),
  390. _catalog_row(missing_ready, status="ready"),
  391. _catalog_row(invalid_pending, status="pending"),
  392. ]
  393. )
  394. result = PostgresArtifactResolver(engine, store).reconcile(
  395. limit=10,
  396. grace_seconds=300,
  397. )
  398. assert result == {
  399. "pending_finalized": 1,
  400. "pending_deleted": 1,
  401. "ready_failed": 1,
  402. "orphans_deleted": 1,
  403. }
  404. assert ("dataops-rules", old_key) not in client.objects
  405. assert ("dataops-rules", fresh_key) in client.objects
  406. assert ("dataops-rules", invalid_key) not in client.objects
  407. assert (
  408. "dataops-rules",
  409. "rules/not-a-safe-catalog-key.parquet",
  410. ) in client.objects
  411. assert any(
  412. row["handoff_status"] == "ready"
  413. and row["artifact_ref"] == valid_pending["artifact_ref"]
  414. for row in engine.rows
  415. )
  416. assert any(
  417. row["handoff_status"] == "failed"
  418. and row["artifact_ref"] == missing_ready["artifact_ref"]
  419. for row in engine.rows
  420. )
  421. assert any(
  422. row["handoff_status"] == "failed"
  423. and row["artifact_ref"] == invalid_pending["artifact_ref"]
  424. and row["failure_code"] == "pending_object_invalid"
  425. for row in engine.rows
  426. )
  427. def test_reconcile_accepts_concurrent_matching_ready_without_downgrade():
  428. from app.runner.artifacts import PostgresArtifactResolver
  429. now = datetime(2026, 7, 23, 12, 0, tzinfo=UTC)
  430. client = ReconcileMinio(now)
  431. store = _store(client, clock=lambda: now)
  432. artifact = store.write(
  433. pl.DataFrame({"id": [1]}),
  434. new_governance_uid(),
  435. 600,
  436. )
  437. engine = ReconcileEngine([_catalog_row(artifact, status="pending")])
  438. engine.finalize_race_to_ready = True
  439. result = PostgresArtifactResolver(engine, store).reconcile(
  440. limit=1,
  441. grace_seconds=300,
  442. )
  443. assert result["pending_finalized"] == 1
  444. assert engine.rows[0]["handoff_status"] == "ready"
  445. assert engine.rows[0].get("failure_code") is None
  446. @pytest.mark.parametrize(
  447. ("status", "storage_error"),
  448. [
  449. ("pending", TimeoutError("MinIO timed out")),
  450. ("ready", TimeoutError("MinIO timed out")),
  451. ("pending", ConnectionError("MinIO network unavailable")),
  452. ("ready", ConnectionError("MinIO network unavailable")),
  453. (
  454. "pending",
  455. S3Error(
  456. "AccessDenied",
  457. "authentication unavailable",
  458. None,
  459. None,
  460. None,
  461. None,
  462. ),
  463. ),
  464. (
  465. "ready",
  466. S3Error(
  467. "AccessDenied",
  468. "authentication unavailable",
  469. None,
  470. None,
  471. None,
  472. None,
  473. ),
  474. ),
  475. ],
  476. )
  477. def test_reconcile_preserves_catalog_on_transient_storage_error(
  478. status, storage_error
  479. ):
  480. from app.runner.artifacts import PostgresArtifactResolver
  481. now = datetime(2026, 7, 23, 12, 0, tzinfo=UTC)
  482. client = ReconcileMinio(now)
  483. store = _store(client, clock=lambda: now)
  484. artifact = store.write(
  485. pl.DataFrame({"id": [1]}),
  486. new_governance_uid(),
  487. 600,
  488. )
  489. key = artifact["artifact_ref"].split("/", 3)[-1]
  490. original_stat = client.stat_object
  491. def fail_stat(bucket, object_key):
  492. if object_key == key:
  493. raise storage_error
  494. return original_stat(bucket, object_key)
  495. client.stat_object = fail_stat
  496. engine = ReconcileEngine([_catalog_row(artifact, status=status)])
  497. result = PostgresArtifactResolver(engine, store).reconcile(
  498. limit=1,
  499. grace_seconds=300,
  500. )
  501. assert result == {
  502. "pending_finalized": 0,
  503. "pending_deleted": 0,
  504. "ready_failed": 0,
  505. "orphans_deleted": 0,
  506. }
  507. assert engine.rows[0]["handoff_status"] == status
  508. assert ("dataops-rules", key) in client.objects
  509. @pytest.mark.parametrize("status", ["pending", "ready"])
  510. def test_reconcile_preserves_catalog_on_transient_download_error(status):
  511. from app.runner.artifacts import PostgresArtifactResolver
  512. now = datetime(2026, 7, 23, 12, 0, tzinfo=UTC)
  513. client = ReconcileMinio(now)
  514. store = _store(client, clock=lambda: now)
  515. artifact = store.write(
  516. pl.DataFrame({"id": [1]}),
  517. new_governance_uid(),
  518. 600,
  519. )
  520. key = artifact["artifact_ref"].split("/", 3)[-1]
  521. def fail_download(_bucket, object_key):
  522. assert object_key == key
  523. raise TimeoutError("MinIO download timed out")
  524. client.get_object = fail_download
  525. engine = ReconcileEngine([_catalog_row(artifact, status=status)])
  526. result = PostgresArtifactResolver(engine, store).reconcile(
  527. limit=1,
  528. grace_seconds=300,
  529. )
  530. assert result["pending_finalized"] == 0
  531. assert result["ready_failed"] == 0
  532. assert engine.rows[0]["handoff_status"] == status
  533. assert ("dataops-rules", key) in client.objects
  534. @pytest.mark.parametrize("status", ["pending", "ready"])
  535. def test_reconcile_handles_confirmed_not_found_during_stream_validation(status):
  536. from app.runner.artifacts import PostgresArtifactResolver
  537. now = datetime(2026, 7, 23, 12, 0, tzinfo=UTC)
  538. client = ReconcileMinio(now)
  539. store = _store(client, clock=lambda: now)
  540. artifact = store.write(
  541. pl.DataFrame({"id": [1]}),
  542. new_governance_uid(),
  543. 600,
  544. )
  545. def disappear_on_download(bucket, object_key):
  546. client.objects.pop((bucket, object_key), None)
  547. raise KeyError(object_key)
  548. client.get_object = disappear_on_download
  549. engine = ReconcileEngine([_catalog_row(artifact, status=status)])
  550. result = PostgresArtifactResolver(engine, store).reconcile(
  551. limit=1,
  552. grace_seconds=300,
  553. )
  554. if status == "pending":
  555. assert result["pending_deleted"] == 1
  556. assert engine.rows == []
  557. else:
  558. assert result["ready_failed"] == 1
  559. assert engine.rows[0]["handoff_status"] == "failed"
  560. assert engine.rows[0]["failure_code"] == "ready_object_missing"
  561. def test_reconcile_streams_pending_content_before_finalize():
  562. from app.runner.artifacts import PostgresArtifactResolver
  563. now = datetime(2026, 7, 23, 12, 0, tzinfo=UTC)
  564. client = ReconcileMinio(now)
  565. store = _store(client, clock=lambda: now)
  566. artifact = store.write(
  567. pl.DataFrame({"id": [1, 2, 3]}),
  568. new_governance_uid(),
  569. 600,
  570. )
  571. key = artifact["artifact_ref"].split("/", 3)[-1]
  572. stored = client.objects[("dataops-rules", key)]
  573. payload = stored["payload"]
  574. midpoint = len(payload) // 2
  575. stored["payload"] = (
  576. payload[:midpoint]
  577. + bytes([payload[midpoint] ^ 1])
  578. + payload[midpoint + 1 :]
  579. )
  580. client.get_calls.clear()
  581. engine = ReconcileEngine([_catalog_row(artifact, status="pending")])
  582. result = PostgresArtifactResolver(engine, store).reconcile(
  583. limit=1,
  584. grace_seconds=300,
  585. )
  586. assert result["pending_finalized"] == 0
  587. assert engine.rows[0]["handoff_status"] == "failed"
  588. assert engine.rows[0]["failure_code"] == "pending_object_invalid"
  589. assert ("dataops-rules", key) not in client.objects
  590. assert client.get_calls == [("dataops-rules", key)]
  591. def test_reconcile_marks_fresh_ready_snapshot_failed_for_invalid_content():
  592. from app.runner.artifacts import PostgresArtifactResolver
  593. now = datetime(2026, 7, 23, 12, 0, tzinfo=UTC)
  594. client = ReconcileMinio(now)
  595. store = _store(client, clock=lambda: now)
  596. artifact = store.write(
  597. pl.DataFrame({"id": [1, 2]}),
  598. new_governance_uid(),
  599. 600,
  600. )
  601. key = artifact["artifact_ref"].split("/", 3)[-1]
  602. stored = client.objects[("dataops-rules", key)]
  603. payload = stored["payload"]
  604. stored["payload"] = bytes([payload[0] ^ 1]) + payload[1:]
  605. engine = ReconcileEngine([_catalog_row(artifact, status="ready")])
  606. result = PostgresArtifactResolver(engine, store).reconcile(
  607. limit=1,
  608. grace_seconds=300,
  609. )
  610. assert result["ready_failed"] == 1
  611. assert engine.rows[0]["handoff_status"] == "failed"
  612. assert engine.rows[0]["failure_code"] == "ready_object_invalid"