test_rule_polars.py 19 KB

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