rule_polars.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  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. governed_write = (
  120. node.get("type") == "rule.apply"
  121. and node.get("purpose") == "write"
  122. and write_authorized
  123. and isinstance(idempotency, dict)
  124. and idempotency.get("strategy") in _IDEMPOTENCY
  125. and bool(str(idempotency.get("key") or "").strip())
  126. )
  127. governed_quality = (
  128. node.get("type") == "quality.check"
  129. and node.get("purpose") == "read"
  130. and not write_authorized
  131. and idempotency is None
  132. )
  133. if not (governed_write or governed_quality):
  134. if node.get("type") == "quality.check":
  135. raise NodeExecutionError(
  136. "quality check must be read only"
  137. )
  138. raise NodeExecutionError(
  139. "governed write authorization and idempotency are required"
  140. )
  141. if (
  142. node.get("type") != "rule.apply"
  143. and node.get("type") != "quality.check"
  144. ):
  145. raise NodeExecutionError(
  146. "unsupported governed Polars node"
  147. )
  148. if parameters in ({}, None):
  149. input_handoff = None
  150. elif (
  151. isinstance(parameters, dict)
  152. and set(parameters) == {"input_artifact"}
  153. and isinstance(parameters["input_artifact"], str)
  154. ):
  155. input_handoff = parameters["input_artifact"]
  156. else:
  157. raise NodeExecutionError(
  158. "bound Polars rule plans accept only one artifact handoff"
  159. )
  160. correlation = _uid(correlation_id, "correlation_id")
  161. if governed_write:
  162. try:
  163. self.artifact_resolver.attest_binding(
  164. binding_id=normalized["output_binding_id"],
  165. binding_hash=normalized["output_binding_hash"],
  166. access_mode="write",
  167. )
  168. except Exception as exc:
  169. raise NodeExecutionError(
  170. "published Polars output binding no longer matches"
  171. ) from exc
  172. if input_handoff is None:
  173. source = _resolve_artifact(
  174. self.artifact_resolver,
  175. binding_id=normalized["input_binding_id"],
  176. binding_hash=normalized["input_binding_hash"],
  177. correlation_id=correlation,
  178. kind="input",
  179. )
  180. else:
  181. try:
  182. self.artifact_resolver.attest_binding(
  183. binding_id=normalized["input_binding_id"],
  184. binding_hash=normalized["input_binding_hash"],
  185. access_mode="read",
  186. )
  187. source = self.artifact_resolver.resolve_handoff(
  188. artifact_ref=input_handoff,
  189. correlation_id=correlation,
  190. )
  191. except Exception as exc:
  192. raise NodeExecutionError(
  193. "upstream Polars artifact handoff was not resolved"
  194. ) from exc
  195. limits = normalized["resource_limits"]
  196. output_path = None
  197. try:
  198. with ExitStack() as stack:
  199. try:
  200. input_path = stack.enter_context(
  201. self.artifact_store.stage(
  202. source["artifact_ref"],
  203. source["digest"],
  204. expected_schema_fields=normalized[
  205. "input_fields"
  206. ],
  207. limits=limits,
  208. )
  209. )
  210. lookup_paths = {}
  211. for operation in normalized["operations"]:
  212. if operation["op"] != "lookup_join":
  213. continue
  214. lookup_artifact = _resolve_artifact(
  215. self.artifact_resolver,
  216. binding_id=operation[
  217. "lookup_binding_id"
  218. ],
  219. binding_hash=operation[
  220. "lookup_binding_hash"
  221. ],
  222. correlation_id=correlation,
  223. kind="lookup",
  224. )
  225. lookup_paths[
  226. operation["lookup_binding_id"]
  227. ] = stack.enter_context(
  228. self.artifact_store.stage(
  229. lookup_artifact["artifact_ref"],
  230. lookup_artifact["digest"],
  231. expected_schema_fields=operation[
  232. "lookup_fields"
  233. ],
  234. limits=limits,
  235. )
  236. )
  237. except ValueError as exc:
  238. raise NodeExecutionError(
  239. "published Polars input artifact is invalid"
  240. ) from exc
  241. with tempfile.NamedTemporaryFile(
  242. prefix="dataops-polars-worker-output-",
  243. suffix=".parquet",
  244. delete=False,
  245. ) as handle:
  246. output_path = handle.name
  247. try:
  248. worker_result = execute_isolated_polars_plan(
  249. {
  250. "plan": normalized,
  251. "input_path": input_path,
  252. "lookup_paths": lookup_paths,
  253. "output_path": output_path,
  254. "masking_policies": self.masking_policies,
  255. },
  256. memory_limit_bytes=limits[
  257. "memory_limit_bytes"
  258. ],
  259. )
  260. except PolarsWorkerResourceError as exc:
  261. raise NodeExecutionError(
  262. "published Polars worker exceeded its hard memory limit"
  263. ) from exc
  264. except PolarsWorkerError as exc:
  265. raise NodeExecutionError(
  266. "published Polars worker execution failed"
  267. ) from exc
  268. if governed_write:
  269. try:
  270. registered = self.artifact_resolver.publish_path(
  271. output_path,
  272. binding_id=normalized["output_binding_id"],
  273. binding_hash=normalized["output_binding_hash"],
  274. correlation_id=correlation,
  275. kind="output",
  276. ttl_seconds=self.artifact_ttl_seconds,
  277. schema_fields=normalized["output_fields"],
  278. limits=limits,
  279. )
  280. except ArtifactCommitUnknown as exc:
  281. raise NodeExecutionError(
  282. "published Polars artifact commit outcome is unknown",
  283. commit_outcome="unknown",
  284. ) from exc
  285. except Exception as exc:
  286. raise NodeExecutionError(
  287. "published Polars output artifact publication failed"
  288. ) from exc
  289. finally:
  290. if output_path is not None:
  291. with suppress(FileNotFoundError):
  292. os.unlink(output_path)
  293. metrics = {key: worker_result[key] for key in _RESULT_KEYS}
  294. if governed_quality:
  295. return {
  296. **metrics,
  297. "commit_outcome": "not_applicable",
  298. }
  299. return {
  300. **{key: registered[key] for key in _PUBLIC_ARTIFACT_KEYS},
  301. **metrics,
  302. "commit_outcome": "committed",
  303. }