VedAstro failures are remembered for the day; the rectification check fits the frontend's wait (issue #130, BUG-1479)
Independent Staging Quality Gate / publish (push) Canceled after 0s
Independent Staging Quality Gate / validate (push) Canceled after 2m17s

- 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:
Jesse_Chen
2026-10-28 12:38:17 +08:00
co-authored by Claude Opus 5.5
parent 06d0c7dc37
commit b9a5415e71
7 changed files with 377 additions and 13 deletions
+6
View File
@@ -1,5 +1,11 @@
# 印度占星 Skill 更新日志
## 2026-10-28 — VedAstro 限流或不可用时,不再每轮重等
- VedAstro 被限流或暂时拿不到数据时,同一张盘当天只问一次;之后的提问直接说明「外部校验暂不可用」,不再每轮多等 0.4~1.5 秒。第二天会重新问。
- 生时校正交付时的外部校验同样记住失败,且整体不超过 11 秒,不会再出现前端已经放弃、后台还在算的情况。
- 成功时的结果和以前完全一样。Skill 版本不变(BUG-1479,issue #130)。
## 2026-10-28 — 星盘页、档案页、合盘页打开更快
- 直接打开或刷新星盘页时,账户、人物和这张盘在网页一到就同时开始读取,不再等页面代码跑完、账户回来以后才开始算盘。
+16
View File
@@ -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`。
+15 -3
View File
@@ -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
@@ -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)
+52 -10
View File
@@ -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
+6
View File
@@ -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:
@@ -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()