Heavy routes run in worker processes, one per heavy slot (issue #133, BUG-1493)

The single ThreadingHTTPServer ran pure-Python v5 score / block_scan / report
reference under one GIL, so the gate's two slots shared a core. start_server
now starts a spawn pool (workers = heavy concurrency, PYTHONHASHSEED=0, engine
pre-imported); those three routes call the same compute method in a worker.
Over HTTP: score alone 4.74 s, two at once 5.01 / 4.58 s (1.06x; in-process
1.56x). Results equal except per-process fingerprints; report markdown equal.
~165 MB extra. A crashed pool answers inline and restarts; off with
JYOTISH_HEAVY_COMPUTE_PROCESSES=0; tests calling compute methods unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jesse_Chen
2026-10-28 14:28:05 +08:00
co-authored by Claude Opus 5.5
parent 5fd79a5649
commit 8d1cb4a53b
6 changed files with 266 additions and 8 deletions
+1
View File
@@ -0,0 +1 @@
"""Process pool for the heaviest API computations (issue #133)."""
+137
View File
@@ -0,0 +1,137 @@
"""Run the heaviest compute endpoints in worker processes (issue #133).
The API server is one ``ThreadingHTTPServer`` process. Its heavy routes are
pure-Python number crunching, so two requests inside the gate's two slots
still shared one core under the GIL: one score alone 1.19 s, two at once
2.35-2.6 s each. Here the same compute methods run on a fresh
``JyotishComputeService`` in a spawn-started worker, one worker per heavy slot,
so two requests use two cores. The heavy-compute gate still decides who runs.
- Enabled only by the server's ``main()`` (and ``JYOTISH_HEAVY_COMPUTE_PROCESSES``
not ``0``); tests that call compute methods in process are unchanged.
- Workers start with ``PYTHONHASHSEED=0`` (ERR-111: Shadbala fingerprints vary
with the hash seed), and are started up front so the first user does not
pay the import.
- A crashed pool is replaced; the request that hit it runs in process.
"""
from __future__ import annotations
import multiprocessing
import os
import sys
import threading
from concurrent.futures import ProcessPoolExecutor
from concurrent.futures.process import BrokenProcessPool
from typing import Any
ENV_PROCESSES = "JYOTISH_HEAVY_COMPUTE_PROCESSES"
POOLED_METHODS = frozenset({
"_compute_rectification_v5_score",
"_compute_rectification_v5_block_scan",
"professional_report_reference",
})
_guard = threading.Lock()
_executor: ProcessPoolExecutor | None = None
_workers = 0
_config: tuple[int, list[str]] | None = None
_service = None
def _worker_init(paths: list[str]) -> None:
for path in reversed(paths):
if path not in sys.path:
sys.path.insert(0, path)
def _worker_call(method: str, body: dict[str, Any]) -> Any:
global _service
from scripts.jyotish_api_server import BadRequest, JyotishComputeService
if _service is None:
_service = JyotishComputeService()
if method == "professional_report_reference":
from scripts.professional_report_reference import (
ProfessionalReportReferenceInputError,
build_professional_report_reference,
)
try:
return build_professional_report_reference(_service, body)
except ProfessionalReportReferenceInputError as exc:
raise BadRequest(str(exc)) from exc
return getattr(_service, method)(body)
def _warm() -> int:
from scripts import jyotish_api_server # noqa: F401 - import the engine once per worker
return os.getpid()
def enabled_by_env() -> bool:
return os.environ.get(ENV_PROCESSES, "1").strip().lower() not in {"0", "false", "no", "off"}
def start(workers: int, paths: list[str]) -> bool:
"""Start the pool with ``workers`` processes; False when switched off."""
global _executor, _workers, _config
if workers < 1 or not enabled_by_env():
return False
_config = (workers, list(paths))
with _guard:
if _executor is not None:
return True
previous = os.environ.get("PYTHONHASHSEED")
os.environ["PYTHONHASHSEED"] = "0"
try:
executor = ProcessPoolExecutor(
max_workers=workers,
mp_context=multiprocessing.get_context("spawn"),
initializer=_worker_init,
initargs=(paths,),
)
# Start every worker now, while the hash seed is in the environment.
for future in [executor.submit(_warm) for _ in range(workers)]:
future.result(timeout=120)
finally:
if previous is None:
os.environ.pop("PYTHONHASHSEED", None)
else:
os.environ["PYTHONHASHSEED"] = previous
_executor, _workers = executor, workers
return True
def active() -> bool:
return _executor is not None
def run(method: str, body: dict[str, Any], inline) -> Any:
"""``method`` in a worker when the pool is up, else ``inline(body)`` in this process."""
executor = _executor
if executor is None or method not in POOLED_METHODS:
return inline(body)
try:
return executor.submit(_worker_call, method, body).result()
except BrokenProcessPool:
_replace_broken(executor)
return inline(body)
def _replace_broken(broken: ProcessPoolExecutor) -> None:
global _executor
with _guard:
if _executor is broken:
_executor = None
broken.shutdown(wait=False, cancel_futures=True)
if _config is not None:
threading.Thread(target=start, args=_config, name="heavy-pool-restart", daemon=True).start()
def stop() -> None:
global _executor
with _guard:
executor, _executor = _executor, None
if executor is not None:
executor.shutdown(wait=True, cancel_futures=True)