rule_polars.py 9.2 KB

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