test_rule_polars.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726
  1. from __future__ import annotations
  2. import copy
  3. from datetime import date, datetime
  4. import polars as pl
  5. import pytest
  6. from app.core.common.identifiers import new_governance_uid
  7. from app.runner.nodes import NodeExecutionError
  8. from tests.core.data_rules.test_polars_compiler import (
  9. _backend,
  10. _binding,
  11. _published_rule,
  12. _schema,
  13. )
  14. from tests.runner.test_artifacts import FakeMinio
  15. def _plan_store(client):
  16. from app.runner.artifacts import ArtifactStore
  17. return ArtifactStore(
  18. client,
  19. bucket="dataops-rules",
  20. max_artifact_bytes=8 * 1024 * 1024,
  21. max_rows=10_000,
  22. memory_limit_bytes=256 * 1024 * 1024,
  23. max_ttl_seconds=3600,
  24. )
  25. class Resolver:
  26. def __init__(self, values, artifact_store):
  27. self.values = values
  28. self.artifact_store = artifact_store
  29. self.calls = []
  30. self.events = []
  31. self.attest_error = None
  32. self.publish_error = None
  33. self.registrations = []
  34. def resolve(self, *, binding_id, correlation_id, kind):
  35. self.calls.append((binding_id, correlation_id))
  36. self.events.append(("resolve", binding_id, kind))
  37. return self.values[binding_id]
  38. def attest_binding(self, *, binding_id, binding_hash, access_mode):
  39. self.events.append(("attest", binding_id, binding_hash, access_mode))
  40. if self.attest_error is not None:
  41. raise self.attest_error
  42. return {"binding_hash": binding_hash}
  43. def publish_path(
  44. self,
  45. path,
  46. *,
  47. binding_id,
  48. correlation_id,
  49. kind,
  50. binding_hash,
  51. ttl_seconds,
  52. schema_fields,
  53. limits,
  54. ):
  55. self.events.append(("publish", binding_id, kind))
  56. if self.publish_error is not None:
  57. raise self.publish_error
  58. artifact = self.artifact_store.write_path(
  59. path,
  60. correlation_id,
  61. ttl_seconds,
  62. schema_fields=schema_fields,
  63. limits=limits,
  64. )
  65. self.registrations.append(
  66. {
  67. "binding_id": binding_id,
  68. "correlation_id": correlation_id,
  69. "artifact": artifact,
  70. "kind": kind,
  71. "binding_hash": binding_hash,
  72. }
  73. )
  74. return artifact
  75. def _compiled_plan(steps):
  76. from app.core.data_rules.compilers.polars import PolarsRuleCompiler
  77. fields = [
  78. ("customer_id", "integer", False),
  79. ("name", "string", True),
  80. ("mobile", "string", True),
  81. ]
  82. input_schema = _schema("bd:customer:raw", fields)
  83. output_schema = _schema("bd:customer:clean", fields)
  84. input_binding = _binding(input_schema, access_mode="read")
  85. output_binding = _binding(output_schema, access_mode="write")
  86. rule = _published_rule(input_schema, output_schema, steps)
  87. compiled = PolarsRuleCompiler().compile(
  88. rule_version=rule,
  89. input_schema=input_schema,
  90. output_schema=output_schema,
  91. input_binding=input_binding,
  92. output_binding=output_binding,
  93. backend=_backend(memory_limit_bytes=128 * 1024 * 1024),
  94. )
  95. return compiled, input_binding
  96. def _node(compiled):
  97. return {
  98. "id": "task5_polars",
  99. "type": "rule.apply",
  100. "purpose": "write",
  101. "idempotency": {
  102. "strategy": "deduplication_key",
  103. "key": "customer_id",
  104. },
  105. "config": {
  106. "component_binding_id": new_governance_uid(),
  107. "rule_version_id": compiled["plan"]["rule_version_id"],
  108. "execution_plan_hash": compiled["plan_hash"],
  109. },
  110. }
  111. def test_polars_adapter_reconstructs_assert_and_deduplicate_and_writes_artifact():
  112. from app.runner.rule_polars import PolarsRulePlanAdapter
  113. compiled, input_binding = _compiled_plan(
  114. [
  115. {
  116. "id": "trim_name",
  117. "op": "normalize_text",
  118. "column": "name",
  119. "trim": True,
  120. },
  121. {
  122. "id": "mobile_format",
  123. "op": "assert",
  124. "expression": "matches(mobile, '^[0-9]{11}$')",
  125. "on_failure": "reject",
  126. "severity": "error",
  127. },
  128. {
  129. "id": "one_customer",
  130. "op": "deduplicate",
  131. "keys": ["customer_id"],
  132. "order_by": ["name"],
  133. "keep": "first",
  134. },
  135. ]
  136. )
  137. store = _plan_store(FakeMinio())
  138. correlation_id = new_governance_uid()
  139. source = store.write(
  140. pl.DataFrame(
  141. {
  142. "customer_id": [1, 1, 2],
  143. "name": [" Alice ", "Alice B", " Bad "],
  144. "mobile": ["13800138000", "13800138000", "invalid"],
  145. }
  146. ).lazy(),
  147. correlation_id,
  148. 600,
  149. schema_fields=compiled["plan"]["input_fields"],
  150. limits=compiled["plan"]["resource_limits"],
  151. )
  152. resolver = Resolver(
  153. {
  154. input_binding["id"]: {
  155. **source,
  156. "binding_hash": compiled["plan"]["input_binding_hash"],
  157. }
  158. },
  159. store,
  160. )
  161. adapter = PolarsRulePlanAdapter(
  162. artifact_store=store,
  163. artifact_resolver=resolver,
  164. masking_policies={
  165. "customer_mobile_last4": "preserve_last_4"
  166. },
  167. artifact_ttl_seconds=300,
  168. )
  169. result = adapter.execute(
  170. plan=compiled["plan"],
  171. node=_node(compiled),
  172. parameters={},
  173. write_authorized=True,
  174. correlation_id=correlation_id,
  175. )
  176. assert result["rows_in"] == 3
  177. assert result["rows_out"] == 1
  178. assert result["rows_rejected"] == 1
  179. assert result["rows_filtered"] == 0
  180. assert result["rows_deduplicated"] == 1
  181. assert result["rows_join_dropped"] == 0
  182. assert result["rows_aggregated"] == 0
  183. assert result["violation_count"] == 1
  184. assert result["violations"] == [
  185. {"step_id": "mobile_format", "count": 1}
  186. ]
  187. assert result["commit_outcome"] == "committed"
  188. assert "schema_fields" not in result
  189. assert resolver.events[0] == (
  190. "attest",
  191. compiled["plan"]["output_binding_id"],
  192. compiled["plan"]["output_binding_hash"],
  193. "write",
  194. )
  195. assert resolver.registrations[0]["kind"] == "output"
  196. assert store.read(
  197. result["artifact_ref"],
  198. result["digest"],
  199. expected_schema_fields=compiled["plan"]["output_fields"],
  200. limits=compiled["plan"]["resource_limits"],
  201. ).collect().to_dicts() == [
  202. {
  203. "customer_id": 1,
  204. "name": "Alice",
  205. "mobile": "13800138000",
  206. }
  207. ]
  208. def test_polars_adapter_reports_unknown_catalog_commit_outcome():
  209. from app.runner.artifacts import ArtifactCommitUnknown
  210. from app.runner.rule_polars import PolarsRulePlanAdapter
  211. compiled, input_binding = _compiled_plan(
  212. [
  213. {
  214. "id": "trim_name",
  215. "op": "normalize_text",
  216. "column": "name",
  217. "trim": True,
  218. }
  219. ]
  220. )
  221. store = _plan_store(FakeMinio())
  222. correlation_id = new_governance_uid()
  223. source = store.write(
  224. pl.DataFrame(
  225. {"customer_id": [1], "name": [" A "], "mobile": ["1"]}
  226. ),
  227. correlation_id,
  228. 600,
  229. schema_fields=compiled["plan"]["input_fields"],
  230. limits=compiled["plan"]["resource_limits"],
  231. )
  232. resolver = Resolver(
  233. {
  234. input_binding["id"]: {
  235. **source,
  236. "binding_hash": compiled["plan"]["input_binding_hash"],
  237. }
  238. },
  239. store,
  240. )
  241. resolver.publish_error = ArtifactCommitUnknown("lost acknowledgement")
  242. with pytest.raises(NodeExecutionError, match="commit outcome") as error:
  243. PolarsRulePlanAdapter(
  244. artifact_store=store,
  245. artifact_resolver=resolver,
  246. ).execute(
  247. plan=compiled["plan"],
  248. node=_node(compiled),
  249. parameters={},
  250. write_authorized=True,
  251. correlation_id=correlation_id,
  252. )
  253. assert error.value.commit_outcome == "unknown"
  254. def test_polars_adapter_fails_closed_for_plan_hash_binding_and_authorization():
  255. from app.runner.rule_polars import PolarsRulePlanAdapter
  256. compiled, input_binding = _compiled_plan(
  257. [
  258. {
  259. "id": "trim_name",
  260. "op": "normalize_text",
  261. "column": "name",
  262. "trim": True,
  263. }
  264. ]
  265. )
  266. store = _plan_store(FakeMinio())
  267. correlation_id = new_governance_uid()
  268. source = store.write(
  269. pl.DataFrame(
  270. {"customer_id": [1], "name": [" A "], "mobile": ["1"]}
  271. ).lazy(),
  272. correlation_id,
  273. 600,
  274. schema_fields=compiled["plan"]["input_fields"],
  275. limits=compiled["plan"]["resource_limits"],
  276. )
  277. resolver = Resolver(
  278. {
  279. input_binding["id"]: {
  280. **source,
  281. "binding_hash": "0" * 64,
  282. }
  283. },
  284. store,
  285. )
  286. adapter = PolarsRulePlanAdapter(
  287. artifact_store=store,
  288. artifact_resolver=resolver,
  289. )
  290. node = _node(compiled)
  291. with pytest.raises(NodeExecutionError, match="authorization"):
  292. adapter.execute(
  293. plan=compiled["plan"],
  294. node=node,
  295. parameters={},
  296. write_authorized=False,
  297. correlation_id=correlation_id,
  298. )
  299. with pytest.raises(NodeExecutionError, match="binding"):
  300. adapter.execute(
  301. plan=compiled["plan"],
  302. node=node,
  303. parameters={},
  304. write_authorized=True,
  305. correlation_id=correlation_id,
  306. )
  307. resolver.values[input_binding["id"]]["binding_hash"] = compiled["plan"][
  308. "input_binding_hash"
  309. ]
  310. node["config"]["execution_plan_hash"] = "0" * 64
  311. with pytest.raises(NodeExecutionError, match="hash"):
  312. adapter.execute(
  313. plan=compiled["plan"],
  314. node=node,
  315. parameters={},
  316. write_authorized=True,
  317. correlation_id=correlation_id,
  318. )
  319. tampered = copy.deepcopy(compiled["plan"])
  320. tampered["operations"][0]["callable"] = "unsafe"
  321. node["config"]["execution_plan_hash"] = compiled["plan_hash"]
  322. with pytest.raises(NodeExecutionError, match="invalid"):
  323. adapter.execute(
  324. plan=tampered,
  325. node=node,
  326. parameters={},
  327. write_authorized=True,
  328. correlation_id=correlation_id,
  329. )
  330. def test_polars_adapter_attests_current_output_binding_before_reading_input():
  331. from app.runner.rule_polars import PolarsRulePlanAdapter
  332. compiled, input_binding = _compiled_plan(
  333. [
  334. {
  335. "id": "trim_name",
  336. "op": "normalize_text",
  337. "column": "name",
  338. "trim": True,
  339. }
  340. ]
  341. )
  342. store = _plan_store(FakeMinio())
  343. correlation_id = new_governance_uid()
  344. source = store.write(
  345. pl.DataFrame(
  346. {"customer_id": [1], "name": [" A "], "mobile": ["1"]}
  347. ),
  348. correlation_id,
  349. 600,
  350. schema_fields=compiled["plan"]["input_fields"],
  351. limits=compiled["plan"]["resource_limits"],
  352. )
  353. resolver = Resolver(
  354. {
  355. input_binding["id"]: {
  356. **source,
  357. "binding_hash": compiled["plan"]["input_binding_hash"],
  358. }
  359. },
  360. store,
  361. )
  362. resolver.attest_error = ValueError("binding changed")
  363. reads_before_execute = list(store.client.get_calls)
  364. with pytest.raises(NodeExecutionError, match="output binding"):
  365. PolarsRulePlanAdapter(
  366. artifact_store=store,
  367. artifact_resolver=resolver,
  368. ).execute(
  369. plan=compiled["plan"],
  370. node=_node(compiled),
  371. parameters={},
  372. write_authorized=True,
  373. correlation_id=correlation_id,
  374. )
  375. assert resolver.events == [
  376. (
  377. "attest",
  378. compiled["plan"]["output_binding_id"],
  379. compiled["plan"]["output_binding_hash"],
  380. "write",
  381. )
  382. ]
  383. assert store.client.get_calls == reads_before_execute
  384. def test_polars_adapter_uses_exact_decimal_and_timestamptz_output_contracts():
  385. from app.core.data_rules.compilers.polars import PolarsRuleCompiler
  386. from app.core.data_rules.execution_contracts import canonical_schema_hash
  387. from app.runner.rule_polars import PolarsRulePlanAdapter
  388. input_schema = _schema(
  389. "bd:payment:raw",
  390. [
  391. ("amount", "string", False),
  392. ("occurred_at", "string", False),
  393. ],
  394. )
  395. output_schema = _schema(
  396. "bd:payment:clean",
  397. [
  398. ("amount", "decimal", False),
  399. ("occurred_at", "timestamptz", False),
  400. ],
  401. )
  402. output_schema["fields"][0].update({"precision": 12, "scale": 2})
  403. output_schema["fields"][1]["timezone"] = "Asia/Shanghai"
  404. output_schema["schema_hash"] = canonical_schema_hash(
  405. output_schema["fields"]
  406. )
  407. source_binding = _binding(input_schema, access_mode="read")
  408. output_binding = _binding(output_schema, access_mode="write")
  409. rule = _published_rule(
  410. input_schema,
  411. output_schema,
  412. [
  413. {
  414. "id": "cast_amount",
  415. "op": "cast",
  416. "column": "amount",
  417. "to": "decimal",
  418. "on_error": "fail",
  419. },
  420. {
  421. "id": "cast_time",
  422. "op": "cast",
  423. "column": "occurred_at",
  424. "to": "timestamptz",
  425. "on_error": "fail",
  426. },
  427. ],
  428. )
  429. compiled = PolarsRuleCompiler().compile(
  430. rule_version=rule,
  431. input_schema=input_schema,
  432. output_schema=output_schema,
  433. input_binding=source_binding,
  434. output_binding=output_binding,
  435. backend=_backend(memory_limit_bytes=128 * 1024 * 1024),
  436. )
  437. store = _plan_store(FakeMinio())
  438. correlation_id = new_governance_uid()
  439. source = store.write(
  440. pl.DataFrame(
  441. {
  442. "amount": ["12.34"],
  443. "occurred_at": ["2026-07-23T12:30:00+08:00"],
  444. }
  445. ),
  446. correlation_id,
  447. 600,
  448. schema_fields=compiled["plan"]["input_fields"],
  449. limits=compiled["plan"]["resource_limits"],
  450. )
  451. resolver = Resolver(
  452. {
  453. source_binding["id"]: {
  454. **source,
  455. "binding_hash": compiled["plan"]["input_binding_hash"],
  456. }
  457. },
  458. store,
  459. )
  460. result = PolarsRulePlanAdapter(
  461. artifact_store=store,
  462. artifact_resolver=resolver,
  463. ).execute(
  464. plan=compiled["plan"],
  465. node=_node(compiled),
  466. parameters={},
  467. write_authorized=True,
  468. correlation_id=correlation_id,
  469. )
  470. output = store.read(
  471. result["artifact_ref"],
  472. result["digest"],
  473. expected_schema_fields=compiled["plan"]["output_fields"],
  474. limits=compiled["plan"]["resource_limits"],
  475. ).collect()
  476. assert output.schema["amount"] == pl.Decimal(precision=12, scale=2)
  477. assert output.schema["occurred_at"] == pl.Datetime(
  478. time_zone="Asia/Shanghai"
  479. )
  480. def test_polars_expression_date_and_timestamp_use_plan_timezone():
  481. from app.core.data_rules.compilers.polars import PolarsRuleCompiler
  482. from app.runner.rule_polars import PolarsRulePlanAdapter
  483. input_schema = _schema(
  484. "bd:event:raw",
  485. [
  486. ("raw_time", "string", False),
  487. ("cast_time", "string", False),
  488. ],
  489. )
  490. output_schema = _schema(
  491. "bd:event:clean",
  492. [
  493. ("raw_time", "string", False),
  494. ("cast_time", "timestamp", False),
  495. ("local_date", "date", False),
  496. ("local_time", "timestamp", False),
  497. ],
  498. )
  499. source_binding = _binding(input_schema, access_mode="read")
  500. output_binding = _binding(output_schema, access_mode="write")
  501. rule = _published_rule(
  502. input_schema,
  503. output_schema,
  504. [
  505. {
  506. "id": "cast_timestamp",
  507. "op": "cast",
  508. "column": "cast_time",
  509. "to": "timestamp",
  510. "on_error": "fail",
  511. },
  512. {
  513. "id": "derive_date",
  514. "op": "derive",
  515. "target": "local_date",
  516. "expression": "date(raw_time)",
  517. },
  518. {
  519. "id": "derive_timestamp",
  520. "op": "derive",
  521. "target": "local_time",
  522. "expression": "timestamp(raw_time)",
  523. },
  524. ],
  525. )
  526. compiled = PolarsRuleCompiler().compile(
  527. rule_version=rule,
  528. input_schema=input_schema,
  529. output_schema=output_schema,
  530. input_binding=source_binding,
  531. output_binding=output_binding,
  532. backend=_backend(memory_limit_bytes=128 * 1024 * 1024),
  533. )
  534. store = _plan_store(FakeMinio())
  535. correlation_id = new_governance_uid()
  536. source = store.write(
  537. pl.DataFrame(
  538. {
  539. "raw_time": ["2026-07-22T16:30:00+00:00"],
  540. "cast_time": ["2026-07-22T16:30:00+00:00"],
  541. }
  542. ),
  543. correlation_id,
  544. 600,
  545. schema_fields=compiled["plan"]["input_fields"],
  546. limits=compiled["plan"]["resource_limits"],
  547. )
  548. resolver = Resolver(
  549. {
  550. source_binding["id"]: {
  551. **source,
  552. "binding_hash": compiled["plan"]["input_binding_hash"],
  553. }
  554. },
  555. store,
  556. )
  557. result = PolarsRulePlanAdapter(
  558. artifact_store=store,
  559. artifact_resolver=resolver,
  560. ).execute(
  561. plan=compiled["plan"],
  562. node=_node(compiled),
  563. parameters={},
  564. write_authorized=True,
  565. correlation_id=correlation_id,
  566. )
  567. output = store.read(
  568. result["artifact_ref"],
  569. result["digest"],
  570. expected_schema_fields=compiled["plan"]["output_fields"],
  571. limits=compiled["plan"]["resource_limits"],
  572. ).collect()
  573. assert output["local_date"].item() == date(2026, 7, 23)
  574. assert output["local_time"].item() == datetime(2026, 7, 23, 0, 30)
  575. assert output["cast_time"].item() == datetime(2026, 7, 23, 0, 30)
  576. assert output.schema["local_time"] == pl.Datetime
  577. def test_rule_executor_attests_polars_canonical_hashes_and_forwards_correlation():
  578. from app.runner.rules import RulePlanExecutor
  579. compiled, _input_binding = _compiled_plan(
  580. [
  581. {
  582. "id": "trim_name",
  583. "op": "normalize_text",
  584. "column": "name",
  585. "trim": True,
  586. }
  587. ]
  588. )
  589. node = _node(compiled)
  590. correlation_id = new_governance_uid()
  591. plan = compiled["plan"]
  592. record = {
  593. "component_binding_id": node["config"]["component_binding_id"],
  594. "rule_version_id": plan["rule_version_id"],
  595. "backend": "polars_batch",
  596. "compiler_version": compiled["compiler_version"],
  597. "plan": plan,
  598. "plan_hash": compiled["plan_hash"],
  599. "schema_hashes": {
  600. "rule_spec_hash": plan["rule_spec_hash"],
  601. "input_schema_snapshot_id": plan["input_schema_snapshot_id"],
  602. "input_schema_hash": plan["input_schema_hash"],
  603. "output_schema_snapshot_id": plan["output_schema_snapshot_id"],
  604. "output_schema_hash": plan["output_schema_hash"],
  605. },
  606. "canonical_rule_spec_hash": plan["rule_spec_hash"],
  607. "canonical_input_schema_snapshot_id": plan[
  608. "input_schema_snapshot_id"
  609. ],
  610. "canonical_input_schema_hash": plan["input_schema_hash"],
  611. "canonical_output_schema_snapshot_id": plan[
  612. "output_schema_snapshot_id"
  613. ],
  614. "canonical_output_schema_hash": plan["output_schema_hash"],
  615. "plan_status": "published",
  616. "rule_status": "published",
  617. "component_kind": "rule.apply",
  618. "binding_idempotency": node["idempotency"],
  619. }
  620. class Repository:
  621. def load(self, **_kwargs):
  622. return record
  623. class Adapter:
  624. def __init__(self):
  625. self.kwargs = None
  626. def execute(self, **kwargs):
  627. self.kwargs = kwargs
  628. return {"rows_in": 1, "rows_out": 1, "rows_rejected": 0}
  629. adapter = Adapter()
  630. result = RulePlanExecutor(
  631. Repository(), adapters={"polars_batch": adapter}
  632. ).execute(
  633. node,
  634. {},
  635. write_authorized=True,
  636. correlation_id=correlation_id,
  637. )
  638. assert result["rows_out"] == 1
  639. assert adapter.kwargs["correlation_id"] == correlation_id
  640. record["canonical_input_schema_hash"] = "0" * 64
  641. with pytest.raises(NodeExecutionError, match="attestation"):
  642. RulePlanExecutor(
  643. Repository(), adapters={"polars_batch": adapter}
  644. ).execute(
  645. node,
  646. {},
  647. write_authorized=True,
  648. correlation_id=correlation_id,
  649. )
  650. def test_node_registry_forwards_trusted_correlation_context():
  651. from app.runner.nodes import NodeRegistry
  652. class Executor:
  653. def __init__(self):
  654. self.correlation_id = None
  655. def execute(self, _node, _parameters, **kwargs):
  656. self.correlation_id = kwargs["correlation_id"]
  657. return {"ok": True}
  658. executor = Executor()
  659. correlation_id = new_governance_uid()
  660. assert NodeRegistry({"rule.apply": executor}).execute(
  661. {"type": "rule.apply"},
  662. {},
  663. write_authorized=True,
  664. correlation_id=correlation_id,
  665. ) == {"ok": True}
  666. assert executor.correlation_id == correlation_id