rule_execution_capacity.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. """Recalculable capacity evidence and fail-closed runtime budget gates."""
  2. from __future__ import annotations
  3. import argparse
  4. import gc
  5. import hashlib
  6. import json
  7. import math
  8. import os
  9. import platform
  10. import tempfile
  11. import threading
  12. import time
  13. from collections.abc import Iterable
  14. from dataclasses import asdict, dataclass
  15. from pathlib import Path
  16. from typing import Any
  17. @dataclass(frozen=True)
  18. class CapacityLimits:
  19. runner_memory_bytes: int
  20. datasource_pool_budget: int
  21. artifact_bytes: int
  22. def __post_init__(self) -> None:
  23. for value in (
  24. self.runner_memory_bytes,
  25. self.datasource_pool_budget,
  26. self.artifact_bytes,
  27. ):
  28. if isinstance(value, bool) or not isinstance(value, int) or value < 1:
  29. raise ValueError("capacity limits must be positive integers")
  30. @dataclass(frozen=True)
  31. class CapacityObservation:
  32. backend: str
  33. rows: int
  34. elapsed_seconds: float
  35. peak_memory_bytes: int
  36. pool_peak: int
  37. artifact_bytes: int
  38. node_durations_seconds: tuple[float, ...]
  39. def __post_init__(self) -> None:
  40. if self.backend not in {"sql_pushdown", "polars_batch"}:
  41. raise ValueError("capacity backend is unsupported")
  42. if isinstance(self.rows, bool) or not isinstance(self.rows, int):
  43. raise ValueError("capacity rows must be an integer")
  44. if self.rows < 1 or self.elapsed_seconds <= 0:
  45. raise ValueError("capacity observation must contain work")
  46. if (
  47. self.peak_memory_bytes < 0
  48. or self.pool_peak < 0
  49. or self.artifact_bytes < 0
  50. ):
  51. raise ValueError("capacity metrics cannot be negative")
  52. if not self.node_durations_seconds or any(
  53. value < 0 for value in self.node_durations_seconds
  54. ):
  55. raise ValueError("node durations must be non-negative")
  56. def _p95(values: tuple[float, ...]) -> float:
  57. ordered = sorted(values)
  58. index = max(0, math.ceil(len(ordered) * 0.95) - 1)
  59. return float(ordered[index])
  60. def assert_capacity_within_limits(
  61. observation: CapacityObservation,
  62. limits: CapacityLimits,
  63. ) -> None:
  64. exceeded = []
  65. if observation.peak_memory_bytes > limits.runner_memory_bytes:
  66. exceeded.append("runner_memory_bytes")
  67. if observation.pool_peak > limits.datasource_pool_budget:
  68. exceeded.append("datasource_pool_budget")
  69. if observation.artifact_bytes > limits.artifact_bytes:
  70. exceeded.append("artifact_bytes")
  71. if exceeded:
  72. raise ValueError(
  73. "capacity limit exceeded: " + ", ".join(exceeded)
  74. )
  75. def digest_payload(payload: dict[str, Any]) -> str:
  76. value = dict(payload)
  77. value.pop("evidence_sha256", None)
  78. canonical = json.dumps(
  79. value,
  80. ensure_ascii=False,
  81. sort_keys=True,
  82. separators=(",", ":"),
  83. )
  84. return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
  85. def write_capacity_evidence(
  86. path: str | Path,
  87. *,
  88. observations: Iterable[CapacityObservation],
  89. limits: CapacityLimits,
  90. machine: dict[str, Any],
  91. ) -> dict[str, Any]:
  92. if not isinstance(machine, dict) or not machine:
  93. raise ValueError("capacity machine metadata is required")
  94. rows = []
  95. for observation in observations:
  96. assert_capacity_within_limits(observation, limits)
  97. item = asdict(observation)
  98. item["node_durations_seconds"] = list(
  99. observation.node_durations_seconds
  100. )
  101. item["throughput_rows_per_second"] = round(
  102. observation.rows / observation.elapsed_seconds, 3
  103. )
  104. item["p95_node_seconds"] = _p95(
  105. observation.node_durations_seconds
  106. )
  107. rows.append(item)
  108. if not rows:
  109. raise ValueError("capacity evidence requires observations")
  110. payload = {
  111. "schema_version": "1.0",
  112. "limits": asdict(limits),
  113. "machine": dict(machine),
  114. "observations": rows,
  115. }
  116. payload["evidence_sha256"] = digest_payload(payload)
  117. target = Path(path)
  118. target.parent.mkdir(parents=True, exist_ok=True)
  119. target.write_text(
  120. json.dumps(
  121. payload,
  122. ensure_ascii=False,
  123. sort_keys=True,
  124. indent=2,
  125. )
  126. + "\n",
  127. encoding="utf-8",
  128. )
  129. return payload
  130. write_capacity_evidence.digest_payload = digest_payload
  131. class _PeakRSSSampler:
  132. def __init__(self) -> None:
  133. self.peak = 0
  134. self._stop = threading.Event()
  135. self._thread: threading.Thread | None = None
  136. def __enter__(self):
  137. import psutil
  138. process = psutil.Process()
  139. def sample() -> None:
  140. while not self._stop.wait(0.02):
  141. self.peak = max(self.peak, process.memory_info().rss)
  142. self.peak = process.memory_info().rss
  143. self._thread = threading.Thread(target=sample, daemon=True)
  144. self._thread.start()
  145. return self
  146. def __exit__(self, exc_type, exc, traceback) -> None:
  147. self._stop.set()
  148. if self._thread is not None:
  149. self._thread.join(timeout=1)
  150. def _plan_memory_bytes(value: Any) -> int:
  151. if isinstance(value, dict):
  152. memory_kib = 0
  153. for key, item in value.items():
  154. if key == "Peak Memory Usage" and isinstance(item, (int, float)) or (
  155. key == "Sort Space Used"
  156. and value.get("Sort Space Type") == "Memory"
  157. and isinstance(item, (int, float))
  158. ):
  159. memory_kib = max(memory_kib, int(item))
  160. memory_kib = max(memory_kib, _plan_memory_bytes(item) // 1024)
  161. return memory_kib * 1024
  162. if isinstance(value, list):
  163. return max((_plan_memory_bytes(item) for item in value), default=0)
  164. return 0
  165. def measure_sql_pushdown(rows: int, database_url: str) -> CapacityObservation:
  166. """Run real PostgreSQL normalize/assert/deduplicate operators."""
  167. import psycopg2
  168. statements = (
  169. """
  170. SELECT sum(length(mobile))
  171. FROM (
  172. SELECT btrim(
  173. CASE WHEN gs %% 20 = 0
  174. THEN ' 1380013800x ' ELSE ' 13800138000 ' END
  175. ) AS mobile
  176. FROM generate_series(1, %s) AS gs
  177. ) normalized
  178. """,
  179. """
  180. SELECT sum(
  181. (btrim(
  182. CASE WHEN gs %% 20 = 0
  183. THEN ' 1380013800x ' ELSE ' 13800138000 ' END
  184. ) ~ '^[0-9]{11}$')::int
  185. )
  186. FROM generate_series(1, %s) AS gs
  187. """,
  188. """
  189. SELECT count(*)
  190. FROM (
  191. SELECT (gs / 2)::bigint AS customer_id, max(gs) AS updated_at
  192. FROM generate_series(1, %s) AS gs
  193. GROUP BY (gs / 2)::bigint
  194. ) deduplicated
  195. """,
  196. )
  197. durations = []
  198. peak_memory = 0
  199. started = time.monotonic()
  200. connection = psycopg2.connect(database_url, connect_timeout=10)
  201. try:
  202. connection.set_session(readonly=True, autocommit=True)
  203. with connection.cursor() as cursor:
  204. for statement in statements:
  205. cursor.execute(
  206. "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) " + statement,
  207. (rows,),
  208. )
  209. document = cursor.fetchone()[0][0]
  210. durations.append(float(document["Execution Time"]) / 1000)
  211. peak_memory = max(
  212. peak_memory, _plan_memory_bytes(document["Plan"])
  213. )
  214. finally:
  215. connection.close()
  216. return CapacityObservation(
  217. backend="sql_pushdown",
  218. rows=rows,
  219. elapsed_seconds=max(time.monotonic() - started, 0.000001),
  220. peak_memory_bytes=peak_memory,
  221. pool_peak=1,
  222. artifact_bytes=0,
  223. node_durations_seconds=tuple(durations),
  224. )
  225. def measure_polars_batch(rows: int) -> CapacityObservation:
  226. """Run a real streaming Polars rule and materialize its Parquet artifact."""
  227. import polars as pl
  228. durations = []
  229. started = time.monotonic()
  230. with tempfile.TemporaryDirectory(prefix="data-rule-capacity-") as directory:
  231. artifact = Path(directory) / "output.parquet"
  232. with _PeakRSSSampler() as sampler:
  233. node_started = time.monotonic()
  234. source = pl.LazyFrame(
  235. {
  236. "row_id": pl.arange(1, rows + 1, eager=True),
  237. }
  238. ).with_columns(
  239. (pl.col("row_id") // 2).alias("customer_id"),
  240. pl.when(pl.col("row_id") % 20 == 0)
  241. .then(pl.lit(" 1380013800x "))
  242. .otherwise(pl.lit(" 13800138000 "))
  243. .alias("mobile"),
  244. pl.col("row_id").alias("updated_at"),
  245. )
  246. durations.append(time.monotonic() - node_started)
  247. node_started = time.monotonic()
  248. normalized = source.with_columns(
  249. pl.col("mobile").str.strip_chars().alias("mobile")
  250. )
  251. durations.append(time.monotonic() - node_started)
  252. node_started = time.monotonic()
  253. checked = normalized.with_columns(
  254. pl.col("mobile")
  255. .str.contains(r"^[0-9]{11}$")
  256. .alias("_rule_valid")
  257. )
  258. durations.append(time.monotonic() - node_started)
  259. node_started = time.monotonic()
  260. output = (
  261. checked.sort(["customer_id", "updated_at"])
  262. .unique(subset=["customer_id"], keep="last")
  263. .sort("customer_id")
  264. )
  265. output.sink_parquet(
  266. artifact,
  267. compression="zstd",
  268. engine="streaming",
  269. )
  270. durations.append(time.monotonic() - node_started)
  271. artifact_bytes = artifact.stat().st_size
  272. peak_memory = sampler.peak
  273. del source, normalized, checked, output
  274. gc.collect()
  275. return CapacityObservation(
  276. backend="polars_batch",
  277. rows=rows,
  278. elapsed_seconds=max(time.monotonic() - started, 0.000001),
  279. peak_memory_bytes=peak_memory,
  280. pool_peak=0,
  281. artifact_bytes=artifact_bytes,
  282. node_durations_seconds=tuple(durations),
  283. )
  284. def _write_target_manifest(
  285. path: Path,
  286. *,
  287. requested_rows: list[int],
  288. observations: list[CapacityObservation],
  289. failures: list[dict[str, Any]],
  290. ) -> None:
  291. measured = sorted({item.rows for item in observations})
  292. maximum = max(measured, default=0)
  293. missing = sorted(set(requested_rows) - set(measured))
  294. reason = ""
  295. if missing:
  296. reason = "; ".join(
  297. f"{item['backend']}@{item['rows']}: {item['reason']}"
  298. for item in failures
  299. ) or "not measured"
  300. payload = {
  301. "schema_version": "1.0",
  302. "requested_rows": requested_rows,
  303. "measured_rows": measured,
  304. "backends": ["sql_pushdown", "polars_batch"],
  305. "hardware_limit": {
  306. "maximum_measured_rows": maximum,
  307. "reason": reason,
  308. },
  309. "failures": failures,
  310. }
  311. path.parent.mkdir(parents=True, exist_ok=True)
  312. path.write_text(
  313. json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2)
  314. + "\n",
  315. encoding="utf-8",
  316. )
  317. def main() -> int:
  318. parser = argparse.ArgumentParser(
  319. description="Measure real SQL and Polars data-rule capacity"
  320. )
  321. parser.add_argument(
  322. "--database-url",
  323. default=os.environ.get("CAPACITY_DATABASE_URL"),
  324. )
  325. parser.add_argument(
  326. "--rows",
  327. nargs="+",
  328. type=int,
  329. default=[100_000, 1_000_000, 10_000_000],
  330. )
  331. parser.add_argument(
  332. "--evidence",
  333. type=Path,
  334. default=Path("docs/validation/data-rule-m5-capacity-evidence.json"),
  335. )
  336. parser.add_argument(
  337. "--targets",
  338. type=Path,
  339. default=Path("docs/validation/data-rule-m5-capacity-targets.json"),
  340. )
  341. parser.add_argument(
  342. "--runner-memory-bytes",
  343. type=int,
  344. default=2 * 1024 * 1024 * 1024,
  345. )
  346. parser.add_argument(
  347. "--artifact-bytes",
  348. type=int,
  349. default=512 * 1024 * 1024,
  350. )
  351. parser.add_argument(
  352. "--logical-cpu-count",
  353. type=int,
  354. default=os.cpu_count() or 0,
  355. )
  356. args = parser.parse_args()
  357. if not args.database_url:
  358. parser.error("CAPACITY_DATABASE_URL or --database-url is required")
  359. requested = sorted(set(args.rows))
  360. if not requested or requested[0] < 1:
  361. parser.error("rows must be positive")
  362. limits = CapacityLimits(
  363. runner_memory_bytes=args.runner_memory_bytes,
  364. datasource_pool_budget=2,
  365. artifact_bytes=args.artifact_bytes,
  366. )
  367. observations: list[CapacityObservation] = []
  368. failures: list[dict[str, Any]] = []
  369. for row_count in requested:
  370. for backend, measure in (
  371. (
  372. "sql_pushdown",
  373. lambda rows=row_count: measure_sql_pushdown(
  374. rows, args.database_url
  375. ),
  376. ),
  377. (
  378. "polars_batch",
  379. lambda rows=row_count: measure_polars_batch(rows),
  380. ),
  381. ):
  382. try:
  383. observation = measure()
  384. assert_capacity_within_limits(observation, limits)
  385. observations.append(observation)
  386. print(
  387. json.dumps(
  388. {
  389. "backend": backend,
  390. "rows": row_count,
  391. "elapsed_seconds": observation.elapsed_seconds,
  392. "peak_memory_bytes": observation.peak_memory_bytes,
  393. "artifact_bytes": observation.artifact_bytes,
  394. },
  395. sort_keys=True,
  396. ),
  397. flush=True,
  398. )
  399. except Exception as exc:
  400. failures.append(
  401. {
  402. "backend": backend,
  403. "rows": row_count,
  404. "reason": f"{type(exc).__name__}: {str(exc)[:300]}",
  405. }
  406. )
  407. print(json.dumps(failures[-1], sort_keys=True), flush=True)
  408. if observations:
  409. import psutil
  410. write_capacity_evidence(
  411. args.evidence,
  412. observations=observations,
  413. limits=limits,
  414. machine={
  415. "platform": platform.platform(),
  416. "logical_cpu_count": (
  417. args.logical_cpu_count
  418. or psutil.cpu_count(logical=True)
  419. or os.cpu_count()
  420. or "unavailable"
  421. ),
  422. "physical_memory_bytes": psutil.virtual_memory().total,
  423. "python": platform.python_version(),
  424. "database": "PostgreSQL 16 Docker",
  425. "measurement_note": (
  426. "SQL memory is PostgreSQL plan-reported peak operator "
  427. "memory; Polars memory is process RSS."
  428. ),
  429. },
  430. )
  431. _write_target_manifest(
  432. args.targets,
  433. requested_rows=requested,
  434. observations=observations,
  435. failures=failures,
  436. )
  437. return 0 if observations and not failures else 1
  438. if __name__ == "__main__":
  439. raise SystemExit(main())