rule_polars.py 17 KB

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