test_polars_worker.py 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  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",
  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_regex_peak_allocation_fails_inside_isolated_worker(tmp_path):
  83. schema = _schema(
  84. "bd:regex:raw",
  85. [("text", "string", False)],
  86. )
  87. plan = _compile_worker_plan(
  88. input_schema=schema,
  89. output_schema={
  90. **schema,
  91. "id": _schema(
  92. "bd:regex:clean",
  93. [("text", "string", False)],
  94. )["id"],
  95. "schema_ref": "bd:regex:clean",
  96. },
  97. steps=[
  98. {
  99. "id": "expand",
  100. "op": "regex_replace",
  101. "column": "text",
  102. "pattern": "a",
  103. "replacement": "x" * 64,
  104. }
  105. ],
  106. )
  107. pl.DataFrame({"text": ["a" * 1_000_000]}).write_parquet(
  108. tmp_path / "input.parquet"
  109. )
  110. _assert_worker_resource_failure(plan, tmp_path)
  111. def test_group_peak_allocation_fails_inside_isolated_worker(tmp_path):
  112. input_schema = _schema(
  113. "bd:group:raw",
  114. [("group_key", "string", False), ("amount", "integer", False)],
  115. )
  116. output_schema = _schema(
  117. "bd:group:clean",
  118. [
  119. ("group_key", "string", False),
  120. ("total_amount", "integer", False),
  121. ],
  122. )
  123. plan = _compile_worker_plan(
  124. input_schema=input_schema,
  125. output_schema=output_schema,
  126. steps=[
  127. {
  128. "id": "sum_by_key",
  129. "op": "aggregate",
  130. "group_by": ["group_key"],
  131. "aggregations": {
  132. "total_amount": {
  133. "function": "sum",
  134. "column": "amount",
  135. }
  136. },
  137. }
  138. ],
  139. )
  140. row_count = 300_000
  141. pl.DataFrame(
  142. {
  143. "group_key": [
  144. f"group-{index:040d}" for index in range(row_count)
  145. ],
  146. "amount": [1] * row_count,
  147. }
  148. ).write_parquet(tmp_path / "input.parquet")
  149. _assert_worker_resource_failure(plan, tmp_path)
  150. def test_join_peak_allocation_fails_inside_isolated_worker(tmp_path):
  151. input_schema = _schema(
  152. "bd:join:raw",
  153. [("id", "integer", False)],
  154. )
  155. lookup_schema = _schema(
  156. "bd:join:lookup",
  157. [("lookup_id", "integer", False), ("label", "string", False)],
  158. )
  159. output_schema = _schema(
  160. "bd:join:clean",
  161. [("id", "integer", False), ("label", "string", False)],
  162. )
  163. lookup_binding = _binding(
  164. lookup_schema,
  165. access_mode="read",
  166. object_ref="join-lookup",
  167. )
  168. plan = _compile_worker_plan(
  169. input_schema=input_schema,
  170. output_schema=output_schema,
  171. steps=[
  172. {
  173. "id": "join_label",
  174. "op": "lookup_join",
  175. "lookup": {
  176. "binding_id": lookup_binding["id"],
  177. "left_on": ["id"],
  178. "right_on": ["lookup_id"],
  179. "select": {"label": "label"},
  180. "how": "left",
  181. },
  182. }
  183. ],
  184. lookup_context={
  185. lookup_binding["id"]: {
  186. "binding": lookup_binding,
  187. "schema": lookup_schema,
  188. }
  189. },
  190. )
  191. row_count = 250_000
  192. pl.DataFrame({"id": range(row_count)}).write_parquet(
  193. tmp_path / "input.parquet"
  194. )
  195. lookup_path = tmp_path / "lookup.parquet"
  196. pl.DataFrame(
  197. {
  198. "lookup_id": range(row_count),
  199. "label": [f"label-{index:048d}" for index in range(row_count)],
  200. }
  201. ).write_parquet(lookup_path)
  202. _assert_worker_resource_failure(
  203. plan,
  204. tmp_path,
  205. lookup_paths={lookup_binding["id"]: str(lookup_path)},
  206. )