test_artifact_handoff.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  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 app.core.common.identifiers import new_governance_uid
  9. from tests.runner.test_artifacts import FakeMinio, _store
  10. class _Result:
  11. def __init__(self, row=None, *, rowcount=0):
  12. self.row = row
  13. self.rowcount = rowcount
  14. def mappings(self):
  15. return self
  16. def one_or_none(self):
  17. return self.row
  18. def all(self):
  19. if self.row is None:
  20. return []
  21. return self.row if isinstance(self.row, list) else [self.row]
  22. class _HandoffConnection:
  23. def __init__(self, engine, *, transactional):
  24. self.engine = engine
  25. self.transactional = transactional
  26. self.backup = None
  27. self.finalized = False
  28. def __enter__(self):
  29. self.backup = copy.deepcopy(self.engine.row)
  30. return self
  31. def __exit__(self, exc_type, *_args):
  32. if exc_type is not None:
  33. self.engine.row = self.backup
  34. return False
  35. if self.finalized and self.engine.finalize_mode:
  36. if self.engine.finalize_mode == "rollback_unknown":
  37. self.engine.row = self.backup
  38. self.engine.fail_recheck = True
  39. raise RuntimeError("database commit acknowledgement lost")
  40. return False
  41. def execute(self, statement, parameters):
  42. sql = str(statement)
  43. self.engine.events.append(sql)
  44. if (
  45. "FROM public.dataflow_dataset_bindings" in sql
  46. and "JOIN" not in sql
  47. ):
  48. if self.engine.fail_reserve:
  49. raise RuntimeError("database unavailable")
  50. return _Result(copy.deepcopy(self.engine.binding))
  51. if "INSERT INTO public.rule_run_artifacts" in sql:
  52. if self.engine.row is not None:
  53. return _Result(None)
  54. self.engine.row = {
  55. "id": parameters["id"],
  56. "correlation_id": parameters["correlation_id"],
  57. "binding_id": parameters["binding_id"],
  58. "artifact_ref": parameters["artifact_ref"],
  59. "artifact_digest": parameters["artifact_digest"],
  60. "row_count": parameters["row_count"],
  61. "schema_hash": parameters["schema_hash"],
  62. "schema_fields": json.loads(parameters["schema_fields"]),
  63. "artifact_kind": parameters["artifact_kind"],
  64. "binding_hash": parameters["binding_hash"],
  65. "expires_at": parameters["expires_at"],
  66. "handoff_status": "pending",
  67. }
  68. return _Result(copy.deepcopy(self.engine.row), rowcount=1)
  69. if (
  70. "UPDATE public.rule_run_artifacts" in sql
  71. and "handoff_status = 'ready'" in sql
  72. ):
  73. if self.engine.row is not None:
  74. self.engine.row["handoff_status"] = "ready"
  75. self.finalized = True
  76. return _Result(copy.deepcopy(self.engine.row), rowcount=1)
  77. return _Result(None)
  78. if (
  79. "DELETE FROM public.rule_run_artifacts" in sql
  80. and "handoff_status = 'pending'" in sql
  81. ):
  82. deleted = self.engine.row is not None
  83. self.engine.row = None
  84. return _Result(rowcount=int(deleted))
  85. if "FROM public.rule_run_artifacts" in sql:
  86. if self.engine.fail_recheck:
  87. raise RuntimeError("database recheck unavailable")
  88. row = copy.deepcopy(self.engine.row)
  89. if "JOIN public.dataflow_dataset_bindings" in sql:
  90. if (
  91. row is None
  92. or row["handoff_status"] != "ready"
  93. or row["binding_hash"] != self.engine.binding[
  94. "binding_hash"
  95. ]
  96. ):
  97. row = None
  98. elif row is not None:
  99. row["current_binding_hash"] = self.engine.binding[
  100. "binding_hash"
  101. ]
  102. return _Result(row)
  103. raise AssertionError(sql)
  104. class HandoffEngine:
  105. def __init__(self, binding):
  106. self.binding = dict(binding)
  107. self.row = None
  108. self.events = []
  109. self.fail_reserve = False
  110. self.fail_recheck = False
  111. self.finalize_mode = None
  112. def begin(self):
  113. return _HandoffConnection(self, transactional=True)
  114. def connect(self):
  115. return _HandoffConnection(self, transactional=False)
  116. class EventMinio(FakeMinio):
  117. def __init__(self, events):
  118. super().__init__()
  119. self.events = events
  120. self.fail_upload = False
  121. def put_object(self, *args, **kwargs):
  122. self.events.append("MINIO PUT")
  123. if self.fail_upload:
  124. raise RuntimeError("object upload failed")
  125. return super().put_object(*args, **kwargs)
  126. def _binding(binding_hash):
  127. return {
  128. "binding_hash": binding_hash,
  129. "access_mode": "read_write",
  130. "object_kind": "parquet_artifact",
  131. }
  132. def _publish_fixture(tmp_path, *, engine=None, client=None):
  133. from app.runner.artifacts import PostgresArtifactResolver
  134. binding_id = new_governance_uid()
  135. binding_hash = "b" * 64
  136. engine = engine or HandoffEngine(_binding(binding_hash))
  137. client = client or EventMinio(engine.events)
  138. store = _store(client)
  139. path = tmp_path / "handoff.parquet"
  140. pl.DataFrame({"id": [1]}).write_parquet(path)
  141. resolver = PostgresArtifactResolver(engine, store)
  142. return {
  143. "binding_id": binding_id,
  144. "binding_hash": binding_hash,
  145. "client": client,
  146. "engine": engine,
  147. "path": path,
  148. "resolver": resolver,
  149. "store": store,
  150. }
  151. def _publish(fixture):
  152. return fixture["resolver"].publish_path(
  153. str(fixture["path"]),
  154. binding_id=fixture["binding_id"],
  155. binding_hash=fixture["binding_hash"],
  156. correlation_id=new_governance_uid(),
  157. kind="output",
  158. ttl_seconds=60,
  159. schema_fields=[
  160. {"name": "id", "type": "integer", "nullable": False}
  161. ],
  162. )
  163. def test_publish_reserves_pending_before_upload_and_finalizes_ready(tmp_path):
  164. fixture = _publish_fixture(tmp_path)
  165. result = _publish(fixture)
  166. events = fixture["engine"].events
  167. assert next(
  168. index
  169. for index, event in enumerate(events)
  170. if "INSERT INTO public.rule_run_artifacts" in event
  171. ) < events.index("MINIO PUT")
  172. assert events.index("MINIO PUT") < next(
  173. index
  174. for index, event in enumerate(events)
  175. if "handoff_status = 'ready'" in event
  176. )
  177. assert fixture["engine"].row["handoff_status"] == "ready"
  178. assert fixture["engine"].row["binding_hash"] == fixture["binding_hash"]
  179. assert result["artifact_ref"] == fixture["engine"].row["artifact_ref"]
  180. def test_reserve_locks_and_rejects_stale_binding_before_upload(tmp_path):
  181. fixture = _publish_fixture(tmp_path)
  182. fixture["engine"].binding["binding_hash"] = "c" * 64
  183. with pytest.raises(ValueError, match="binding"):
  184. _publish(fixture)
  185. assert any(
  186. "FOR SHARE" in event
  187. for event in fixture["engine"].events
  188. if "dataflow_dataset_bindings" in event
  189. )
  190. assert "MINIO PUT" not in fixture["engine"].events
  191. assert fixture["engine"].row is None
  192. def test_reserve_crash_never_uploads_an_object(tmp_path):
  193. from app.runner.artifacts import ArtifactCommitUnknown
  194. fixture = _publish_fixture(tmp_path)
  195. fixture["engine"].fail_reserve = True
  196. with pytest.raises(ArtifactCommitUnknown):
  197. _publish(fixture)
  198. assert "MINIO PUT" not in fixture["engine"].events
  199. assert fixture["client"].objects == {}
  200. def test_upload_failure_removes_pending_reservation(tmp_path):
  201. fixture = _publish_fixture(tmp_path)
  202. fixture["client"].fail_upload = True
  203. with pytest.raises(RuntimeError, match="upload"):
  204. _publish(fixture)
  205. assert fixture["engine"].row is None
  206. assert fixture["client"].objects == {}
  207. def test_finalize_commit_after_client_error_is_rechecked_as_success(tmp_path):
  208. fixture = _publish_fixture(tmp_path)
  209. fixture["engine"].finalize_mode = "committed_then_error"
  210. result = _publish(fixture)
  211. assert fixture["engine"].row["handoff_status"] == "ready"
  212. assert result["artifact_ref"] == fixture["engine"].row["artifact_ref"]
  213. assert fixture["client"].objects
  214. def test_unconfirmed_finalize_returns_unknown_without_deleting_object(tmp_path):
  215. from app.runner.artifacts import ArtifactCommitUnknown
  216. fixture = _publish_fixture(tmp_path)
  217. fixture["engine"].finalize_mode = "rollback_unknown"
  218. with pytest.raises(ArtifactCommitUnknown):
  219. _publish(fixture)
  220. assert fixture["client"].objects
  221. class ReconcileMinio(FakeMinio):
  222. def __init__(self, now):
  223. super().__init__()
  224. self.now = now
  225. self.ages = {}
  226. def list_objects(self, bucket, *, prefix, recursive):
  227. assert recursive is True
  228. return [
  229. SimpleNamespace(
  230. object_name=key,
  231. last_modified=self.ages.get(key, self.now),
  232. )
  233. for object_bucket, key in sorted(self.objects)
  234. if object_bucket == bucket and key.startswith(prefix)
  235. ]
  236. class ReconcileEngine:
  237. def __init__(self, rows):
  238. self.rows = [dict(row) for row in rows]
  239. self.events = []
  240. def begin(self):
  241. return _ReconcileConnection(self)
  242. def connect(self):
  243. return _ReconcileConnection(self)
  244. class _ReconcileConnection:
  245. def __init__(self, engine):
  246. self.engine = engine
  247. def __enter__(self):
  248. return self
  249. def __exit__(self, *_args):
  250. return False
  251. def execute(self, statement, parameters):
  252. sql = str(statement)
  253. self.engine.events.append(sql)
  254. if (
  255. "SELECT id::text AS id" in sql
  256. and "handoff_status IN" in sql
  257. ):
  258. return _Result(
  259. copy.deepcopy(self.engine.rows[: parameters["limit"]])
  260. )
  261. if "SELECT artifact_ref" in sql and "handoff_status" not in sql:
  262. return _Result(
  263. [{"artifact_ref": row["artifact_ref"]} for row in self.engine.rows]
  264. )
  265. row = next(
  266. (
  267. item
  268. for item in self.engine.rows
  269. if item["id"] == parameters.get("id")
  270. ),
  271. None,
  272. )
  273. if (
  274. "UPDATE public.rule_run_artifacts" in sql
  275. and "handoff_status = 'ready'" in sql
  276. ):
  277. if row is not None:
  278. row["handoff_status"] = "ready"
  279. return _Result(copy.deepcopy(row), rowcount=int(row is not None))
  280. if (
  281. "UPDATE public.rule_run_artifacts" in sql
  282. and "handoff_status = 'failed'" in sql
  283. ):
  284. if row is not None:
  285. row["handoff_status"] = "failed"
  286. row["failure_code"] = parameters["failure_code"]
  287. return _Result(rowcount=int(row is not None))
  288. if "DELETE FROM public.rule_run_artifacts" in sql:
  289. before = len(self.engine.rows)
  290. self.engine.rows = [
  291. item
  292. for item in self.engine.rows
  293. if item["id"] != parameters["id"]
  294. ]
  295. return _Result(rowcount=before - len(self.engine.rows))
  296. raise AssertionError(sql)
  297. def _catalog_row(artifact, *, status, binding_hash="d" * 64):
  298. return {
  299. "id": new_governance_uid(),
  300. "artifact_ref": artifact["artifact_ref"],
  301. "artifact_digest": artifact["digest"],
  302. "row_count": artifact["row_count"],
  303. "schema_hash": artifact["schema_hash"],
  304. "schema_fields": artifact["schema_fields"],
  305. "expires_at": artifact["expires_at"],
  306. "handoff_status": status,
  307. "binding_hash": binding_hash,
  308. "binding_id": new_governance_uid(),
  309. "artifact_kind": "output",
  310. "correlation_id": artifact["artifact_ref"].split("/")[4],
  311. }
  312. def test_reconcile_repairs_both_catalog_and_object_store_safely():
  313. from app.runner.artifacts import PostgresArtifactResolver
  314. now = datetime(2026, 7, 23, 12, 0, tzinfo=UTC)
  315. client = ReconcileMinio(now)
  316. store = _store(client, clock=lambda: now)
  317. valid_pending = store.write(
  318. pl.DataFrame({"id": [1]}),
  319. new_governance_uid(),
  320. 600,
  321. )
  322. missing_pending = store.write(
  323. pl.DataFrame({"id": [2]}),
  324. new_governance_uid(),
  325. 600,
  326. )
  327. store.delete(missing_pending["artifact_ref"])
  328. missing_ready = store.write(
  329. pl.DataFrame({"id": [3]}),
  330. new_governance_uid(),
  331. 600,
  332. )
  333. store.delete(missing_ready["artifact_ref"])
  334. invalid_pending = store.write(
  335. pl.DataFrame({"id": [6]}),
  336. new_governance_uid(),
  337. 600,
  338. )
  339. invalid_key = invalid_pending["artifact_ref"].split("/", 3)[-1]
  340. client.objects[("dataops-rules", invalid_key)][
  341. "content_type"
  342. ] = "text/plain"
  343. old_orphan = store.write(
  344. pl.DataFrame({"id": [4]}),
  345. new_governance_uid(),
  346. 600,
  347. )
  348. fresh_orphan = store.write(
  349. pl.DataFrame({"id": [5]}),
  350. new_governance_uid(),
  351. 600,
  352. )
  353. old_key = old_orphan["artifact_ref"].split("/", 3)[-1]
  354. fresh_key = fresh_orphan["artifact_ref"].split("/", 3)[-1]
  355. client.ages[old_key] = now - timedelta(minutes=20)
  356. client.ages[fresh_key] = now - timedelta(seconds=30)
  357. client.objects[
  358. ("dataops-rules", "rules/not-a-safe-catalog-key.parquet")
  359. ] = copy.deepcopy(next(iter(client.objects.values())))
  360. client.ages["rules/not-a-safe-catalog-key.parquet"] = now - timedelta(
  361. hours=1
  362. )
  363. engine = ReconcileEngine(
  364. [
  365. _catalog_row(valid_pending, status="pending"),
  366. _catalog_row(missing_pending, status="pending"),
  367. _catalog_row(missing_ready, status="ready"),
  368. _catalog_row(invalid_pending, status="pending"),
  369. ]
  370. )
  371. result = PostgresArtifactResolver(engine, store).reconcile(
  372. limit=10,
  373. grace_seconds=300,
  374. )
  375. assert result == {
  376. "pending_finalized": 1,
  377. "pending_deleted": 1,
  378. "ready_failed": 1,
  379. "orphans_deleted": 1,
  380. }
  381. assert ("dataops-rules", old_key) not in client.objects
  382. assert ("dataops-rules", fresh_key) in client.objects
  383. assert ("dataops-rules", invalid_key) not in client.objects
  384. assert (
  385. "dataops-rules",
  386. "rules/not-a-safe-catalog-key.parquet",
  387. ) in client.objects
  388. assert any(
  389. row["handoff_status"] == "ready"
  390. and row["artifact_ref"] == valid_pending["artifact_ref"]
  391. for row in engine.rows
  392. )
  393. assert any(
  394. row["handoff_status"] == "failed"
  395. and row["artifact_ref"] == missing_ready["artifact_ref"]
  396. for row in engine.rows
  397. )
  398. assert any(
  399. row["handoff_status"] == "failed"
  400. and row["artifact_ref"] == invalid_pending["artifact_ref"]
  401. and row["failure_code"] == "pending_object_invalid"
  402. for row in engine.rows
  403. )