"""Day-long memory and a time budget for the rectification VedAstro check (issue #130). ``/api/rectification/v5/vedastro-validate`` runs up to two minute snapshots and one range scan per supported event for each candidate, each with the adapter's own timeout. When VedAstro is rate limited every delivery turn ran them all again (worst case about 15 s), and the total could outlast the 12 s the frontend waits. - A result that failed because of VedAstro itself (timeout, exception, no official response) is kept in memory for a day, keyed by the evidence fingerprint and the candidate pair. A result decided by the evidence (a pass, or layers that do not discriminate) is not kept: it is computed again. - The whole check has ``VALIDATE_BUDGET_SECONDS`` (under the frontend's 12 s). A call that would start past the budget is not made and counts as a timeout; one that starts near the end runs with the time that is left. Calls with time to spare keep the adapter's own timeout, so a normal run is byte-identical to before. """ from __future__ import annotations import hashlib import json import threading import time from collections import OrderedDict from typing import Any, Callable try: from scripts.vedastro_runtime_context import temporary_timeout_seconds except ModuleNotFoundError: # pragma: no cover - script execution path from vedastro_runtime_context import temporary_timeout_seconds KEEP_SECONDS = 24 * 60 * 60 MAX_ENTRIES = 256 # The frontend's postEngine timeout for this route is 12 s. VALIDATE_BUDGET_SECONDS = 11.0 # Below this the adapter floors the timeout at 1 s anyway; do not start a call. MIN_CALL_SECONDS = 1.0 SERVICE_BLOCKERS = frozenset({"vedastro_timeout", "vedastro_exception", "vedastro_official_response_missing"}) _entries: "OrderedDict[str, tuple[float, dict[str, Any]]]" = OrderedDict() _guard = threading.Lock() def validation_key(request_body: dict[str, Any], candidate_times: list[str]) -> str: """Evidence fingerprint (the whole request without the pair) plus the pair, in order.""" canonical = json.dumps( {"request": request_body, "candidate_times": list(candidate_times)}, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str, ) return hashlib.sha256(canonical.encode("utf-8")).hexdigest() def remembered_failure(key: str, *, now: float | None = None) -> dict[str, Any] | None: moment = time.time() if now is None else now with _guard: entry = _entries.get(key) if entry is None: return None if entry[0] <= moment: _entries.pop(key, None) return None _entries.move_to_end(key) return json.loads(json.dumps(entry[1])) def remember_if_service_failure(key: str, result: dict[str, Any], *, now: float | None = None) -> bool: blockers = set(result.get("blockers") or []) if result.get("passed") or not blockers & SERVICE_BLOCKERS: return False moment = time.time() if now is None else now with _guard: _entries[key] = (moment + KEEP_SECONDS, json.loads(json.dumps(result))) _entries.move_to_end(key) while len(_entries) > MAX_ENTRIES: _entries.popitem(last=False) return True def clear() -> None: with _guard: _entries.clear() class ValidateBudget: """One check's deadline, shared by all its adapter calls.""" def __init__( self, adapter_timeout: Callable[[], float] | None = None, *, seconds: float = VALIDATE_BUDGET_SECONDS, clock: Callable[[], float] = time.monotonic, ) -> None: self._adapter_timeout = adapter_timeout self._clock = clock self._deadline = clock() + seconds def call(self, safe_call: Callable[..., dict[str, Any]], call: Callable[..., Any], *args: Any, **kwargs: Any) -> dict[str, Any]: remaining = self._deadline - self._clock() if remaining < MIN_CALL_SECONDS: return {"available": False, "status": "timeout", "_failure_kind": "timeout"} # An adapter that does not say its timeout is left alone until the budget runs out. per_call = self._adapter_timeout() if self._adapter_timeout is not None else float("inf") if remaining >= per_call or per_call == float("inf"): return safe_call(call, *args, **kwargs) with temporary_timeout_seconds(remaining): return safe_call(call, *args, **kwargs)