Skip to content

[PR] Roll back the incremental canonical when the upstream forward fails | fix(lane): forward 失败后回滚增量 canonical,客户端重试同一 delta 不再被迫全量 resync - #4881

Open
ranxianglei wants to merge 3 commits into
masterfrom
2026-10-27_incremental-forward-rollback
Open

ranxianglei wants to merge 3 commits into
masterfrom
2026-10-27_incremental-forward-rollback

Conversation

@ranxianglei

Copy link
Copy Markdown
Owner

Closes #4867

问题

增量回合并入服务端 canonical(canonical 前进)之后,转发上游失败(抛异常,或完成但非 2xx)时 canonical 不回滚:客户端拿不到 ack 头(#3344 只在 2xx 打),下一回合用同一 delta 重试 → sinceSeq !== serverSeq → 409 seq-mismatch → 被迫全量 resync。每次上游 5xx/断流都白付一次整段历史重发。

修法(src/incremental-lane.ts)

  • applyTurn 之前对回合前状态做一轮快照:msgs 保留旧数组引用(applyTurn 赋的是新数组)、marks 克隆(applyTurn 原地改 Map)、外加 system/boundary 标量;
  • 异常路径:forward 包 try/catch——仅当「ack 不可能已到达客户端」(2xx 头未 flush)才回滚再抛出;若 2xx 头已发给客户端,ack 已生效,回滚反而造成反向漂移(客户端已推进、服务端没有);
  • 非 2xx 完成路径:按最终状态码判定(writeHead wrapper 里捕获),非 2xx 即回滚;其后既有 wrap-up 的指针登记自然落到旧分支行上,温缓存与冷重建保持一致;
  • resync 回合同样适用:快照恢复是 issue 所说「截断」的一般化(整体恢复被替换的前缀)。被拒分支残留在 append-only 表中的行由既有 409→resync 路径兜底(issue 划定的范围)。

边界说明:2xx 头已发出后流中断的场景,ack 视为已送达、不回滚——该场景本就不产生 409 循环,不在本次范围。

行为变更披露(旧值 → 新值)

  • 旧行为:forward 失败后,客户端重试同一 sinceSeq → 409 seq-mismatch → 全量 resync;
  • 新行为:同一 delta 直接重试成功(canonical 回到回合前长度)。成功路径行为完全不变;
  • 新增一条 warn 日志(回滚事件,含 conversationId 与 from/to 长度)。无新配置项、无 wire 协议变化、无缓存格式变化。

验证

  • 新增回归测试 tests/issue4867-incremental-forward-failure-rollback.test.ts(4 例:seed/delta/resync 失败后重试 + direct-call forward 抛异常):打补丁前全部红(精确复现 409 / canonical 停在推进后长度),打补丁后全部绿;
  • 既有增量车道相关测试 58/58 绿(incremental-lane / ack-pull / client-recovery / issue3304 / issue3343 / issue3433 / omp-native-incremental / opencode-incremental);
  • typecheck 干净;全量套件判定以 CI 为准。

@ranxianglei ranxianglei changed the title fix(lane): forward 失败后回滚增量 canonical,客户端重试同一 delta 不再被迫全量 resync | Roll back the incremental canonical when the upstream forward fails [PR] Roll back the incremental canonical when the upstream forward fails | fix(lane): forward 失败后回滚增量 canonical,客户端重试同一 delta 不再被迫全量 resync Oct 26, 2026
@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

📦 Built Package Artifact

Branch: 2026-10-27_incremental-forward-rollback (6787c75)

Latest build — install this exact version

npm install -g billion-context@0.0.9176-pr.4881.f50444b

This exact version is immutable and reproducible. The rolling pr tag tracks the newest test build across all PRs.

Recent builds (commit → version)

commit version build
6787c75 billion-context@0.0.9176-pr.4881.f50444b build
cec85bb billion-context@0.0.7196-pr.4881.681f1cf build
f679c7d billion-context@0.0.7117-pr.4881.223b391 build
d553129 billion-context@0.0.7112-pr.4881.415b233 build
a102b74 billion-context@0.0.6839-pr.4881.ed62c08 build
1421a8a billion-context@0.0.6368-pr.4881.bbea934 build
d2ca83c billion-context@0.0.6348-pr.4881.197f0bb build
3b8a7cd billion-context@0.0.6329-pr.4881.298f42a build
66ff3e0 billion-context@0.0.6323-pr.4881.b9960eb build
226c25c billion-context@0.0.6317-pr.4881.6a787c5 build

Download artifact

  1. Download the artifact from the Actions run
  2. Extract the tarball and install:
tar xzf billion-context-pr4881.tgz
npm install -g package

Install instructions are refreshed on each push.

@ranxianglei

Copy link
Copy Markdown
Owner Author

(ai: vllm-qwen/qwen3.8-27b) Merge-guard sync: origin/master (23158db) merged into this branch, pushed as 66ff3e0 — clean merge, zero conflicts (master touched no file this PR changes); PR now mergeable=true, CI re-running on the new head (all guard jobs green so far, test lanes in progress).

@ranxianglei

Copy link
Copy Markdown
Owner Author

(ai: vllm-qwen/qwen3.8-27b) Behind-alert resolved: no new master commits since this morning's sync (master tip still 23158db, branch head 66ff3e0 already contains it — mergeable=true, 0 behind); the repeated alerts came from a stale local mirror ref of this branch, now fast-forwarded to 66ff3e0 to match GitHub.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

(ai: vllm-qwen/qwen3.8-27b) Merge-guard task closed out: (1) synced origin/master into this branch and pushed 66ff3e0 — clean merge, zero conflicts; (2) the repeated "behind" alerts turned out to be a stale local mirror ref of this branch (frozen at pre-sync 226c25c), which I fast-forwarded to 66ff3e0 after verifying its attached worktree was clean and idle. Final state: head 66ff3e0 contains all of master tip 23158db, mergeable=true, 0 behind, CI green on all guard jobs with test lanes completing. Nothing pending from me — ready for human merge once CI is fully green.

ranxianglei pushed a commit that referenced this pull request Oct 26, 2026
…already flushed)

Review supplement for #4881: the rollback gate in handleIncrementalTurn only fires while the ack cannot have reached the client, but the new suite only asserted the roll-back side. Add the negative-direction case: a forward that throws AFTER the 2xx head (carrying x-bili-applied-seq) was flushed must NOT roll back — the client already advanced its pointer, and a rollback would be reverse drift whose next delta 409s.
@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

(ai: vllm-qwen/qwen3.8-27b)

查重: 不重复 —— 检索 open+closed 的 issue/PR(canonical rollback / forward failure / 409 resync 家族),无同机制条目;本 PR 即 #4867 的唯一修复。

标签: bug + dsh/omp/opencode/pi(全部插件增量车道)+ scope/wiring(请求管道)+ p2(可用但每次上游抖动白付一次全量重发)。

独立审核结论

我没有照抄 issue 侧分析,重新读了 diff 与相关代码路径,并本地定向验证。

快照完整性(修法核心):对照 applyTurn 的实际写操作逐一核对——entry.msgs 是赋新数组([...base, ...deltaMsgs],旧引用有效)、entry.marks 是原地 set(克隆必需且已做)、boundary/system/systemSet 是标量重赋值——五个可写字段全覆盖,无遗漏。system 更新发生在 applyTurn 之后、快照窗口之内,回滚时一并恢复,正确。

门禁条件 vs 客户端契约:dsh-native.ts onResponse 确认客户端只在「200 + x-bili-applied-seq」时推进指针;200 无头被当作非标记响应(不推进),其他状态码保持原状态。因此「2xx 头未 flush 才回滚」的条件恰好等于「ack 不可能已到达客户端」——包括隐式头边界(headersSent 但 headStatus undefined,未打头→客户端不推进→回滚正确)。异常路径若 2xx 头已发出则不回滚,避免反向漂移,方向正确。

wrap-up 一致性:非 2xx 完成路径回滚后,recordCanonicalPointers(sessionId, entry.msgs) 用的是回滚后的数组,指针落到旧分支行,温缓存与冷重建一致。forward 前的持久化顺序(recordResyncBaseSeq/persistLaneState)先于本 PR 存在;重启窗口内的短暂漂移分析为无害(marks 多余条目无主、system 下轮无条件覆盖、失败的 resync 重试本身是整体替换,自愈)。

回归清点(逐项):成功路径行为不变;异常路径中 2xx 头已发出的场景前后都不回滚(不变);ack 头契约(#3344)不变;turn lock/pendingPtrWork 生命周期不变(finally 在异常路径完整释放);probe 端点在失败后报告回合前值(更诚实,且 adoption 另有 boundary hash 门,不会被污染)。未发现回归。

本地验证(定向):新测试文件 4/4 绿;既有 8 个增量车道套件(incremental-lane / ack-pull / client-recovery / issue3304 / issue3343 / issue3433 / omp-native-incremental / opencode-incremental)58/58 绿;typecheck 干净。上一 head(66ff3e0,含 master 合入)CI 全绿:test 全部分片(ubuntu/windows × node22/24)、static-gates、kernel-guard/lane-guard/config-surface-guard/version-guard/docs-sync/lock-sync/build-artifact 均 success。

Review 发现一处小问题 → 已直接修掉

回滚门禁只测了正向(seed/delta/resync 失败→回滚→重试落地;抛异常→回滚),负向没有钉住:「2xx 头已 flush 后抛异常 → 不回滚」这条分支无任何测试,违反仓库 §3 Gate Test Discipline(#3617:每个 gate 必须双向断言)。已在 PR 分支补上该用例(forward 在 writeHead(200) 后抛错 → probe 断言 canonical 保持推进长度 → 已采纳 ack 的客户端从 seq 2 继续无需 resync),独立标注 commit 3b8a7cd,本地 5/5 + typecheck 干净,新 head CI 排队中。

结构化八问

  1. 是什么问题:增量回合并入服务端 canonical 后,若转发上游失败,canonical 不回滚而客户端没拿到 ack,重试同一 delta 撞 409 seq-mismatch,被迫整段历史重发一次。
  2. 什么原因导致的:车道设计是「先入账再转发」(canonical 必须先前进,forward 才能发组装后的历史),但失败路径缺补偿——advance 不可逆 + ack 头只在 2xx 打,形成「服务端已推进、客户端未推进」的楔子。
  3. 影响范围与严重程度:全部插件增量车道(dsh/omp/opencode/pi);触发条件 = 回合进行中上游 5xx/断流;严重度为成本/体验(每次抖动多付一次全量重发 + 一次 409 往返),无数据丢失、无可用性影响 → P2。
  4. 怎么修复的:applyTurn 前对回合前状态做完整快照(msgs 旧引用 + marks 克隆 + 标量);失败路径按「ack 是否可能已到达客户端」补偿——异常路径仅 2xx 头未 flush 时回滚,非 2xx 完成路径按最终状态码回滚;wrap-up 指针登记自然落到旧行,温缓存与冷重建保持一致。
  5. 是否有回归问题:无。已审既有行为:成功路径、2xx 头已发出的异常路径、ack 头契约、turn lock 生命周期、probe 语义、指针/marks 持久化——逐项结论见上。
  6. 是否破坏缓存:否。回滚字节与客户端本地历史一致,下一轮出站前缀字节稳定;无去重键/游标/缓存格式变化。
  7. 是否改了设计方向:否。「先入账后转发」保留,回滚是同层补偿而非流程重排。
  8. 是否引入了新的配置:无。新增一条 warn 日志(回滚事件,含 conversationId 与 from/to 长度)。

判定:方向正确、实现完整、回归面受控、双向门禁现已齐备;唯一发现(负向测试缺失)已在 PR 分支修掉并推送。等 3b8a7cd 的 CI 转绿即可合并。

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

(ai: vllm-qwen/qwen3.8-27b)

CI 确认: 新 head 3b8a7cd(补负向门禁测试的 commit)的 CI 已全部通过——完整翻页核对全部 check runs(129 条、42 个不同检查项,按检查名取最新一次):28 success + 14 skipped,零红零挂起。期间出现的 cancelled 条目是同一 push 触发重复 run 被并发策略取消的旧副本,每个检查名的最新一次均为绿。PR mergeable=true,merge_state 显示可能略有滞后,以检查实际状态为准。

可以合并了。

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 (ai: vllm-qwen/qwen3.8-27b) Final merge-guard state: after the review lane's follow-up push, branch head is now 3b8a7cd (adds the missing negative-direction rollback-gate test) — verified locally it still fully contains master tip 23158db (0 behind, 3 ahead), so no further sync is needed from me. My work on this PR is complete: master sync merged+pushed (66ff3e0), stale local mirror ref converged, outcome comments posted. Handing off to the review lane + human merge once CI on 3b8a7cd is green. No further action expected from me on this PR.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 (ai: vllm-qwen/qwen3.8-27b) Merge-guard sync #2: master had genuinely moved since the morning sync (23158db → 0cf53c8, +62 commits across the dsh/WS/anchor/catalog lanes) and none of them touch this PR's two files, so origin/master merged into the branch cleanly with zero conflicts — pushed as d2ca83c (now contains all of master: 0 behind, 4 ahead); PR mergeable=true, CI re-running on the new head. Also re-converged the stale local mirror ref of this branch (it had lagged at 66ff3e0 after yesterday's external push) so the merge guard's local view matches GitHub again.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 (ai: vllm-qwen/qwen3.8-27b) Behind-alert #4 was genuine — synced: master had moved 0cf53c8 → e4cc685 (+5: hermes ACP session binding #4856/#4855 + docs/goldens, zero overlap with this PR's files); merged cleanly into the branch and pushed as 1421a8a (zero conflicts). Verified via API: head 1421a8a on base e4cc685, mergeable=true, checks green so far (20 success / gated lanes skipped / 2 queued); local mirror ref re-converged in step.

ranxianglei pushed a commit that referenced this pull request Oct 30, 2026
…back snapshot

Merging master brought #5155's per-turn render cache into Canonical
(renderPrefix + marksVersion). applyTurn bumps marksVersion and nulls
renderPrefix on resync, and laneItemsForTurn refreshes the prefix over the
NEW history before the forward lands. The #4867 pre-turn snapshot predates
those fields, so a rolled-back turn left a prefix cache rendered from the
rejected branch: if its len/marksVersion gates happened to match a later
turn's cut, laneItemsForTurn would splice rejected-branch bytes into an
accepted body. Snapshot and restore both fields.
@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

(ai: vllm-qwen/qwen3.8-27b)

Both new "conflict" triggers verified stale — no action taken: head f679c7de2 already fully contains live master (44067a5bc, test-merge = "Already up to date", zero conflicts); current state is mergeable with no conflict, blocked only by pending CI (3 pending / 0 failed). Superseding my previous outcome comment above.

ranxianglei pushed a commit that referenced this pull request Oct 30, 2026
…cold-tail storage-mode pin

Clears the inherited #5140 cold-restored-parent raw-tail CI red on this branch.
Net delta vs f679c7d is exactly tests/plugin-fork.test.ts (+9/-1): pin
BILI_STORAGE_UNIFIED="full" for the raw-tail proof (the legacy runner baseline has
no unified rows so forkSnapshot fails closed with 409 SNAPSHOT_UNAVAILABLE) and restore
the ambient value in finally. Docs/config registrations from ci-fixes were already
present via master@44067a5bc. No source changes.
@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 (ai: vllm-qwen/qwen3.8-27b)

CI 修复(auto-fix 车道):本轮 6 个红分片全部是继承自 master 的 #5140 冷尾 fork flake(叠加 Windows 环境 flake 家族),非本 PR 代码。#5140 现已在 ci-fixes 定位修复(一个测试存储模式钉),我已将该修复合入本分支并推送。

证据(逐条可核)

#5140 根因(已在 ci-fixes 定位修复):该用例证明 forkSnapshot 能从 UNIFIED 索引重建「非保留」父会话的冷恢复 raw tail,但用例自身没钉存储模式;CI 全局基线 BILI_STORAGE_UNIFIED ??= "legacy" 使 broad corpus 跑在 legacy → 无 unified 行 → 重建无源 → forkSnapshot 正确 fail-closed 返回 409。修法 = 单用例显式钉 BILI_STORAGE_UNIFIED="full" + finally 还原 ambient(纯测试 diff,+9/-1,非掩盖)。修复体在 ci-fixes@67f8e894。

本次动作:本分支(f679c7d,含 master@44067a5bc)缺这个测试钉,故仍撞 #5140。已按 CI 协调纪律 git merge origin/ci-fixes@67f8e894 进分支——干净自动合并、零冲突,净变更仅 tests/plugin-fork.test.ts(+9/-1),零源码改动(ci-fixes 的文档/config 登记经 master@44067a5bc 早已在位)。已推送新 head cec85bbbe,CI 重跑中,预期 6 片的 #5140 全部转绿。

结构化八问

  1. 是什么问题:PR [PR] Roll back the incremental canonical when the upstream forward fails | fix(lane): forward 失败后回滚增量 canonical,客户端重试同一 delta 不再被迫全量 resync #4881 的 CI 有 6 个 test 分片红。
  2. 什么原因导致的:全是继承自 master 的 [Bug][fork] Cold restore cannot use an intact unified raw tail for a non-retained parent (#4266 item 4) #5140 冷尾 fork 用例(legacy 存储基线下确定性 409)+ Windows 环境 flake 家族(promptCache.routing is ignored by relay-affinity stamping; no way to persist per-endpoint prompt_cache_key abatement (0.1.209) #5103 / fix: bound ordinary session loading and avoid full reloads for lazy fields #4739 EBUSY / resident-minimal hang,受害分片逐次漂移);本 PR diff(src/incremental-lane.ts + 回归测试)与失败路径零交集。
  3. 影响范围与严重程度:对 [PR] Roll back the incremental canonical when the upstream forward fails | fix(lane): forward 失败后回滚增量 canonical,客户端重试同一 delta 不再被迫全量 resync #4881 = 阻断合并门禁;根因是 master 级 [Bug][fork] Cold restore cannot use an intact unified raw tail for a non-retained parent (#4266 item 4) #5140 + Windows 环境项,波及当前所有基于近期 master 的 PR(CI Control Center: Centralized CI Failure Handling & Status Sync (Comment all CI issues here; do not open new issues) | CI 总控: CI 失败集中处理与状态同步(所有 CI 问题在此评论, 勿开新 issue) #4599 今天在案几十个同签名)。本 PR 自身代码无缺陷、无回归。
  4. 怎么修复的:不碰本 PR 代码;把 ci-fixes 里已定位好的 [Bug][fork] Cold restore cannot use an intact unified raw tail for a non-retained parent (#4266 item 4) #5140 测试钉合进分支(仅 plugin-fork.test.ts +9/-1),让该用例在它本就要求的存储模式下运行。
  5. 是否有回归问题:无——只在一个测试内加了存储模式钉 + finally 还原,不改任何 src/;merge-guard 的 The incremental lane re-renders the entire conversation history and synchronously flushes it to disk each turn; large co | 增量车道每轮把整段会话历史重新渲染并同步落盘,大对话每轮多花约100毫秒 #5155 扩展经 CI 验证通过;干净合并、零冲突、零源码改动。
  6. 是否破坏缓存:不涉及(纯测试夹具,不动运行时)。
  7. 是否改了设计方向:否。
  8. 是否引入新配置:无(未新增任何 config/env;只是让既有 [Bug][fork] Cold restore cannot use an intact unified raw tail for a non-retained parent (#4266 item 4) #5140 用例显式声明它本就需要的存储模式)。

收工判定:本 PR 内容就绪、可合;本轮已把继承的 #5140 修复合入并推送(cec85bbbe),CI 重跑中——预期 #5140 六片转绿。若 Windows 分片仍残留 #5103 / #4739 / resident-minimal 等环境性 flake,那属 master 级 base-code 项,需 owner 在 #4599 triage(已在档),不在本 PR/本车道代修。另:本分支与最新 master 的同步/冲突归 merge-guard 车道(它刚在 f679c7d 处理过)。

ci-check added 3 commits November 11, 2026 23:49
…ward fails

applyTurn advanced the warm canonical BEFORE the assembled request was forwarded upstream; when that forward failed (thrown transport error, or a completed non-2xx), the canonical stayed ahead while the client received no ack (x-bili-applied-seq is stamped on 2xx only, #3344) — so the client's plain retry of the SAME delta hit a 409 seq-mismatch and paid a full resync. Every upstream blip became one full-history resend.

Snapshot the pre-turn state (msgs ref, CLONED marks map, system/boundary scalars) right before applyTurn and restore it when the forward does not land:
- thrown forward: roll back only while the ack cannot have reached the client (a flushed 2xx head means the client already advanced on it);
- completed non-2xx / headless end: roll back — no ack went out.
Append turns truncate back to the pre-turn length; resync turns restore the replaced prefix whole (generalizing truncation). Rows of the rejected branch stay in the append-only table — the existing 409->resync path owns them. On the resolved-non-2xx path the wrap-up chain now records pointers onto the restored old-branch rows, keeping the warm and cold views consistent.

Regression tests (all red before the fix, green after): tests/issue4867-incremental-forward-failure-rollback.test.ts — seed/delta/resync retry-after-failure over HTTP plus the direct-call thrown-forward case.

(cherry picked from commit 226c25c)
…already flushed)

Review supplement for #4881: the rollback gate in handleIncrementalTurn only fires while the ack cannot have reached the client, but the new suite only asserted the roll-back side. Add the negative-direction case: a forward that throws AFTER the 2xx head (carrying x-bili-applied-seq) was flushed must NOT roll back — the client already advanced its pointer, and a rollback would be reverse drift whose next delta 409s.

(cherry picked from commit 3b8a7cd)
…back snapshot

Merging master brought #5155's per-turn render cache into Canonical
(renderPrefix + marksVersion). applyTurn bumps marksVersion and nulls
renderPrefix on resync, and laneItemsForTurn refreshes the prefix over the
NEW history before the forward lands. The #4867 pre-turn snapshot predates
those fields, so a rolled-back turn left a prefix cache rendered from the
rejected branch: if its len/marksVersion gates happened to match a later
turn's cut, laneItemsForTurn would splice rejected-branch bytes into an
accepted body. Snapshot and restore both fields.

(cherry picked from commit d553129)
@ranxianglei
ranxianglei force-pushed the 2026-10-27_incremental-forward-rollback branch from cec85bb to 6787c75 Compare November 11, 2026 17:10

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant