test_artifacts.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601
  1. from __future__ import annotations
  2. import io
  3. from datetime import UTC, datetime, timedelta
  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_upload_path_preserves_one_second_ttl_after_validation_delay(tmp_path):
  190. now = datetime(2026, 7, 23, 10, 0, tzinfo=UTC)
  191. clock_value = [now]
  192. store = _store(FakeMinio(), clock=lambda: clock_value[0])
  193. path = tmp_path / "one-second.parquet"
  194. pl.DataFrame({"id": [1]}).write_parquet(path)
  195. artifact = store.prepare_path(
  196. str(path),
  197. new_governance_uid(),
  198. 1,
  199. schema_fields=[
  200. {"name": "id", "type": "integer", "nullable": False}
  201. ],
  202. )
  203. clock_value[0] = now + timedelta(microseconds=1)
  204. uploaded = store.upload_path(str(path), artifact)
  205. assert uploaded["artifact_ref"] == artifact["artifact_ref"]
  206. def test_artifact_store_does_not_return_ref_for_corrupted_server_content():
  207. class CorruptingMinio(FakeMinio):
  208. def put_object(self, bucket, key, *args, **kwargs):
  209. super().put_object(bucket, key, *args, **kwargs)
  210. payload = self.objects[(bucket, key)]["payload"]
  211. self.objects[(bucket, key)]["payload"] = bytes(
  212. [payload[0] ^ 1]
  213. ) + payload[1:]
  214. store = _store(CorruptingMinio())
  215. with pytest.raises(ValueError, match="digest|size"):
  216. store.write(
  217. pl.DataFrame({"id": [1]}).lazy(),
  218. new_governance_uid(),
  219. 60,
  220. )
  221. assert not store.client.objects
  222. def test_artifact_store_applies_plan_limits_before_download_and_serialization():
  223. client = FakeMinio()
  224. store = _store(client, max_rows=100)
  225. correlation_id = new_governance_uid()
  226. artifact = store.write(
  227. pl.DataFrame({"id": [1, 2]}).lazy(),
  228. correlation_id,
  229. 60,
  230. )
  231. calls_before = len(client.get_calls)
  232. with pytest.raises(ValueError, match="row count"):
  233. store.read(
  234. artifact["artifact_ref"],
  235. artifact["digest"],
  236. expected_schema_fields=artifact["schema_fields"],
  237. limits={
  238. "max_rows": 1,
  239. "max_artifact_bytes": 1024 * 1024,
  240. "memory_limit_bytes": 4 * 1024 * 1024,
  241. },
  242. )
  243. assert len(client.get_calls) == calls_before
  244. with pytest.raises(ValueError, match="row count"):
  245. store.write(
  246. pl.DataFrame({"id": [1, 2]}).lazy(),
  247. correlation_id,
  248. 60,
  249. limits={
  250. "max_rows": 1,
  251. "max_artifact_bytes": 1024 * 1024,
  252. "memory_limit_bytes": 4 * 1024 * 1024,
  253. },
  254. )
  255. def test_artifact_stage_streams_to_tempfile_and_preflights_parquet_footer(
  256. ):
  257. import inspect
  258. from app.runner.artifacts import ArtifactStore
  259. client = FakeMinio()
  260. store = _store(client)
  261. correlation_id = new_governance_uid()
  262. artifact = store.write(
  263. pl.DataFrame({"id": [1, 2]}),
  264. correlation_id,
  265. 60,
  266. )
  267. assert "BytesIO" not in inspect.getsource(ArtifactStore.stage)
  268. with store.stage(
  269. artifact["artifact_ref"],
  270. artifact["digest"],
  271. expected_schema_fields=artifact["schema_fields"],
  272. ) as staged:
  273. assert pl.scan_parquet(staged).collect().height == 2
  274. def test_artifact_stage_rejects_compression_bomb_from_footer_before_scan():
  275. from app.runner.artifacts import ArtifactStore
  276. client = FakeMinio()
  277. store = ArtifactStore(
  278. client,
  279. bucket="dataops-rules",
  280. max_artifact_bytes=4 * 1024 * 1024,
  281. max_rows=1_000,
  282. memory_limit_bytes=64 * 1024 * 1024,
  283. max_ttl_seconds=3600,
  284. )
  285. correlation_id = new_governance_uid()
  286. artifact = store.write(
  287. pl.DataFrame(
  288. {
  289. "payload": [
  290. f"{index}-" + ("compressible-value-" * 10_000)
  291. for index in range(100)
  292. ]
  293. }
  294. ),
  295. correlation_id,
  296. 60,
  297. )
  298. calls_before = len(client.get_calls)
  299. with pytest.raises(
  300. ValueError, match="uncompressed|footer"
  301. ), store.stage(
  302. artifact["artifact_ref"],
  303. artifact["digest"],
  304. expected_schema_fields=artifact["schema_fields"],
  305. limits={
  306. "max_rows": 1_000,
  307. "max_artifact_bytes": 4 * 1024 * 1024,
  308. "memory_limit_bytes": 1024 * 1024,
  309. },
  310. ):
  311. raise AssertionError("compression bomb must not be exposed")
  312. assert len(client.get_calls) == calls_before + 1
  313. def test_artifact_prepare_reserves_key_before_exact_path_upload(tmp_path):
  314. client = FakeMinio()
  315. store = _store(client)
  316. correlation_id = new_governance_uid()
  317. path = tmp_path / "prepared.parquet"
  318. pl.DataFrame({"id": [1, 2]}).write_parquet(path)
  319. fields = [{"name": "id", "type": "integer", "nullable": False}]
  320. prepared = store.prepare_path(
  321. str(path),
  322. correlation_id,
  323. 60,
  324. schema_fields=fields,
  325. )
  326. assert client.objects == {}
  327. assert prepared["artifact_ref"].startswith(
  328. f"minio://dataops-rules/rules/{correlation_id}/"
  329. )
  330. uploaded = store.upload_path(str(path), prepared)
  331. assert uploaded == prepared
  332. assert store.describe(prepared["artifact_ref"])["digest"] == prepared[
  333. "digest"
  334. ]
  335. def test_artifact_schema_contract_covers_nullability_decimal_and_timezone():
  336. client = FakeMinio()
  337. store = _store(client)
  338. correlation_id = new_governance_uid()
  339. fields = [
  340. {
  341. "name": "amount",
  342. "type": "decimal",
  343. "nullable": False,
  344. "precision": 12,
  345. "scale": 2,
  346. },
  347. {
  348. "name": "occurred_at",
  349. "type": "timestamptz",
  350. "nullable": False,
  351. "timezone": "Asia/Shanghai",
  352. },
  353. ]
  354. frame = pl.DataFrame(
  355. {
  356. "amount": ["12.34"],
  357. "occurred_at": ["2026-07-23T10:00:00+08:00"],
  358. }
  359. ).with_columns(
  360. pl.col("amount").cast(pl.Decimal(12, 2)),
  361. pl.col("occurred_at")
  362. .str.to_datetime(time_zone="Asia/Shanghai")
  363. .alias("occurred_at"),
  364. )
  365. artifact = store.write(
  366. frame.lazy(),
  367. correlation_id,
  368. 60,
  369. schema_fields=fields,
  370. )
  371. assert artifact["schema_fields"] == fields
  372. store.read(
  373. artifact["artifact_ref"],
  374. artifact["digest"],
  375. expected_schema_fields=fields,
  376. ).collect()
  377. copy_fields = [dict(field) for field in fields]
  378. copy_fields[1] = {**copy_fields[1], "timezone": "UTC"}
  379. with pytest.raises(ValueError, match="timezone|schema"):
  380. store.read(
  381. artifact["artifact_ref"],
  382. artifact["digest"],
  383. expected_schema_fields=copy_fields,
  384. )
  385. with pytest.raises(ValueError, match="nullable"):
  386. store.write(
  387. pl.DataFrame(
  388. {
  389. "amount": [None],
  390. "occurred_at": [None],
  391. },
  392. schema={
  393. "amount": pl.Decimal(12, 2),
  394. "occurred_at": pl.Datetime(
  395. "us", "Asia/Shanghai"
  396. ),
  397. },
  398. ).lazy(),
  399. correlation_id,
  400. 60,
  401. schema_fields=fields,
  402. )
  403. def test_artifact_cleanup_is_expired_and_correlation_scoped_only():
  404. client = FakeMinio()
  405. now = datetime(2026, 7, 23, 10, 0, tzinfo=UTC)
  406. store = _store(client, clock=lambda: now)
  407. first = new_governance_uid()
  408. second = new_governance_uid()
  409. expired = store.write(pl.DataFrame({"id": [1]}), first, 10)
  410. active = store.write(pl.DataFrame({"id": [2]}), second, 300)
  411. now = datetime(2026, 7, 23, 10, 0, 20, tzinfo=UTC)
  412. assert store.cleanup_expired(first) == 1
  413. assert not any(
  414. key == expired["artifact_ref"].split("/", 3)[-1]
  415. for _bucket, key in client.objects
  416. )
  417. assert store.describe(active["artifact_ref"])["row_count"] == 1
  418. class _Rows:
  419. def __init__(self, row=None):
  420. self.row = row
  421. def mappings(self):
  422. return self
  423. def one_or_none(self):
  424. return self.row
  425. def all(self):
  426. if self.row is None:
  427. return []
  428. return self.row if isinstance(self.row, list) else [self.row]
  429. class _Connection:
  430. def __init__(self, engine):
  431. self.engine = engine
  432. def __enter__(self):
  433. return self
  434. def __exit__(self, *_args):
  435. return None
  436. def execute(self, statement, parameters):
  437. sql = str(statement)
  438. self.engine.calls.append((sql, dict(parameters)))
  439. return _Rows(self.engine.handler(sql, parameters))
  440. class _Engine:
  441. def __init__(self, handler):
  442. self.handler = handler
  443. self.calls = []
  444. def connect(self):
  445. return _Connection(self)
  446. def begin(self):
  447. return _Connection(self)
  448. def test_postgres_artifact_resolver_uses_catalog_and_rechecks_binding_hash():
  449. from app.runner.artifacts import PostgresArtifactResolver
  450. store = _store(FakeMinio())
  451. correlation_id = new_governance_uid()
  452. binding_id = new_governance_uid()
  453. binding_hash = "a" * 64
  454. artifact = store.write(
  455. pl.DataFrame({"id": [1]}),
  456. correlation_id,
  457. 60,
  458. )
  459. def handler(sql, _parameters):
  460. if "FROM public.rule_run_artifacts" in sql:
  461. return {
  462. **artifact,
  463. "artifact_digest": artifact["digest"],
  464. "catalog_binding_hash": binding_hash,
  465. "current_binding_hash": binding_hash,
  466. }
  467. raise AssertionError(sql)
  468. engine = _Engine(handler)
  469. resolved = PostgresArtifactResolver(engine, store).resolve(
  470. binding_id=binding_id,
  471. correlation_id=correlation_id,
  472. kind="input",
  473. )
  474. assert resolved["artifact_ref"] == artifact["artifact_ref"]
  475. assert resolved["binding_hash"] == binding_hash
  476. assert "dataflow_dataset_bindings b" in engine.calls[0][0]
  477. assert "a.correlation_id = CAST(:correlation_id AS uuid)" in (
  478. engine.calls[0][0]
  479. )
  480. assert "a.handoff_status = 'ready'" in engine.calls[0][0]
  481. assert "a.binding_hash = b.binding_hash" in engine.calls[0][0]
  482. with pytest.raises(ValueError, match="correlation"):
  483. PostgresArtifactResolver(engine, store).resolve(
  484. binding_id=binding_id,
  485. correlation_id=new_governance_uid(),
  486. kind="input",
  487. )
  488. def test_catalog_cleanup_deletes_expired_object_and_directory_row():
  489. from app.runner.artifacts import PostgresArtifactResolver
  490. store = _store(FakeMinio())
  491. correlation_id = new_governance_uid()
  492. artifact = store.write(
  493. pl.DataFrame({"id": [1]}),
  494. correlation_id,
  495. 60,
  496. )
  497. row_id = new_governance_uid()
  498. deleted_ids = []
  499. def handler(sql, parameters):
  500. if "DELETE FROM public.rule_run_artifacts" in sql:
  501. deleted_ids.append(parameters["id"])
  502. return None
  503. if "FROM public.rule_run_artifacts" in sql:
  504. assert "FOR UPDATE SKIP LOCKED" in sql
  505. assert parameters["limit"] == 10
  506. return [{"id": row_id, "artifact_ref": artifact["artifact_ref"]}]
  507. raise AssertionError(sql)
  508. resolver = PostgresArtifactResolver(_Engine(handler), store)
  509. assert resolver.cleanup_expired(limit=10) == 1
  510. assert deleted_ids == [row_id]
  511. assert not store.client.objects