rule_polars.py 10 KB

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