test_polars_worker.py 12 KB

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