rule_polars.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558
  1. """Runner adapter that reconstructs allowlisted Polars LazyFrame operations."""
  2. from __future__ import annotations
  3. from contextlib import suppress
  4. from decimal import Decimal
  5. from typing import Any
  6. import polars as pl
  7. from app.core.common.identifiers import ensure_governance_uid
  8. from app.core.data_rules.compilers.polars import (
  9. bound_polars_plan_hash,
  10. validate_bound_polars_plan,
  11. )
  12. from app.runner.nodes import NodeExecutionError
  13. _TYPE_MAP = {
  14. "boolean": pl.Boolean,
  15. "date": pl.Date,
  16. "double": pl.Float64,
  17. "float": pl.Float32,
  18. "integer": pl.Int64,
  19. "string": pl.String,
  20. "timestamp": pl.Datetime,
  21. }
  22. _IDEMPOTENCY = {
  23. "deduplication_key",
  24. "partition_replace",
  25. "upsert",
  26. }
  27. def _uid(value: Any, label: str) -> str:
  28. try:
  29. return ensure_governance_uid({"uid": str(value)})
  30. except ValueError as exc:
  31. raise NodeExecutionError(f"{label} is invalid") from exc
  32. class _ExpressionCompiler:
  33. def compile(self, ast: dict[str, Any]) -> pl.Expr:
  34. kind = ast["kind"]
  35. if kind == "identifier":
  36. return pl.col(ast["name"])
  37. if kind == "literal":
  38. value = ast["value"]
  39. if ast["type"] == "decimal":
  40. value = Decimal(value)
  41. return pl.lit(value)
  42. if kind == "unary":
  43. operand = self.compile(ast["operand"])
  44. return ~operand if ast["operator"] == "!" else -operand
  45. if kind == "binary":
  46. left = self.compile(ast["left"])
  47. right = self.compile(ast["right"])
  48. return {
  49. "||": lambda: left | right,
  50. "&&": lambda: left & right,
  51. "==": lambda: left == right,
  52. "!=": lambda: left != right,
  53. "<": lambda: left < right,
  54. "<=": lambda: left <= right,
  55. ">": lambda: left > right,
  56. ">=": lambda: left >= right,
  57. "+": lambda: left + right,
  58. "-": lambda: left - right,
  59. "*": lambda: left * right,
  60. "/": lambda: left / right,
  61. "%": lambda: left % right,
  62. }[ast["operator"]]()
  63. function = ast["function"]
  64. arguments = [self.compile(item) for item in ast["arguments"]]
  65. if function == "matches":
  66. pattern = ast["arguments"][1]["value"]
  67. return arguments[0].str.contains(pattern, strict=True)
  68. if function == "lower":
  69. return arguments[0].str.to_lowercase()
  70. if function == "upper":
  71. return arguments[0].str.to_uppercase()
  72. if function == "trim":
  73. return arguments[0].str.strip_chars()
  74. if function == "length":
  75. return arguments[0].str.len_chars()
  76. if function == "coalesce":
  77. return pl.coalesce(arguments)
  78. if function == "date":
  79. return arguments[0].cast(pl.Date, strict=True)
  80. if function == "timestamp":
  81. return arguments[0].cast(pl.Datetime, strict=True)
  82. if function == "abs":
  83. return arguments[0].abs()
  84. raise NodeExecutionError("published Polars expression is unsupported")
  85. def _cast_type(field: dict[str, Any]) -> pl.DataType:
  86. name = field["type"]
  87. if name == "decimal":
  88. precision = field.get("precision")
  89. scale = field.get("scale")
  90. if precision is None or scale is None:
  91. raise NodeExecutionError(
  92. "published decimal cast contract is incomplete"
  93. )
  94. return pl.Decimal(precision=precision, scale=scale)
  95. if name == "timestamptz":
  96. timezone = field.get("timezone")
  97. if not timezone:
  98. raise NodeExecutionError(
  99. "published timestamptz cast contract is incomplete"
  100. )
  101. return pl.Datetime(time_zone=timezone)
  102. dtype = _TYPE_MAP.get(name)
  103. if dtype is None:
  104. raise NodeExecutionError("published Polars cast type is unsupported")
  105. return dtype
  106. def _bounded_materialize(
  107. frame: pl.LazyFrame | pl.DataFrame,
  108. limits: dict[str, int],
  109. label: str,
  110. ) -> pl.DataFrame:
  111. try:
  112. collected = (
  113. frame.lazy() if isinstance(frame, pl.DataFrame) else frame
  114. ).head(limits["max_rows"] + 1).collect(engine="streaming")
  115. except Exception as exc:
  116. raise NodeExecutionError(
  117. f"published Polars {label} materialization failed"
  118. ) from exc
  119. if collected.height > limits["max_rows"]:
  120. raise NodeExecutionError(
  121. f"published Polars {label} exceeds its row limit"
  122. )
  123. if collected.estimated_size() > limits["memory_limit_bytes"]:
  124. raise NodeExecutionError(
  125. f"published Polars {label} exceeds its memory limit"
  126. )
  127. return collected
  128. def _resolve_artifact(
  129. resolver,
  130. *,
  131. binding_id: str,
  132. binding_hash: str,
  133. correlation_id: str,
  134. ) -> dict[str, Any]:
  135. try:
  136. artifact = resolver.resolve(
  137. binding_id=binding_id,
  138. correlation_id=correlation_id,
  139. )
  140. except Exception as exc:
  141. raise NodeExecutionError(
  142. "published Polars artifact binding was not resolved"
  143. ) from exc
  144. if (
  145. not isinstance(artifact, dict)
  146. or artifact.get("binding_hash") != binding_hash
  147. or not isinstance(artifact.get("artifact_ref"), str)
  148. or not isinstance(artifact.get("digest"), str)
  149. ):
  150. raise NodeExecutionError(
  151. "published Polars artifact binding does not match"
  152. )
  153. return artifact
  154. class PolarsRulePlanAdapter:
  155. """Execute one digest-bound Polars plan and write one bounded artifact."""
  156. def __init__(
  157. self,
  158. *,
  159. artifact_store,
  160. artifact_resolver,
  161. masking_policies=None,
  162. artifact_ttl_seconds=3600,
  163. ):
  164. self.artifact_store = artifact_store
  165. self.artifact_resolver = artifact_resolver
  166. self.masking_policies = dict(masking_policies or {})
  167. self.artifact_ttl_seconds = int(artifact_ttl_seconds)
  168. def execute(
  169. self,
  170. *,
  171. plan,
  172. node,
  173. parameters,
  174. write_authorized,
  175. correlation_id=None,
  176. ):
  177. try:
  178. normalized = validate_bound_polars_plan(plan)
  179. except ValueError as exc:
  180. raise NodeExecutionError(
  181. "published Polars rule plan is invalid"
  182. ) from exc
  183. config = node.get("config") or {}
  184. if config.get("execution_plan_hash") != bound_polars_plan_hash(
  185. normalized
  186. ):
  187. raise NodeExecutionError("published Polars rule plan hash does not match")
  188. if config.get("rule_version_id") != normalized["rule_version_id"]:
  189. raise NodeExecutionError("published Polars rule id does not match")
  190. idempotency = node.get("idempotency")
  191. if (
  192. node.get("type") != "rule.apply"
  193. or node.get("purpose") != "write"
  194. or not write_authorized
  195. or not isinstance(idempotency, dict)
  196. or idempotency.get("strategy") not in _IDEMPOTENCY
  197. or not str(idempotency.get("key") or "").strip()
  198. ):
  199. raise NodeExecutionError(
  200. "governed write authorization and idempotency are required"
  201. )
  202. if parameters not in ({}, None):
  203. raise NodeExecutionError(
  204. "bound Polars rule plans do not accept runtime parameters"
  205. )
  206. correlation = _uid(correlation_id, "correlation_id")
  207. try:
  208. self.artifact_resolver.attest_binding(
  209. binding_id=normalized["output_binding_id"],
  210. binding_hash=normalized["output_binding_hash"],
  211. access_mode="write",
  212. )
  213. except Exception as exc:
  214. raise NodeExecutionError(
  215. "published Polars output binding no longer matches"
  216. ) from exc
  217. source = _resolve_artifact(
  218. self.artifact_resolver,
  219. binding_id=normalized["input_binding_id"],
  220. binding_hash=normalized["input_binding_hash"],
  221. correlation_id=correlation,
  222. )
  223. try:
  224. frame = self.artifact_store.read(
  225. source["artifact_ref"],
  226. source["digest"],
  227. expected_schema_fields=normalized["input_fields"],
  228. limits=normalized["resource_limits"],
  229. )
  230. except ValueError as exc:
  231. raise NodeExecutionError(
  232. "published Polars input artifact is invalid"
  233. ) from exc
  234. limits = normalized["resource_limits"]
  235. initial = _bounded_materialize(frame, limits, "input")
  236. rows_in = initial.height
  237. frame = initial.lazy()
  238. expressions = _ExpressionCompiler()
  239. violations = []
  240. rows_rejected = 0
  241. rows_filtered = 0
  242. rows_deduplicated = 0
  243. rows_join_dropped = 0
  244. rows_aggregated = 0
  245. output_fields = {
  246. field["name"]: field for field in normalized["output_fields"]
  247. }
  248. for index, operation in enumerate(normalized["operations"]):
  249. op = operation["op"]
  250. before = _bounded_materialize(
  251. frame, limits, f"{op} input {index}"
  252. ).height
  253. if op == "normalize_text":
  254. expression = pl.col(operation["column"])
  255. if operation["trim"]:
  256. expression = expression.str.strip_chars()
  257. if operation["lowercase"]:
  258. expression = expression.str.to_lowercase()
  259. if operation["uppercase"]:
  260. expression = expression.str.to_uppercase()
  261. frame = frame.with_columns(
  262. expression.alias(operation["column"])
  263. )
  264. elif op == "regex_replace":
  265. frame = frame.with_columns(
  266. pl.col(operation["column"])
  267. .str.replace_all(
  268. operation["pattern"],
  269. operation["replacement"],
  270. )
  271. .alias(operation["column"])
  272. )
  273. elif op == "fill_null":
  274. frame = frame.with_columns(
  275. pl.col(operation["column"])
  276. .fill_null(operation["value"])
  277. .alias(operation["column"])
  278. )
  279. elif op == "filter":
  280. frame = frame.filter(
  281. expressions.compile(operation["expression_ast"]).fill_null(
  282. False
  283. )
  284. )
  285. after = _bounded_materialize(
  286. frame, limits, f"{op} output {index}"
  287. )
  288. rows_filtered += before - after.height
  289. frame = after.lazy()
  290. continue
  291. elif op == "assert":
  292. predicate = expressions.compile(
  293. operation["expression_ast"]
  294. ).fill_null(False)
  295. invalid = int(
  296. frame.select((~predicate).sum().alias("count"))
  297. .collect(engine="streaming")
  298. .item()
  299. or 0
  300. )
  301. violations.append(
  302. {"step_id": operation["step_id"], "count": invalid}
  303. )
  304. rows_rejected += invalid
  305. frame = frame.filter(predicate)
  306. elif op == "derive":
  307. frame = frame.with_columns(
  308. expressions.compile(operation["expression_ast"]).alias(
  309. operation["target"]
  310. )
  311. )
  312. elif op == "map_values":
  313. column = pl.col(operation["column"])
  314. frame = frame.with_columns(
  315. column.replace_strict(
  316. operation["mapping"],
  317. default=column,
  318. ).alias(operation["column"])
  319. )
  320. elif op == "cast":
  321. target_field = output_fields.get(operation["column"])
  322. if target_field is None:
  323. target_field = {
  324. "type": operation["to"],
  325. }
  326. column_expression = pl.col(operation["column"])
  327. source_dtype = frame.collect_schema()[operation["column"]]
  328. if (
  329. source_dtype == pl.String
  330. and target_field["type"] == "timestamptz"
  331. ):
  332. column_expression = column_expression.str.to_datetime(
  333. time_zone=target_field["timezone"],
  334. strict=True,
  335. )
  336. elif (
  337. source_dtype == pl.String
  338. and target_field["type"] == "timestamp"
  339. ):
  340. column_expression = column_expression.str.to_datetime(
  341. strict=True,
  342. )
  343. else:
  344. column_expression = column_expression.cast(
  345. _cast_type(target_field), strict=True
  346. )
  347. frame = frame.with_columns(
  348. column_expression.alias(operation["column"])
  349. )
  350. elif op == "deduplicate":
  351. order = [
  352. *operation["order_by"],
  353. *sorted(
  354. name
  355. for name in frame.collect_schema().names()
  356. if name not in operation["order_by"]
  357. ),
  358. ]
  359. descending = operation["keep"] == "last"
  360. frame = (
  361. frame.sort(
  362. order,
  363. descending=[descending] * len(order),
  364. nulls_last=True,
  365. )
  366. .unique(
  367. subset=operation["keys"],
  368. keep="first",
  369. maintain_order=True,
  370. )
  371. )
  372. after = _bounded_materialize(
  373. frame, limits, f"{op} output {index}"
  374. )
  375. rows_deduplicated += before - after.height
  376. frame = after.lazy()
  377. continue
  378. elif op == "mask":
  379. if self.masking_policies.get(
  380. operation["policy_id"]
  381. ) != operation["policy_kind"]:
  382. raise NodeExecutionError(
  383. "published masking policy is not registered"
  384. )
  385. column = pl.col(operation["column"])
  386. if operation["policy_kind"] == "redact":
  387. masked = pl.when(column.is_null()).then(None).otherwise(
  388. pl.lit("***")
  389. )
  390. elif operation["policy_kind"] == "preserve_last_4":
  391. masked = pl.when(column.is_null()).then(None).otherwise(
  392. pl.lit("***") + column.str.slice(-4)
  393. )
  394. else:
  395. raise NodeExecutionError(
  396. "published masking policy is unsupported"
  397. )
  398. frame = frame.with_columns(masked.alias(operation["column"]))
  399. elif op == "aggregate":
  400. aggregations = []
  401. for aggregate in operation["aggregations"]:
  402. expression = pl.col(aggregate["column"])
  403. function = aggregate["function"]
  404. if function == "count":
  405. expression = expression.count()
  406. elif function == "sum":
  407. expression = expression.sum()
  408. elif function == "min":
  409. expression = expression.min()
  410. elif function == "max":
  411. expression = expression.max()
  412. elif function == "mean":
  413. expression = expression.mean()
  414. aggregations.append(
  415. expression.alias(aggregate["target"])
  416. )
  417. frame = frame.group_by(
  418. operation["group_by"], maintain_order=True
  419. ).agg(aggregations)
  420. after = _bounded_materialize(
  421. frame, limits, f"{op} output {index}"
  422. )
  423. rows_aggregated += before - after.height
  424. frame = after.lazy()
  425. continue
  426. elif op == "lookup_join":
  427. lookup_artifact = _resolve_artifact(
  428. self.artifact_resolver,
  429. binding_id=operation["lookup_binding_id"],
  430. binding_hash=operation["lookup_binding_hash"],
  431. correlation_id=correlation,
  432. )
  433. try:
  434. lookup = self.artifact_store.read(
  435. lookup_artifact["artifact_ref"],
  436. lookup_artifact["digest"],
  437. expected_schema_fields=operation["lookup_fields"],
  438. limits=limits,
  439. )
  440. except ValueError as exc:
  441. raise NodeExecutionError(
  442. "published Polars lookup artifact is invalid"
  443. ) from exc
  444. lookup = _bounded_materialize(
  445. lookup, limits, f"lookup input {index}"
  446. ).lazy()
  447. duplicate_count = (
  448. lookup.group_by(operation["right_on"])
  449. .len()
  450. .filter(pl.col("len") > 1)
  451. .select(pl.len())
  452. .collect(engine="streaming")
  453. .item()
  454. )
  455. if duplicate_count:
  456. raise NodeExecutionError(
  457. "published Polars lookup keys are not unique"
  458. )
  459. selected_sources = list(operation["select"].values())
  460. lookup = lookup.select(
  461. list(dict.fromkeys(
  462. [*operation["right_on"], *selected_sources]
  463. ))
  464. ).rename(
  465. {
  466. source_name: target_name
  467. for target_name, source_name in operation[
  468. "select"
  469. ].items()
  470. if source_name not in operation["right_on"]
  471. }
  472. )
  473. frame = frame.join(
  474. lookup,
  475. left_on=operation["left_on"],
  476. right_on=operation["right_on"],
  477. how=operation["how"],
  478. )
  479. after = _bounded_materialize(
  480. frame, limits, f"{op} output {index}"
  481. )
  482. if after.height > before:
  483. raise NodeExecutionError(
  484. "published Polars lookup join expanded its input"
  485. )
  486. rows_join_dropped += before - after.height
  487. frame = after.lazy()
  488. continue
  489. frame = _bounded_materialize(
  490. frame, limits, f"{op} output {index}"
  491. ).lazy()
  492. output_names = [field["name"] for field in normalized["output_fields"]]
  493. frame = frame.select(output_names)
  494. bounded = _bounded_materialize(frame, limits, "output")
  495. try:
  496. artifact = self.artifact_store.write(
  497. bounded.lazy(),
  498. correlation,
  499. self.artifact_ttl_seconds,
  500. schema_fields=normalized["output_fields"],
  501. limits=limits,
  502. )
  503. except ValueError as exc:
  504. raise NodeExecutionError(
  505. "published Polars output artifact write failed"
  506. ) from exc
  507. try:
  508. self.artifact_resolver.register(
  509. binding_id=normalized["output_binding_id"],
  510. correlation_id=correlation,
  511. artifact=artifact,
  512. kind="output",
  513. binding_hash=normalized["output_binding_hash"],
  514. )
  515. except Exception as exc:
  516. delete = getattr(self.artifact_store, "delete", None)
  517. if callable(delete):
  518. with suppress(Exception):
  519. delete(artifact["artifact_ref"])
  520. raise NodeExecutionError(
  521. "published Polars output artifact registration failed"
  522. ) from exc
  523. violation_count = sum(item["count"] for item in violations)
  524. return {
  525. **artifact,
  526. "rows_in": rows_in,
  527. "rows_out": bounded.height,
  528. "rows_rejected": rows_rejected,
  529. "rows_filtered": rows_filtered,
  530. "rows_deduplicated": rows_deduplicated,
  531. "rows_join_dropped": rows_join_dropped,
  532. "rows_aggregated": rows_aggregated,
  533. "violation_count": violation_count,
  534. "violations": violations,
  535. "commit_outcome": "committed",
  536. }