polars_worker.py 20 KB

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