test_polars_worker.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. from __future__ import annotations
  2. import os
  3. import polars as pl
  4. import pytest
  5. from tests.core.data_rules.test_polars_compiler import (
  6. _backend,
  7. _binding,
  8. _published_rule,
  9. _schema,
  10. )
  11. def _compile_worker_plan(
  12. *,
  13. input_schema,
  14. output_schema,
  15. steps,
  16. lookup_context=None,
  17. ):
  18. from app.core.data_rules.compilers.polars import PolarsRuleCompiler
  19. input_binding = _binding(input_schema, access_mode="read")
  20. output_binding = _binding(output_schema, access_mode="write")
  21. backend = _backend(
  22. max_rows=1_000_000,
  23. max_artifact_bytes=256 * 1024 * 1024,
  24. memory_limit_bytes=16 * 1024 * 1024,
  25. lookup_bindings=lookup_context or {},
  26. )
  27. rule = _published_rule(
  28. input_schema,
  29. output_schema,
  30. steps,
  31. )
  32. return PolarsRuleCompiler().compile(
  33. rule_version=rule,
  34. input_schema=input_schema,
  35. output_schema=output_schema,
  36. input_binding=input_binding,
  37. output_binding=output_binding,
  38. backend=backend,
  39. )["plan"]
  40. def _assert_worker_resource_failure(plan, tmp_path, *, lookup_paths=None):
  41. from app.runner.polars_worker import (
  42. PolarsWorkerResourceError,
  43. execute_isolated_polars_plan,
  44. )
  45. with pytest.raises(
  46. PolarsWorkerResourceError,
  47. match="hard memory limit|bounded execution time",
  48. ):
  49. execute_isolated_polars_plan(
  50. {
  51. "plan": plan,
  52. "input_path": str(tmp_path / "input.parquet"),
  53. "lookup_paths": lookup_paths or {},
  54. "output_path": str(tmp_path / "output.parquet"),
  55. "masking_policies": {},
  56. },
  57. memory_limit_bytes=plan["resource_limits"][
  58. "memory_limit_bytes"
  59. ],
  60. )
  61. def test_isolated_polars_worker_runs_outside_runner_process():
  62. from app.runner.polars_worker import run_isolated_memory_probe
  63. result = run_isolated_memory_probe(
  64. allocate_bytes=1_024,
  65. memory_limit_bytes=16 * 1024 * 1024,
  66. )
  67. assert result["worker_pid"] != os.getpid()
  68. assert result["allocated_bytes"] == 1_024
  69. def test_isolated_polars_worker_fails_deterministically_at_hard_memory_limit():
  70. from app.runner.polars_worker import (
  71. PolarsWorkerResourceError,
  72. run_isolated_memory_probe,
  73. )
  74. with pytest.raises(
  75. PolarsWorkerResourceError,
  76. match="hard memory limit",
  77. ):
  78. run_isolated_memory_probe(
  79. allocate_bytes=128 * 1024 * 1024,
  80. memory_limit_bytes=8 * 1024 * 1024,
  81. )
  82. def test_worker_start_failure_closes_pipe_endpoints_and_maps_safe_error(
  83. monkeypatch,
  84. ):
  85. from app.runner import polars_worker
  86. class Endpoint:
  87. def __init__(self):
  88. self.closed = False
  89. def close(self):
  90. self.closed = True
  91. class Process:
  92. pid = None
  93. def __init__(self):
  94. self.joined = False
  95. self.closed = False
  96. def start(self):
  97. raise OSError("sensitive spawn detail")
  98. def is_alive(self):
  99. return False
  100. def join(self, timeout=None):
  101. self.joined = True
  102. def close(self):
  103. self.closed = True
  104. parent = Endpoint()
  105. child = Endpoint()
  106. process = Process()
  107. class Context:
  108. def Pipe(self, duplex):
  109. assert duplex is False
  110. return parent, child
  111. def Process(self, **_kwargs):
  112. return process
  113. monkeypatch.setattr(
  114. polars_worker.multiprocessing,
  115. "get_context",
  116. lambda _method: Context(),
  117. )
  118. with pytest.raises(
  119. polars_worker.PolarsWorkerError,
  120. match="failed to start safely",
  121. ) as error:
  122. polars_worker.run_isolated_memory_probe(
  123. allocate_bytes=1,
  124. memory_limit_bytes=1024,
  125. )
  126. assert "sensitive" not in str(error.value)
  127. assert parent.closed is True
  128. assert child.closed is True
  129. assert process.closed is True
  130. def test_regex_peak_allocation_fails_inside_isolated_worker(tmp_path):
  131. schema = _schema(
  132. "bd:regex:raw",
  133. [("text", "string", False)],
  134. )
  135. plan = _compile_worker_plan(
  136. input_schema=schema,
  137. output_schema={
  138. **schema,
  139. "id": _schema(
  140. "bd:regex:clean",
  141. [("text", "string", False)],
  142. )["id"],
  143. "schema_ref": "bd:regex:clean",
  144. },
  145. steps=[
  146. {
  147. "id": "expand",
  148. "op": "regex_replace",
  149. "column": "text",
  150. "pattern": "a",
  151. "replacement": "x" * 64,
  152. }
  153. ],
  154. )
  155. pl.DataFrame({"text": ["a" * 1_000_000]}).write_parquet(
  156. tmp_path / "input.parquet"
  157. )
  158. _assert_worker_resource_failure(plan, tmp_path)
  159. def test_group_peak_allocation_fails_inside_isolated_worker(tmp_path):
  160. input_schema = _schema(
  161. "bd:group:raw",
  162. [("group_key", "string", False), ("amount", "integer", False)],
  163. )
  164. output_schema = _schema(
  165. "bd:group:clean",
  166. [
  167. ("group_key", "string", False),
  168. ("total_amount", "integer", False),
  169. ],
  170. )
  171. plan = _compile_worker_plan(
  172. input_schema=input_schema,
  173. output_schema=output_schema,
  174. steps=[
  175. {
  176. "id": "sum_by_key",
  177. "op": "aggregate",
  178. "group_by": ["group_key"],
  179. "aggregations": {
  180. "total_amount": {
  181. "function": "sum",
  182. "column": "amount",
  183. }
  184. },
  185. }
  186. ],
  187. )
  188. row_count = 300_000
  189. pl.DataFrame(
  190. {
  191. "group_key": [
  192. f"group-{index:040d}" for index in range(row_count)
  193. ],
  194. "amount": [1] * row_count,
  195. }
  196. ).write_parquet(tmp_path / "input.parquet")
  197. _assert_worker_resource_failure(plan, tmp_path)
  198. def test_join_peak_allocation_fails_inside_isolated_worker(tmp_path):
  199. input_schema = _schema(
  200. "bd:join:raw",
  201. [("id", "integer", False)],
  202. )
  203. lookup_schema = _schema(
  204. "bd:join:lookup",
  205. [("lookup_id", "integer", False), ("label", "string", False)],
  206. )
  207. output_schema = _schema(
  208. "bd:join:clean",
  209. [("id", "integer", False), ("label", "string", False)],
  210. )
  211. lookup_binding = _binding(
  212. lookup_schema,
  213. access_mode="read",
  214. object_ref="join-lookup",
  215. )
  216. plan = _compile_worker_plan(
  217. input_schema=input_schema,
  218. output_schema=output_schema,
  219. steps=[
  220. {
  221. "id": "join_label",
  222. "op": "lookup_join",
  223. "lookup": {
  224. "binding_id": lookup_binding["id"],
  225. "left_on": ["id"],
  226. "right_on": ["lookup_id"],
  227. "select": {"label": "label"},
  228. "how": "left",
  229. },
  230. }
  231. ],
  232. lookup_context={
  233. lookup_binding["id"]: {
  234. "binding": lookup_binding,
  235. "schema": lookup_schema,
  236. }
  237. },
  238. )
  239. row_count = 250_000
  240. pl.DataFrame({"id": range(row_count)}).write_parquet(
  241. tmp_path / "input.parquet"
  242. )
  243. lookup_path = tmp_path / "lookup.parquet"
  244. pl.DataFrame(
  245. {
  246. "lookup_id": range(row_count),
  247. "label": [f"label-{index:048d}" for index in range(row_count)],
  248. }
  249. ).write_parquet(lookup_path)
  250. _assert_worker_resource_failure(
  251. plan,
  252. tmp_path,
  253. lookup_paths={lookup_binding["id"]: str(lookup_path)},
  254. )