Files
Jyotisha/scripts/rectification/vedastro_validate_cache.py
T
Jesse_ChenandClaude Opus 5.5 b9a5415e71
Independent Staging Quality Gate / publish (push) Canceled after 0s
Independent Staging Quality Gate / validate (push) Canceled after 2m17s
VedAstro failures are remembered for the day; the rectification check fits the frontend's wait (issue #130, BUG-1479)
- Foreground gateway: a run that ended without official data is written to
  the existing same-day negative snapshot store; later turns that day answer
  from it without starting the gateway (second call <= 0.15 s, same fields).
  Success still goes to the positive cache first; a consult-gate deferral and
  a join timeout are not failures.
- /v5/vedastro-validate: a result blocked by VedAstro itself is kept in memory
  for a day per (evidence fingerprint, candidate pair); evidence-decided
  results are not. The whole check has 11 s (frontend waits 12 s); calls with
  time to spare keep the adapter timeout, so normal output is unchanged.
- conftest also isolates the negative store and clears the check memory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:38:17 +08:00

112 lines
4.4 KiB
Python

"""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)