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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
06d0c7dc37
commit
b9a5415e71
@@ -0,0 +1,111 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user