test_rule_polars.py 16 KB

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