rule_polars.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. """Runner adapter for OS-isolated, allowlisted Polars execution."""
  2. from __future__ import annotations
  3. import os
  4. import tempfile
  5. from contextlib import ExitStack, suppress
  6. from typing import Any
  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. from app.runner.polars_worker import (
  14. PolarsWorkerError,
  15. PolarsWorkerResourceError,
  16. execute_isolated_polars_plan,
  17. )
  18. _IDEMPOTENCY = {
  19. "deduplication_key",
  20. "partition_replace",
  21. "upsert",
  22. }
  23. _PUBLIC_ARTIFACT_KEYS = (
  24. "artifact_ref",
  25. "digest",
  26. "row_count",
  27. "schema_hash",
  28. "expires_at",
  29. )
  30. _RESULT_KEYS = (
  31. "rows_in",
  32. "rows_out",
  33. "rows_rejected",
  34. "rows_filtered",
  35. "rows_deduplicated",
  36. "rows_join_dropped",
  37. "rows_aggregated",
  38. "violation_count",
  39. "violations",
  40. )
  41. def _uid(value: Any, label: str) -> str:
  42. try:
  43. return ensure_governance_uid({"uid": str(value)})
  44. except ValueError as exc:
  45. raise NodeExecutionError(f"{label} is invalid") from exc
  46. def _resolve_artifact(
  47. resolver,
  48. *,
  49. binding_id: str,
  50. binding_hash: str,
  51. correlation_id: str,
  52. kind: str,
  53. ) -> dict[str, Any]:
  54. try:
  55. artifact = resolver.resolve(
  56. binding_id=binding_id,
  57. correlation_id=correlation_id,
  58. kind=kind,
  59. )
  60. except Exception as exc:
  61. raise NodeExecutionError(
  62. "published Polars artifact binding was not resolved"
  63. ) from exc
  64. if (
  65. not isinstance(artifact, dict)
  66. or artifact.get("binding_hash") != binding_hash
  67. or not isinstance(artifact.get("artifact_ref"), str)
  68. or not isinstance(artifact.get("digest"), str)
  69. or not isinstance(artifact.get("schema_fields"), list)
  70. ):
  71. raise NodeExecutionError(
  72. "published Polars artifact binding does not match"
  73. )
  74. return artifact
  75. class PolarsRulePlanAdapter:
  76. """Stage bounded inputs and execute the plan in a hard-limited process."""
  77. def __init__(
  78. self,
  79. *,
  80. artifact_store,
  81. artifact_resolver,
  82. masking_policies=None,
  83. artifact_ttl_seconds=3600,
  84. ):
  85. self.artifact_store = artifact_store
  86. self.artifact_resolver = artifact_resolver
  87. self.masking_policies = dict(masking_policies or {})
  88. self.artifact_ttl_seconds = int(artifact_ttl_seconds)
  89. def execute(
  90. self,
  91. *,
  92. plan,
  93. node,
  94. parameters,
  95. write_authorized,
  96. correlation_id=None,
  97. ):
  98. try:
  99. normalized = validate_bound_polars_plan(plan)
  100. except ValueError as exc:
  101. raise NodeExecutionError(
  102. "published Polars rule plan is invalid"
  103. ) from exc
  104. config = node.get("config") or {}
  105. if config.get("execution_plan_hash") != bound_polars_plan_hash(
  106. normalized
  107. ):
  108. raise NodeExecutionError(
  109. "published Polars rule plan hash does not match"
  110. )
  111. if config.get("rule_version_id") != normalized["rule_version_id"]:
  112. raise NodeExecutionError(
  113. "published Polars rule id does not match"
  114. )
  115. idempotency = node.get("idempotency")
  116. if (
  117. node.get("type") != "rule.apply"
  118. or node.get("purpose") != "write"
  119. or not write_authorized
  120. or not isinstance(idempotency, dict)
  121. or idempotency.get("strategy") not in _IDEMPOTENCY
  122. or not str(idempotency.get("key") or "").strip()
  123. ):
  124. raise NodeExecutionError(
  125. "governed write authorization and idempotency are required"
  126. )
  127. if parameters not in ({}, None):
  128. raise NodeExecutionError(
  129. "bound Polars rule plans do not accept runtime parameters"
  130. )
  131. correlation = _uid(correlation_id, "correlation_id")
  132. try:
  133. self.artifact_resolver.attest_binding(
  134. binding_id=normalized["output_binding_id"],
  135. binding_hash=normalized["output_binding_hash"],
  136. access_mode="write",
  137. )
  138. except Exception as exc:
  139. raise NodeExecutionError(
  140. "published Polars output binding no longer matches"
  141. ) from exc
  142. source = _resolve_artifact(
  143. self.artifact_resolver,
  144. binding_id=normalized["input_binding_id"],
  145. binding_hash=normalized["input_binding_hash"],
  146. correlation_id=correlation,
  147. kind="input",
  148. )
  149. limits = normalized["resource_limits"]
  150. output_path = None
  151. try:
  152. with ExitStack() as stack:
  153. try:
  154. input_path = stack.enter_context(
  155. self.artifact_store.stage(
  156. source["artifact_ref"],
  157. source["digest"],
  158. expected_schema_fields=normalized[
  159. "input_fields"
  160. ],
  161. limits=limits,
  162. )
  163. )
  164. lookup_paths = {}
  165. for operation in normalized["operations"]:
  166. if operation["op"] != "lookup_join":
  167. continue
  168. lookup_artifact = _resolve_artifact(
  169. self.artifact_resolver,
  170. binding_id=operation[
  171. "lookup_binding_id"
  172. ],
  173. binding_hash=operation[
  174. "lookup_binding_hash"
  175. ],
  176. correlation_id=correlation,
  177. kind="lookup",
  178. )
  179. lookup_paths[
  180. operation["lookup_binding_id"]
  181. ] = stack.enter_context(
  182. self.artifact_store.stage(
  183. lookup_artifact["artifact_ref"],
  184. lookup_artifact["digest"],
  185. expected_schema_fields=operation[
  186. "lookup_fields"
  187. ],
  188. limits=limits,
  189. )
  190. )
  191. except ValueError as exc:
  192. raise NodeExecutionError(
  193. "published Polars input artifact is invalid"
  194. ) from exc
  195. with tempfile.NamedTemporaryFile(
  196. prefix="dataops-polars-worker-output-",
  197. suffix=".parquet",
  198. delete=False,
  199. ) as handle:
  200. output_path = handle.name
  201. try:
  202. worker_result = execute_isolated_polars_plan(
  203. {
  204. "plan": normalized,
  205. "input_path": input_path,
  206. "lookup_paths": lookup_paths,
  207. "output_path": output_path,
  208. "masking_policies": self.masking_policies,
  209. },
  210. memory_limit_bytes=limits[
  211. "memory_limit_bytes"
  212. ],
  213. )
  214. except PolarsWorkerResourceError as exc:
  215. raise NodeExecutionError(
  216. "published Polars worker exceeded its hard memory limit"
  217. ) from exc
  218. except PolarsWorkerError as exc:
  219. raise NodeExecutionError(
  220. "published Polars worker execution failed"
  221. ) from exc
  222. try:
  223. artifact = self.artifact_store.write_path(
  224. output_path,
  225. correlation,
  226. self.artifact_ttl_seconds,
  227. schema_fields=normalized["output_fields"],
  228. limits=limits,
  229. )
  230. except ValueError as exc:
  231. raise NodeExecutionError(
  232. "published Polars output artifact write failed"
  233. ) from exc
  234. finally:
  235. if output_path is not None:
  236. with suppress(FileNotFoundError):
  237. os.unlink(output_path)
  238. try:
  239. registered = self.artifact_resolver.register(
  240. binding_id=normalized["output_binding_id"],
  241. correlation_id=correlation,
  242. artifact=artifact,
  243. kind="output",
  244. binding_hash=normalized["output_binding_hash"],
  245. )
  246. except Exception as exc:
  247. with suppress(Exception):
  248. self.artifact_store.delete(artifact["artifact_ref"])
  249. raise NodeExecutionError(
  250. "published Polars output artifact registration failed"
  251. ) from exc
  252. return {
  253. **{key: registered[key] for key in _PUBLIC_ARTIFACT_KEYS},
  254. **{key: worker_result[key] for key in _RESULT_KEYS},
  255. "commit_outcome": "committed",
  256. }