test_artifacts.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  1. from __future__ import annotations
  2. import io
  3. from datetime import UTC, datetime
  4. from types import SimpleNamespace
  5. import polars as pl
  6. import pytest
  7. from app.core.common.identifiers import new_governance_uid
  8. class Response(io.BytesIO):
  9. def release_conn(self):
  10. return None
  11. class FakeMinio:
  12. def __init__(self):
  13. self.buckets = {"dataops-rules"}
  14. self.objects = {}
  15. self.get_calls = []
  16. self.removed = []
  17. def bucket_exists(self, bucket):
  18. return bucket in self.buckets
  19. def make_bucket(self, bucket):
  20. self.buckets.add(bucket)
  21. def put_object(
  22. self,
  23. bucket,
  24. key,
  25. data,
  26. length,
  27. *,
  28. content_type,
  29. metadata,
  30. ):
  31. payload = data.read(length)
  32. self.objects[(bucket, key)] = {
  33. "payload": payload,
  34. "content_type": content_type,
  35. "metadata": {
  36. f"x-amz-meta-{name.lower()}": str(value)
  37. for name, value in metadata.items()
  38. },
  39. }
  40. def stat_object(self, bucket, key):
  41. item = self.objects[(bucket, key)]
  42. return SimpleNamespace(
  43. size=len(item["payload"]),
  44. content_type=item["content_type"],
  45. metadata=item["metadata"],
  46. )
  47. def get_object(self, bucket, key):
  48. self.get_calls.append((bucket, key))
  49. return Response(self.objects[(bucket, key)]["payload"])
  50. def remove_object(self, bucket, key):
  51. self.removed.append((bucket, key))
  52. self.objects.pop((bucket, key), None)
  53. def list_objects(self, bucket, *, prefix, recursive):
  54. assert recursive is True
  55. return [
  56. SimpleNamespace(object_name=key)
  57. for object_bucket, key in sorted(self.objects)
  58. if object_bucket == bucket and key.startswith(prefix)
  59. ]
  60. def _store(client, *, clock=None, max_rows=100):
  61. from app.runner.artifacts import ArtifactStore
  62. return ArtifactStore(
  63. client,
  64. bucket="dataops-rules",
  65. max_artifact_bytes=1024 * 1024,
  66. max_rows=max_rows,
  67. memory_limit_bytes=4 * 1024 * 1024,
  68. max_ttl_seconds=3600,
  69. clock=clock,
  70. )
  71. def test_artifact_store_generates_key_and_round_trips_digest_bound_lazyframe():
  72. client = FakeMinio()
  73. store = _store(client)
  74. correlation_id = new_governance_uid()
  75. artifact = store.write(
  76. pl.DataFrame(
  77. {
  78. "customer_id": [1, 2],
  79. "name": ["Alice", "Bob"],
  80. }
  81. ).lazy(),
  82. correlation_id,
  83. 300,
  84. )
  85. assert artifact["artifact_ref"].startswith(
  86. f"minio://dataops-rules/rules/{correlation_id}/"
  87. )
  88. assert artifact["artifact_ref"].endswith(".parquet")
  89. assert artifact["digest"]
  90. assert artifact["row_count"] == 2
  91. assert artifact["schema_hash"]
  92. assert artifact["expires_at"].endswith("Z")
  93. assert "dataops-test" not in repr(artifact)
  94. assert store.describe(artifact["artifact_ref"]) == artifact
  95. frame = store.read(artifact["artifact_ref"], artifact["digest"])
  96. assert isinstance(frame, pl.LazyFrame)
  97. assert frame.collect().to_dicts() == [
  98. {"customer_id": 1, "name": "Alice"},
  99. {"customer_id": 2, "name": "Bob"},
  100. ]
  101. @pytest.mark.parametrize(
  102. ("mutation", "message"),
  103. [
  104. (
  105. lambda item: item["metadata"].update(
  106. {"x-amz-meta-sha256": "0" * 64}
  107. ),
  108. "digest",
  109. ),
  110. (
  111. lambda item: item["metadata"].update(
  112. {"x-amz-meta-schema-sha256": "0" * 64}
  113. ),
  114. "schema",
  115. ),
  116. (
  117. lambda item: item["metadata"].update(
  118. {"x-amz-meta-expires-at": "2000-01-01T00:00:00Z"}
  119. ),
  120. "expired",
  121. ),
  122. (
  123. lambda item: item.update({"content_type": "text/plain"}),
  124. "content type",
  125. ),
  126. ],
  127. )
  128. def test_artifact_store_rejects_tampered_digest_schema_ttl_and_content(
  129. mutation, message
  130. ):
  131. client = FakeMinio()
  132. now = datetime(2026, 7, 23, 10, 0, tzinfo=UTC)
  133. store = _store(client, clock=lambda: now)
  134. artifact = store.write(
  135. pl.DataFrame({"id": [1]}).lazy(),
  136. new_governance_uid(),
  137. 300,
  138. )
  139. key = artifact["artifact_ref"].split("/", 3)[-1]
  140. mutation(client.objects[("dataops-rules", key)])
  141. with pytest.raises(ValueError, match=message):
  142. store.read(artifact["artifact_ref"], artifact["digest"])
  143. def test_artifact_store_rejects_rows_size_ttl_and_unowned_references():
  144. client = FakeMinio()
  145. store = _store(client, max_rows=2)
  146. with pytest.raises(ValueError, match="row"):
  147. store.write(
  148. pl.DataFrame({"id": [1, 2, 3]}).lazy(),
  149. new_governance_uid(),
  150. 60,
  151. )
  152. with pytest.raises(ValueError, match="TTL"):
  153. store.write(
  154. pl.DataFrame({"id": [1]}).lazy(),
  155. new_governance_uid(),
  156. 7200,
  157. )
  158. with pytest.raises(ValueError, match="artifact reference"):
  159. store.read(
  160. "minio://other-bucket/rules/unsafe/value.parquet",
  161. "0" * 64,
  162. )
  163. def test_artifact_store_does_not_return_ref_for_corrupted_server_content():
  164. class CorruptingMinio(FakeMinio):
  165. def put_object(self, bucket, key, *args, **kwargs):
  166. super().put_object(bucket, key, *args, **kwargs)
  167. payload = self.objects[(bucket, key)]["payload"]
  168. self.objects[(bucket, key)]["payload"] = bytes(
  169. [payload[0] ^ 1]
  170. ) + payload[1:]
  171. store = _store(CorruptingMinio())
  172. with pytest.raises(ValueError, match="digest|size"):
  173. store.write(
  174. pl.DataFrame({"id": [1]}).lazy(),
  175. new_governance_uid(),
  176. 60,
  177. )
  178. assert not store.client.objects
  179. def test_artifact_store_applies_plan_limits_before_download_and_serialization():
  180. client = FakeMinio()
  181. store = _store(client, max_rows=100)
  182. correlation_id = new_governance_uid()
  183. artifact = store.write(
  184. pl.DataFrame({"id": [1, 2]}).lazy(),
  185. correlation_id,
  186. 60,
  187. )
  188. calls_before = len(client.get_calls)
  189. with pytest.raises(ValueError, match="row count"):
  190. store.read(
  191. artifact["artifact_ref"],
  192. artifact["digest"],
  193. limits={
  194. "max_rows": 1,
  195. "max_artifact_bytes": 1024 * 1024,
  196. "memory_limit_bytes": 4 * 1024 * 1024,
  197. },
  198. )
  199. assert len(client.get_calls) == calls_before
  200. with pytest.raises(ValueError, match="row count"):
  201. store.write(
  202. pl.DataFrame({"id": [1, 2]}).lazy(),
  203. correlation_id,
  204. 60,
  205. limits={
  206. "max_rows": 1,
  207. "max_artifact_bytes": 1024 * 1024,
  208. "memory_limit_bytes": 4 * 1024 * 1024,
  209. },
  210. )
  211. def test_artifact_schema_contract_covers_nullability_decimal_and_timezone():
  212. client = FakeMinio()
  213. store = _store(client)
  214. correlation_id = new_governance_uid()
  215. fields = [
  216. {
  217. "name": "amount",
  218. "type": "decimal",
  219. "nullable": False,
  220. "precision": 12,
  221. "scale": 2,
  222. },
  223. {
  224. "name": "occurred_at",
  225. "type": "timestamptz",
  226. "nullable": False,
  227. "timezone": "Asia/Shanghai",
  228. },
  229. ]
  230. frame = pl.DataFrame(
  231. {
  232. "amount": ["12.34"],
  233. "occurred_at": ["2026-07-23T10:00:00+08:00"],
  234. }
  235. ).with_columns(
  236. pl.col("amount").cast(pl.Decimal(12, 2)),
  237. pl.col("occurred_at")
  238. .str.to_datetime(time_zone="Asia/Shanghai")
  239. .alias("occurred_at"),
  240. )
  241. artifact = store.write(
  242. frame.lazy(),
  243. correlation_id,
  244. 60,
  245. schema_fields=fields,
  246. )
  247. assert artifact["schema_fields"] == fields
  248. store.read(
  249. artifact["artifact_ref"],
  250. artifact["digest"],
  251. expected_schema_fields=fields,
  252. ).collect()
  253. copy_fields = [dict(field) for field in fields]
  254. copy_fields[1] = {**copy_fields[1], "timezone": "UTC"}
  255. with pytest.raises(ValueError, match="timezone|schema"):
  256. store.read(
  257. artifact["artifact_ref"],
  258. artifact["digest"],
  259. expected_schema_fields=copy_fields,
  260. )
  261. with pytest.raises(ValueError, match="nullable"):
  262. store.write(
  263. pl.DataFrame(
  264. {
  265. "amount": [None],
  266. "occurred_at": [None],
  267. },
  268. schema={
  269. "amount": pl.Decimal(12, 2),
  270. "occurred_at": pl.Datetime(
  271. "us", "Asia/Shanghai"
  272. ),
  273. },
  274. ).lazy(),
  275. correlation_id,
  276. 60,
  277. schema_fields=fields,
  278. )
  279. def test_artifact_cleanup_is_expired_and_correlation_scoped_only():
  280. client = FakeMinio()
  281. now = datetime(2026, 7, 23, 10, 0, tzinfo=UTC)
  282. store = _store(client, clock=lambda: now)
  283. first = new_governance_uid()
  284. second = new_governance_uid()
  285. expired = store.write(pl.DataFrame({"id": [1]}), first, 10)
  286. active = store.write(pl.DataFrame({"id": [2]}), second, 300)
  287. now = datetime(2026, 7, 23, 10, 0, 20, tzinfo=UTC)
  288. assert store.cleanup_expired(first) == 1
  289. assert not any(
  290. key == expired["artifact_ref"].split("/", 3)[-1]
  291. for _bucket, key in client.objects
  292. )
  293. assert store.describe(active["artifact_ref"])["row_count"] == 1
  294. class _Rows:
  295. def __init__(self, row=None):
  296. self.row = row
  297. def mappings(self):
  298. return self
  299. def one_or_none(self):
  300. return self.row
  301. class _Connection:
  302. def __init__(self, engine):
  303. self.engine = engine
  304. def __enter__(self):
  305. return self
  306. def __exit__(self, *_args):
  307. return None
  308. def execute(self, statement, parameters):
  309. sql = str(statement)
  310. self.engine.calls.append((sql, dict(parameters)))
  311. return _Rows(self.engine.handler(sql, parameters))
  312. class _Engine:
  313. def __init__(self, handler):
  314. self.handler = handler
  315. self.calls = []
  316. def connect(self):
  317. return _Connection(self)
  318. def begin(self):
  319. return _Connection(self)
  320. def test_postgres_artifact_resolver_uses_catalog_and_rechecks_binding_hash():
  321. from app.runner.artifacts import PostgresArtifactResolver
  322. store = _store(FakeMinio())
  323. correlation_id = new_governance_uid()
  324. binding_id = new_governance_uid()
  325. binding_hash = "a" * 64
  326. artifact = store.write(
  327. pl.DataFrame({"id": [1]}),
  328. correlation_id,
  329. 60,
  330. )
  331. def handler(sql, _parameters):
  332. if "FROM public.rule_run_artifacts" in sql:
  333. return {
  334. **artifact,
  335. "artifact_digest": artifact["digest"],
  336. "binding_hash": binding_hash,
  337. }
  338. raise AssertionError(sql)
  339. engine = _Engine(handler)
  340. resolved = PostgresArtifactResolver(engine, store).resolve(
  341. binding_id=binding_id,
  342. correlation_id=correlation_id,
  343. )
  344. assert resolved["artifact_ref"] == artifact["artifact_ref"]
  345. assert resolved["binding_hash"] == binding_hash
  346. assert "dataflow_dataset_bindings b" in engine.calls[0][0]
  347. assert "a.correlation_id = CAST(:correlation_id AS uuid)" in (
  348. engine.calls[0][0]
  349. )
  350. with pytest.raises(ValueError, match="correlation"):
  351. PostgresArtifactResolver(engine, store).resolve(
  352. binding_id=binding_id,
  353. correlation_id=new_governance_uid(),
  354. )
  355. def test_postgres_artifact_resolver_attests_and_registers_stable_handoff():
  356. from app.runner.artifacts import PostgresArtifactResolver
  357. store = _store(FakeMinio())
  358. correlation_id = new_governance_uid()
  359. binding_id = new_governance_uid()
  360. binding_hash = "b" * 64
  361. artifact = store.write(
  362. pl.DataFrame({"id": [1]}),
  363. correlation_id,
  364. 60,
  365. )
  366. def handler(sql, parameters):
  367. if "FROM public.dataflow_dataset_bindings" in sql:
  368. return {
  369. "binding_hash": binding_hash,
  370. "access_mode": "read_write",
  371. "object_kind": "parquet_artifact",
  372. }
  373. if "INSERT INTO public.rule_run_artifacts" in sql:
  374. assert parameters["artifact_digest"] == artifact["digest"]
  375. assert parameters["schema_fields"]
  376. return None
  377. raise AssertionError(sql)
  378. engine = _Engine(handler)
  379. resolver = PostgresArtifactResolver(engine, store)
  380. resolver.attest_binding(
  381. binding_id=binding_id,
  382. binding_hash=binding_hash,
  383. access_mode="write",
  384. )
  385. resolver.register(
  386. binding_id=binding_id,
  387. correlation_id=correlation_id,
  388. artifact=artifact,
  389. kind="output",
  390. binding_hash=binding_hash,
  391. )
  392. assert sum(
  393. "FROM public.dataflow_dataset_bindings" in sql
  394. for sql, _parameters in engine.calls
  395. ) == 2
  396. assert any(
  397. "INSERT INTO public.rule_run_artifacts" in sql
  398. for sql, _parameters in engine.calls
  399. )