fix(chart): skip unused synastry overview and doomed report subprocesses
Synastry charts now skip the unused external overview and run together. Reports skip PyJHora subprocesses when that library is missing, and a full heavy-compute slot logs which paths hold it. Splitting quotas stays a simulation. Gate, growth, and report pytest passed, as did the frontend route tests, tsc, lint, and the A/B script. Co-Authored-By: Grok (agent loop) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Grok (agent loop)
Cursor
parent
9274491681
commit
336286e428
@@ -8,13 +8,17 @@ must not call this gate.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
DEFAULT_CONCURRENCY = 2
|
||||
DEFAULT_RETRY_AFTER_SECONDS = 2
|
||||
ENV_CONCURRENCY = "JYOTISH_HEAVY_COMPUTE_CONCURRENCY"
|
||||
ENV_RETRY_AFTER = "JYOTISH_HEAVY_COMPUTE_RETRY_AFTER_SECONDS"
|
||||
ENV_GATE_CHART_OVERVIEW = "JYOTISH_GATE_CHART_OVERVIEW"
|
||||
_LOG = logging.getLogger("jyotish.heavy_compute")
|
||||
|
||||
HEAVY_COMPUTE_PATHS = frozenset(
|
||||
{
|
||||
@@ -46,6 +50,26 @@ HEAVY_COMPUTE_PATHS = frozenset(
|
||||
_state_lock = threading.Lock()
|
||||
_semaphore: threading.BoundedSemaphore | None = None
|
||||
_retry_after_seconds = DEFAULT_RETRY_AFTER_SECONDS
|
||||
_limit = DEFAULT_CONCURRENCY
|
||||
_holders: list[dict] = []
|
||||
_holder_seq = 0
|
||||
|
||||
|
||||
class _HeldSlot:
|
||||
"""One acquired heavy-compute permit, paired with the path that holds it."""
|
||||
|
||||
def __init__(self, semaphore: threading.BoundedSemaphore, holder_id: int) -> None:
|
||||
self._semaphore = semaphore
|
||||
self._holder_id = holder_id
|
||||
self._released = False
|
||||
|
||||
def release(self) -> None:
|
||||
with _state_lock:
|
||||
if self._released:
|
||||
return
|
||||
self._released = True
|
||||
_holders[:] = [item for item in _holders if item["id"] != self._holder_id]
|
||||
self._semaphore.release()
|
||||
|
||||
|
||||
class HeavyComputeBusy(RuntimeError):
|
||||
@@ -80,7 +104,7 @@ def reset_heavy_compute_gate(
|
||||
retry_after_seconds: int | None = None,
|
||||
) -> None:
|
||||
"""Rebuild the process-wide semaphore. Tests must call this after env changes."""
|
||||
global _semaphore, _retry_after_seconds
|
||||
global _semaphore, _retry_after_seconds, _limit, _holders
|
||||
resolved_limit = (
|
||||
limit
|
||||
if limit is not None
|
||||
@@ -93,31 +117,96 @@ def reset_heavy_compute_gate(
|
||||
)
|
||||
with _state_lock:
|
||||
_retry_after_seconds = resolved_retry
|
||||
_limit = resolved_limit
|
||||
_holders = []
|
||||
_semaphore = threading.BoundedSemaphore(resolved_limit)
|
||||
|
||||
|
||||
def _ensure_locked() -> tuple[threading.BoundedSemaphore, int]:
|
||||
global _semaphore, _retry_after_seconds
|
||||
global _semaphore, _retry_after_seconds, _limit
|
||||
if _semaphore is None:
|
||||
limit = _parse_positive_int(os.environ.get(ENV_CONCURRENCY), DEFAULT_CONCURRENCY)
|
||||
_limit = _parse_positive_int(os.environ.get(ENV_CONCURRENCY), DEFAULT_CONCURRENCY)
|
||||
_retry_after_seconds = _parse_positive_int(
|
||||
os.environ.get(ENV_RETRY_AFTER), DEFAULT_RETRY_AFTER_SECONDS
|
||||
)
|
||||
_semaphore = threading.BoundedSemaphore(limit)
|
||||
_semaphore = threading.BoundedSemaphore(_limit)
|
||||
return _semaphore, _retry_after_seconds
|
||||
|
||||
|
||||
def acquire_heavy_compute_slot(path: str) -> threading.BoundedSemaphore | None:
|
||||
def _holder_view(now: float) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"path": item["path"],
|
||||
"held_seconds": round(max(now - item["started"], 0.0), 3),
|
||||
}
|
||||
for item in _holders
|
||||
]
|
||||
|
||||
|
||||
def _acquire_recorded(path: str) -> _HeldSlot:
|
||||
"""Take one permit and remember which path holds it. Fail fast when full."""
|
||||
global _holder_seq
|
||||
with _state_lock:
|
||||
semaphore, retry_after = _ensure_locked()
|
||||
if not semaphore.acquire(blocking=False):
|
||||
snapshot = _holder_view(time.monotonic())
|
||||
failed = True
|
||||
slot = None
|
||||
else:
|
||||
_holder_seq += 1
|
||||
holder_id = _holder_seq
|
||||
_holders.append({"id": holder_id, "path": path, "started": time.monotonic()})
|
||||
failed = False
|
||||
snapshot = []
|
||||
slot = _HeldSlot(semaphore, holder_id)
|
||||
if failed:
|
||||
_LOG.warning(
|
||||
"heavy_compute_busy rejected_path=%s holders=%s",
|
||||
path,
|
||||
snapshot,
|
||||
)
|
||||
raise HeavyComputeBusy(retry_after)
|
||||
assert slot is not None
|
||||
return slot
|
||||
|
||||
|
||||
def acquire_heavy_compute_slot(path: str) -> _HeldSlot | None:
|
||||
"""Acquire a slot for a gated path. Light paths return None. Fail-fast on saturation."""
|
||||
if not is_heavy_compute_path(path):
|
||||
return None
|
||||
return _acquire_recorded(path)
|
||||
|
||||
|
||||
def _chart_overview_gate_enabled() -> bool:
|
||||
return os.environ.get(ENV_GATE_CHART_OVERVIEW, "").strip() == "1"
|
||||
|
||||
|
||||
def acquire_heavy_compute_slot_for_request(path: str, body: dict | None) -> _HeldSlot | None:
|
||||
"""Path gate, plus the chart-overview switch and the warm-consultation exception.
|
||||
|
||||
``/api/chart`` stays out of ``HEAVY_COMPUTE_PATHS``. It takes a slot only when
|
||||
``JYOTISH_GATE_CHART_OVERVIEW=1``, the body does not skip the VedAstro overview,
|
||||
and the body is not an async enqueue. Default is off.
|
||||
"""
|
||||
payload = body if isinstance(body, dict) else {}
|
||||
if path == "/api/consultation_workflow" and payload.get("consult_card_warm_only") is True:
|
||||
return None
|
||||
if path == "/api/chart" and _chart_overview_gate_enabled():
|
||||
if payload.get("async") or payload.get("enqueue"):
|
||||
return None
|
||||
if payload.get("skip_vedastro_main_entry_overview"):
|
||||
return None
|
||||
return _acquire_recorded(path)
|
||||
return acquire_heavy_compute_slot(path)
|
||||
|
||||
|
||||
def heavy_compute_status() -> dict:
|
||||
"""Process-local counts only. No request body and no user identity."""
|
||||
with _state_lock:
|
||||
semaphore, retry_after = _ensure_locked()
|
||||
if not semaphore.acquire(blocking=False):
|
||||
raise HeavyComputeBusy(retry_after)
|
||||
return semaphore
|
||||
_ensure_locked()
|
||||
return {"limit": _limit, "in_use": len(_holders)}
|
||||
|
||||
|
||||
def release_heavy_compute_slot(slot: threading.BoundedSemaphore | None) -> None:
|
||||
def release_heavy_compute_slot(slot: _HeldSlot | None) -> None:
|
||||
if slot is not None:
|
||||
slot.release()
|
||||
|
||||
Reference in New Issue
Block a user