test_artifact_handoff.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728
  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 (
  277. "SELECT artifact_ref" in sql
  278. and "ANY(CAST(:artifact_refs AS text[]))" in sql
  279. ):
  280. return _Result(
  281. [
  282. {"artifact_ref": row["artifact_ref"]}
  283. for row in self.engine.rows
  284. if row["artifact_ref"]
  285. in parameters["artifact_refs"]
  286. ]
  287. )
  288. row = next(
  289. (
  290. item
  291. for item in self.engine.rows
  292. if item["id"] == parameters.get("id")
  293. ),
  294. None,
  295. )
  296. if (
  297. "UPDATE public.rule_run_artifacts" in sql
  298. and "handoff_status = 'ready'" in sql
  299. ):
  300. if self.engine.finalize_race_to_ready and row is not None:
  301. row["handoff_status"] = "ready"
  302. return _Result(None, rowcount=0)
  303. if row is not None and row["handoff_status"] == "pending":
  304. row["handoff_status"] = "ready"
  305. return _Result(copy.deepcopy(row), rowcount=1)
  306. return _Result(None, rowcount=0)
  307. if (
  308. "UPDATE public.rule_run_artifacts" in sql
  309. and "handoff_status = 'failed'" in sql
  310. ):
  311. if (
  312. row is not None
  313. and row["handoff_status"] == parameters["expected_status"]
  314. ):
  315. row["handoff_status"] = "failed"
  316. row["failure_code"] = parameters["failure_code"]
  317. return _Result(rowcount=1)
  318. return _Result(rowcount=0)
  319. if "DELETE FROM public.rule_run_artifacts" in sql:
  320. before = len(self.engine.rows)
  321. self.engine.rows = [
  322. item
  323. for item in self.engine.rows
  324. if item["id"] != parameters["id"]
  325. ]
  326. return _Result(rowcount=before - len(self.engine.rows))
  327. raise AssertionError(sql)
  328. def _catalog_row(artifact, *, status, binding_hash="d" * 64):
  329. return {
  330. "id": new_governance_uid(),
  331. "artifact_ref": artifact["artifact_ref"],
  332. "artifact_digest": artifact["digest"],
  333. "row_count": artifact["row_count"],
  334. "schema_hash": artifact["schema_hash"],
  335. "schema_fields": artifact["schema_fields"],
  336. "expires_at": artifact["expires_at"],
  337. "handoff_status": status,
  338. "binding_hash": binding_hash,
  339. "binding_id": new_governance_uid(),
  340. "artifact_kind": "output",
  341. "correlation_id": artifact["artifact_ref"].split("/")[4],
  342. }
  343. def test_reconcile_repairs_both_catalog_and_object_store_safely():
  344. from app.runner.artifacts import PostgresArtifactResolver
  345. now = datetime(2026, 7, 23, 12, 0, tzinfo=UTC)
  346. client = ReconcileMinio(now)
  347. store = _store(client, clock=lambda: now)
  348. valid_pending = store.write(
  349. pl.DataFrame({"id": [1]}),
  350. new_governance_uid(),
  351. 600,
  352. )
  353. missing_pending = store.write(
  354. pl.DataFrame({"id": [2]}),
  355. new_governance_uid(),
  356. 600,
  357. )
  358. store.delete(missing_pending["artifact_ref"])
  359. missing_ready = store.write(
  360. pl.DataFrame({"id": [3]}),
  361. new_governance_uid(),
  362. 600,
  363. )
  364. store.delete(missing_ready["artifact_ref"])
  365. invalid_pending = store.write(
  366. pl.DataFrame({"id": [6]}),
  367. new_governance_uid(),
  368. 600,
  369. )
  370. invalid_key = invalid_pending["artifact_ref"].split("/", 3)[-1]
  371. client.objects[("dataops-rules", invalid_key)][
  372. "content_type"
  373. ] = "text/plain"
  374. old_orphan = store.write(
  375. pl.DataFrame({"id": [4]}),
  376. new_governance_uid(),
  377. 600,
  378. )
  379. fresh_orphan = store.write(
  380. pl.DataFrame({"id": [5]}),
  381. new_governance_uid(),
  382. 600,
  383. )
  384. old_key = old_orphan["artifact_ref"].split("/", 3)[-1]
  385. fresh_key = fresh_orphan["artifact_ref"].split("/", 3)[-1]
  386. client.ages[old_key] = now - timedelta(minutes=20)
  387. client.ages[fresh_key] = now - timedelta(seconds=30)
  388. client.objects[
  389. ("dataops-rules", "rules/not-a-safe-catalog-key.parquet")
  390. ] = copy.deepcopy(next(iter(client.objects.values())))
  391. client.ages["rules/not-a-safe-catalog-key.parquet"] = now - timedelta(
  392. hours=1
  393. )
  394. engine = ReconcileEngine(
  395. [
  396. _catalog_row(valid_pending, status="pending"),
  397. _catalog_row(missing_pending, status="pending"),
  398. _catalog_row(missing_ready, status="ready"),
  399. _catalog_row(invalid_pending, status="pending"),
  400. ]
  401. )
  402. result = PostgresArtifactResolver(engine, store).reconcile(
  403. limit=10,
  404. grace_seconds=300,
  405. )
  406. assert result == {
  407. "pending_finalized": 1,
  408. "pending_deleted": 1,
  409. "ready_failed": 1,
  410. "orphans_deleted": 1,
  411. }
  412. assert ("dataops-rules", old_key) not in client.objects
  413. assert ("dataops-rules", fresh_key) in client.objects
  414. assert ("dataops-rules", invalid_key) not in client.objects
  415. assert (
  416. "dataops-rules",
  417. "rules/not-a-safe-catalog-key.parquet",
  418. ) in client.objects
  419. assert any(
  420. row["handoff_status"] == "ready"
  421. and row["artifact_ref"] == valid_pending["artifact_ref"]
  422. for row in engine.rows
  423. )
  424. assert any(
  425. row["handoff_status"] == "failed"
  426. and row["artifact_ref"] == missing_ready["artifact_ref"]
  427. for row in engine.rows
  428. )
  429. assert any(
  430. row["handoff_status"] == "failed"
  431. and row["artifact_ref"] == invalid_pending["artifact_ref"]
  432. and row["failure_code"] == "pending_object_invalid"
  433. for row in engine.rows
  434. )
  435. def test_reconcile_accepts_concurrent_matching_ready_without_downgrade():
  436. from app.runner.artifacts import PostgresArtifactResolver
  437. now = datetime(2026, 7, 23, 12, 0, tzinfo=UTC)
  438. client = ReconcileMinio(now)
  439. store = _store(client, clock=lambda: now)
  440. artifact = store.write(
  441. pl.DataFrame({"id": [1]}),
  442. new_governance_uid(),
  443. 600,
  444. )
  445. engine = ReconcileEngine([_catalog_row(artifact, status="pending")])
  446. engine.finalize_race_to_ready = True
  447. result = PostgresArtifactResolver(engine, store).reconcile(
  448. limit=1,
  449. grace_seconds=300,
  450. )
  451. assert result["pending_finalized"] == 1
  452. assert engine.rows[0]["handoff_status"] == "ready"
  453. assert engine.rows[0].get("failure_code") is None
  454. @pytest.mark.parametrize(
  455. ("status", "storage_error"),
  456. [
  457. ("pending", TimeoutError("MinIO timed out")),
  458. ("ready", TimeoutError("MinIO timed out")),
  459. ("pending", ConnectionError("MinIO network unavailable")),
  460. ("ready", ConnectionError("MinIO network unavailable")),
  461. (
  462. "pending",
  463. S3Error(
  464. "AccessDenied",
  465. "authentication unavailable",
  466. None,
  467. None,
  468. None,
  469. None,
  470. ),
  471. ),
  472. (
  473. "ready",
  474. S3Error(
  475. "AccessDenied",
  476. "authentication unavailable",
  477. None,
  478. None,
  479. None,
  480. None,
  481. ),
  482. ),
  483. ],
  484. )
  485. def test_reconcile_preserves_catalog_on_transient_storage_error(
  486. status, storage_error
  487. ):
  488. from app.runner.artifacts import PostgresArtifactResolver
  489. now = datetime(2026, 7, 23, 12, 0, tzinfo=UTC)
  490. client = ReconcileMinio(now)
  491. store = _store(client, clock=lambda: now)
  492. artifact = store.write(
  493. pl.DataFrame({"id": [1]}),
  494. new_governance_uid(),
  495. 600,
  496. )
  497. key = artifact["artifact_ref"].split("/", 3)[-1]
  498. original_stat = client.stat_object
  499. def fail_stat(bucket, object_key):
  500. if object_key == key:
  501. raise storage_error
  502. return original_stat(bucket, object_key)
  503. client.stat_object = fail_stat
  504. engine = ReconcileEngine([_catalog_row(artifact, status=status)])
  505. result = PostgresArtifactResolver(engine, store).reconcile(
  506. limit=1,
  507. grace_seconds=300,
  508. )
  509. assert result == {
  510. "pending_finalized": 0,
  511. "pending_deleted": 0,
  512. "ready_failed": 0,
  513. "orphans_deleted": 0,
  514. }
  515. assert engine.rows[0]["handoff_status"] == status
  516. assert ("dataops-rules", key) in client.objects
  517. @pytest.mark.parametrize("status", ["pending", "ready"])
  518. def test_reconcile_preserves_catalog_on_transient_download_error(status):
  519. from app.runner.artifacts import PostgresArtifactResolver
  520. now = datetime(2026, 7, 23, 12, 0, tzinfo=UTC)
  521. client = ReconcileMinio(now)
  522. store = _store(client, clock=lambda: now)
  523. artifact = store.write(
  524. pl.DataFrame({"id": [1]}),
  525. new_governance_uid(),
  526. 600,
  527. )
  528. key = artifact["artifact_ref"].split("/", 3)[-1]
  529. def fail_download(_bucket, object_key):
  530. assert object_key == key
  531. raise TimeoutError("MinIO download timed out")
  532. client.get_object = fail_download
  533. engine = ReconcileEngine([_catalog_row(artifact, status=status)])
  534. result = PostgresArtifactResolver(engine, store).reconcile(
  535. limit=1,
  536. grace_seconds=300,
  537. )
  538. assert result["pending_finalized"] == 0
  539. assert result["ready_failed"] == 0
  540. assert engine.rows[0]["handoff_status"] == status
  541. assert ("dataops-rules", key) in client.objects
  542. @pytest.mark.parametrize("status", ["pending", "ready"])
  543. def test_reconcile_handles_confirmed_not_found_during_stream_validation(status):
  544. from app.runner.artifacts import PostgresArtifactResolver
  545. now = datetime(2026, 7, 23, 12, 0, tzinfo=UTC)
  546. client = ReconcileMinio(now)
  547. store = _store(client, clock=lambda: now)
  548. artifact = store.write(
  549. pl.DataFrame({"id": [1]}),
  550. new_governance_uid(),
  551. 600,
  552. )
  553. def disappear_on_download(bucket, object_key):
  554. client.objects.pop((bucket, object_key), None)
  555. raise KeyError(object_key)
  556. client.get_object = disappear_on_download
  557. engine = ReconcileEngine([_catalog_row(artifact, status=status)])
  558. result = PostgresArtifactResolver(engine, store).reconcile(
  559. limit=1,
  560. grace_seconds=300,
  561. )
  562. if status == "pending":
  563. assert result["pending_deleted"] == 1
  564. assert engine.rows == []
  565. else:
  566. assert result["ready_failed"] == 1
  567. assert engine.rows[0]["handoff_status"] == "failed"
  568. assert engine.rows[0]["failure_code"] == "ready_object_missing"
  569. def test_reconcile_streams_pending_content_before_finalize():
  570. from app.runner.artifacts import PostgresArtifactResolver
  571. now = datetime(2026, 7, 23, 12, 0, tzinfo=UTC)
  572. client = ReconcileMinio(now)
  573. store = _store(client, clock=lambda: now)
  574. artifact = store.write(
  575. pl.DataFrame({"id": [1, 2, 3]}),
  576. new_governance_uid(),
  577. 600,
  578. )
  579. key = artifact["artifact_ref"].split("/", 3)[-1]
  580. stored = client.objects[("dataops-rules", key)]
  581. payload = stored["payload"]
  582. midpoint = len(payload) // 2
  583. stored["payload"] = (
  584. payload[:midpoint]
  585. + bytes([payload[midpoint] ^ 1])
  586. + payload[midpoint + 1 :]
  587. )
  588. client.get_calls.clear()
  589. engine = ReconcileEngine([_catalog_row(artifact, status="pending")])
  590. result = PostgresArtifactResolver(engine, store).reconcile(
  591. limit=1,
  592. grace_seconds=300,
  593. )
  594. assert result["pending_finalized"] == 0
  595. assert engine.rows[0]["handoff_status"] == "failed"
  596. assert engine.rows[0]["failure_code"] == "pending_object_invalid"
  597. assert ("dataops-rules", key) not in client.objects
  598. assert client.get_calls == [("dataops-rules", key)]
  599. def test_reconcile_marks_fresh_ready_snapshot_failed_for_invalid_content():
  600. from app.runner.artifacts import PostgresArtifactResolver
  601. now = datetime(2026, 7, 23, 12, 0, tzinfo=UTC)
  602. client = ReconcileMinio(now)
  603. store = _store(client, clock=lambda: now)
  604. artifact = store.write(
  605. pl.DataFrame({"id": [1, 2]}),
  606. new_governance_uid(),
  607. 600,
  608. )
  609. key = artifact["artifact_ref"].split("/", 3)[-1]
  610. stored = client.objects[("dataops-rules", key)]
  611. payload = stored["payload"]
  612. stored["payload"] = bytes([payload[0] ^ 1]) + payload[1:]
  613. engine = ReconcileEngine([_catalog_row(artifact, status="ready")])
  614. result = PostgresArtifactResolver(engine, store).reconcile(
  615. limit=1,
  616. grace_seconds=300,
  617. )
  618. assert result["ready_failed"] == 1
  619. assert engine.rows[0]["handoff_status"] == "failed"
  620. assert engine.rows[0]["failure_code"] == "ready_object_invalid"