test_artifacts.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704
  1. from __future__ import annotations
  2. import io
  3. import json
  4. from datetime import UTC, datetime
  5. from types import SimpleNamespace
  6. import polars as pl
  7. import pytest
  8. from app.core.common.identifiers import new_governance_uid
  9. class Response(io.BytesIO):
  10. def release_conn(self):
  11. return None
  12. class FakeMinio:
  13. def __init__(self):
  14. self.buckets = {"dataops-rules"}
  15. self.objects = {}
  16. self.get_calls = []
  17. self.removed = []
  18. def bucket_exists(self, bucket):
  19. return bucket in self.buckets
  20. def make_bucket(self, bucket):
  21. self.buckets.add(bucket)
  22. def put_object(
  23. self,
  24. bucket,
  25. key,
  26. data,
  27. length,
  28. *,
  29. content_type,
  30. metadata,
  31. ):
  32. payload = data.read(length)
  33. self.objects[(bucket, key)] = {
  34. "payload": payload,
  35. "content_type": content_type,
  36. "metadata": {
  37. f"x-amz-meta-{name.lower()}": str(value)
  38. for name, value in metadata.items()
  39. },
  40. }
  41. def stat_object(self, bucket, key):
  42. item = self.objects[(bucket, key)]
  43. return SimpleNamespace(
  44. size=len(item["payload"]),
  45. content_type=item["content_type"],
  46. metadata=item["metadata"],
  47. )
  48. def get_object(self, bucket, key):
  49. self.get_calls.append((bucket, key))
  50. return Response(self.objects[(bucket, key)]["payload"])
  51. def remove_object(self, bucket, key):
  52. self.removed.append((bucket, key))
  53. self.objects.pop((bucket, key), None)
  54. def list_objects(self, bucket, *, prefix, recursive):
  55. assert recursive is True
  56. return [
  57. SimpleNamespace(object_name=key)
  58. for object_bucket, key in sorted(self.objects)
  59. if object_bucket == bucket and key.startswith(prefix)
  60. ]
  61. def _store(client, *, clock=None, max_rows=100):
  62. from app.runner.artifacts import ArtifactStore
  63. return ArtifactStore(
  64. client,
  65. bucket="dataops-rules",
  66. max_artifact_bytes=1024 * 1024,
  67. max_rows=max_rows,
  68. memory_limit_bytes=4 * 1024 * 1024,
  69. max_ttl_seconds=3600,
  70. clock=clock,
  71. )
  72. def test_artifact_store_generates_key_and_round_trips_digest_bound_lazyframe():
  73. client = FakeMinio()
  74. store = _store(client)
  75. correlation_id = new_governance_uid()
  76. artifact = store.write(
  77. pl.DataFrame(
  78. {
  79. "customer_id": [1, 2],
  80. "name": ["Alice", "Bob"],
  81. }
  82. ).lazy(),
  83. correlation_id,
  84. 300,
  85. )
  86. assert artifact["artifact_ref"].startswith(
  87. f"minio://dataops-rules/rules/{correlation_id}/"
  88. )
  89. assert artifact["artifact_ref"].endswith(".parquet")
  90. assert artifact["digest"]
  91. assert artifact["row_count"] == 2
  92. assert artifact["schema_hash"]
  93. assert artifact["expires_at"].endswith("Z")
  94. assert "dataops-test" not in repr(artifact)
  95. assert store.describe(artifact["artifact_ref"]) == {
  96. key: artifact[key]
  97. for key in (
  98. "artifact_ref",
  99. "digest",
  100. "row_count",
  101. "schema_hash",
  102. "expires_at",
  103. )
  104. }
  105. stored = next(iter(client.objects.values()))
  106. assert "x-amz-meta-schema-contract" not in stored["metadata"]
  107. assert sum(
  108. len(key) + len(value)
  109. for key, value in stored["metadata"].items()
  110. ) <= 2_048
  111. frame = store.read(
  112. artifact["artifact_ref"],
  113. artifact["digest"],
  114. expected_schema_fields=artifact["schema_fields"],
  115. )
  116. assert isinstance(frame, pl.LazyFrame)
  117. assert frame.collect().to_dicts() == [
  118. {"customer_id": 1, "name": "Alice"},
  119. {"customer_id": 2, "name": "Bob"},
  120. ]
  121. @pytest.mark.parametrize(
  122. ("mutation", "message"),
  123. [
  124. (
  125. lambda item: item["metadata"].update(
  126. {"x-amz-meta-sha256": "0" * 64}
  127. ),
  128. "digest",
  129. ),
  130. (
  131. lambda item: item["metadata"].update(
  132. {"x-amz-meta-schema-sha256": "0" * 64}
  133. ),
  134. "schema",
  135. ),
  136. (
  137. lambda item: item["metadata"].update(
  138. {"x-amz-meta-expires-at": "2000-01-01T00:00:00Z"}
  139. ),
  140. "expired",
  141. ),
  142. (
  143. lambda item: item.update({"content_type": "text/plain"}),
  144. "content type",
  145. ),
  146. ],
  147. )
  148. def test_artifact_store_rejects_tampered_digest_schema_ttl_and_content(
  149. mutation, message
  150. ):
  151. client = FakeMinio()
  152. now = datetime(2026, 7, 23, 10, 0, tzinfo=UTC)
  153. store = _store(client, clock=lambda: now)
  154. artifact = store.write(
  155. pl.DataFrame({"id": [1]}).lazy(),
  156. new_governance_uid(),
  157. 300,
  158. )
  159. key = artifact["artifact_ref"].split("/", 3)[-1]
  160. mutation(client.objects[("dataops-rules", key)])
  161. with pytest.raises(ValueError, match=message):
  162. store.read(
  163. artifact["artifact_ref"],
  164. artifact["digest"],
  165. expected_schema_fields=artifact["schema_fields"],
  166. )
  167. def test_artifact_store_rejects_rows_size_ttl_and_unowned_references():
  168. client = FakeMinio()
  169. store = _store(client, max_rows=2)
  170. with pytest.raises(ValueError, match="row"):
  171. store.write(
  172. pl.DataFrame({"id": [1, 2, 3]}).lazy(),
  173. new_governance_uid(),
  174. 60,
  175. )
  176. with pytest.raises(ValueError, match="TTL"):
  177. store.write(
  178. pl.DataFrame({"id": [1]}).lazy(),
  179. new_governance_uid(),
  180. 7200,
  181. )
  182. with pytest.raises(ValueError, match="artifact reference"):
  183. store.read(
  184. "minio://other-bucket/rules/unsafe/value.parquet",
  185. "0" * 64,
  186. expected_schema_fields=[
  187. {"name": "id", "type": "integer", "nullable": True}
  188. ],
  189. )
  190. def test_artifact_store_does_not_return_ref_for_corrupted_server_content():
  191. class CorruptingMinio(FakeMinio):
  192. def put_object(self, bucket, key, *args, **kwargs):
  193. super().put_object(bucket, key, *args, **kwargs)
  194. payload = self.objects[(bucket, key)]["payload"]
  195. self.objects[(bucket, key)]["payload"] = bytes(
  196. [payload[0] ^ 1]
  197. ) + payload[1:]
  198. store = _store(CorruptingMinio())
  199. with pytest.raises(ValueError, match="digest|size"):
  200. store.write(
  201. pl.DataFrame({"id": [1]}).lazy(),
  202. new_governance_uid(),
  203. 60,
  204. )
  205. assert not store.client.objects
  206. def test_artifact_store_applies_plan_limits_before_download_and_serialization():
  207. client = FakeMinio()
  208. store = _store(client, max_rows=100)
  209. correlation_id = new_governance_uid()
  210. artifact = store.write(
  211. pl.DataFrame({"id": [1, 2]}).lazy(),
  212. correlation_id,
  213. 60,
  214. )
  215. calls_before = len(client.get_calls)
  216. with pytest.raises(ValueError, match="row count"):
  217. store.read(
  218. artifact["artifact_ref"],
  219. artifact["digest"],
  220. expected_schema_fields=artifact["schema_fields"],
  221. limits={
  222. "max_rows": 1,
  223. "max_artifact_bytes": 1024 * 1024,
  224. "memory_limit_bytes": 4 * 1024 * 1024,
  225. },
  226. )
  227. assert len(client.get_calls) == calls_before
  228. with pytest.raises(ValueError, match="row count"):
  229. store.write(
  230. pl.DataFrame({"id": [1, 2]}).lazy(),
  231. correlation_id,
  232. 60,
  233. limits={
  234. "max_rows": 1,
  235. "max_artifact_bytes": 1024 * 1024,
  236. "memory_limit_bytes": 4 * 1024 * 1024,
  237. },
  238. )
  239. def test_artifact_stage_streams_to_tempfile_and_preflights_parquet_footer(
  240. ):
  241. import inspect
  242. from app.runner.artifacts import ArtifactStore
  243. client = FakeMinio()
  244. store = _store(client)
  245. correlation_id = new_governance_uid()
  246. artifact = store.write(
  247. pl.DataFrame({"id": [1, 2]}),
  248. correlation_id,
  249. 60,
  250. )
  251. assert "BytesIO" not in inspect.getsource(ArtifactStore.stage)
  252. with store.stage(
  253. artifact["artifact_ref"],
  254. artifact["digest"],
  255. expected_schema_fields=artifact["schema_fields"],
  256. ) as staged:
  257. assert pl.scan_parquet(staged).collect().height == 2
  258. def test_artifact_stage_rejects_compression_bomb_from_footer_before_scan():
  259. from app.runner.artifacts import ArtifactStore
  260. client = FakeMinio()
  261. store = ArtifactStore(
  262. client,
  263. bucket="dataops-rules",
  264. max_artifact_bytes=4 * 1024 * 1024,
  265. max_rows=1_000,
  266. memory_limit_bytes=64 * 1024 * 1024,
  267. max_ttl_seconds=3600,
  268. )
  269. correlation_id = new_governance_uid()
  270. artifact = store.write(
  271. pl.DataFrame(
  272. {
  273. "payload": [
  274. f"{index}-" + ("compressible-value-" * 10_000)
  275. for index in range(100)
  276. ]
  277. }
  278. ),
  279. correlation_id,
  280. 60,
  281. )
  282. calls_before = len(client.get_calls)
  283. with pytest.raises(
  284. ValueError, match="uncompressed|footer"
  285. ), store.stage(
  286. artifact["artifact_ref"],
  287. artifact["digest"],
  288. expected_schema_fields=artifact["schema_fields"],
  289. limits={
  290. "max_rows": 1_000,
  291. "max_artifact_bytes": 4 * 1024 * 1024,
  292. "memory_limit_bytes": 1024 * 1024,
  293. },
  294. ):
  295. raise AssertionError("compression bomb must not be exposed")
  296. assert len(client.get_calls) == calls_before + 1
  297. def test_artifact_schema_contract_covers_nullability_decimal_and_timezone():
  298. client = FakeMinio()
  299. store = _store(client)
  300. correlation_id = new_governance_uid()
  301. fields = [
  302. {
  303. "name": "amount",
  304. "type": "decimal",
  305. "nullable": False,
  306. "precision": 12,
  307. "scale": 2,
  308. },
  309. {
  310. "name": "occurred_at",
  311. "type": "timestamptz",
  312. "nullable": False,
  313. "timezone": "Asia/Shanghai",
  314. },
  315. ]
  316. frame = pl.DataFrame(
  317. {
  318. "amount": ["12.34"],
  319. "occurred_at": ["2026-07-23T10:00:00+08:00"],
  320. }
  321. ).with_columns(
  322. pl.col("amount").cast(pl.Decimal(12, 2)),
  323. pl.col("occurred_at")
  324. .str.to_datetime(time_zone="Asia/Shanghai")
  325. .alias("occurred_at"),
  326. )
  327. artifact = store.write(
  328. frame.lazy(),
  329. correlation_id,
  330. 60,
  331. schema_fields=fields,
  332. )
  333. assert artifact["schema_fields"] == fields
  334. store.read(
  335. artifact["artifact_ref"],
  336. artifact["digest"],
  337. expected_schema_fields=fields,
  338. ).collect()
  339. copy_fields = [dict(field) for field in fields]
  340. copy_fields[1] = {**copy_fields[1], "timezone": "UTC"}
  341. with pytest.raises(ValueError, match="timezone|schema"):
  342. store.read(
  343. artifact["artifact_ref"],
  344. artifact["digest"],
  345. expected_schema_fields=copy_fields,
  346. )
  347. with pytest.raises(ValueError, match="nullable"):
  348. store.write(
  349. pl.DataFrame(
  350. {
  351. "amount": [None],
  352. "occurred_at": [None],
  353. },
  354. schema={
  355. "amount": pl.Decimal(12, 2),
  356. "occurred_at": pl.Datetime(
  357. "us", "Asia/Shanghai"
  358. ),
  359. },
  360. ).lazy(),
  361. correlation_id,
  362. 60,
  363. schema_fields=fields,
  364. )
  365. def test_artifact_cleanup_is_expired_and_correlation_scoped_only():
  366. client = FakeMinio()
  367. now = datetime(2026, 7, 23, 10, 0, tzinfo=UTC)
  368. store = _store(client, clock=lambda: now)
  369. first = new_governance_uid()
  370. second = new_governance_uid()
  371. expired = store.write(pl.DataFrame({"id": [1]}), first, 10)
  372. active = store.write(pl.DataFrame({"id": [2]}), second, 300)
  373. now = datetime(2026, 7, 23, 10, 0, 20, tzinfo=UTC)
  374. assert store.cleanup_expired(first) == 1
  375. assert not any(
  376. key == expired["artifact_ref"].split("/", 3)[-1]
  377. for _bucket, key in client.objects
  378. )
  379. assert store.describe(active["artifact_ref"])["row_count"] == 1
  380. class _Rows:
  381. def __init__(self, row=None):
  382. self.row = row
  383. def mappings(self):
  384. return self
  385. def one_or_none(self):
  386. return self.row
  387. def all(self):
  388. if self.row is None:
  389. return []
  390. return self.row if isinstance(self.row, list) else [self.row]
  391. class _Connection:
  392. def __init__(self, engine):
  393. self.engine = engine
  394. def __enter__(self):
  395. return self
  396. def __exit__(self, *_args):
  397. return None
  398. def execute(self, statement, parameters):
  399. sql = str(statement)
  400. self.engine.calls.append((sql, dict(parameters)))
  401. return _Rows(self.engine.handler(sql, parameters))
  402. class _Engine:
  403. def __init__(self, handler):
  404. self.handler = handler
  405. self.calls = []
  406. def connect(self):
  407. return _Connection(self)
  408. def begin(self):
  409. return _Connection(self)
  410. def test_postgres_artifact_resolver_uses_catalog_and_rechecks_binding_hash():
  411. from app.runner.artifacts import PostgresArtifactResolver
  412. store = _store(FakeMinio())
  413. correlation_id = new_governance_uid()
  414. binding_id = new_governance_uid()
  415. binding_hash = "a" * 64
  416. artifact = store.write(
  417. pl.DataFrame({"id": [1]}),
  418. correlation_id,
  419. 60,
  420. )
  421. def handler(sql, _parameters):
  422. if "FROM public.rule_run_artifacts" in sql:
  423. return {
  424. **artifact,
  425. "artifact_digest": artifact["digest"],
  426. "binding_hash": binding_hash,
  427. }
  428. raise AssertionError(sql)
  429. engine = _Engine(handler)
  430. resolved = PostgresArtifactResolver(engine, store).resolve(
  431. binding_id=binding_id,
  432. correlation_id=correlation_id,
  433. kind="input",
  434. )
  435. assert resolved["artifact_ref"] == artifact["artifact_ref"]
  436. assert resolved["binding_hash"] == binding_hash
  437. assert "dataflow_dataset_bindings b" in engine.calls[0][0]
  438. assert "a.correlation_id = CAST(:correlation_id AS uuid)" in (
  439. engine.calls[0][0]
  440. )
  441. with pytest.raises(ValueError, match="correlation"):
  442. PostgresArtifactResolver(engine, store).resolve(
  443. binding_id=binding_id,
  444. correlation_id=new_governance_uid(),
  445. kind="input",
  446. )
  447. def test_postgres_artifact_resolver_attests_and_registers_stable_handoff():
  448. from app.runner.artifacts import PostgresArtifactResolver
  449. store = _store(FakeMinio())
  450. correlation_id = new_governance_uid()
  451. binding_id = new_governance_uid()
  452. binding_hash = "b" * 64
  453. artifact = store.write(
  454. pl.DataFrame({"id": [1]}),
  455. correlation_id,
  456. 60,
  457. )
  458. def handler(sql, parameters):
  459. if "FROM public.dataflow_dataset_bindings" in sql:
  460. return {
  461. "binding_hash": binding_hash,
  462. "access_mode": "read_write",
  463. "object_kind": "parquet_artifact",
  464. }
  465. if "INSERT INTO public.rule_run_artifacts" in sql:
  466. assert parameters["artifact_digest"] == artifact["digest"]
  467. assert parameters["schema_fields"]
  468. return {
  469. "artifact_ref": parameters["artifact_ref"],
  470. "artifact_digest": parameters["artifact_digest"],
  471. "row_count": parameters["row_count"],
  472. "schema_hash": parameters["schema_hash"],
  473. "schema_fields": json.loads(parameters["schema_fields"]),
  474. "expires_at": parameters["expires_at"],
  475. }
  476. raise AssertionError(sql)
  477. engine = _Engine(handler)
  478. resolver = PostgresArtifactResolver(engine, store)
  479. resolver.attest_binding(
  480. binding_id=binding_id,
  481. binding_hash=binding_hash,
  482. access_mode="write",
  483. )
  484. resolver.register(
  485. binding_id=binding_id,
  486. correlation_id=correlation_id,
  487. artifact=artifact,
  488. kind="output",
  489. binding_hash=binding_hash,
  490. )
  491. assert sum(
  492. "FROM public.dataflow_dataset_bindings" in sql
  493. for sql, _parameters in engine.calls
  494. ) == 2
  495. assert any(
  496. "INSERT INTO public.rule_run_artifacts" in sql
  497. for sql, _parameters in engine.calls
  498. )
  499. def test_artifact_catalog_reuses_same_digest_and_rejects_digest_conflict():
  500. from app.runner.artifacts import PostgresArtifactResolver
  501. store = _store(FakeMinio())
  502. correlation_id = new_governance_uid()
  503. binding_id = new_governance_uid()
  504. binding_hash = "c" * 64
  505. catalog = {}
  506. def handler(sql, parameters):
  507. if "FROM public.dataflow_dataset_bindings" in sql:
  508. return {
  509. "binding_hash": binding_hash,
  510. "access_mode": "read_write",
  511. "object_kind": "parquet_artifact",
  512. }
  513. if "INSERT INTO public.rule_run_artifacts" in sql:
  514. key = (
  515. parameters["correlation_id"],
  516. parameters["binding_id"],
  517. parameters["artifact_kind"],
  518. )
  519. if key in catalog:
  520. return None
  521. catalog[key] = {
  522. "artifact_ref": parameters["artifact_ref"],
  523. "artifact_digest": parameters["artifact_digest"],
  524. "row_count": parameters["row_count"],
  525. "schema_hash": parameters["schema_hash"],
  526. "schema_fields": json.loads(parameters["schema_fields"]),
  527. "expires_at": parameters["expires_at"],
  528. }
  529. return catalog[key]
  530. if "FROM public.rule_run_artifacts" in sql:
  531. key = (
  532. parameters["correlation_id"],
  533. parameters["binding_id"],
  534. parameters["artifact_kind"],
  535. )
  536. return catalog.get(key)
  537. raise AssertionError(sql)
  538. engine = _Engine(handler)
  539. resolver = PostgresArtifactResolver(engine, store)
  540. first = store.write(
  541. pl.DataFrame({"id": [1]}),
  542. correlation_id,
  543. 60,
  544. )
  545. first_registered = resolver.register(
  546. binding_id=binding_id,
  547. correlation_id=correlation_id,
  548. artifact=first,
  549. kind="output",
  550. binding_hash=binding_hash,
  551. )
  552. retry = store.write(
  553. pl.DataFrame({"id": [1]}),
  554. correlation_id,
  555. 60,
  556. )
  557. retry_registered = resolver.register(
  558. binding_id=binding_id,
  559. correlation_id=correlation_id,
  560. artifact=retry,
  561. kind="output",
  562. binding_hash=binding_hash,
  563. )
  564. assert retry_registered["artifact_ref"] == first_registered["artifact_ref"]
  565. assert retry["artifact_ref"] != first_registered["artifact_ref"]
  566. assert (
  567. "dataops-rules",
  568. retry["artifact_ref"].split("/", 3)[-1],
  569. ) in store.client.removed
  570. conflict = store.write(
  571. pl.DataFrame({"id": [2]}),
  572. correlation_id,
  573. 60,
  574. )
  575. with pytest.raises(ValueError, match="immutable|digest"):
  576. resolver.register(
  577. binding_id=binding_id,
  578. correlation_id=correlation_id,
  579. artifact=conflict,
  580. kind="output",
  581. binding_hash=binding_hash,
  582. )
  583. assert (
  584. "dataops-rules",
  585. conflict["artifact_ref"].split("/", 3)[-1],
  586. ) in store.client.removed
  587. assert len(catalog) == 1
  588. def test_catalog_cleanup_deletes_expired_object_and_directory_row():
  589. from app.runner.artifacts import PostgresArtifactResolver
  590. store = _store(FakeMinio())
  591. correlation_id = new_governance_uid()
  592. artifact = store.write(
  593. pl.DataFrame({"id": [1]}),
  594. correlation_id,
  595. 60,
  596. )
  597. row_id = new_governance_uid()
  598. deleted_ids = []
  599. def handler(sql, parameters):
  600. if "DELETE FROM public.rule_run_artifacts" in sql:
  601. deleted_ids.append(parameters["id"])
  602. return None
  603. if "FROM public.rule_run_artifacts" in sql:
  604. assert "FOR UPDATE SKIP LOCKED" in sql
  605. assert parameters["limit"] == 10
  606. return [{"id": row_id, "artifact_ref": artifact["artifact_ref"]}]
  607. raise AssertionError(sql)
  608. resolver = PostgresArtifactResolver(_Engine(handler), store)
  609. assert resolver.cleanup_expired(limit=10) == 1
  610. assert deleted_ids == [row_id]
  611. assert not store.client.objects