test_artifacts.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580
  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"]) == {
  95. key: artifact[key]
  96. for key in (
  97. "artifact_ref",
  98. "digest",
  99. "row_count",
  100. "schema_hash",
  101. "expires_at",
  102. )
  103. }
  104. stored = next(iter(client.objects.values()))
  105. assert "x-amz-meta-schema-contract" not in stored["metadata"]
  106. assert sum(
  107. len(key) + len(value)
  108. for key, value in stored["metadata"].items()
  109. ) <= 2_048
  110. frame = store.read(
  111. artifact["artifact_ref"],
  112. artifact["digest"],
  113. expected_schema_fields=artifact["schema_fields"],
  114. )
  115. assert isinstance(frame, pl.LazyFrame)
  116. assert frame.collect().to_dicts() == [
  117. {"customer_id": 1, "name": "Alice"},
  118. {"customer_id": 2, "name": "Bob"},
  119. ]
  120. @pytest.mark.parametrize(
  121. ("mutation", "message"),
  122. [
  123. (
  124. lambda item: item["metadata"].update(
  125. {"x-amz-meta-sha256": "0" * 64}
  126. ),
  127. "digest",
  128. ),
  129. (
  130. lambda item: item["metadata"].update(
  131. {"x-amz-meta-schema-sha256": "0" * 64}
  132. ),
  133. "schema",
  134. ),
  135. (
  136. lambda item: item["metadata"].update(
  137. {"x-amz-meta-expires-at": "2000-01-01T00:00:00Z"}
  138. ),
  139. "expired",
  140. ),
  141. (
  142. lambda item: item.update({"content_type": "text/plain"}),
  143. "content type",
  144. ),
  145. ],
  146. )
  147. def test_artifact_store_rejects_tampered_digest_schema_ttl_and_content(
  148. mutation, message
  149. ):
  150. client = FakeMinio()
  151. now = datetime(2026, 7, 23, 10, 0, tzinfo=UTC)
  152. store = _store(client, clock=lambda: now)
  153. artifact = store.write(
  154. pl.DataFrame({"id": [1]}).lazy(),
  155. new_governance_uid(),
  156. 300,
  157. )
  158. key = artifact["artifact_ref"].split("/", 3)[-1]
  159. mutation(client.objects[("dataops-rules", key)])
  160. with pytest.raises(ValueError, match=message):
  161. store.read(
  162. artifact["artifact_ref"],
  163. artifact["digest"],
  164. expected_schema_fields=artifact["schema_fields"],
  165. )
  166. def test_artifact_store_rejects_rows_size_ttl_and_unowned_references():
  167. client = FakeMinio()
  168. store = _store(client, max_rows=2)
  169. with pytest.raises(ValueError, match="row"):
  170. store.write(
  171. pl.DataFrame({"id": [1, 2, 3]}).lazy(),
  172. new_governance_uid(),
  173. 60,
  174. )
  175. with pytest.raises(ValueError, match="TTL"):
  176. store.write(
  177. pl.DataFrame({"id": [1]}).lazy(),
  178. new_governance_uid(),
  179. 7200,
  180. )
  181. with pytest.raises(ValueError, match="artifact reference"):
  182. store.read(
  183. "minio://other-bucket/rules/unsafe/value.parquet",
  184. "0" * 64,
  185. expected_schema_fields=[
  186. {"name": "id", "type": "integer", "nullable": True}
  187. ],
  188. )
  189. def test_artifact_store_does_not_return_ref_for_corrupted_server_content():
  190. class CorruptingMinio(FakeMinio):
  191. def put_object(self, bucket, key, *args, **kwargs):
  192. super().put_object(bucket, key, *args, **kwargs)
  193. payload = self.objects[(bucket, key)]["payload"]
  194. self.objects[(bucket, key)]["payload"] = bytes(
  195. [payload[0] ^ 1]
  196. ) + payload[1:]
  197. store = _store(CorruptingMinio())
  198. with pytest.raises(ValueError, match="digest|size"):
  199. store.write(
  200. pl.DataFrame({"id": [1]}).lazy(),
  201. new_governance_uid(),
  202. 60,
  203. )
  204. assert not store.client.objects
  205. def test_artifact_store_applies_plan_limits_before_download_and_serialization():
  206. client = FakeMinio()
  207. store = _store(client, max_rows=100)
  208. correlation_id = new_governance_uid()
  209. artifact = store.write(
  210. pl.DataFrame({"id": [1, 2]}).lazy(),
  211. correlation_id,
  212. 60,
  213. )
  214. calls_before = len(client.get_calls)
  215. with pytest.raises(ValueError, match="row count"):
  216. store.read(
  217. artifact["artifact_ref"],
  218. artifact["digest"],
  219. expected_schema_fields=artifact["schema_fields"],
  220. limits={
  221. "max_rows": 1,
  222. "max_artifact_bytes": 1024 * 1024,
  223. "memory_limit_bytes": 4 * 1024 * 1024,
  224. },
  225. )
  226. assert len(client.get_calls) == calls_before
  227. with pytest.raises(ValueError, match="row count"):
  228. store.write(
  229. pl.DataFrame({"id": [1, 2]}).lazy(),
  230. correlation_id,
  231. 60,
  232. limits={
  233. "max_rows": 1,
  234. "max_artifact_bytes": 1024 * 1024,
  235. "memory_limit_bytes": 4 * 1024 * 1024,
  236. },
  237. )
  238. def test_artifact_stage_streams_to_tempfile_and_preflights_parquet_footer(
  239. ):
  240. import inspect
  241. from app.runner.artifacts import ArtifactStore
  242. client = FakeMinio()
  243. store = _store(client)
  244. correlation_id = new_governance_uid()
  245. artifact = store.write(
  246. pl.DataFrame({"id": [1, 2]}),
  247. correlation_id,
  248. 60,
  249. )
  250. assert "BytesIO" not in inspect.getsource(ArtifactStore.stage)
  251. with store.stage(
  252. artifact["artifact_ref"],
  253. artifact["digest"],
  254. expected_schema_fields=artifact["schema_fields"],
  255. ) as staged:
  256. assert pl.scan_parquet(staged).collect().height == 2
  257. def test_artifact_stage_rejects_compression_bomb_from_footer_before_scan():
  258. from app.runner.artifacts import ArtifactStore
  259. client = FakeMinio()
  260. store = ArtifactStore(
  261. client,
  262. bucket="dataops-rules",
  263. max_artifact_bytes=4 * 1024 * 1024,
  264. max_rows=1_000,
  265. memory_limit_bytes=64 * 1024 * 1024,
  266. max_ttl_seconds=3600,
  267. )
  268. correlation_id = new_governance_uid()
  269. artifact = store.write(
  270. pl.DataFrame(
  271. {
  272. "payload": [
  273. f"{index}-" + ("compressible-value-" * 10_000)
  274. for index in range(100)
  275. ]
  276. }
  277. ),
  278. correlation_id,
  279. 60,
  280. )
  281. calls_before = len(client.get_calls)
  282. with pytest.raises(
  283. ValueError, match="uncompressed|footer"
  284. ), store.stage(
  285. artifact["artifact_ref"],
  286. artifact["digest"],
  287. expected_schema_fields=artifact["schema_fields"],
  288. limits={
  289. "max_rows": 1_000,
  290. "max_artifact_bytes": 4 * 1024 * 1024,
  291. "memory_limit_bytes": 1024 * 1024,
  292. },
  293. ):
  294. raise AssertionError("compression bomb must not be exposed")
  295. assert len(client.get_calls) == calls_before + 1
  296. def test_artifact_prepare_reserves_key_before_exact_path_upload(tmp_path):
  297. client = FakeMinio()
  298. store = _store(client)
  299. correlation_id = new_governance_uid()
  300. path = tmp_path / "prepared.parquet"
  301. pl.DataFrame({"id": [1, 2]}).write_parquet(path)
  302. fields = [{"name": "id", "type": "integer", "nullable": False}]
  303. prepared = store.prepare_path(
  304. str(path),
  305. correlation_id,
  306. 60,
  307. schema_fields=fields,
  308. )
  309. assert client.objects == {}
  310. assert prepared["artifact_ref"].startswith(
  311. f"minio://dataops-rules/rules/{correlation_id}/"
  312. )
  313. uploaded = store.upload_path(str(path), prepared)
  314. assert uploaded == prepared
  315. assert store.describe(prepared["artifact_ref"])["digest"] == prepared[
  316. "digest"
  317. ]
  318. def test_artifact_schema_contract_covers_nullability_decimal_and_timezone():
  319. client = FakeMinio()
  320. store = _store(client)
  321. correlation_id = new_governance_uid()
  322. fields = [
  323. {
  324. "name": "amount",
  325. "type": "decimal",
  326. "nullable": False,
  327. "precision": 12,
  328. "scale": 2,
  329. },
  330. {
  331. "name": "occurred_at",
  332. "type": "timestamptz",
  333. "nullable": False,
  334. "timezone": "Asia/Shanghai",
  335. },
  336. ]
  337. frame = pl.DataFrame(
  338. {
  339. "amount": ["12.34"],
  340. "occurred_at": ["2026-07-23T10:00:00+08:00"],
  341. }
  342. ).with_columns(
  343. pl.col("amount").cast(pl.Decimal(12, 2)),
  344. pl.col("occurred_at")
  345. .str.to_datetime(time_zone="Asia/Shanghai")
  346. .alias("occurred_at"),
  347. )
  348. artifact = store.write(
  349. frame.lazy(),
  350. correlation_id,
  351. 60,
  352. schema_fields=fields,
  353. )
  354. assert artifact["schema_fields"] == fields
  355. store.read(
  356. artifact["artifact_ref"],
  357. artifact["digest"],
  358. expected_schema_fields=fields,
  359. ).collect()
  360. copy_fields = [dict(field) for field in fields]
  361. copy_fields[1] = {**copy_fields[1], "timezone": "UTC"}
  362. with pytest.raises(ValueError, match="timezone|schema"):
  363. store.read(
  364. artifact["artifact_ref"],
  365. artifact["digest"],
  366. expected_schema_fields=copy_fields,
  367. )
  368. with pytest.raises(ValueError, match="nullable"):
  369. store.write(
  370. pl.DataFrame(
  371. {
  372. "amount": [None],
  373. "occurred_at": [None],
  374. },
  375. schema={
  376. "amount": pl.Decimal(12, 2),
  377. "occurred_at": pl.Datetime(
  378. "us", "Asia/Shanghai"
  379. ),
  380. },
  381. ).lazy(),
  382. correlation_id,
  383. 60,
  384. schema_fields=fields,
  385. )
  386. def test_artifact_cleanup_is_expired_and_correlation_scoped_only():
  387. client = FakeMinio()
  388. now = datetime(2026, 7, 23, 10, 0, tzinfo=UTC)
  389. store = _store(client, clock=lambda: now)
  390. first = new_governance_uid()
  391. second = new_governance_uid()
  392. expired = store.write(pl.DataFrame({"id": [1]}), first, 10)
  393. active = store.write(pl.DataFrame({"id": [2]}), second, 300)
  394. now = datetime(2026, 7, 23, 10, 0, 20, tzinfo=UTC)
  395. assert store.cleanup_expired(first) == 1
  396. assert not any(
  397. key == expired["artifact_ref"].split("/", 3)[-1]
  398. for _bucket, key in client.objects
  399. )
  400. assert store.describe(active["artifact_ref"])["row_count"] == 1
  401. class _Rows:
  402. def __init__(self, row=None):
  403. self.row = row
  404. def mappings(self):
  405. return self
  406. def one_or_none(self):
  407. return self.row
  408. def all(self):
  409. if self.row is None:
  410. return []
  411. return self.row if isinstance(self.row, list) else [self.row]
  412. class _Connection:
  413. def __init__(self, engine):
  414. self.engine = engine
  415. def __enter__(self):
  416. return self
  417. def __exit__(self, *_args):
  418. return None
  419. def execute(self, statement, parameters):
  420. sql = str(statement)
  421. self.engine.calls.append((sql, dict(parameters)))
  422. return _Rows(self.engine.handler(sql, parameters))
  423. class _Engine:
  424. def __init__(self, handler):
  425. self.handler = handler
  426. self.calls = []
  427. def connect(self):
  428. return _Connection(self)
  429. def begin(self):
  430. return _Connection(self)
  431. def test_postgres_artifact_resolver_uses_catalog_and_rechecks_binding_hash():
  432. from app.runner.artifacts import PostgresArtifactResolver
  433. store = _store(FakeMinio())
  434. correlation_id = new_governance_uid()
  435. binding_id = new_governance_uid()
  436. binding_hash = "a" * 64
  437. artifact = store.write(
  438. pl.DataFrame({"id": [1]}),
  439. correlation_id,
  440. 60,
  441. )
  442. def handler(sql, _parameters):
  443. if "FROM public.rule_run_artifacts" in sql:
  444. return {
  445. **artifact,
  446. "artifact_digest": artifact["digest"],
  447. "catalog_binding_hash": binding_hash,
  448. "current_binding_hash": binding_hash,
  449. }
  450. raise AssertionError(sql)
  451. engine = _Engine(handler)
  452. resolved = PostgresArtifactResolver(engine, store).resolve(
  453. binding_id=binding_id,
  454. correlation_id=correlation_id,
  455. kind="input",
  456. )
  457. assert resolved["artifact_ref"] == artifact["artifact_ref"]
  458. assert resolved["binding_hash"] == binding_hash
  459. assert "dataflow_dataset_bindings b" in engine.calls[0][0]
  460. assert "a.correlation_id = CAST(:correlation_id AS uuid)" in (
  461. engine.calls[0][0]
  462. )
  463. assert "a.handoff_status = 'ready'" in engine.calls[0][0]
  464. assert "a.binding_hash = b.binding_hash" in engine.calls[0][0]
  465. with pytest.raises(ValueError, match="correlation"):
  466. PostgresArtifactResolver(engine, store).resolve(
  467. binding_id=binding_id,
  468. correlation_id=new_governance_uid(),
  469. kind="input",
  470. )
  471. def test_catalog_cleanup_deletes_expired_object_and_directory_row():
  472. from app.runner.artifacts import PostgresArtifactResolver
  473. store = _store(FakeMinio())
  474. correlation_id = new_governance_uid()
  475. artifact = store.write(
  476. pl.DataFrame({"id": [1]}),
  477. correlation_id,
  478. 60,
  479. )
  480. row_id = new_governance_uid()
  481. deleted_ids = []
  482. def handler(sql, parameters):
  483. if "DELETE FROM public.rule_run_artifacts" in sql:
  484. deleted_ids.append(parameters["id"])
  485. return None
  486. if "FROM public.rule_run_artifacts" in sql:
  487. assert "FOR UPDATE SKIP LOCKED" in sql
  488. assert parameters["limit"] == 10
  489. return [{"id": row_id, "artifact_ref": artifact["artifact_ref"]}]
  490. raise AssertionError(sql)
  491. resolver = PostgresArtifactResolver(_Engine(handler), store)
  492. assert resolver.cleanup_expired(limit=10) == 1
  493. assert deleted_ids == [row_id]
  494. assert not store.client.objects