BUG-734: probe_official_rest_health 不是 ping —— 它把一份虚构 smoke 排盘 POST 给官方 HoroscopePredictions(实测单次 1,034 ms),还经 call() 吃掉对方 每分钟 5 个限流令牌之一,而结论零 TTL。连续 6 轮前台请求下,第 6 次探测会被 本地限流器挡下、反过来报出假的 official_blocked,真正的业务调用还要和它抢令牌。 加进程级 TTL 缓存(成功 60 s / 失败 10 s,配置变即失效,缓存键只记 key 配没配 的布尔)。force_refresh 默认 True:诊断端点调的是裸 gateway_status() 且该文件 本轮不得改,默认实时才不会让运维看到旧结论;run_gateway_packet 显式走 False。 探测在锁外执行。6 轮实测:探测 6→1、握手 5→1、限流令牌 6→1。 连接复用(任务书 6.2)按让步顺序砍掉:实测 urllib.request 没有连接池,模块级 共享 opener 在 6 次请求下仍开 6 条 TCP 连接,按任务书写法只能骗过「同一个 opener 实例」的断言而省不掉任何握手。另立单。 BUG-735: yoga_engine._eval_custom 对静态规则表的表达式每请求重新编译。实测每次 检测 386 次源码编译(182 次 eval 字符串 + 204 次 ast.parse/compile)。拆出 _build_custom_exec_globals(每次新建,绑定当前盘 ctx)与 lru_cache 的 _compiled_eval_code / _compiled_exec_code,只缓存 code object,绝不缓存求值 结果。求值语义逐条对齐改前。第二次检测起编译 0 次,单次 27.4 ms → 3.3 ms。 等价用同进程差分(BUG-733 的做法):192 条去重表达式 × 3 张公开虚构示例盘, 与逐字复刻的改前实现同值,另有反向验证证明差分非恒真。 未改 scripts/jyotish_api_server.py。既有断言一条未改。新增 23 条测试。 快速门唯一红的 test_chat_page_uses_authenticated_cloud_persistence 经干净 origin/staging 检出复跑确认为基线红,记入 BLOCKED.md BLK-002,不顺手修。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
14 KiB
PROGRESS · 外网健康探测与运行时重复编译(2026-09-16)
工作树:.worktrees/consultation-residual-hotspots-20260916
分支:codex/consultation-residual-hotspots-20260916
基线:origin/staging @ 37e6c519(任务书写的是 f8e607c2,开工时远端已前进;docs/BUG_HISTORY.md 最大号核对结果仍是 BUG-733,本单占 734 / 735 不变)
本机 Linux,.venv Python 3.13 + pytest。无 Docker。外网可达(本单的探测实测是真打了 api.vedastro.org)。
未改 scripts/jyotish_api_server.py(一行都没动)、未改 workflow、未 SSH 生产、未写入任何 key、未升级依赖。Skill 未 bump,无用户可感知行为变化,因此未写 CHANGELOG。
任务状态
| 任务 | 状态 | 说明 |
|---|---|---|
| 6.3 yoga 表达式编译缓存(BUG-735) | 完成 | 每次检测 386 次源码编译 → 第二次起 0;27.4 ms → 3.3 ms |
| 6.1 健康探测 TTL(BUG-734) | 完成 | 6 轮前台请求 6 次探测 → 1 次;对方限流令牌 6 → 1 |
| 6.2 连接复用(BUG-734 的另一半) | 砍(让步顺序第 3 条) | 实测证明任务书设想的做法省不掉任何握手,见下节 |
| 6.4 两条 Bug 历史 | 完成 | BUG-734 / BUG-735 均 resolved |
6.2 为什么砍:共享 opener 在 urllib 上省不掉握手
任务书设想「给 vedastro_rest_bridge 一个模块级共享 opener,摊掉 TLS 握手」,并把验收定为「断言默认路径用的是同一个 opener 实例」。
按这条做出来能通过那条断言,但握手一次都省不掉——那会是一个假修复。实测(本地计数 TCP accept 的服务器,6 次顺序 POST):
| 做法 | 6 次请求 → 实际 TCP 连接数 |
|---|---|
urllib.request.urlopen(今天) |
6 |
模块级共享 urllib.request.build_opener() |
6 |
根因:urllib.request 没有连接池。AbstractHTTPHandler.do_open 每次新建连接,并强制发 Connection: close(实测抓到的请求头即为默认 urllib 头,服务端声明 HTTP/1.1 keep-alive 也无效)。
共享的只是 handler 链,不是连接。
真要摊掉握手,需要一个带连接池的客户端(http.client 手工维护 keep-alive 连接池并处理陈旧连接重试,或引入 requests/urllib3)。前者要改所有业务调用共用的 call(),与本单「零行为变化」的口径不匹配;后者是加依赖,AGENTS §7.7 不允许顺手做。建议单独立单,不要在本单里塞。
残留握手开销:6.1 之后,每轮前台请求不再有探测那一次握手(本机实测单次 TLS 握手 207 ms,与任务书 0.186 s 同量级)。剩下的每一次真实业务请求仍各付一次握手;命中 BUG-727 快照缓存的轮次本来就不发请求,所以稳态下残留的是「快照未命中时的那几次」。
改前 / 改后同口径实测
BUG-734 · 探测次数、握手次数、对方限流令牌
口径:连续 6 轮前台请求。BEFORE = gateway_status(force_refresh=True)(就是改前的代码路径,改前没有缓存、每次必探);AFTER = gateway_status(force_refresh=False),也就是 run_gateway_packet 现在调的那一条。
外网出口用假 urlopen 计数(避免为了做测量去打满对方额度);「TLS 握手 = 出站请求数」这一等式由上一节的连接计数实验证明。
| 探测次数 | TLS 握手 | 消耗对方限流令牌 | |
|---|---|---|---|
| 改前 | 6 | 5 | 6 |
| 改后 | 1 | 1 | 1 |
改前第 6 轮的握手数是 5 而不是 6,因为第 6 次探测自己被本地限流器挡下了。逐轮健康结论:
改前: official_verified ×5, 第 6 轮 official_blocked ← 健康探测把自己饿死了
改后: official_verified ×6
这是本单最值得记的一条:对方免费层是 5 次/分钟,光是「服务还活着吗」这个探测就能在 6 轮之内把额度吃光,然后反过来报告服务不可用,真正的业务调用还要和它抢令牌。它不只是慢,是会造成假的 official_blocked。
单次真实探测的墙钟(真打 api.vedastro.org,消耗 1 个令牌):1,034 ms,status=official_verified。任务书估的是 ~0.5 s,本机实测更贵。这就是每轮省下来的东西。
BUG-735 · 编译次数
口径:同一张公开虚构示例盘连续检测 3 次,统计「源码 → 字节码」的编译次数。
必须把两件事都数上:eval(字符串) 的编译发生在 CPython 内部、不经过 builtins.compile,所以计数 = eval(str) 调用数 + builtins.compile 调用数。
改前:
| 轮次 | eval(str) | eval(code) | compile() | 源码编译合计 | ms | 命中 yoga 数 |
|---|---|---|---|---|---|---|
| 1 | 182 | 0 | 204 | 386 | 27.4 | 70 |
| 2 | 182 | 0 | 204 | 386 | 29.2 | 70 |
| 3 | 182 | 0 | 204 | 386 | 27.3 | 70 |
改后:
| 轮次 | eval(str) | eval(code) | compile() | 源码编译合计 | ms | 命中 yoga 数 |
|---|---|---|---|---|---|---|
| 1 | 0 | 80 | 384 | 384 | 28.1 | 70 |
| 2 | 0 | 80 | 0 | 0 | 3.3 | 70 |
| 3 | 0 | 80 | 0 | 0 | 3.3 | 70 |
3 轮合计 1,158 → 384,且改后的 384 只发生在进程第一次。稳态单次检测 27.4 ms → 3.3 ms(8.3×),命中 yoga 数三轮都是 70,不变。
数字对得上规则表:references/yoga_rules.json 共 198 条 expr、去重 192 条、最长 2,614 字符、其中 103 条是多语句。
冷缓存那一轮的 384 = 192 次 eval 模式编译 + 96 条多语句 ×(ast.parse 1 次 + compile 1 次)。
改后 eval(code) 是 80 而不是 182,因为改前那 102 次「先抛 SyntaxError 再退到 exec」现在由缓存直接返回 None,不再走一遍 eval。
改了什么
scripts/yoga_engine.py(BUG-735)
_eval_custom拆成两段:_build_custom_exec_globals(ctx, rule, bindings)负责造求值命名空间(每次调用都新建,绑定当前这张盘的ctx),_eval_custom只做「造命名空间 → 求值 → 包装成 combination」。- 新增模块级
_compiled_eval_code(expr)/_compiled_exec_code(expr),各带functools.lru_cache(maxsize=512),存的是 code object。_capture_tail_expr从函数内部提到模块级(纯 AST 改写,无闭包)。 - 新增
_run_custom_expr(expr, exec_globals):不带任何缓存装饰器,按改前语义求值。
语义逐条对齐改前:先表达式模式,编译期 SyntaxError 退到「改写末尾表达式后 exec」;两条路的异常仍然全吞、结果为 None;ast.parse 仍对 expr.strip();exec 分支 filename 仍是 <yoga_custom>,eval 分支用 CPython 对字符串 eval 的默认 <string>。
运行期(而非编译期)抛 SyntaxError 的情况也保留了退到 exec 分支的老行为,所以「第一次确定走哪条路」不会在这个边角上和改前分叉。
scripts/vedastro_gateway.py(BUG-734)
- 探测主体改名
_probe_official_rest_health_uncached(),逻辑一字未改。 probe_official_rest_health(*, force_refresh=True)外加 TTL 缓存;gateway_status(*, force_refresh=True)透传。run_gateway_packet改调gateway_status(force_refresh=False)—— 这是每一轮前台请求都走的那条路(professional_reading、rectification_gate、/vedastro/gateway/run都经过它)。- 缓存键 =
(mode, official endpoint, self-host endpoint, network enabled, 是否配了 API key, JYOTISH_SKIP_LOCAL_ENV),配置一变立刻失效。只记 key 配没配的布尔,绝不把 key 的值放进键(AGENTS §8),有断言守着。 - 成功 TTL 60 s、失败 TTL 10 s,具名常量且注释写了依据。探测在锁外执行——不得在等外网时按住进程级锁(BUG-718 的教训)。
与任务书的一处偏离:force_refresh 的默认值
任务书 §6.1 写「gateway_status() 增加 force_refresh 形参(默认 False),诊断端点走 force_refresh=True」。
但红线 §5.5 同时写「不得改 scripts/jyotish_api_server.py,主文件那一行调用保持不变」,而诊断端点 _compute_vedastro_gateway_status 调的正是裸的 gateway_status()。两条放在一起,默认值只能是 True:
- 默认
False→ 诊断端点拿到缓存结果 → 违反决策记录第 1 条「不得让运维看到一个 60 秒前的假象」; - 默认
True→ 所有既有调用方(含诊断端点、既有测试)行为一字不变,只有显式写了force_refresh=False的前台路径吃缓存。
选了后者:默认实时,前台显式选缓存。这样也满足任务书的验收口径(「force_refresh=True 永远打真探测」「既有断言一条不改仍绿」),只是默认值方向相反。测试 test_force_refresh_defaults_to_true_so_the_diagnostic_endpoint_stays_live 用 inspect.signature 把这个默认值钉死,并断言连调两次 gateway_status() 会探测两次。
等价证明(同进程差分,不写跨机 golden)
按 BUG-733 立下的做法:参照实现在测试文件里逐字复刻改前的 _eval_custom 尾部,与生产实现在同一进程、同一批 exec_globals 上对跑。
test_cached_compile_matches_pre_change_behaviour_for_every_rule_expression:规则表全部 192 条去重表达式 × 3 张公开虚构示例盘 = 576 次逐条同值(!=与bool()两个口径都比)。- 反向验证
test_reference_differential_can_actually_fail:把参照侧换成另一条表达式,差分立刻分叉——证明这个断言不是恒真。
红线自查
| 红线 | 怎么守的 |
|---|---|
| §5.1 绝不缓存 yoga 求值结果 | _run_custom_expr 无缓存装饰器(有断言);test_same_expression_on_different_charts_never_reuses_a_result 用同一条 house_of('Sun') 在两张盘上必须得到 10 与 5 两个不同答案,多语句路径同样验;test_cache_stores_code_objects_not_results 断言缓存里是 types.CodeType |
| §5.2 eval 语义不放宽 | exec 不换 eval、eval 不换 exec;异常处理、.strip()、filename 全部保留;576 次同进程差分 |
| §5.3 编译缓存有界 + expr 只来自静态规则表 | maxsize=512(去重 192 条)有断言;test_rule_expressions_come_only_from_the_static_rules_table 断言规则表在仓库内、cond.get("expr" 全仓仅 1 个读取点、生产入口 jyotish_engine.py / jyotish_api_server.py 的 detect_yogas( 调用不传 rules_path。未发现任何用户输入能进入 expr,无需写 BLOCKED.md |
| §5.4 失败不得被长时间缓存 | 失败 10 s < 成功 60 s,有断言;test_failure_is_not_cached_for_the_success_ttl 证明失败 TTL 过后、成功 TTL 之内服务恢复能被看见 |
§5.5 不改 jyotish_api_server.py |
git diff --stat 里没有这个文件 |
| §5.6 不顺手升级依赖 / 修无关 warning | 未动 requirements*;既有 utcfromtimestamp DeprecationWarning 原样留着没碰 |
| AGENTS §8 隐私 | 缓存键只存布尔;测试与文档里的盘全是公开虚构数据;没有任何 key、出生资料进仓 |
既有断言
| 文件 | 原值 | 新值 | 原因 |
|---|---|---|---|
| — | — | — | 一条都没改。既有测试文件零改动,只新增两个文件 |
测试
| 命令 | 结果 |
|---|---|
pytest tests/test_vedastro_gateway.py tests/test_vedastro_runtime_ops.py tests/test_vedastro_snapshot_cache.py tests/test_api_server_security.py tests/test_yoga*.py tests/test_vedastro_health_probe_cache.py(任务书 §8 全部目标) |
187 passed, 1 skipped(skip 为既有 skip,非本单引入) |
pytest tests/test_yoga_expr_compile_cache.py |
9 passed(含 192×3 差分) |
pytest tests/test_vedastro_health_probe_cache.py |
14 passed |
scripts/run_quality_gate.py --profile quick |
1 failed, 791 passed, 1 skipped,退出码 1。唯一红的 tests/test_supabase_user_data_contract.py::test_chat_page_uses_authenticated_cloud_persistence 基线即红,见下节 |
scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45(AGENTS §9,本单涉及外部 oracle) |
全绿:python_runtime_ok / fragment_scan_ok / external_engine_adapters_ok / remote_visibility_status=verified / focused_tests_ok |
新增测试 23 条,测试总数只增不减。
快速门那一条红是基线红,不是本单引入
test_chat_page_uses_authenticated_cloud_persistence 断言 'user_id: user.id' in create_route,而
frontend/src/app/api/sessions/route.ts 早已改成用 chatSessionCreateInsertRow(...) 构造插入行,
源码里没有这个字面量了。是断言过期。
证据(三层,逐层收紧):
- 本单只改了
scripts/yoga_engine.py与scripts/vedastro_gateway.py,git status --short frontend/为空; git diff origin/staging -- frontend/src/app/api/sessions tests/test_supabase_user_data_contract.py为空——测试与被测文件都与基线逐字相同;git worktree add --detach <tmp> origin/staging拉出一个干净检出,在那里单跑这条测试,同样失败。
已记入 BLOCKED.md 的 BLK-003。按 AGENTS §7.7 不顺手修:改这条断言要先确认「插入行里 user 归属现在由谁保证」,属于前端归属,另立单,不得把断言删了变绿。
注意一个记录口径:第一次跑快速门时我用了 ... | tail -40,拿到的退出码是 tail 的 0 而不是门禁的,一度误判为全绿。
第二次改用 EXIT=$? 直接取门禁退出码才看到 1。门禁退出码不要隔着管道取。
环境缺口
- 快速门基线红:
BLOCKED.md的 BLK-003。本机--profile quick退出码 1,唯一红的那条在干净的origin/staging检出上同样红。 - 无 Docker:数据库与部署套件跑不了。本单不动表、不动迁移、不动部署,不受影响。
- staging 实测:本机测的是进程级缓存行为与本机墙钟。
/api/vedastro_gateway/status在真实容器里是否仍然实时、前台每轮是否真的只剩 1 次探测,要等部署后看。本单没有把这一条写成通过。 - 真实探测只打了 1 次(1,034 ms 那次),没有为了凑数据去打满对方 5 次/分钟的额度。