polars_worker.py 23 KB

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