Files
Jyotisha/docs/tasks/PROGRESS-consultation-residual-hotspots-20260916.md
T
Jesse_ChenandClaude Opus 5 63c419f375
Independent Staging Quality Gate / validate (push) Canceled after 27s
Independent Staging Quality Gate / publish (push) Canceled after 0s
perf(consultation): 健康探测加 TTL、yoga 表达式只编译一次
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
2026-09-16 02:01:33 +00:00

175 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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(...)` 构造插入行,
源码里没有这个字面量了。是断言过期。
证据(三层,逐层收紧):
1. 本单只改了 `scripts/yoga_engine.py` 与 `scripts/vedastro_gateway.py`,`git status --short frontend/` 为空;
2. `git diff origin/staging -- frontend/src/app/api/sessions tests/test_supabase_user_data_contract.py` 为空——测试与被测文件都与基线逐字相同;
3. `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 次/分钟的额度。