From 4f643aa0950106611a2e010b3c8f5f366e41d316 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 16 Sep 2026 00:55:05 +0000 Subject: [PATCH] =?UTF-8?q?docs(tasks):=20=E7=BC=93=E5=AD=98=E7=94=9F?= =?UTF-8?q?=E6=95=88=E5=90=8E=E5=89=A9=E4=B8=8B=E7=9A=84=E4=B8=A4=E5=9D=97?= =?UTF-8?q?=E2=80=94=E2=80=94=E5=A4=96=E7=BD=91=E5=81=A5=E5=BA=B7=E6=8E=A2?= =?UTF-8?q?=E6=B5=8B=E4=B8=8E=E8=BF=90=E8=A1=8C=E6=97=B6=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E7=BC=96=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 f8e607c2 上重测:BUG-727 的快照缓存确实每轮命中、响应体 52 万→40 万 字符,但耗时几乎没变。6 次调用累计 5.30 s,网络仍占 70%(poll 2.42 + ssl read 0.76 + 握手 0.56),本地 swisseph 只剩 2.4%。 - BUG-734:剩下那一次外网是 gateway_status 的健康探测。它不是 ping—— probe_calculate_health 把一份虚构排盘 POST 给 api.vedastro.org,并且 consume_rate_token() 会烧掉对方一个业务限流令牌;opener 默认 None, 每次新建 TLS(6 次调用 3 次握手,单次 0.186 s);结果零 TTL。 修法:成功 60 s / 失败 10 s 的 TTL,配置变即失效,诊断端点走 force_refresh 保持实时;共享 opener 摊掉握手。 - BUG-735:yoga_engine._eval_custom 对 references/yoga_rules.json 的 192 条静态表达式每请求重新编译 102 次,多语句的还要先抛一次 SyntaxError 再 parse + 改写 AST + compile,占 9.4%。 修法:lru_cache 存 (mode, code object)。硬红线:绝不缓存求值结果, 那会跨用户串盘。 等价证明沿用 BUG-733 的同进程差分,不再写跨机 golden。 纯文档推送,不触发门禁、不发布镜像、不部署。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45 --- docs/tasks/README.md | 1 + ...consultation-residual-hotspots-20260916.md | 199 ++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 docs/tasks/TASK-consultation-residual-hotspots-20260916.md diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 8629145d..e7eb705b 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -244,6 +244,7 @@ | `TASK-freeze-metric-change-20260915.md` | — | **规则单(后面两单的前置,无 BUG 号)**:两条增长冻结余量都用完(`page.tsx` 1,951/1,951 余 **0**;`jyotish_api_server.py` 11,334/11,363 余 **29**),冻结从「逼新代码往外走」退化成「拦路」。实证:`page.tsx` 行数砍 59% 但 `Home()` 的 `useState` 从 56 涨到 **66**(拆的是代码不是状态);api server **225 个类方法只有 12 处真碰 HTTP 上下文**,4 处 `__new__` 伪造空壳就是这么来的。**产品拍板换口径**:主门改成「`Home()` 的 useState/useRef 不得增长」与「类方法数 + `__new__` 计数不得增长」,行数降级为粗护栏;**同时推翻 §6「参数式 hook 内部保持 0 个 React hook」**(那正是状态搬不走的原因)。改 `AGENTS.md` §6 + 两个合同测试,不碰业务代码 | 待领取 | — | | `TASK-home-state-lowering-20260915.md` | — | **page.tsx 状态下沉第一簇(串行在 freeze-metric-change + C2 + R3 之后)**:66 个 state 里 `rectification*` 占 **15** 个,而它们服务的 `` 本来就是 `dynamic()` 懒加载子树、挂着 24 个 props;`useRectificationSurface` 要解构约 56 个参数。把这簇搬进子树,`Home()` 的 useState 从 66 降到 ≤ 53。**零行为变化**;第一步必须先把 15 个逐个分类(只服务子树 / 外壳也要读)。产品否决了 Context Provider 与外部 store 两条路。不占 BUG 号 | 待领取 | — | | `TASK-rectification-engine-memoization-fix-20260915.md` | — | **验收修复单(只改测试,一行实现不许动)**:BUG-721 的实现**等价性成立**(我在改前 `6b3248bf` / 改后 `e4788dfc` 同机跑同一 payload,`candidate_scores` 逐字相同),9 条计数断言全过;但等价 golden 在本机复现不出来——4 处浮点尾数差(score 1.0e-4 ×2、`margin_percent` 1.1e-3 ×2)。**复发自 BUG-712**(「不得对全精度浮点做整体 `==`」,那一单只落在 ephemeris 一处)。而 `tests/test_rectification_*.py` 在 `CORE_PYTEST_TARGETS` 里,**staging 门禁靠机器舍入碰巧一致才是绿的**。修法:主证据换成**同进程差分**(把 static context 的四个缓存键置 `None` 即可回退旧路径,A/B 严格相等),golden 降为离散字段严格相等 + 浮点带容差(容差按实测 1.1e-3 推);**禁止重建 golden 来「修」**。另含六份 golden 的仓库级排查。BUG-733 | 待领取 | — | +| `TASK-consultation-residual-hotspots-20260916.md` | — | **性能单(与拆解单文件不重叠,可并行)**:BUG-727 的快照缓存已验收生效(逐轮 trace 确认每轮 hit、响应体 52 万→40 万字符),但耗时没降——重测 6 次调用 5.30 s 里**网络仍占 70 %**(poll 2.42 + ssl read 0.76 + 握手 0.56),本地占星计算只剩 **2.4 %**。剩下那一次外网是 `gateway_status → probe_official_rest_health → probe_calculate_health`:**它不是 ping,是把一份虚构排盘 POST 给 `api.vedastro.org`**,而且 `consume_rate_token()` 会**烧掉对方一个业务限流令牌**,还不复用连接(6 次调用 3 次 TLS 握手,单次 0.186 s),结果零 TTL(BUG-734)。另 `yoga_engine._eval_custom` 对静态规则表的 192 条表达式**每请求重新编译 102 次**(多语句的还要先抛一次 SyntaxError 再 parse+改写 AST+compile),占 9.4 %(BUG-735)。修法:探测加 TTL(成功 60 s / 失败 10 s,配置变即失效,**诊断端点必须 force_refresh 保持实时**)+ 共享 opener;表达式缓存 code object。**硬红线:绝不缓存 yoga 求值结果**(会跨用户串盘)。等价证明用 BUG-733 的同进程差分,不再写跨机 golden。BUG 段 734–735 | 待领取 | — | ## 命名与归档 diff --git a/docs/tasks/TASK-consultation-residual-hotspots-20260916.md b/docs/tasks/TASK-consultation-residual-hotspots-20260916.md new file mode 100644 index 00000000..e59bc496 --- /dev/null +++ b/docs/tasks/TASK-consultation-residual-hotspots-20260916.md @@ -0,0 +1,199 @@ +# TASK · 缓存生效后剩下的两块:外网健康探测与运行时重复编译 + +- 日期:2026-09-16 +- 基线 commit:`origin/staging` @ `f8e607c2` +- 执行分支:`codex/consultation-residual-hotspots-20260916` +- 落点:`scripts/vedastro_gateway.py`、`scripts/vedastro_rest_bridge.py`、`scripts/yoga_engine.py`(以及各自的新增测试) +- 与 API server 拆解单**文件不重叠**(本单一行都不碰 `scripts/jyotish_api_server.py`),可并行 +- 规模:一处探测缓存 + 一处连接复用 + 一处编译缓存。**零行为变化、零输出变化。** + +--- + +## 1. 为什么现在做:上一轮的缓存生效了,瓶颈换了位置 + +`TASK-consultation-external-evidence-cache-20260915`(BUG-727/728)已合入并由我验收通过。我在 `f8e607c2` 上重测,**快照缓存每一轮都命中**(用 trace 逐轮确认),响应体也从 52 万字符降到 40 万。但耗时几乎没变: + +| | 改前 | 改后(快照缓存已命中) | +| --- | ---: | ---: | +| career | 928 ms | 1,007 / 918 ms | +| wealth | 493 ms | 951 / 497 ms | +| general | 491 ms | 492 ms | + +连续 6 次调用累计 5.30 秒,按 `tottime` 排: + +| | 耗时 | 占比 | +| --- | ---: | ---: | +| `select.poll`(等外网 socket) | 2.422 s | 46 % | +| `_ssl._SSLSocket.read` | 0.762 s | 14 % | +| `_ssl._SSLSocket.do_handshake` | 0.556 s | 10 % | +| **网络合计** | **3.740 s** | **70 %** | +| 运行时 AST 编译(`compile` + `ast._fix` + `iter_child_nodes` + `iter_fields`) | 0.497 s | 9.4 % | +| `swisseph.calc_ut`(真正的占星计算) | 0.129 s | **2.4 %** | + +逐轮 trace 的结果最能说明问题: + +``` +第 1 轮: 1109 ms 快照缓存=hit 外网 urlopen=1 次 +第 2 轮: 992 ms 快照缓存=hit 外网 urlopen=1 次 +第 3 轮: 1018 ms 快照缓存=hit 外网 urlopen=1 次 +第 4 轮: 529 ms 快照缓存=hit 外网 urlopen=0 次 +``` + +**快照命中了,但每轮还是多打一次外网;那一次值 ~0.5 秒。** + +## 2. 事故实证 + +### 2.1 BUG-734 · 每轮拿一次「服务还活着吗」,代价是一次完整业务请求 + +调用链(符号定位): + +``` +vedastro_gateway.run_gateway_packet + └─ vedastro_gateway.gateway_status() + └─ probe_official_rest_health() + └─ vedastro_rest_bridge.probe_calculate_health() + └─ call("HoroscopePredictions", 虚构 smoke payload) + └─ urllib.request.urlopen(...) +``` + +三件事叠在一起: + +1. **这不是 ping,是一次完整的业务调用。** `probe_calculate_health` 的 docstring 写着「POST a fictional smoke horoscope and report whether Status is Pass」——它把一份虚构的排盘请求发给 `api.vedastro.org/api/HoroscopePredictions`,用返回的 Status 当健康信号。 +2. **它会消耗对方的限流额度。** `call()` 开头 `consume_rate_token()`(除非 `skip_rate_limit=True`,而探测没传)。也就是说:**每一轮普通聊天都在拿一个外部限流令牌去做健康检查**,真正的业务调用反而要和它抢。 +3. **没有连接复用。** `call()` 的 `opener` 默认 `None` → 走 `urllib.request.urlopen(req)`,每次新建 TCP + TLS。实测 6 次调用握手 3 次,单次握手 **0.186 s**。 + +而 `probe_official_rest_health()` 的结果**没有任何 TTL**:它检查的是「这个外部服务此刻可用吗」,这种状态不会每 500 毫秒变一次。 + +### 2.2 BUG-735 · 每个请求把同一批规则表达式重新编译 102 次 + +`scripts/yoga_engine.py` 的 `_eval_custom`(`cond.get("expr")`)对每条规则做: + +```python +result = eval(expr, exec_globals, exec_globals) # expr 是源码字符串 → 每次调用都重新编译 +except SyntaxError: + tree = ast.parse(expr.strip(), mode="exec") # 多语句表达式走这条 + tree.body = _capture_tail_expr(tree.body) + exec(compile(tree, "", "exec"), ...) +``` + +cProfile 的调用方视图(单请求): + +| 被调用 | 次数 | 调用方 | +| --- | ---: | --- | +| `builtins.compile` | 102 | `ast.py:30(parse)` | +| `builtins.compile` | 102 | `yoga_engine.py:1235(_eval_custom)` | + +`ast.parse` 也被调了 102 次,说明**相当一部分表达式是多语句的**——它们每次都要先抛一次 `SyntaxError`、被捕获、再 parse、再改写 AST、再 compile。每个请求重来一遍。 + +表达式的来源是仓库里的静态规则表 `references/yoga_rules.json`:**198 条 `expr`,去重后 192 条,最长 2,614 字符**。它们在进程生命周期内不会变。 + +## 3. 根因 + +两处同一个形状:**把「只依赖静态输入的昂贵结果」放在了每请求都走的路径上**。 + +- 外部服务的可用性只依赖配置与对方状态,却每轮重新探测一次,还顺手烧掉一个限流令牌; +- 规则表达式的字节码只依赖表达式文本,却每轮重新编译 102 次。 + +上一轮的引擎记忆化(BUG-721)修的是同一类问题的第三个实例(候选分钟不变量被放在事件循环里)。 + +## 4. 决策记录 + +产品 2026-09-16 授权本单: + +1. **健康探测要缓存,但诊断端点必须仍然是实时的。** `gateway_status()` 现在有两类调用方——`jyotish_api_server._compute_vedastro_gateway_status`(诊断端点,人在看「现在到底通不通」)和 `run_gateway_packet`(每轮聊天)。**缓存只能加在聊天这条路上,或者给诊断端点一个强制刷新的入口**;不得让运维看到一个 60 秒前的假象。 +2. **缓存的是「探测结果」,不是「业务答案」。** 本单不碰快照缓存(BUG-727 已做),也不改任何业务数据的新鲜度口径。 +3. **yoga 表达式只缓存编译产物,不缓存求值结果。** 求值依赖每次不同的盘上下文(`exec_globals`),**绝不能缓存 `result`**——那会让所有人拿到同一张盘的 yoga 判定。 +4. **零输出变化。** 两处改完,同一份请求的响应必须与改前逐字相同(计时字段除外)。 + +## 5. 硬红线 + +1. **不得缓存 yoga 的求值结果,只缓存 code object。** 这条如果做错,后果是跨用户串盘,比性能问题严重得多。 +2. **`eval` 的语义不得放宽**:今天的行为是「先按表达式求值,`SyntaxError` 时退到改写过的多语句 exec」。缓存版本必须在**第一次**确定该表达式走哪条路,之后固定走那条,结果与改前逐条相同。不得把 `exec` 换成 `eval` 或反之。 +3. **编译缓存必须有界**,并且要在测试里断言 `expr` 只来自仓库内的静态规则表、不含任何用户输入派生的字符串。若发现有用户输入能进入 `expr`,**立刻停手**写进 `BLOCKED.md`——那是一个远比性能严重的问题。 +4. **失败不得被长时间缓存。** 探测失败(服务挂了、限流、超时)的 TTL 必须显著短于成功的 TTL,否则对方恢复了我们还要再瞎等一分钟。 +5. **不得改 `scripts/jyotish_api_server.py`**(AGENTS §6,且它是拆解单的独占文件)。诊断端点若需要强制刷新,用参数从 `vedastro_gateway` 侧暴露,主文件那一行调用保持不变。 +6. 不得顺手升级依赖、不得顺手修不在本单里的 warning。 + +## 6. 任务分解 + +### 6.1 健康探测加 TTL(BUG-734 主体) + +在 `vedastro_gateway` 里给 `probe_official_rest_health()` 的结果加进程级缓存: + +- 键:影响探测结论的**有效配置**(endpoint、network enabled、是否配了 `VEDASTRO_API_KEY`、mode)。配置一变立刻失效。 +- 成功 TTL 建议 **60 秒**,失败 TTL 建议 **10 秒**(数值写成具名常量并在注释里说明依据)。 +- `gateway_status()` 增加一个显式的 `force_refresh` 形参(默认 `False`)。诊断端点走 `force_refresh=True`。 + +- 验收:新增 pytest——同一配置下连调 3 次,底层 `probe_calculate_health` 只被调 **1** 次(monkeypatch 计数);把时钟推过 TTL 后再调,计数变 2。 +- 验收:探测失败后,在成功 TTL 之内、失败 TTL 之外再调一次,必须重新探测。 +- 验收:改变配置(例如注入 `VEDASTRO_API_KEY`)必须让缓存失效。 +- 验收:`force_refresh=True` 永远打真探测;`tests/test_vedastro_gateway.py`、`tests/test_vedastro_runtime_ops.py` 既有断言一条不改仍绿。 + +### 6.2 连接复用(BUG-734 的另一半) + +`call()` 已经接受 `opener` 形参,但默认 `None` 每次新建连接。给 `vedastro_rest_bridge` 一个模块级共享 opener(线程安全),让探测与业务调用共用,摊掉 TLS 握手。 + +- 验收:新增测试断言默认路径使用的是同一个 opener 实例(不是每次新建)。 +- 验收:`consume_rate_token()` 的调用次数不变——**复用连接不等于绕过限流**。 +- 验收:进度记录里贴出改前/改后的握手次数实测(改前:6 次调用 3 次握手)。 + +### 6.3 yoga 表达式编译缓存(BUG-735) + +抽一个 `_compiled_expr(expr: str)` 帮助函数,用 `functools.lru_cache`(`maxsize` 取 ≥ 256,规则表去重后 192 条)返回 `(mode, code_object)`:第一次按现有逻辑决定这条表达式走 `eval` 还是走「改写 AST 后 exec」,之后直接用缓存的 code object。`exec_globals` 仍然每次新建,**只有 code object 被复用**。 + +- 验收:新增 pytest 计数断言——同一个盘跑两次 yoga 判定,`builtins.compile` 的调用次数第二次为 **0**(或至少不随请求数线性增长)。 +- 验收:**等价断言**——对 `references/yoga_rules.json` 里全部 192 条去重表达式,改前/改后在同一张公开示例盘上的判定结果逐条相同(用同进程差分,参照 `tests/test_rectification_engine_memoization.py` 里 BUG-733 建立的做法,不要再写跨机 golden)。 +- 验收:源码合同断言 `expr` 的来源只有规则表,且缓存里存的是 code object 不是求值结果。 + +### 6.4 两条 Bug 历史 + +同一变更内写进 `docs/BUG_HISTORY.md`: + +- **BUG-734**:每轮聊天用一次完整业务请求做健康探测、消耗对方限流令牌、且不复用连接。**关联 BUG-727**(快照缓存那一单——它修的是快照,没覆盖探测)、**BUG-161 / BUG-301**(前台外部证据的两条既有红线)。防复发:**前台路径上的外部服务探测必须有 TTL,且不得消耗业务限流额度;探测与业务调用必须共用连接。** +- **BUG-735**:静态规则表达式每请求重新编译 102 次。**关联 BUG-721**(同一形状的第三个实例:只依赖静态输入的昂贵结果被放在每请求路径上)。防复发:**`eval` / `exec` 的入参若来自静态规则表,必须缓存编译产物;缓存 code object,永远不缓存求值结果。** + +## 7. 让步顺序 + +1. 6.3(yoga 编译缓存)最先做——最独立、风险最低、9.4 % 立刻到手。 +2. 6.1(探测 TTL)次之,是 70 % 里最大的一块。 +3. 6.2(连接复用)可以砍到下一轮,砍了在进度记录里写明还剩多少握手开销。 +4. 6.4 不得砍。 + +## 8. 开工前置命令 + +```bash +git fetch origin --prune +git worktree add -b codex/consultation-residual-hotspots-20260916 \ + .worktrees/consultation-residual-hotspots-20260916 origin/staging +cd .worktrees/consultation-residual-hotspots-20260916 +git status -sb | head -1 +python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45 # AGENTS §9:涉及外部 oracle +``` + +开工前必读:`docs/research/pre_work_error_ledger.md`(AGENTS §9);`docs/BUG_HISTORY.md` 的 **BUG-161 / BUG-301 / BUG-727**(前台外部证据的三条既有边界)与 **BUG-733**(同进程差分怎么写)。 + +验收命令: + +```bash +.venv/bin/python -m 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_engine*.py +.venv/bin/python scripts/run_quality_gate.py --profile quick +``` + +## 9. 预期收益(本机实测基线) + +改前(快照缓存已生效的稳态):6 次调用 5.30 s,网络 70 %、运行时编译 9.4 %、本地占星计算 2.4 %。 + +本单做完后,同一组 6 次调用里应当只剩 **1 次**真实探测(第一次)而不是 3–6 次,编译次数应当只剩 **192 次**(进程首次)而不是每请求 204 次。**进度记录必须贴出改前/改后的同口径实测**,不得只写「变快了」。 + +## 10. BUG 编号起点 + +基线 `f8e607c2` 上 `docs/BUG_HISTORY.md` 最大号为 **BUG-733**。本单预占 **BUG-734 / 735**。开工时核对当时实际最大号。 + +## 11. 不在本单范围 + +- 快照缓存本身(BUG-727 已做并验收) +- `scripts/jyotish_api_server.py` 的任何改动(拆解单独占) +- 三域上限 3 → N 的放宽(仍待 staging 实测) +- yoga 规则内容、判定口径、置信度边界