From b9a5415e71feb6bccbd7b4106a9c3d60c5ac26dd Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 28 Oct 2026 12:30:58 +0800 Subject: [PATCH] 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 --- CHANGELOG.md | 6 + docs/BUG_HISTORY.md | 16 ++ scripts/jyotish_api_server.py | 18 +- .../rectification/vedastro_validate_cache.py | 111 ++++++++++++ scripts/vedastro_foreground.py | 62 ++++++- tests/conftest.py | 6 + tests/test_vedastro_negative_day_20261028.py | 171 ++++++++++++++++++ 7 files changed, 377 insertions(+), 13 deletions(-) create mode 100644 scripts/rectification/vedastro_validate_cache.py create mode 100644 tests/test_vedastro_negative_day_20261028.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 02122753..4fe21da2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 印度占星 Skill 更新日志 +## 2026-10-28 — VedAstro 限流或不可用时,不再每轮重等 + +- VedAstro 被限流或暂时拿不到数据时,同一张盘当天只问一次;之后的提问直接说明「外部校验暂不可用」,不再每轮多等 0.4~1.5 秒。第二天会重新问。 +- 生时校正交付时的外部校验同样记住失败,且整体不超过 11 秒,不会再出现前端已经放弃、后台还在算的情况。 +- 成功时的结果和以前完全一样。Skill 版本不变(BUG-1479,issue #130)。 + ## 2026-10-28 — 星盘页、档案页、合盘页打开更快 - 直接打开或刷新星盘页时,账户、人物和这张盘在网页一到就同时开始读取,不再等页面代码跑完、账户回来以后才开始算盘。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 76b65184..0b9e84a0 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -20187,3 +20187,19 @@ - 关联:BUG-1127(首页早读)、BUG-1477(`?c=` 早读)、BUG-1403(合盘开关未返回时不闪选人页)、BUG-1469(#120 次级页首屏)。 - 复发自:无 - 修复版本:分支 `codex/secondary-page-early-reads-20261028`。 + +## BUG-1479 | 问 VedAstro 失败或被限流时,咨询每一轮、校正交付每一轮都重新等一遍 + +- 状态:resolved(代码 + 回归测试;待部署) +- 首次发现 / 最近更新:2026-10-28 / 2026-10-28(issue #118 代码体检第 5、10 条 → #130) +- 影响面:咨询每一轮的前台 VedAstro 网关(`scripts/vedastro_foreground.py`);生时校正交付期的 `/api/rectification/v5/vedastro-validate`(`scripts/jyotish_api_server.py` `_compute_rectification_v5_vedastro_validate`、新模块 `scripts/rectification/vedastro_validate_cache.py`);`tests/conftest.py`。 +- 现象:① 前台快照只缓存成功结果,VedAstro 被限流或拿不到官方数据时,同一张盘每问一轮都重新起子进程去问,每次 0.4 秒到 1.5 秒;② 校正交付期每一轮都把两候选的分钟快照和每个事件的区间扫描重跑一遍,失败了下一轮再重跑(最坏约 15 秒),而且总时长可能超过前端只等的 12 秒。 +- 触发条件:VedAstro 免费额度被限流、服务不可用或返回不了官方数据时的任何咨询轮、校正交付轮。 +- 根因:失败结果在前台层不留;适配层只记 300 秒;校正检查没有负缓存,也没有整体时限。 +- 修复:① 前台网关真正跑过、但没拿到官方数据(被挡、限流、只有本地回退、它自己超时)的结果,按(盘, UTC 日期)记进已有的同日负缓存(`vedastro_consultation_snapshot` 那一份,产品 10-28 定:记一天);当天之后的轮次直接用它,不再起子进程。成功仍先查成功快照,成功结果照旧只进成功缓存;咨询闸门写的「本轮推迟」记录没有真的问过 VedAstro,前台不把它当失败;前台等待超时、任务在开始前就被取消的,也不记。② 校正检查按(证据指纹 = 除候选对以外的整个请求, 候选对)在进程内记住「因 VedAstro 自身失败(超时、异常、拿不到官方响应)」的结果一天;由证据决定的结果(通过、分不开)不记,下次照算。③ 校正检查整体限 11 秒(前端等 12 秒):时间够的调用照用适配层原来的超时,所以正常情况输出与以前逐字节相同;临近截止的调用只用剩下的时间;剩余不足 1 秒的调用不再发起,按超时记。 +- 验证:新测试 `tests/test_vedastro_negative_day_20261028.py`:网关失败一次后,同一张盘当天第二次 `finish` ≤0.15 秒、网关只调用一次、返回的 `vedastro_gateway` 与第一次完全相同;第二天另算;成功路径结果不变、不写负缓存;「本轮推迟」记录不挡前台;前台等待超时本身不记、后台任务拿到成功后下一轮直接用;校正检查失败后同一证据和候选对第二次不再调用适配层、结果相同,换候选对照算;通过或「分不开」不记;一天后过期;时限内用原超时、临近截止缩短、超时后不再发起。`tests/conftest.py` 的自动夹具同时隔离负缓存目录、清空校正检查记忆,避免测试互相串。既有 VedAstro 前台与校正检查测试全过;`test_api_server_growth_contract.py` 通过(主文件 +13 行,类方法数不变)。Python 快速门全部 Python 步骤通过。 +- 未做:体检提到「只用于展示覆盖率的目录查询挪到后台」。挪动会改变第一次调用返回里目录相关字段,与本单「成功路径输出逐字节不变」冲突,没做;成功结果当天已有快照缓存,重复轮次本来就不再查目录。 +- 防复发:上述测试。外部服务失败要和成功一样有缓存期;多次外部调用的接口要有总时限,且不超过调用方的等待。 +- 关联:BUG-734(健康探测走缓存)、BUG-727 / BUG-301(前台快照)。 +- 复发自:无 +- 修复版本:分支 `codex/vedastro-negative-day-20261028`。 diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 863f9367..69035703 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -8447,10 +8447,18 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin): if any(value not in allowed_candidate_times for value in candidate_times): raise BadRequest('candidate_times must fall within the V5 candidate range') + # issue #130: a VedAstro failure for this evidence and pair is kept for a day, + # and the whole check stays inside the frontend's wait. + from scripts.rectification.vedastro_validate_cache import ValidateBudget, remember_if_service_failure, remembered_failure, validation_key + remembered_key = validation_key(request_body, candidate_times) + remembered = remembered_failure(remembered_key) + if remembered is not None: + return remembered selected_events, eligible_event_count, unsupported_events = _select_vedastro_rectification_events( request['events'] ) adapter = _load_local_module('vedastro_service_adapter') + budget = ValidateBudget(getattr(adapter, '_timeout_seconds', None)) minute_cache = {} range_scan_cache = {} minute_reports = [] @@ -8473,7 +8481,8 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin): 'tz': request['tz'], } if candidate_time not in minute_cache: - minute_cache[candidate_time] = _safe_vedastro_adapter_call( + minute_cache[candidate_time] = budget.call( + _safe_vedastro_adapter_call, adapter.run_rectification_minute_snapshot_for_case, candidate_case, case_id=f'rectification_v5_{role}_{candidate_time.replace(":", "")}', @@ -8487,7 +8496,8 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin): for event, adapter_domain, mapping_mode, start_date, end_date in selected_events: cache_key = (candidate_time, adapter_domain, start_date, end_date) if cache_key not in range_scan_cache: - range_scan_cache[cache_key] = _safe_vedastro_adapter_call( + range_scan_cache[cache_key] = budget.call( + _safe_vedastro_adapter_call, adapter.run_range_scan_for_case, candidate_case, adapter_domain, @@ -8545,7 +8555,7 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin): blockers = list(dict.fromkeys(blockers)) passed = not blockers - return { + result = { 'success': True, 'endpoint': 'rectification_v5_vedastro_validate', 'status': 'pass' if passed else 'fail', @@ -8574,6 +8584,8 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin): 'candidates': candidate_validations, }, } + remember_if_service_failure(remembered_key, result) + return result def _compute_rectification_v5_range_reading(self, body): from scripts.rectification.api_service import range_reading diff --git a/scripts/rectification/vedastro_validate_cache.py b/scripts/rectification/vedastro_validate_cache.py new file mode 100644 index 00000000..e0d59b0d --- /dev/null +++ b/scripts/rectification/vedastro_validate_cache.py @@ -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) diff --git a/scripts/vedastro_foreground.py b/scripts/vedastro_foreground.py index 9cfc85c5..288c1692 100644 --- a/scripts/vedastro_foreground.py +++ b/scripts/vedastro_foreground.py @@ -9,6 +9,12 @@ budget: how long a worker may keep running after it has started. workers: process-wide pool. Extra work queues; join still caps the wait. A timed-out join must cancel its future so queued work never starts. + +A gateway run that ends without official data (blocked, rate limited, local +fallback, its own timeout) is remembered for that chart and UTC day in the +existing negative snapshot store, so later turns that day answer at once +instead of starting the gateway again (issue #130). A success still wins: +the positive snapshot is looked up first. budget <= k * join (k=2) keeps leftover occupancy of an already-running worker from stretching later requests past the join cap. The previous default budget of 8s with a 1.5s join left cancelled-too-late workers @@ -22,6 +28,11 @@ from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeou from typing import Any try: + from scripts.vedastro_consultation_snapshot import ( + DEFERRED_IN_CONSULTATION, + lookup_negative_snapshot, + store_negative_snapshot, + ) from scripts.vedastro_runtime_context import temporary_timeout_seconds from scripts.vedastro_snapshot_cache import ( annotate_stale_gateway, @@ -32,6 +43,11 @@ try: store_snapshot, ) except ModuleNotFoundError: # pragma: no cover - script execution path + from vedastro_consultation_snapshot import ( + DEFERRED_IN_CONSULTATION, + lookup_negative_snapshot, + store_negative_snapshot, + ) from vedastro_runtime_context import temporary_timeout_seconds from vedastro_snapshot_cache import ( annotate_stale_gateway, @@ -108,17 +124,37 @@ def _run_foreground_vedastro_gateway(handler, body: dict, cancel_event: threadin return result if isinstance(result, dict) else _blocked_foreground_vedastro(reason="gateway_invocation_error") +def _remember_gateway_outcome(body: dict, result: dict) -> None: + """Success into the snapshot cache; a failure that really ran, for the day.""" + try: + if is_cacheable_gateway(result): + store_snapshot(body, result) + elif result.get("official_closure_reason") != "foreground_optional_evidence_timeout": + store_negative_snapshot(body, result) + except (OSError, ValueError): + pass + + +def _failed_today(body: dict, today: str) -> dict | None: + """Today's remembered failure. A consult-gate deferral never ran, so it does not count.""" + try: + negative = lookup_negative_snapshot(body, today=today) + except (OSError, ValueError): + return None + if negative is None or negative.get("official_closure_reason") == DEFERRED_IN_CONSULTATION: + return None + return negative + + def _run_foreground_vedastro_gateway_and_store( handler, body: dict, cancel_event: threading.Event | None = None, ) -> dict: result = _run_foreground_vedastro_gateway(handler, body, cancel_event) - if is_cacheable_gateway(result): - try: - store_snapshot(body, result) - except OSError: - pass + # A run cancelled before it started never asked VedAstro: nothing to remember. + if not (cancel_event is not None and cancel_event.is_set() and result.get("official_closure_reason") == "foreground_optional_evidence_timeout"): + _remember_gateway_outcome(body, result) return result @@ -194,6 +230,16 @@ class ForegroundVedastroSession: "refresh_submitted": freshness == "stale", } return self + failed = _failed_today(self.body, today) + if failed is not None: + self.cached_gateway = failed + self.meta = { + "freshness": "negative", + "reference_date": today, + "served_on_utc_date": today, + "refresh_submitted": False, + } + return self self.meta = { "freshness": "miss", "reference_date": today, @@ -228,11 +274,7 @@ class ForegroundVedastroSession: ) if not isinstance(result, dict): return _blocked_foreground_vedastro(reason="gateway_invocation_error") - if is_cacheable_gateway(result): - try: - store_snapshot(self.body, result) - except OSError: - pass + _remember_gateway_outcome(self.body, result) return result diff --git a/tests/conftest.py b/tests/conftest.py index 0c0abc6e..352cfd55 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -50,6 +50,12 @@ def pytest_runtest_setup() -> None: def _isolate_vedastro_snapshot_cache(tmp_path_factory, monkeypatch) -> None: cache_dir = tmp_path_factory.mktemp("vedastro_snapshot_cache") monkeypatch.setenv("JYOTISH_VEDASTRO_SNAPSHOT_CACHE_DIR", str(cache_dir)) + # Foreground failures are remembered for the day there too (issue #130). + negative_dir = tmp_path_factory.mktemp("vedastro_snapshot_negative_cache") + monkeypatch.setenv("JYOTISH_VEDASTRO_SNAPSHOT_NEGATIVE_CACHE_DIR", str(negative_dir)) + from scripts.rectification.vedastro_validate_cache import clear as clear_validate_memory + + clear_validate_memory() def pytest_collection_modifyitems(items) -> None: diff --git a/tests/test_vedastro_negative_day_20261028.py b/tests/test_vedastro_negative_day_20261028.py new file mode 100644 index 00000000..725fa32f --- /dev/null +++ b/tests/test_vedastro_negative_day_20261028.py @@ -0,0 +1,171 @@ +"""Issue #130: a failed VedAstro call is remembered for the day, and the +rectification check stays inside the frontend's wait.""" + +from __future__ import annotations + +import threading +import time + +import pytest + +import scripts.vedastro_foreground as foreground +from scripts.rectification import vedastro_validate_cache as validate_cache +from scripts.jyotish_api_server import JyotishAPIHandler +from scripts.vedastro_consultation_snapshot import DEFERRED_IN_CONSULTATION, lookup_negative_snapshot, store_negative_snapshot +from tests.test_rectification_v5_vedastro_validation import request + +BODY = {"year": 1990, "month": 3, "day": 14, "hour": 9, "minute": 30, "lat": 31.23, "lon": 121.47, "tz": 8, + "ayanamsa": "lahiri", "today": "2026-10-28"} +BLOCKED = {"scope": "vedastro_gateway_run", "status": "official_blocked", "official_closure_state": "official_blocked", + "official_closure_reason": "official_raw_response_missing", "official_capability_catalog": {"status": "rate_limited"}} +VERIFIED = {"scope": "vedastro_gateway_run", "status": "ok", "official_closure_state": "official_verified", + "official_closure_reason": "official_raw_response_present", "official_raw_response": {"ok": True}} + + +class Handler: + def __init__(self, result, delay=0.3): + self.result, self.delay, self.calls = result, delay, 0 + + def _compute_vedastro_gateway_run(self, body): + self.calls += 1 + time.sleep(self.delay) + return dict(self.result) + + +def _consult(handler): + session = foreground.start_foreground_vedastro(handler, BODY, defer_optional_external_evidence=True) + started = time.perf_counter() + gateway = foreground.finish_foreground_vedastro(session) + return gateway, time.perf_counter() - started, session.meta + + +def _wait_for_worker(): + # The join returned; the worker stores its outcome right after. + deadline = time.time() + 3 + while time.time() < deadline and not foreground._FOREGROUND_VEDASTRO_EXECUTOR._work_queue.empty(): + time.sleep(0.01) + time.sleep(0.05) + + +def test_a_failure_is_answered_from_memory_for_the_rest_of_the_day(): + handler = Handler(BLOCKED) + first, first_seconds, first_meta = _consult(handler) + _wait_for_worker() + second, second_seconds, second_meta = _consult(handler) + assert handler.calls == 1, "the gateway ran once" + assert first_seconds >= 0.25 + assert second_seconds <= 0.15, second_seconds + assert second == first, "the same vedastro_gateway fields as the failed call" + assert first_meta["freshness"] == "miss" and second_meta["freshness"] == "negative" + # Tomorrow is another key. + assert lookup_negative_snapshot(BODY, today="2026-10-29") is None + + +def test_success_is_unchanged_and_never_written_as_a_failure(): + handler = Handler(VERIFIED, delay=0.05) + first, _, _ = _consult(handler) + _wait_for_worker() + second, seconds, meta = _consult(handler) + assert first == VERIFIED + assert handler.calls == 1 and meta["freshness"] == "fresh" and seconds <= 0.15 + assert second["official_closure_state"] == "official_verified" + assert lookup_negative_snapshot(BODY, today="2026-10-28") is None + + +def test_a_consult_gate_deferral_is_not_a_failure(): + store_negative_snapshot(BODY, {"status": "official_blocked", "official_closure_state": "official_blocked", + "official_closure_reason": DEFERRED_IN_CONSULTATION}, today="2026-10-28") + handler = Handler(VERIFIED, delay=0.01) + gateway, _, _ = _consult(handler) + assert handler.calls == 1, "a deferral never asked VedAstro, so the gateway still runs" + assert gateway == VERIFIED + + +def test_a_join_timeout_alone_is_not_remembered(monkeypatch): + monkeypatch.setenv("JYOTISH_FOREGROUND_VEDASTRO_JOIN_SECONDS", "0.05") + release = threading.Event() + + class Slow(Handler): + def _compute_vedastro_gateway_run(self, body): + self.calls += 1 + release.wait(2) + return dict(VERIFIED) + + handler = Slow(VERIFIED) + gateway, _, _ = _consult(handler) + assert gateway["official_closure_reason"] == "foreground_optional_evidence_timeout" + assert lookup_negative_snapshot(BODY, today="2026-10-28") is None + release.set() + _wait_for_worker() + # The worker finished with data: the next turn has it. + again, _, meta = _consult(Handler(BLOCKED)) + assert again["official_closure_state"] == "official_verified" and meta["freshness"] == "fresh" + + +class FailingAdapter: + calls = 0 + + @staticmethod + def _timeout_seconds(): + return 4 + + @classmethod + def run_rectification_minute_snapshot_for_case(cls, case, case_id="user_chart"): + cls.calls += 1 + raise TimeoutError("rate limited") + + @classmethod + def run_range_scan_for_case(cls, case, domain, start, end, case_id="user_chart"): + cls.calls += 1 + raise TimeoutError("rate limited") + + +def test_a_failed_rectification_check_is_kept_for_the_evidence_and_pair(monkeypatch): + FailingAdapter.calls = 0 + monkeypatch.setattr("scripts.jyotish_api_server._load_local_module", lambda name: FailingAdapter) + handler = object.__new__(JyotishAPIHandler) + first = handler._compute_rectification_v5_vedastro_validate(request()) + calls = FailingAdapter.calls + second = handler._compute_rectification_v5_vedastro_validate(request()) + assert calls > 0 and FailingAdapter.calls == calls, "no VedAstro call the second time" + assert second == first and "vedastro_timeout" in first["blockers"] + # Another pair is other evidence. + handler._compute_rectification_v5_vedastro_validate(request(["05:14", "05:13"])) + assert FailingAdapter.calls > calls + + +def test_a_decided_result_is_not_kept(): + key = validate_cache.validation_key({"a": 1}, ["05:13", "05:14"]) + assert not validate_cache.remember_if_service_failure(key, {"passed": True, "blockers": []}) + assert not validate_cache.remember_if_service_failure(key, {"passed": False, "blockers": ["vedastro_minute_sensitive_layers_not_discriminated"]}) + assert validate_cache.remembered_failure(key) is None + assert validate_cache.remember_if_service_failure(key, {"passed": False, "blockers": ["vedastro_timeout"]}, now=1000.0) + assert validate_cache.remembered_failure(key, now=1000.0 + validate_cache.KEEP_SECONDS - 1) is not None + assert validate_cache.remembered_failure(key, now=1000.0 + validate_cache.KEEP_SECONDS + 1) is None + + +def test_the_check_stops_starting_calls_at_its_budget(): + now = [0.0] + seen = [] + budget = validate_cache.ValidateBudget(lambda: 4.0, seconds=11.0, clock=lambda: now[0]) + + def safe_call(call, *args, **kwargs): + from scripts.vedastro_runtime_context import timeout_override_seconds + seen.append(timeout_override_seconds()) + return call(*args, **kwargs) + + work = lambda: {"status": "ok"} + assert budget.call(safe_call, work) == {"status": "ok"} + now[0] = 8.5 + budget.call(safe_call, work) + now[0] = 10.5 + skipped = budget.call(safe_call, work) + assert seen == [None, 2.5], "full timeout with time to spare, the rest near the end" + assert skipped == {"available": False, "status": "timeout", "_failure_kind": "timeout"} + assert validate_cache.VALIDATE_BUDGET_SECONDS < 12, "under the frontend's 12 s wait" + + +@pytest.fixture(autouse=True) +def _fresh_executor_state(): + yield + _wait_for_worker()