rule_polars.py 10 KB

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