polars_worker.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599
  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. violation_sample = []
  185. metrics = {
  186. "rows_rejected": 0,
  187. "rows_filtered": 0,
  188. "rows_deduplicated": 0,
  189. "rows_join_dropped": 0,
  190. "rows_aggregated": 0,
  191. }
  192. for operation in plan["operations"]:
  193. op = operation["op"]
  194. before = _count(frame)
  195. if op == "normalize_text":
  196. expression = pl.col(operation["column"])
  197. if operation["trim"]:
  198. expression = expression.str.strip_chars()
  199. if operation["lowercase"]:
  200. expression = expression.str.to_lowercase()
  201. if operation["uppercase"]:
  202. expression = expression.str.to_uppercase()
  203. frame = frame.with_columns(
  204. expression.alias(operation["column"])
  205. )
  206. elif op == "regex_replace":
  207. frame = frame.with_columns(
  208. pl.col(operation["column"])
  209. .str.replace_all(
  210. operation["pattern"], operation["replacement"]
  211. )
  212. .alias(operation["column"])
  213. )
  214. elif op == "fill_null":
  215. frame = frame.with_columns(
  216. pl.col(operation["column"])
  217. .fill_null(operation["value"])
  218. .alias(operation["column"])
  219. )
  220. elif op == "filter":
  221. frame = frame.filter(
  222. expressions.compile(operation["expression_ast"]).fill_null(
  223. False
  224. )
  225. )
  226. metrics["rows_filtered"] += before - _count(frame)
  227. elif op == "assert":
  228. predicate = expressions.compile(
  229. operation["expression_ast"]
  230. ).fill_null(False)
  231. remaining_sample = 100 - len(violation_sample)
  232. if remaining_sample > 0:
  233. redacted = [
  234. pl.when(pl.col(name).is_null())
  235. .then(None)
  236. .otherwise(pl.lit("[REDACTED]"))
  237. .alias(name)
  238. for name in frame.collect_schema().names()
  239. ]
  240. violation_sample.extend(
  241. frame.filter(~predicate)
  242. .head(remaining_sample)
  243. .select(redacted)
  244. .collect(engine="streaming")
  245. .to_dicts()
  246. )
  247. invalid = int(
  248. frame.select((~predicate).sum().alias("count"))
  249. .collect(engine="streaming")
  250. .item()
  251. or 0
  252. )
  253. violations.append(
  254. {"step_id": operation["step_id"], "count": invalid}
  255. )
  256. metrics["rows_rejected"] += invalid
  257. frame = frame.filter(predicate)
  258. elif op == "derive":
  259. frame = frame.with_columns(
  260. expressions.compile(operation["expression_ast"]).alias(
  261. operation["target"]
  262. )
  263. )
  264. elif op == "map_values":
  265. column = pl.col(operation["column"])
  266. frame = frame.with_columns(
  267. column.replace_strict(
  268. operation["mapping"], default=column
  269. ).alias(operation["column"])
  270. )
  271. elif op == "cast":
  272. target = operation["target_field"]
  273. expression = pl.col(operation["column"])
  274. source_type = frame.collect_schema()[operation["column"]]
  275. if source_type == pl.String and target["type"] == "timestamp":
  276. expression = (
  277. expression.str.to_datetime(
  278. time_zone=plan["timezone"],
  279. strict=True,
  280. ).dt.replace_time_zone(None)
  281. )
  282. elif (
  283. source_type == pl.String
  284. and target["type"] == "timestamptz"
  285. ):
  286. expression = expression.str.to_datetime(
  287. time_zone=target["timezone"], strict=True
  288. )
  289. else:
  290. expression = expression.cast(
  291. _cast_type(target), strict=True
  292. )
  293. frame = frame.with_columns(
  294. expression.alias(operation["column"])
  295. )
  296. elif op == "deduplicate":
  297. order = [
  298. *operation["order_by"],
  299. *sorted(
  300. name
  301. for name in frame.collect_schema().names()
  302. if name not in operation["order_by"]
  303. ),
  304. ]
  305. descending = operation["keep"] == "last"
  306. frame = (
  307. frame.sort(
  308. order,
  309. descending=[descending] * len(order),
  310. nulls_last=True,
  311. )
  312. .unique(
  313. subset=operation["keys"],
  314. keep="first",
  315. maintain_order=True,
  316. )
  317. )
  318. metrics["rows_deduplicated"] += before - _count(frame)
  319. elif op == "mask":
  320. if job["masking_policies"].get(
  321. operation["policy_id"]
  322. ) != operation["policy_kind"]:
  323. raise ValueError("masking policy is not registered")
  324. column = pl.col(operation["column"])
  325. if operation["policy_kind"] == "redact":
  326. masked = pl.when(column.is_null()).then(None).otherwise(
  327. pl.lit("***")
  328. )
  329. else:
  330. masked = pl.when(column.is_null()).then(None).otherwise(
  331. pl.lit("***") + column.str.slice(-4)
  332. )
  333. frame = frame.with_columns(masked.alias(operation["column"]))
  334. elif op == "aggregate":
  335. aggregations = []
  336. for aggregate in operation["aggregations"]:
  337. expression = pl.col(aggregate["column"])
  338. function = aggregate["function"]
  339. if function == "count":
  340. expression = expression.count()
  341. elif function == "sum":
  342. expression = expression.sum()
  343. elif function == "min":
  344. expression = expression.min()
  345. elif function == "max":
  346. expression = expression.max()
  347. elif function == "mean":
  348. expression = expression.mean()
  349. aggregations.append(
  350. expression.alias(aggregate["target"])
  351. )
  352. frame = frame.group_by(
  353. operation["group_by"], maintain_order=True
  354. ).agg(aggregations)
  355. metrics["rows_aggregated"] += before - _count(frame)
  356. elif op == "lookup_join":
  357. lookup = pl.scan_parquet(
  358. job["lookup_paths"][operation["lookup_binding_id"]]
  359. )
  360. _validate_lazy_schema(lookup, operation["lookup_fields"])
  361. duplicates = (
  362. lookup.group_by(operation["right_on"])
  363. .len()
  364. .filter(pl.col("len") > 1)
  365. .select(pl.len())
  366. .collect(engine="streaming")
  367. .item()
  368. )
  369. if duplicates:
  370. raise ValueError("lookup keys are not unique")
  371. selected = list(operation["select"].values())
  372. lookup = lookup.select(
  373. list(
  374. dict.fromkeys(
  375. [*operation["right_on"], *selected]
  376. )
  377. )
  378. ).rename(
  379. {
  380. source: target
  381. for target, source in operation["select"].items()
  382. if source not in operation["right_on"]
  383. }
  384. )
  385. frame = frame.join(
  386. lookup,
  387. left_on=operation["left_on"],
  388. right_on=operation["right_on"],
  389. how=operation["how"],
  390. )
  391. after = _count(frame)
  392. if after > before:
  393. raise ValueError("lookup join expanded its input")
  394. metrics["rows_join_dropped"] += before - after
  395. frame = frame.select(
  396. [field["name"] for field in plan["output_fields"]]
  397. )
  398. _validate_lazy_schema(frame, plan["output_fields"])
  399. rows_out = _count(frame)
  400. if rows_out > plan["resource_limits"]["max_rows"]:
  401. raise ValueError("output row limit exceeded")
  402. frame.sink_parquet(job["output_path"], engine="streaming")
  403. return {
  404. "worker_pid": os.getpid(),
  405. "rows_in": rows_in,
  406. "rows_out": rows_out,
  407. **metrics,
  408. "violation_count": sum(item["count"] for item in violations),
  409. "violations": violations,
  410. "_violation_sample": violation_sample,
  411. }
  412. def _polars_job_entry(
  413. connection,
  414. job: dict[str, Any],
  415. memory_limit_bytes: int,
  416. ) -> None:
  417. # Initialize the Rust engine/thread pool before measuring the worker's
  418. # immutable runtime baseline. The plan allowance applies only afterwards.
  419. pl.DataFrame({"_warmup": [1]}).lazy().collect(engine="streaming")
  420. process = psutil.Process()
  421. memory = process.memory_info()
  422. connection.send(
  423. {
  424. "kind": "ready",
  425. "pid": os.getpid(),
  426. "baseline_rss": memory.rss,
  427. }
  428. )
  429. try:
  430. _set_process_memory_limit(
  431. baseline_vms=memory.vms,
  432. memory_limit_bytes=memory_limit_bytes,
  433. )
  434. result = _execute_polars_job(job)
  435. connection.send({"kind": "result", **result})
  436. except (MemoryError, OSError):
  437. with suppress(Exception):
  438. connection.send({"kind": "resource_error"})
  439. except BaseException as exc:
  440. message = str(exc).lower()
  441. kind = (
  442. "resource_error"
  443. if any(
  444. token in message
  445. for token in ("memory", "allocate", "allocation")
  446. )
  447. else "worker_error"
  448. )
  449. with suppress(Exception):
  450. connection.send({"kind": kind})
  451. finally:
  452. connection.close()
  453. def _run_process(
  454. *,
  455. target,
  456. args: tuple[Any, ...],
  457. memory_limit_bytes: int,
  458. timeout_seconds: float = 15.0,
  459. ) -> dict[str, Any]:
  460. parent = None
  461. child = None
  462. process = None
  463. started = False
  464. try:
  465. context = multiprocessing.get_context("spawn")
  466. parent, child = context.Pipe(duplex=False)
  467. process = context.Process(
  468. target=target,
  469. args=(child, *args, memory_limit_bytes),
  470. daemon=True,
  471. )
  472. process.start()
  473. started = True
  474. child.close()
  475. deadline = time.monotonic() + timeout_seconds
  476. baseline_rss = None
  477. while time.monotonic() < deadline:
  478. if parent.poll(0.01):
  479. try:
  480. message = parent.recv()
  481. except EOFError as exc:
  482. raise PolarsWorkerResourceError(
  483. "Polars worker crossed its hard memory limit"
  484. ) from exc
  485. kind = message.get("kind")
  486. if kind == "ready":
  487. baseline_rss = int(message["baseline_rss"])
  488. elif kind == "result":
  489. process.join(timeout=1)
  490. return message
  491. elif kind == "resource_error":
  492. raise PolarsWorkerResourceError(
  493. "Polars worker exceeded its hard memory limit"
  494. )
  495. else:
  496. raise PolarsWorkerError(
  497. "Polars worker execution failed"
  498. )
  499. if baseline_rss is not None and process.is_alive():
  500. try:
  501. rss = psutil.Process(process.pid).memory_info().rss
  502. except (psutil.Error, ProcessLookupError):
  503. rss = baseline_rss
  504. if rss - baseline_rss > memory_limit_bytes:
  505. process.kill()
  506. process.join(timeout=1)
  507. raise PolarsWorkerResourceError(
  508. "Polars worker exceeded its hard memory limit"
  509. )
  510. if not process.is_alive():
  511. process.join(timeout=1)
  512. raise PolarsWorkerResourceError(
  513. "Polars worker crossed its hard memory limit"
  514. )
  515. process.kill()
  516. process.join(timeout=1)
  517. raise PolarsWorkerResourceError(
  518. "Polars worker exceeded its bounded execution time"
  519. )
  520. except (PolarsWorkerError, PolarsWorkerResourceError):
  521. raise
  522. except Exception as exc:
  523. raise PolarsWorkerError(
  524. "Polars worker failed to start safely"
  525. ) from exc
  526. finally:
  527. if child is not None:
  528. with suppress(Exception):
  529. child.close()
  530. if parent is not None:
  531. with suppress(Exception):
  532. parent.close()
  533. if process is not None:
  534. if started:
  535. with suppress(Exception):
  536. if process.is_alive():
  537. process.kill()
  538. with suppress(Exception):
  539. process.join(timeout=1)
  540. close = getattr(process, "close", None)
  541. if callable(close):
  542. with suppress(Exception):
  543. close()
  544. def run_isolated_memory_probe(
  545. *,
  546. allocate_bytes: int,
  547. memory_limit_bytes: int,
  548. ) -> dict[str, Any]:
  549. """Exercise the same process boundary used by plan execution."""
  550. return _run_process(
  551. target=_memory_probe_entry,
  552. args=(int(allocate_bytes),),
  553. memory_limit_bytes=int(memory_limit_bytes),
  554. )
  555. def execute_isolated_polars_plan(
  556. job: dict[str, Any],
  557. *,
  558. memory_limit_bytes: int,
  559. ) -> dict[str, Any]:
  560. return _run_process(
  561. target=_polars_job_entry,
  562. args=(job,),
  563. memory_limit_bytes=memory_limit_bytes,
  564. timeout_seconds=60.0,
  565. )