- 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>
112 lines
4.4 KiB
Python
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)
|