test_polars_worker.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. from __future__ import annotations
  2. import os
  3. from concurrent.futures import ThreadPoolExecutor
  4. import polars as pl
  5. import pytest
  6. from tests.core.data_rules.test_polars_compiler import (
  7. _backend,
  8. _binding,
  9. _published_rule,
  10. _schema,
  11. )
  12. def _compile_worker_plan(
  13. *,
  14. input_schema,
  15. output_schema,
  16. steps,
  17. lookup_context=None,
  18. ):
  19. from app.core.data_rules.compilers.polars import PolarsRuleCompiler
  20. input_binding = _binding(input_schema, access_mode="read")
  21. output_binding = _binding(output_schema, access_mode="write")
  22. backend = _backend(
  23. max_rows=1_000_000,
  24. max_artifact_bytes=256 * 1024 * 1024,
  25. memory_limit_bytes=16 * 1024 * 1024,
  26. lookup_bindings=lookup_context or {},
  27. )
  28. rule = _published_rule(
  29. input_schema,
  30. output_schema,
  31. steps,
  32. )
  33. return PolarsRuleCompiler().compile(
  34. rule_version=rule,
  35. input_schema=input_schema,
  36. output_schema=output_schema,
  37. input_binding=input_binding,
  38. output_binding=output_binding,
  39. backend=backend,
  40. )["plan"]
  41. def _assert_worker_resource_failure(plan, tmp_path, *, lookup_paths=None):
  42. from app.runner.polars_worker import (
  43. PolarsWorkerResourceError,
  44. execute_isolated_polars_plan,
  45. )
  46. with pytest.raises(
  47. PolarsWorkerResourceError,
  48. match="hard memory limit|bounded execution time",
  49. ):
  50. execute_isolated_polars_plan(
  51. {
  52. "plan": plan,
  53. "input_path": str(tmp_path / "input.parquet"),
  54. "lookup_paths": lookup_paths or {},
  55. "output_path": str(tmp_path / "output.parquet"),
  56. "masking_policies": {},
  57. },
  58. memory_limit_bytes=plan["resource_limits"][
  59. "memory_limit_bytes"
  60. ],
  61. )
  62. def test_isolated_polars_worker_runs_outside_runner_process():
  63. from app.runner.polars_worker import run_isolated_memory_probe
  64. result = run_isolated_memory_probe(
  65. allocate_bytes=1_024,
  66. memory_limit_bytes=16 * 1024 * 1024,
  67. )
  68. assert result["worker_pid"] != os.getpid()
  69. assert result["allocated_bytes"] == 1_024
  70. def test_isolated_polars_worker_fails_deterministically_at_hard_memory_limit():
  71. from app.runner.polars_worker import (
  72. PolarsWorkerResourceError,
  73. run_isolated_memory_probe,
  74. )
  75. with pytest.raises(
  76. PolarsWorkerResourceError,
  77. match="hard memory limit",
  78. ):
  79. run_isolated_memory_probe(
  80. allocate_bytes=128 * 1024 * 1024,
  81. memory_limit_bytes=8 * 1024 * 1024,
  82. )
  83. def test_worker_start_failure_closes_pipe_endpoints_and_maps_safe_error(
  84. monkeypatch,
  85. ):
  86. from app.runner import polars_worker
  87. class Endpoint:
  88. def __init__(self):
  89. self.closed = False
  90. def close(self):
  91. self.closed = True
  92. class Process:
  93. pid = None
  94. def __init__(self):
  95. self.joined = False
  96. self.closed = False
  97. def start(self):
  98. raise OSError("sensitive spawn detail")
  99. def is_alive(self):
  100. return False
  101. def join(self, timeout=None):
  102. self.joined = True
  103. def close(self):
  104. self.closed = True
  105. parent = Endpoint()
  106. child = Endpoint()
  107. process = Process()
  108. class Context:
  109. def Pipe(self, duplex):
  110. assert duplex is False
  111. return parent, child
  112. def Process(self, **_kwargs):
  113. return process
  114. monkeypatch.setattr(
  115. polars_worker.multiprocessing,
  116. "get_context",
  117. lambda _method: Context(),
  118. )
  119. with pytest.raises(
  120. polars_worker.PolarsWorkerError,
  121. match="failed to start safely",
  122. ) as error:
  123. polars_worker.run_isolated_memory_probe(
  124. allocate_bytes=1,
  125. memory_limit_bytes=1024,
  126. )
  127. assert "sensitive" not in str(error.value)
  128. assert parent.closed is True
  129. assert child.closed is True
  130. assert process.closed is True
  131. def test_worker_rejects_golden_row_drift_inside_isolated_boundary(tmp_path):
  132. from app.runner.polars_worker import (
  133. PolarsWorkerError,
  134. execute_isolated_polars_plan,
  135. )
  136. schema = _schema("bd:golden:input", [("value", "string", False)])
  137. plan = _compile_worker_plan(
  138. input_schema=schema,
  139. output_schema={
  140. **schema,
  141. "id": _schema(
  142. "bd:golden:output", [("value", "string", False)]
  143. )["id"],
  144. "schema_ref": "bd:golden:output",
  145. },
  146. steps=[
  147. {
  148. "id": "trim_value",
  149. "op": "normalize_text",
  150. "column": "value",
  151. "trim": True,
  152. }
  153. ],
  154. )
  155. plan["resource_limits"]["memory_limit_bytes"] = 128 * 1024 * 1024
  156. input_path = tmp_path / "golden-input.parquet"
  157. golden_path = tmp_path / "golden-expected.parquet"
  158. pl.DataFrame({"value": ["actual"]}).write_parquet(input_path)
  159. pl.DataFrame({"value": ["expected"]}).write_parquet(golden_path)
  160. with pytest.raises(PolarsWorkerError, match="execution failed"):
  161. execute_isolated_polars_plan(
  162. {
  163. "plan": plan,
  164. "input_path": str(input_path),
  165. "lookup_paths": {},
  166. "output_path": str(tmp_path / "golden-output.parquet"),
  167. "golden_path": str(golden_path),
  168. "golden_digest": "a" * 64,
  169. "masking_policies": {},
  170. },
  171. memory_limit_bytes=plan["resource_limits"][
  172. "memory_limit_bytes"
  173. ],
  174. )
  175. def test_concurrent_large_golden_comparisons_fail_closed(tmp_path):
  176. from app.runner.polars_worker import (
  177. PolarsWorkerError,
  178. execute_isolated_polars_plan,
  179. )
  180. schema = _schema("bd:golden:large", [("value", "string", False)])
  181. plan = _compile_worker_plan(
  182. input_schema=schema,
  183. output_schema={
  184. **schema,
  185. "id": _schema(
  186. "bd:golden:large-output",
  187. [("value", "string", False)],
  188. )["id"],
  189. "schema_ref": "bd:golden:large-output",
  190. },
  191. steps=[
  192. {
  193. "id": "trim_value",
  194. "op": "normalize_text",
  195. "column": "value",
  196. "trim": True,
  197. }
  198. ],
  199. )
  200. plan["resource_limits"]["memory_limit_bytes"] = 128 * 1024 * 1024
  201. input_path = tmp_path / "large-input.parquet"
  202. golden_path = tmp_path / "large-golden.parquet"
  203. pl.DataFrame({"value": ["actual"]}).write_parquet(input_path)
  204. pl.DataFrame(
  205. {"value": [f"expected-{index:08d}" for index in range(200_000)]}
  206. ).write_parquet(golden_path)
  207. def compare(index):
  208. try:
  209. execute_isolated_polars_plan(
  210. {
  211. "plan": plan,
  212. "input_path": str(input_path),
  213. "lookup_paths": {},
  214. "output_path": str(
  215. tmp_path / f"large-output-{index}.parquet"
  216. ),
  217. "golden_path": str(golden_path),
  218. "golden_digest": "b" * 64,
  219. "masking_policies": {},
  220. },
  221. memory_limit_bytes=plan["resource_limits"][
  222. "memory_limit_bytes"
  223. ],
  224. )
  225. except PolarsWorkerError:
  226. return "rejected"
  227. return "unsafe-success"
  228. with ThreadPoolExecutor(max_workers=2) as executor:
  229. results = list(executor.map(compare, range(2)))
  230. assert results == ["rejected", "rejected"]
  231. def test_regex_peak_allocation_fails_inside_isolated_worker(tmp_path):
  232. schema = _schema(
  233. "bd:regex:raw",
  234. [("text", "string", False)],
  235. )
  236. plan = _compile_worker_plan(
  237. input_schema=schema,
  238. output_schema={
  239. **schema,
  240. "id": _schema(
  241. "bd:regex:clean",
  242. [("text", "string", False)],
  243. )["id"],
  244. "schema_ref": "bd:regex:clean",
  245. },
  246. steps=[
  247. {
  248. "id": "expand",
  249. "op": "regex_replace",
  250. "column": "text",
  251. "pattern": "a",
  252. "replacement": "x" * 64,
  253. }
  254. ],
  255. )
  256. pl.DataFrame({"text": ["a" * 1_000_000]}).write_parquet(
  257. tmp_path / "input.parquet"
  258. )
  259. _assert_worker_resource_failure(plan, tmp_path)
  260. def test_group_peak_allocation_fails_inside_isolated_worker(tmp_path):
  261. input_schema = _schema(
  262. "bd:group:raw",
  263. [("group_key", "string", False), ("amount", "integer", False)],
  264. )
  265. output_schema = _schema(
  266. "bd:group:clean",
  267. [
  268. ("group_key", "string", False),
  269. ("total_amount", "integer", False),
  270. ],
  271. )
  272. plan = _compile_worker_plan(
  273. input_schema=input_schema,
  274. output_schema=output_schema,
  275. steps=[
  276. {
  277. "id": "sum_by_key",
  278. "op": "aggregate",
  279. "group_by": ["group_key"],
  280. "aggregations": {
  281. "total_amount": {
  282. "function": "sum",
  283. "column": "amount",
  284. }
  285. },
  286. }
  287. ],
  288. )
  289. row_count = 300_000
  290. pl.DataFrame(
  291. {
  292. "group_key": [
  293. f"group-{index:040d}" for index in range(row_count)
  294. ],
  295. "amount": [1] * row_count,
  296. }
  297. ).write_parquet(tmp_path / "input.parquet")
  298. _assert_worker_resource_failure(plan, tmp_path)
  299. def test_join_peak_allocation_fails_inside_isolated_worker(tmp_path):
  300. input_schema = _schema(
  301. "bd:join:raw",
  302. [("id", "integer", False)],
  303. )
  304. lookup_schema = _schema(
  305. "bd:join:lookup",
  306. [("lookup_id", "integer", False), ("label", "string", False)],
  307. )
  308. output_schema = _schema(
  309. "bd:join:clean",
  310. [("id", "integer", False), ("label", "string", False)],
  311. )
  312. lookup_binding = _binding(
  313. lookup_schema,
  314. access_mode="read",
  315. object_ref="join-lookup",
  316. )
  317. plan = _compile_worker_plan(
  318. input_schema=input_schema,
  319. output_schema=output_schema,
  320. steps=[
  321. {
  322. "id": "join_label",
  323. "op": "lookup_join",
  324. "lookup": {
  325. "binding_id": lookup_binding["id"],
  326. "left_on": ["id"],
  327. "right_on": ["lookup_id"],
  328. "select": {"label": "label"},
  329. "how": "left",
  330. },
  331. }
  332. ],
  333. lookup_context={
  334. lookup_binding["id"]: {
  335. "binding": lookup_binding,
  336. "schema": lookup_schema,
  337. }
  338. },
  339. )
  340. row_count = 250_000
  341. pl.DataFrame({"id": range(row_count)}).write_parquet(
  342. tmp_path / "input.parquet"
  343. )
  344. lookup_path = tmp_path / "lookup.parquet"
  345. pl.DataFrame(
  346. {
  347. "lookup_id": range(row_count),
  348. "label": [f"label-{index:048d}" for index in range(row_count)],
  349. }
  350. ).write_parquet(lookup_path)
  351. _assert_worker_resource_failure(
  352. plan,
  353. tmp_path,
  354. lookup_paths={lookup_binding["id"]: str(lookup_path)},
  355. )