fix(consult): limit career direction and single-flight chart warm
The career checklist now says the direction section only covers strength, resistance, timing, and three events. Warm builds one packet per cache key and skips when its own cap is full. A three-repeat biography run still has one career answer that names a direction from a planet nature, so this is not accepted. Not pushed.
This commit is contained in:
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
@@ -23,6 +24,38 @@ KEEP_SECONDS = 2 * 24 * 60 * 60
|
||||
|
||||
PacketBuilder = Callable[[dict[str, Any]], dict[str, Any]]
|
||||
|
||||
# One build per cache key. A later caller waits and reads the stored packet.
|
||||
class _Flight:
|
||||
def __init__(self) -> None:
|
||||
self.done = threading.Event()
|
||||
self.error: BaseException | None = None
|
||||
|
||||
|
||||
_flights: dict[str, _Flight] = {}
|
||||
_flights_guard = threading.Lock()
|
||||
|
||||
# Chart-save warm has its own cap. When it is full the warm is skipped.
|
||||
# A user request does not take this slot and does not receive 429 from it.
|
||||
WARM_CONCURRENCY_LIMIT = 1
|
||||
_warm_guard = threading.Lock()
|
||||
_warm_inflight = 0
|
||||
|
||||
|
||||
def _acquire_warm_slot() -> bool:
|
||||
global _warm_inflight
|
||||
with _warm_guard:
|
||||
if _warm_inflight >= WARM_CONCURRENCY_LIMIT:
|
||||
return False
|
||||
_warm_inflight += 1
|
||||
return True
|
||||
|
||||
|
||||
def _release_warm_slot() -> None:
|
||||
global _warm_inflight
|
||||
with _warm_guard:
|
||||
if _warm_inflight > 0:
|
||||
_warm_inflight -= 1
|
||||
|
||||
|
||||
def cache_dir() -> Path:
|
||||
path = ROOT / "scratch" / "local" / "consult_full_data_cache"
|
||||
@@ -104,34 +137,63 @@ def prune_cache(now: float | None = None) -> int:
|
||||
return removed
|
||||
|
||||
|
||||
def _read_cached(path: Path, started: float, now: Callable[[], float]) -> dict[str, Any]:
|
||||
packet = json.loads(path.read_text(encoding="utf-8"))
|
||||
return {"packet": packet, "hit": True, "elapsed_s": now() - started, "slow": False}
|
||||
|
||||
|
||||
def get_or_build(
|
||||
identity: dict[str, Any],
|
||||
builder: PacketBuilder | None = None,
|
||||
*,
|
||||
clock: Callable[[], float] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return the packet. A miss calls builder once and stores the packet."""
|
||||
"""Return the packet. Concurrent callers for one key share one build."""
|
||||
prune_cache()
|
||||
now = clock or time.perf_counter
|
||||
started = now()
|
||||
path = _cache_path(identity)
|
||||
if path.is_file():
|
||||
packet = json.loads(path.read_text(encoding="utf-8"))
|
||||
return _read_cached(path, started, now)
|
||||
key = cache_key(identity)
|
||||
with _flights_guard:
|
||||
flight = _flights.get(key)
|
||||
owner = flight is None
|
||||
if owner:
|
||||
flight = _Flight()
|
||||
_flights[key] = flight
|
||||
assert flight is not None
|
||||
if not owner:
|
||||
flight.done.wait()
|
||||
if flight.error is not None:
|
||||
raise flight.error
|
||||
if path.is_file():
|
||||
return _read_cached(path, started, now)
|
||||
raise RuntimeError("consult_full_data_unavailable")
|
||||
try:
|
||||
if path.is_file():
|
||||
return _read_cached(path, started, now)
|
||||
if builder is None:
|
||||
builder = build_full_data_packet
|
||||
packet = builder(identity)
|
||||
temporary = path.with_suffix(".json.tmp")
|
||||
temporary.write_text(json.dumps(packet, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
elapsed = now() - started
|
||||
return {"packet": packet, "hit": True, "elapsed_s": elapsed, "slow": False}
|
||||
if builder is None:
|
||||
builder = build_full_data_packet
|
||||
packet = builder(identity)
|
||||
temporary = path.with_suffix(".json.tmp")
|
||||
temporary.write_text(json.dumps(packet, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
elapsed = now() - started
|
||||
return {
|
||||
"packet": packet,
|
||||
"hit": False,
|
||||
"elapsed_s": elapsed,
|
||||
"slow": elapsed > SLOW_MISS_SECONDS,
|
||||
}
|
||||
return {
|
||||
"packet": packet,
|
||||
"hit": False,
|
||||
"elapsed_s": elapsed,
|
||||
"slow": elapsed > SLOW_MISS_SECONDS,
|
||||
}
|
||||
except Exception as exc:
|
||||
flight.error = exc
|
||||
raise
|
||||
finally:
|
||||
flight.done.set()
|
||||
with _flights_guard:
|
||||
if _flights.get(key) is flight:
|
||||
_flights.pop(key, None)
|
||||
|
||||
|
||||
def _attach_graha_drishti(packet: dict[str, Any], reading: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -186,22 +248,39 @@ def build_full_data_packet(identity: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
|
||||
def warm_consult_packet(body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Build today's packet after a chart save. The response stays small."""
|
||||
try:
|
||||
cached = get_or_build(identity_from_body(dict(body or {})))
|
||||
except Exception as exc:
|
||||
"""Build today's packet after a chart save. The response stays small.
|
||||
|
||||
At most WARM_CONCURRENCY_LIMIT warms run at once. A full cap skips this
|
||||
warm. The skip is not a user-request 429, and a failure here does not
|
||||
fail the chart save (the save route does not wait on this response).
|
||||
"""
|
||||
if not _acquire_warm_slot():
|
||||
return {
|
||||
"success": False,
|
||||
"success": True,
|
||||
"endpoint": "consult_card_warm",
|
||||
"error_type": type(exc).__name__,
|
||||
"skipped": True,
|
||||
"hit": False,
|
||||
"elapsed_s": 0.0,
|
||||
"slow": False,
|
||||
}
|
||||
return {
|
||||
"success": True,
|
||||
"endpoint": "consult_card_warm",
|
||||
"hit": bool(cached["hit"]),
|
||||
"elapsed_s": cached["elapsed_s"],
|
||||
"slow": bool(cached["slow"]),
|
||||
}
|
||||
try:
|
||||
try:
|
||||
cached = get_or_build(identity_from_body(dict(body or {})))
|
||||
except Exception as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"endpoint": "consult_card_warm",
|
||||
"error_type": type(exc).__name__,
|
||||
}
|
||||
return {
|
||||
"success": True,
|
||||
"endpoint": "consult_card_warm",
|
||||
"hit": bool(cached["hit"]),
|
||||
"elapsed_s": cached["elapsed_s"],
|
||||
"slow": bool(cached["slow"]),
|
||||
}
|
||||
finally:
|
||||
_release_warm_slot()
|
||||
|
||||
|
||||
def _requested_domains(body: dict[str, Any]) -> list[str]:
|
||||
|
||||
Reference in New Issue
Block a user