polars_worker.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581
  1. """OS-isolated execution boundary for allocation-heavy Polars work."""
  2. from __future__ import annotations
  3. import multiprocessing
  4. import os
  5. import time
  6. from contextlib import suppress
  7. from decimal import Decimal
  8. from typing import Any
  9. try:
  10. import resource
  11. except ImportError: # pragma: no cover - fail-closed portability guard
  12. resource = None
  13. import polars as pl
  14. import psutil
  15. from app.core.data_rules.compilers.polars import validate_bound_polars_plan
  16. class PolarsWorkerError(RuntimeError):
  17. """A bounded worker failed without exposing internal data."""
  18. class PolarsWorkerResourceError(PolarsWorkerError):
  19. """The worker crossed a hard process resource boundary."""
  20. def _set_process_memory_limit(
  21. *,
  22. baseline_vms: int,
  23. memory_limit_bytes: int,
  24. ) -> None:
  25. if resource is None or not hasattr(resource, "RLIMIT_AS"):
  26. raise OSError("OS address-space limits are unavailable")
  27. hard_limit = baseline_vms + memory_limit_bytes
  28. resource.setrlimit(resource.RLIMIT_AS, (hard_limit, hard_limit))
  29. def _memory_probe_entry(
  30. connection,
  31. allocate_bytes: int,
  32. memory_limit_bytes: int,
  33. ) -> None:
  34. process = psutil.Process()
  35. memory = process.memory_info()
  36. connection.send(
  37. {
  38. "kind": "ready",
  39. "pid": os.getpid(),
  40. "baseline_rss": memory.rss,
  41. }
  42. )
  43. try:
  44. _set_process_memory_limit(
  45. baseline_vms=memory.vms,
  46. memory_limit_bytes=memory_limit_bytes,
  47. )
  48. allocated = bytearray(allocate_bytes)
  49. for index in range(0, len(allocated), 4096):
  50. allocated[index] = 1
  51. connection.send(
  52. {
  53. "kind": "result",
  54. "worker_pid": os.getpid(),
  55. "allocated_bytes": len(allocated),
  56. }
  57. )
  58. except (MemoryError, OSError):
  59. with suppress(Exception):
  60. connection.send({"kind": "resource_error"})
  61. except BaseException:
  62. with suppress(Exception):
  63. connection.send({"kind": "worker_error"})
  64. finally:
  65. connection.close()
  66. class _ExpressionCompiler:
  67. def __init__(self, timezone: str):
  68. self.timezone = timezone
  69. def compile(self, ast: dict[str, Any]) -> pl.Expr:
  70. kind = ast["kind"]
  71. if kind == "identifier":
  72. return pl.col(ast["name"])
  73. if kind == "literal":
  74. value = ast["value"]
  75. if ast["type"] == "decimal":
  76. value = Decimal(value)
  77. return pl.lit(value)
  78. if kind == "unary":
  79. operand = self.compile(ast["operand"])
  80. return ~operand if ast["operator"] == "!" else -operand
  81. if kind == "binary":
  82. left = self.compile(ast["left"])
  83. right = self.compile(ast["right"])
  84. return {
  85. "||": lambda: left | right,
  86. "&&": lambda: left & right,
  87. "==": lambda: left == right,
  88. "!=": lambda: left != right,
  89. "<": lambda: left < right,
  90. "<=": lambda: left <= right,
  91. ">": lambda: left > right,
  92. ">=": lambda: left >= right,
  93. "+": lambda: left + right,
  94. "-": lambda: left - right,
  95. "*": lambda: left * right,
  96. "/": lambda: left / right,
  97. "%": lambda: left % right,
  98. }[ast["operator"]]()
  99. function = ast["function"]
  100. arguments = [self.compile(item) for item in ast["arguments"]]
  101. if function == "matches":
  102. return arguments[0].str.contains(
  103. ast["arguments"][1]["value"], strict=True
  104. )
  105. if function == "lower":
  106. return arguments[0].str.to_lowercase()
  107. if function == "upper":
  108. return arguments[0].str.to_uppercase()
  109. if function == "trim":
  110. return arguments[0].str.strip_chars()
  111. if function == "length":
  112. return arguments[0].str.len_chars()
  113. if function == "coalesce":
  114. return pl.coalesce(arguments)
  115. if function == "date":
  116. return (
  117. arguments[0]
  118. .cast(pl.String)
  119. .str.to_datetime(time_zone=self.timezone, strict=True)
  120. .dt.date()
  121. )
  122. if function == "timestamp":
  123. return (
  124. arguments[0]
  125. .cast(pl.String)
  126. .str.to_datetime(time_zone=self.timezone, strict=True)
  127. .dt.replace_time_zone(None)
  128. )
  129. if function == "abs":
  130. return arguments[0].abs()
  131. raise ValueError("published Polars expression is unsupported")
  132. def _cast_type(field: dict[str, Any]) -> pl.DataType:
  133. field_type = field["type"]
  134. if field_type == "boolean":
  135. return pl.Boolean
  136. if field_type == "date":
  137. return pl.Date
  138. if field_type == "decimal":
  139. return pl.Decimal(
  140. precision=field["precision"],
  141. scale=field["scale"],
  142. )
  143. if field_type == "double":
  144. return pl.Float64
  145. if field_type == "float":
  146. return pl.Float32
  147. if field_type == "integer":
  148. return pl.Int64
  149. if field_type == "string":
  150. return pl.String
  151. if field_type == "timestamp":
  152. return pl.Datetime
  153. if field_type == "timestamptz":
  154. return pl.Datetime(time_zone=field["timezone"])
  155. raise ValueError("published Polars cast type is unsupported")
  156. def _count(frame: pl.LazyFrame) -> int:
  157. return int(frame.select(pl.len()).collect(engine="streaming").item())
  158. def _validate_lazy_schema(
  159. frame: pl.LazyFrame,
  160. fields: list[dict[str, Any]],
  161. ) -> None:
  162. schema = frame.collect_schema()
  163. if set(schema.names()) != {field["name"] for field in fields}:
  164. raise ValueError("artifact schema fields do not match")
  165. for field in fields:
  166. if schema[field["name"]] != _cast_type(field):
  167. raise ValueError("artifact schema type does not match")
  168. required = [
  169. field["name"] for field in fields if not field["nullable"]
  170. ]
  171. if required:
  172. counts = frame.select(
  173. [pl.col(name).null_count().alias(name) for name in required]
  174. ).collect(engine="streaming")
  175. if any(counts[name].item() for name in required):
  176. raise ValueError("artifact nullable contract does not match")
  177. def _execute_polars_job(job: dict[str, Any]) -> dict[str, Any]:
  178. plan = validate_bound_polars_plan(job["plan"])
  179. frame = pl.scan_parquet(job["input_path"])
  180. _validate_lazy_schema(frame, plan["input_fields"])
  181. rows_in = _count(frame)
  182. expressions = _ExpressionCompiler(plan["timezone"])
  183. violations = []
  184. metrics = {
  185. "rows_rejected": 0,
  186. "rows_filtered": 0,
  187. "rows_deduplicated": 0,
  188. "rows_join_dropped": 0,
  189. "rows_aggregated": 0,
  190. }
  191. for operation in plan["operations"]:
  192. op = operation["op"]
  193. before = _count(frame)
  194. if op == "normalize_text":
  195. expression = pl.col(operation["column"])
  196. if operation["trim"]:
  197. expression = expression.str.strip_chars()
  198. if operation["lowercase"]:
  199. expression = expression.str.to_lowercase()
  200. if operation["uppercase"]:
  201. expression = expression.str.to_uppercase()
  202. frame = frame.with_columns(
  203. expression.alias(operation["column"])
  204. )
  205. elif op == "regex_replace":
  206. frame = frame.with_columns(
  207. pl.col(operation["column"])
  208. .str.replace_all(
  209. operation["pattern"], operation["replacement"]
  210. )
  211. .alias(operation["column"])
  212. )
  213. elif op == "fill_null":
  214. frame = frame.with_columns(
  215. pl.col(operation["column"])
  216. .fill_null(operation["value"])
  217. .alias(operation["column"])
  218. )
  219. elif op == "filter":
  220. frame = frame.filter(
  221. expressions.compile(operation["expression_ast"]).fill_null(
  222. False
  223. )
  224. )
  225. metrics["rows_filtered"] += before - _count(frame)
  226. elif op == "assert":
  227. predicate = expressions.compile(
  228. operation["expression_ast"]
  229. ).fill_null(False)
  230. invalid = int(
  231. frame.select((~predicate).sum().alias("count"))
  232. .collect(engine="streaming")
  233. .item()
  234. or 0
  235. )
  236. violations.append(
  237. {"step_id": operation["step_id"], "count": invalid}
  238. )
  239. metrics["rows_rejected"] += invalid
  240. frame = frame.filter(predicate)
  241. elif op == "derive":
  242. frame = frame.with_columns(
  243. expressions.compile(operation["expression_ast"]).alias(
  244. operation["target"]
  245. )
  246. )
  247. elif op == "map_values":
  248. column = pl.col(operation["column"])
  249. frame = frame.with_columns(
  250. column.replace_strict(
  251. operation["mapping"], default=column
  252. ).alias(operation["column"])
  253. )
  254. elif op == "cast":
  255. target = operation["target_field"]
  256. expression = pl.col(operation["column"])
  257. source_type = frame.collect_schema()[operation["column"]]
  258. if source_type == pl.String and target["type"] == "timestamp":
  259. expression = (
  260. expression.str.to_datetime(
  261. time_zone=plan["timezone"],
  262. strict=True,
  263. ).dt.replace_time_zone(None)
  264. )
  265. elif (
  266. source_type == pl.String
  267. and target["type"] == "timestamptz"
  268. ):
  269. expression = expression.str.to_datetime(
  270. time_zone=target["timezone"], strict=True
  271. )
  272. else:
  273. expression = expression.cast(
  274. _cast_type(target), strict=True
  275. )
  276. frame = frame.with_columns(
  277. expression.alias(operation["column"])
  278. )
  279. elif op == "deduplicate":
  280. order = [
  281. *operation["order_by"],
  282. *sorted(
  283. name
  284. for name in frame.collect_schema().names()
  285. if name not in operation["order_by"]
  286. ),
  287. ]
  288. descending = operation["keep"] == "last"
  289. frame = (
  290. frame.sort(
  291. order,
  292. descending=[descending] * len(order),
  293. nulls_last=True,
  294. )
  295. .unique(
  296. subset=operation["keys"],
  297. keep="first",
  298. maintain_order=True,
  299. )
  300. )
  301. metrics["rows_deduplicated"] += before - _count(frame)
  302. elif op == "mask":
  303. if job["masking_policies"].get(
  304. operation["policy_id"]
  305. ) != operation["policy_kind"]:
  306. raise ValueError("masking policy is not registered")
  307. column = pl.col(operation["column"])
  308. if operation["policy_kind"] == "redact":
  309. masked = pl.when(column.is_null()).then(None).otherwise(
  310. pl.lit("***")
  311. )
  312. else:
  313. masked = pl.when(column.is_null()).then(None).otherwise(
  314. pl.lit("***") + column.str.slice(-4)
  315. )
  316. frame = frame.with_columns(masked.alias(operation["column"]))
  317. elif op == "aggregate":
  318. aggregations = []
  319. for aggregate in operation["aggregations"]:
  320. expression = pl.col(aggregate["column"])
  321. function = aggregate["function"]
  322. if function == "count":
  323. expression = expression.count()
  324. elif function == "sum":
  325. expression = expression.sum()
  326. elif function == "min":
  327. expression = expression.min()
  328. elif function == "max":
  329. expression = expression.max()
  330. elif function == "mean":
  331. expression = expression.mean()
  332. aggregations.append(
  333. expression.alias(aggregate["target"])
  334. )
  335. frame = frame.group_by(
  336. operation["group_by"], maintain_order=True
  337. ).agg(aggregations)
  338. metrics["rows_aggregated"] += before - _count(frame)
  339. elif op == "lookup_join":
  340. lookup = pl.scan_parquet(
  341. job["lookup_paths"][operation["lookup_binding_id"]]
  342. )
  343. _validate_lazy_schema(lookup, operation["lookup_fields"])
  344. duplicates = (
  345. lookup.group_by(operation["right_on"])
  346. .len()
  347. .filter(pl.col("len") > 1)
  348. .select(pl.len())
  349. .collect(engine="streaming")
  350. .item()
  351. )
  352. if duplicates:
  353. raise ValueError("lookup keys are not unique")
  354. selected = list(operation["select"].values())
  355. lookup = lookup.select(
  356. list(
  357. dict.fromkeys(
  358. [*operation["right_on"], *selected]
  359. )
  360. )
  361. ).rename(
  362. {
  363. source: target
  364. for target, source in operation["select"].items()
  365. if source not in operation["right_on"]
  366. }
  367. )
  368. frame = frame.join(
  369. lookup,
  370. left_on=operation["left_on"],
  371. right_on=operation["right_on"],
  372. how=operation["how"],
  373. )
  374. after = _count(frame)
  375. if after > before:
  376. raise ValueError("lookup join expanded its input")
  377. metrics["rows_join_dropped"] += before - after
  378. frame = frame.select(
  379. [field["name"] for field in plan["output_fields"]]
  380. )
  381. _validate_lazy_schema(frame, plan["output_fields"])
  382. rows_out = _count(frame)
  383. if rows_out > plan["resource_limits"]["max_rows"]:
  384. raise ValueError("output row limit exceeded")
  385. frame.sink_parquet(job["output_path"], engine="streaming")
  386. return {
  387. "worker_pid": os.getpid(),
  388. "rows_in": rows_in,
  389. "rows_out": rows_out,
  390. **metrics,
  391. "violation_count": sum(item["count"] for item in violations),
  392. "violations": violations,
  393. }
  394. def _polars_job_entry(
  395. connection,
  396. job: dict[str, Any],
  397. memory_limit_bytes: int,
  398. ) -> None:
  399. # Initialize the Rust engine/thread pool before measuring the worker's
  400. # immutable runtime baseline. The plan allowance applies only afterwards.
  401. pl.DataFrame({"_warmup": [1]}).lazy().collect(engine="streaming")
  402. process = psutil.Process()
  403. memory = process.memory_info()
  404. connection.send(
  405. {
  406. "kind": "ready",
  407. "pid": os.getpid(),
  408. "baseline_rss": memory.rss,
  409. }
  410. )
  411. try:
  412. _set_process_memory_limit(
  413. baseline_vms=memory.vms,
  414. memory_limit_bytes=memory_limit_bytes,
  415. )
  416. result = _execute_polars_job(job)
  417. connection.send({"kind": "result", **result})
  418. except (MemoryError, OSError):
  419. with suppress(Exception):
  420. connection.send({"kind": "resource_error"})
  421. except BaseException as exc:
  422. message = str(exc).lower()
  423. kind = (
  424. "resource_error"
  425. if any(
  426. token in message
  427. for token in ("memory", "allocate", "allocation")
  428. )
  429. else "worker_error"
  430. )
  431. with suppress(Exception):
  432. connection.send({"kind": kind})
  433. finally:
  434. connection.close()
  435. def _run_process(
  436. *,
  437. target,
  438. args: tuple[Any, ...],
  439. memory_limit_bytes: int,
  440. timeout_seconds: float = 15.0,
  441. ) -> dict[str, Any]:
  442. parent = None
  443. child = None
  444. process = None
  445. started = False
  446. try:
  447. context = multiprocessing.get_context("spawn")
  448. parent, child = context.Pipe(duplex=False)
  449. process = context.Process(
  450. target=target,
  451. args=(child, *args, memory_limit_bytes),
  452. daemon=True,
  453. )
  454. process.start()
  455. started = True
  456. child.close()
  457. deadline = time.monotonic() + timeout_seconds
  458. baseline_rss = None
  459. while time.monotonic() < deadline:
  460. if parent.poll(0.01):
  461. try:
  462. message = parent.recv()
  463. except EOFError as exc:
  464. raise PolarsWorkerResourceError(
  465. "Polars worker crossed its hard memory limit"
  466. ) from exc
  467. kind = message.get("kind")
  468. if kind == "ready":
  469. baseline_rss = int(message["baseline_rss"])
  470. elif kind == "result":
  471. process.join(timeout=1)
  472. return message
  473. elif kind == "resource_error":
  474. raise PolarsWorkerResourceError(
  475. "Polars worker exceeded its hard memory limit"
  476. )
  477. else:
  478. raise PolarsWorkerError(
  479. "Polars worker execution failed"
  480. )
  481. if baseline_rss is not None and process.is_alive():
  482. try:
  483. rss = psutil.Process(process.pid).memory_info().rss
  484. except (psutil.Error, ProcessLookupError):
  485. rss = baseline_rss
  486. if rss - baseline_rss > memory_limit_bytes:
  487. process.kill()
  488. process.join(timeout=1)
  489. raise PolarsWorkerResourceError(
  490. "Polars worker exceeded its hard memory limit"
  491. )
  492. if not process.is_alive():
  493. process.join(timeout=1)
  494. raise PolarsWorkerResourceError(
  495. "Polars worker crossed its hard memory limit"
  496. )
  497. process.kill()
  498. process.join(timeout=1)
  499. raise PolarsWorkerResourceError(
  500. "Polars worker exceeded its bounded execution time"
  501. )
  502. except (PolarsWorkerError, PolarsWorkerResourceError):
  503. raise
  504. except Exception as exc:
  505. raise PolarsWorkerError(
  506. "Polars worker failed to start safely"
  507. ) from exc
  508. finally:
  509. if child is not None:
  510. with suppress(Exception):
  511. child.close()
  512. if parent is not None:
  513. with suppress(Exception):
  514. parent.close()
  515. if process is not None:
  516. if started:
  517. with suppress(Exception):
  518. if process.is_alive():
  519. process.kill()
  520. with suppress(Exception):
  521. process.join(timeout=1)
  522. close = getattr(process, "close", None)
  523. if callable(close):
  524. with suppress(Exception):
  525. close()
  526. def run_isolated_memory_probe(
  527. *,
  528. allocate_bytes: int,
  529. memory_limit_bytes: int,
  530. ) -> dict[str, Any]:
  531. """Exercise the same process boundary used by plan execution."""
  532. return _run_process(
  533. target=_memory_probe_entry,
  534. args=(int(allocate_bytes),),
  535. memory_limit_bytes=int(memory_limit_bytes),
  536. )
  537. def execute_isolated_polars_plan(
  538. job: dict[str, Any],
  539. *,
  540. memory_limit_bytes: int,
  541. ) -> dict[str, Any]:
  542. return _run_process(
  543. target=_polars_job_entry,
  544. args=(job,),
  545. memory_limit_bytes=memory_limit_bytes,
  546. timeout_seconds=60.0,
  547. )