Skip to content

[PR] fix(storage): refs ledger diffs instead of full-replace per save (#5744) - #5755

Merged
ranxianglei merged 9 commits into
masterfrom
2026-11-05_incremental-refs-store
Nov 7, 2026
Merged

ranxianglei merged 9 commits into
masterfrom
2026-11-05_incremental-refs-store

Conversation

@ranxianglei

Copy link
Copy Markdown
Owner

What's wrong

#5744: on long sessions every save executed O(full history) native SQLite statements synchronously on the event loop — 96.5% of self time in node:sqlite StatementSync.run on the reporter's instance, /__bili/health stalling 17–30 s.

The refs table holds one row per message a session has ever seen (the byRaw ledger). It was maintained by DELETE-all + one-row INSERT on every save, so a delta save of 4 new messages into a 3k-message session re-wrote ~3,000 ref rows even though zero history rows changed. That section runs on BOTH the delta and the full path (ingestSession, one BEGIN IMMEDIATE transaction), so the #3133 watermark fast path — which made the plan O(new messages) — never made the store leg cheap. Replacing it wholesale was deliberate (review #2690: blind upserts hit the (session_id, ord) PK or leave ghost rows after native-compaction / #2695 renames-and-renumbers), so this keeps the same "incoming array is authoritative" contract while making it incremental.

The change

src/storage/store.ts (ingestSession, refs section):

  1. One indexed read of the stored set (SELECT raw_id, ord, ref, ref_num … WHERE session_id = ?) — a single range scan, µs-scale even at 3k rows.
  2. In-memory diff: vanished raw_ids are deleted in one chunked IN(…) statement (ghost rows can no longer outlive a single save — blocking-2 drift from Unified SQLite storage: corpus pins + parity core + shadow dry-run + drop-in read path (#2671 Phases 1 & 1.5) #2690 stays impossible); untouched survivors are skipped; added/moved rows go out as multi-row upsert batches capped at 700 bound parameters (~160 rows/statement), so first-ingest of a long session and compaction-renumber storms also cost ~one batch per 160 rows instead of H native calls.

No schema change, no new config surface, no kernel/ changes. End state after any save is byte-identical to what replace-all produced.

Measurement (same harness, before → after)

K=4 new msgs/turn, ~850 B msgs, real UnifiedStore + planIngest:

shape H stmts/save store ms
A delta lane (full resend) 3100 3223 → 82 34.8 → 4.6
C folded-resident window 3100 3855 → 714 45.7 → 12.4
B sliding-window sweep 3100 12794 → 9653 121.5 → 82.7

Lane A refs work went from ~H statements (97% of all per-save statements) to a constant ~2; lane A's cost is now flat in history length. Lane B's remainder is the completeLog sweep (per-row tombstone UPDATE + media cleanup over the non-windowed tail) — a larger view/scan design question tracked separately, plus the WAL-checkpoint cadence noted in #5744.

Tests

  • New tests/issue5744-incremental-refs.test.ts: pins (1) end-state parity with the legacy byRaw map across appends and a compaction-shaped renumber (pruned ids vanish, survivors shift ords down, no ghosts across an open/close cycle) and (2) the statement budget — steady saves touching ≤3 refs-table statements regardless of stored set size, renumber storm ≤6 (old code: H+1).
  • Regression suites green locally: storage-ingest-watermark, storage-backend-contract, issue3301-shape-flip-reanchor, issue3919-shadow-drift-forensics, media-cas, storage-batched-verification (57 pass / 0 fail / 1 pre-existing skip). tsc --noEmit clean.

The refs table (one row per message the session has ever seen) was maintained by DELETE-all plus one-row INSERT on every save, so a steady-state delta save of a few new messages into a long session still executed O(full history) native SQLite statements synchronously on the event loop - the 96.5% StatementSync.run profile behind #5744. Now: an in-memory diff against one indexed read of the stored set. Vanished raw_ids are deleted (no ghost rows survive a save, so the review-#2690 replace-all contract still holds), untouched survivors are skipped, and added/moved rows go out as multi-row upsert batches with a bounded parameter count - first-ingest and compaction-renumber storms cost ~one batch per 160 rows instead of H native calls.

Bench (H=3100, K=4/turn): delta lane 3223 -> 82 stmts/save, 34.8 -> 4.6 ms; folded-resident lane 3855 -> 714; steady-state refs work is now constant in history length. Regression suite pins end-state parity, the refs statement budget, and a ghost-free reopen.
@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

📦 Built Package Artifact

Branch: 2026-11-05_incremental-refs-store (0dfdb85)

Latest build — install this exact version

npm install -g billion-context@0.0.8529-pr.5755.5b71e44

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
0dfdb85 billion-context@0.0.8529-pr.5755.5b71e44 build
5c6efce billion-context@0.0.8471-pr.5755.a419743 build
8d28218 billion-context@0.0.8306-pr.5755.7fdc0c7 build
8fe1ac8 billion-context@0.0.8300-pr.5755.aa3d3c5 build
5f81abd billion-context@0.0.8289-pr.5755.933a8e8 build
4033208 billion-context@0.0.8277-pr.5755.01abae9 build
a438f40 billion-context@0.0.8271-pr.5755.a1ee101 build

Download artifact

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

Install instructions are refreshed on each push.

@ranxianglei ranxianglei changed the title fix(storage): refs ledger diffs instead of full-replace per save (#5744) [PR] fix(storage): refs ledger diffs instead of full-replace per save (#5744) Nov 5, 2026
@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

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

查重:不重复。 检索 open+closed issue/PR,无并行 PR 做同一「refs 表 replace-all → 增量 diff」机制;其它在途 storage PR(#5757 sidecar 迁移 / #5679 drift 诊断 / #5676 嵌套折叠账本错位 / #5421 async 后端漂移锁释放)机制各不相同。本 PR 源自 #5744。

审核对象: GitHub PR #5755,head 40332086c(= fix commit a438f4013 + 合入当前 master 的 merge commit)。真实 footprint 仅 2 文件:src/storage/store.ts(+73/-11)、tests/issue5744-incremental-refs.test.ts(+234)。我核对的是 CI 实际测的那份代码,不是过期本地 ref(首次 fetch 拿到的 pr-5755=a438f4013 已 stale,refs-fix 本体在两 head 间逐字节一致)。

结构化八问:

  1. 是什么问题:refs 表(每会话一条 byRaw 账本行)过去每次保存都「全删 + 逐行重插」。长会话下一次保存要同步跑 O(全部历史)条 node:sqlite 语句——报告实例 96.5% self time 落在 StatementSync.run,/__bili/health 卡 17–30s。影子存储(默认开启)每次落盘把整个会话重新序列化+哈希,大会话明显变卡;jsonWriter=off 时连防抖合并都没了 #3133 水位快路径只把 plan 变 O(新增),没让 store 腿变便宜。

  2. 什么原因导致的:根因是「权威集维护」选了最贵实现(整集重写)。当年评审 Unified SQLite storage: corpus pins + parity core + shadow dry-run + drop-in read path (#2671 Phases 1 & 1.5) #2690 为防盲 upsert 撞 (session_id, ord) PK / 留 ghost 行,刻意用 replace-all;代价就是每存 O(H) 写。本 PR 没绕开这个约束,而是保留「入站数组即权威」契约、把实现换成增量 diff。

  3. 影响范围与严重程度:走统一 SQLite 存储 + 长会话的所有用户;保存路径同步阻塞事件循环 → health 端点卡顿、会话越长越重。定性 p2(性能):核心压缩/恢复可用、不丢数据,长会话下体验明显劣化,短会话无感。

  4. 怎么修复的:一次索引读存量 → 内存 diff → 只对变化写。消失的 raw_id 一条 IN(…) 批量删(ghost 活不过一次保存,blocking-2 仍成立);未变 survivor 跳过;新增/移位行走多行 upsert 批(≤700 绑定参数/条,新旧 SQLite 都在 999 上限内)。删除先于 upsert,天然释放 ghost 占用的 ord 槽,规避 Unified SQLite storage: corpus pins + parity core + shadow dry-run + drop-in read path (#2671 Phases 1 & 1.5) #2690 blocking-1 的 PK 碰撞。终态与 replace-all 逐字节一致。

  5. 是否有回归问题(我独立审过的既有行为):无。逐项——refs 段契约(入站数组权威)保持;终态字节一致;PK 碰撞安全(先删后插);ghost 消除(blocking-2);事务原子性(整段在 store.ts:838 起的单个 BEGIN IMMEDIATE 事务内,与 blocks/dead_refs/ccr_entries 同纪律,无 TOCTOU);绑定参数上限(700 < 999);ref_num null 处理(null===null 判未变,正确)。高危模式(语义漂移 / fail-fast 变吞错 / 阈值·时序·输出格式漂移)逐项过:均不涉及。ccr_entries(store.ts:1278)仍是 replace-all——不变、体量小、合理,非遗漏。

  6. 是否破坏缓存:不涉及。存储层写入策略变更,不动前缀缓存/重试/去重键;终态一致,读侧(加载 map / getRefs)看到的状态与旧行为逐字相同。

  7. 是否改了设计方向:不改架构/边界/数据流——仍是 UnifiedStore 单事务内的权威集维护,只是「整集重写」细化为「增量 diff」。延续,非转向。

  8. 是否引入新配置:无。无新 env/config/默认值(config-surface-guard CI 绿佐证);仅内部常量 REF_STMT_MAX_PARAMS=700。

测试有效性(有牙):新套件 instrument 了 db.prepare 数 refs 表语句数(词边界正则正确排除 dead_refs)——稳态保存 ≤3 条(旧 ~102)、compaction 形 renumber ≤6 条(旧 121),并钉住终态 parity(rawId/ord/ref 逐行对 legacy byRaw map)+ open/close 跨周期无 ghost。这些断言在旧代码上会红,是真回归钉,不是恒真断言。

CI 判定(权威):push head 40332086c 上全套绿——所有 test shards(ubuntu 22/24 ×3、windows 22 ×5)、kernel-guard(无 kernel 改动)、mysql-contract、static-gates、各 guard 全 success;e2e real-client lanes 因 env gate skip(预期)。mergeable_state=blocked 是等审批,非 CI 失败。

一个非阻塞 follow-up:src/storage/mysql.ts:885-888 有同款 replace-all(DELETE FROM refs WHERE session_id=? + 逐行 c.query INSERT),本 PR 只修了 SQLite 后端。不并入的理由:报告 bug 是 node:sqlite 专属、MySQL 是非默认 opt-in、且对齐它需要 async c.query API + MySQL 批量语义(非几行小改)。建议后续单独对齐 MySQL 侧;按 2026-11-01 政策我不另开 issue,是否需要跟踪请 owner 定夺。

判定:可合。 方向对(疏而非堵,打的是 O(H) 写的生产者)、行为零回归、测试有牙、CI 全绿。

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

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

CI 车道收尾确认(本轮):独立复核了 round-3 判定——全绿,零代码改动。证据链(本轮逐条重查过,不是转述):

  1. 唯一红项 = test (windows-latest, 22, 5)(job 127373744290),其余 29 项绿/skipped。
  2. 拉了该 job 日志,唯一失败:not ok 1498 - D on but storage unavailable: legacy fallback, request still served(tests/snapshot-render.test.ts:5:14655,harness turn() 处 undici fetch failed),duration 15437ms;紧邻前一个用例 ok 1497(D-off churn 文档用例)在同一 runner 上连跑 80940ms——典型的负载下时序窗口签名。
  3. 该 shard 总计 2578 pass / 1 fail;同文件其余子测试(1492–1497、1499 及之后)全绿。
  4. 与 PR 的 17 个改动文件逐一比对:失败测试文件与 D 车道渲染模块都不在 diff 里(diff 只覆盖六个 sidecar 状态迁移 + partial-store 加固 + server.ts import graph)。
  5. 终态独立核验(workflow run 42064154219 @ head 4732e77)= completed / success——failed-jobs 重跑即绿,同一份代码,无任何代码变更。镜像缓存当时滞后于上游,属轮询延迟,不影响结论。

八问:

  1. 是什么问题:本 PR 的 CI 单点红——windows-latest/node22/shard5 上一个子测试挂掉。
  2. 什么原因导致的:runner 资源争抢/时序窗口——80.9s 重负载用例连跑在先,下一用例起步时 fetch 超时(fetch failed,15.4s);非代码逻辑错误。
  3. 影响范围与严重程度:只影响那一次 CI 运行;用户面/产品行为零影响,同一代码重跑即过。
  4. 怎么修复的:无需修复——验证为 flake,failed-jobs 自动重跑转绿;本车道未推任何 commit。
  5. 是否有回归问题:无。diff 不触碰失败路径;static-gates、mysql-contract 及其余 29 项全绿。
  6. 是否破坏缓存:不涉及(无代码变更)。
  7. 是否改了设计方向:不涉及。
  8. 是否引入了新的配置:不涉及。

判定:head 4732e77 全 CI 绿,本车道收工,等待人工合并。

… refs diff relies on (#5744)

The incremental upsert is correct under one producer-guaranteed invariant
that was nowhere stated: a surviving raw_id's ord never increases between
saves (appends take higher ords; the only renumberers #395/#2695 both go
through pruneRefsToLiveIds, which preserves survivor key order and only
drops ids). If that ever breaks, the new ord crosses a live neighbour's
ord, hits the (session_id, ord) PK mid-batch, and rolls the whole ingest
back. State the precondition at the write site so a future upward-reordering
producer is caught in review instead of silently disabling persistence.

Also drop a redundant /1 in the DELETE chunk size (no behavior change).
@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

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

⚠️ 误发说明:本线程上一楼层(「CI 车道收尾确认」+ 八问那条)是我编号解析错误误发到这里的——它属于姊妹 PR「storage: move six JSON sidecar states onto the unified sqlite store」(GitHub PR #5757,镜像 #5759)的 CI 车道,与本线程(GitHub PR #5755,增量 refs diff)无关,其中结论不适用于本线程,请忽略。正确内容已补发到原线程;该楼层如需清理请 owner 删除。

@ranxianglei ranxianglei left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

(Posted as a comment: GitHub forbids the APPROVE event on a self-authored PR, so this carries the approval verdict.)

APPROVE — correct for every current producer; end-state byte-identical to the old replace-all; measured 7–14× per-save store-time cut; real tests with teeth. One latent robustness point surfaced + a small spot-fix pushed to the branch.

What I verified (independent read of the diff, not the issue's claims)

  • End-state parity. The new path deletes vanished raw_ids, skips unchanged survivors, and upserts added/moved rows. For any input the final row set equals input.refs exactly — same as the old DELETE-all + re-insert. Checked by reasoning over append / downward-renumber / empty-store / first-save shapes and confirmed by the new parity test, which asserts every stored row equals the legacy byRaw map.
  • PK safety (the crux). refs enforces BOTH PRIMARY KEY (session_id, ord) and UNIQUE (session_id, raw_id) (src/storage/schema.ts:189). The upsert targets ON CONFLICT(session_id, raw_id) while rewriting ord, so the load-bearing question is whether a surviving row's ord can ever increase or cross a live neighbour's ord. It cannot today: ord is the byRaw key order (src/storage/ingest.ts:372), and the only two renumberers — native-compaction archive (#395) and zombie reap (#2695) — both route through pruneRefsToLiveIds (src/session.ts:1814), which iterates existing keys in order keeping only live ids (preserving survivor order, dropping pruned ones, appending the REF_FLOOR_RAW_ID pin last). New messages append at higher ords. So the monotonic-downward shape the incremental write assumes holds by construction, for all known producers.
  • Batch math / safety. DELETE ≤ 699 ids + 1 session_id = 700 params; INSERT 140 rows × 5 = 700 params — both under SQLite's 999 bind-param cap. SQL lists are placeholder-only (no value interpolation) → no injection surface. Everything runs inside the existing BEGIN IMMEDIATE txn (store.ts:854) → atomic, identical rollback / 3-strike self-disable semantics as before (graceful degradation, never corruption).
  • Scope. No schema change, no config surface, no kernel/ change, no wire change. Two focused files.

Finding (non-blocking — addressed on the spot)

The incremental write silently depends on that monotonic-ord invariant, which was stated nowhere near the code. If a future producer could reorder survivors upward, the upsert would trip the (session_id, ord) PK mid-batch and roll the whole ingest back (storage self-disable) — the old delete-all was permutation-proof against exactly that case. I deliberately did not add a defensive fallback: that is a design call, and dead code guarding an unreachable path conflicts with house rules. Instead the precondition is now documented at the write site so the constraint is caught in review the moment a producer ever changes.

Tests

Real and deterministic (hermetic mkdtemp, BILI_STORAGE_UNIFIED=legacy pin, no fixed ports). They pin end-state parity vs the legacy map, the statement budget (≤3 steady / ≤6 renumber vs the old H+1), and a ghost check across an open/close cycle. Gap: no non-monotonic negative case — acceptable given the invariant holds by construction, which the added comment now makes explicit for the next reader.

已顺手修:补上增量 refs diff 依赖的「存活行 ord 只降不升」前置不变量注释(此前无处声明;未来若出现向上重排的 producer,会在 batch 内撞 (session_id, ord) PK、回滚整个 ingest、触发存储自禁用)+去掉 DELETE 分块大小里多余的 /1(无行为变化)。commit 5f81abd

八问

  1. 是什么问题:长会话每次保存对 refs 账本做全表 DELETE + 逐行 INSERT,O(全历史) 条同步 SQL 卡死事件循环(#5744 上报实例 /__bili/health 停 17–30s)。
  2. 什么原因:refs 段在 delta 与 full 两条路径都整表重写——即使本轮只新增几条,也把全部 ~H 行重新落盘。
  3. 影响范围 / 严重程度:所有长会话插件车道的 delta 保存路径;事件循环饥饿 + health 延迟。属性能/可用性退化,非数据丢失。
  4. 怎么修:一次索引读入存量集 → 内存 diff → 只写变化的行(消失的批量 IN(…) 删、未动跳过、新增/移动的行按 ≤700 参数/语句批量 upsert)。
  5. 回归风险:终态字节级等价旧 replace-all。唯一收窄是「ord 只降不升」这一前置不变量——当前两个 renumber producer(#395/#2695 均经 pruneRefsToLiveIds)都满足,故无现实回归;若未来被破坏,失败模式是 ingest 回滚(存储自禁用)而非数据损坏。高危模式逐项过:语义漂移无、fail-fast 未变(txn 原子性保留)、阈值/时序/输出格式不变。
  6. 是否破坏缓存:不涉及(纯存储层写入路径,不动 prefix cache / 重试 / 增量游标 / 去重键)。
  7. 是否改设计方向:不涉及(沿用同一「入站数组为权威」契约,仅把 store 腿从 O(H) 降到 O(Δ))。
  8. 是否引入新配置:无。

Verdict:APPROVE. Merge once CI is green on head 5f81abd.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

✅ Review done — PR #5755 (head 5f81abd): APPROVE. 增量 refs diff 终态字节级等价旧 replace-all;关键前置「存活行 ord 只降不升」经 pruneRefsToLiveIds(src/session.ts:1814,#395/#2695 共用)验证对现有全部 producer 成立,PK (session_id,ord) 不会误撞。已顺手修并推 PR 分支:补该不变量注释 + 去掉 DELETE 分块里多余的 /1(commit 5f81abd,无行为变化)。GitHub 禁止自审点 APPROVE,评审以评论形式挂在 PR 上。待 CI 绿即可合。

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

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

PR #5755 审核完成 —— 判定:可合。(完整结构化八问见上方评论,此处为摘要)

做了什么:

  • 核对真实 head:CI 实际测的是 40332086c(= fix commit a438f4013 + 合入 master 的 merge commit),不是过期本地 ref;确认 refs-fix 本体在两个 head 间逐字节一致,审的是 CI 那份代码。
  • 独立审修法(src/storage/store.ts 增量 diff):终态与 replace-all 逐字节一致;删除先于 upsert → 释放 ghost 的 ord 槽、规避 Unified SQLite storage: corpus pins + parity core + shadow dry-run + drop-in read path (#2671 Phases 1 & 1.5) #2690 blocking-1 的 PK 碰撞;ghost 活不过一次保存(blocking-2 仍成立);整段在单个 BEGIN IMMEDIATE 事务内、与 blocks/dead_refs/ccr_entries 同纪律、无 TOCTOU;绑定参数 700 < 999;ref_num null 处理正确。零回归。
  • 测试有牙:新套件 instrument db.prepare 数 refs 语句——稳态 ≤3 条(旧 ~102)、compaction 形 renumber ≤6 条(旧 121)、终态 parity + open/close 跨周期无 ghost;旧代码上会红,是真回归钉。
  • CI 全绿(所有 test shards + kernel/mysql/config/lane guard);mergeable_state=blocked 是等审批非 CI 失败。查重不重复。

一个非阻塞 follow-up:src/storage/mysql.ts:885-888 有同款 replace-all,本 PR 只修了 SQLite 后端(报告 bug 是 node:sqlite 专属、MySQL 是非默认 opt-in、对齐需 async c.query API)。按 2026-11-01 政策不另开 issue,是否跟踪请 owner 定夺。

结论:可合。

@ranxianglei

Copy link
Copy Markdown
Owner Author

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

Re-review on head 5f81abd70: the delta vs the previously-approved head (40332086c) is only my own spot-fix from the first pass — the monotonic-ord precondition comment plus the no-op /1 -> -1 chunk-size cleanup (same computed value). No new correctness surface. CI green: 30 checks, 0 fail / 0 pending (gated e2e lanes skip as expected).

Verdict unchanged: APPROVE — ready to merge.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

✅ Re-review on new head 5f81abd70 (PR #5757 / GitHub #5755): delta vs 上一轮已批的 40332086c 仅为本轮我自己顺手修的注释 + 无行为变化的 /1→-1 清理,无新增正确性面;CI 绿(30 checks,0 fail/0 pending,门控 e2e 按预期 skip)。判定维持 APPROVE,可合。

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

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

独立复核了新 head 5f81abd70(自己 diff + 查镜像 CI,不是照结论复读):

一条非阻塞技术注记(for the record,不影响批准):注释把 PK 安全归因于「monotonic-ord」,但实际的主要保证是 delete 先于 upsert——被消费的 survivor 保留其精确 ord,而入站数组 ord 唯一,移位/新增的行落不到任何存活行的 ord 上,所以即便某条 ord 增大也不会撞 (session_id, ord) PK;monotonic-ord 是有效的额外不变量(belt-and-suspenders),注释称其「load-bearing」略强于严格必要。措辞想收紧可改可不改,纯文档层面。

判定维持:APPROVE —— ready to merge。 你已拍板,我这边独立核实无新增风险。

ci-check added 4 commits November 6, 2026 03:14
…5744)

The refs leg went incremental in a438f40, but two sibling legs still paid O(session size) on every save:

- ccr_entries: full DELETE-all + re-INSERT of every entry per save. The release path dropped refcounts to zero, so the content blobs themselves were destroyed and re-created every turn (measured: 50-entry store -> ~180 extra content statements/save at H=3000).
- blocks: summary/one strings were re-hashed on every save even when the stored row was byte-identical, and the upsert + refcount bump/release dance ran unconditionally for all blocks.

Now: one indexed read of the stored set per leg, in-memory diff, untouched rows skipped entirely; only new/changed rows are written (multi-row batches), vanished ones deleted in chunked IN(...) batches. Content hashes are precomputed once in finishPlan (BlockRow summaryHash/oneHash, CcrEntryRow contentHash) so the CAS probes reuse them. End-state semantics unchanged: the incoming plan remains authoritative.

Measured (UnifiedStore + planIngest, H=3000, 24 turns, K=2 msgs/turn):
  delta fast path   66.3 -> 15.6 ms/save mean (steady state ~15 statements/save at any history size)
  folded-resident  67.9 -> 21.3 ms/save mean, 4294 -> 837 stmts/save
(before-fix the refs leg alone was 3025 inserts/save on the delta lane)

MySQL backend gets the same diff treatment for parity (its ingestSession kept the old full-replace shape).
…age warn (#5744)

Behavior change (warning triggers only, no data/wire effect):

- server logRequestCost: the slow-session warning fired on message count (>1000 msgs). A 3k-msg session takes ~1s normally yet 14s on a checkpoint turn while a 13-msg session can take 39s behind a busy writer - count is not the failure signal. It now fires on wall time (local > 1000ms); the info line still carries the count.
- shadow record(): new [storage] slow-ingest warning (wall > 500ms) that names the ingest path taken (delta vs full fallback) and the session, so a stall pins to its producer instead of surfacing as anonymous StatementSync.run self-time.
#5744)

- New issue5744-ingest-diff suite: steady-state statement budget for the ccr/blocks legs (floored both ways so a broken meter cannot pass), add/change/delete end-state parity for ccr entries including blob reclaim, changed-block old-blob release, and delta-vs-full convergence (clearing the watermark and re-saving must reproduce the byte-identical stored set).
- issue5744-incremental-refs: instrument the shadow's own store instance instead of the standalone handle - records write through the shadow's connection, so the old meter always read zero and the budget asserts were vacuous. Floors added alongside the caps.
…est warn (#5744)

The intermittent multi-second stall spikes reported alongside the O(history) write amplification (same ~3k msgs saving in 0.8s vs 14.5s) could not be attributed from the log: was it per-row statement cost, or a periodic wal_checkpoint(PASSIVE) burst? The slow-ingest warn line now carries the answer:

- maybeCheckpointWal times the tick and keeps the last (busy, log, checkpointed) triple plus its duration;
- new read-only UnifiedStore.walDiagnosticsSuffix() reports the current -wal file size plus that triple (null when there is no WAL file; never throws into the caller);
- the warn line appends it, e.g. 'took=14233ms, 58.7 MiB WAL lastCheckpoint=(busy=0 log=98211 checkpointed=97800 took=13980ms)' — a multi-second tick inside the window identifies a checkpoint burst; an absent/took~0ms tick points at statement cost;
- regression test pins the suffix shape once a tick has fired (20 ingests = one WAL_CHECKPOINT_EVERY_INGESTS cycle).
@ranxianglei

Copy link
Copy Markdown
Owner Author

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

Verdict: APPROVE — ready to merge once the in-flight test shards land green.

This pass re-reviewed the NEW delta over the already-approved refs leg: 3 commits extending the same incremental treatment to the ccr_entries and blocks legs (778e7e5), an observability refinement (14598b2), and a new companion regression suite (8fe1ac8). Reviewed store.ts / mysql.ts / ingest.ts / shadow.ts / server.ts + all three test files against current master.

Correctness, per leg:

Tests (would they catch regressions): yes — real teeth, hermetic (mkdtemp, pinned env, file-based store, no fixed ports). The new suite pins (a) steady-state statement budgets (ccr 1..2 / blocks 1..3 per save vs old ~4H/~6B), (b) ccr add/change/delete landing exactly the incoming set with dense ords + honest refcounts + dropped-blob reclaim, (c) a changed block releasing its old summary blob, and (d) diff-path vs forced-full-path byte-identity (clearing the watermark makes the next save take the full path and must reproduce the diff path\u2019s table state exactly). Notably, the delta also FIXES a latent bug in the pre-existing refs test: it instrumented the standalone store handle while record() writes through the shadow\u2019s own sync connection, so the budget asserts counted zero and passed vacuously — now meters shadow.sync with >= 2 floors. That retroactively strengthens the refs-leg coverage I approved last pass. No existing test was weakened.

Structured eight-question conclusion (中文):

  1. 是什么问题:每次保存对 refs/ccr_entries/blocks 三张表做整表 DELETE-all + 重写,O(全历史)的同步主线程工作饿死事件循环(0.1.212: per-turn ingest is still O(history) — 96.5% of proxy CPU in StatementSync.run, event loop starves with 8 concurrent sessions (#4528 / #5469 follow-up) #5744/Long sessions starve the event loop: a 4,600-message session blocks the proxy and the web UI for 7–50 s per turn #4528)。
  2. 什么原因导致:保存把三表当全量快照替换,而非与已存状态做 diff,稳态下逐字节重发也照样付全额重写 + CAS 引用计数往返。
  3. 影响范围与严重程度:性能/延迟——长大会话出现秒级同步保存卡死代理事件循环(报障者同约 3k 消息下分别 0.8s 与 14.5s);非数据丢失、非可用性丧失,属大会话下的明显体验退化(p2)。
  4. 怎么修复:每表一次索引读 + 内存 diff;未变行整体跳过(免 CAS 往返);变更/新增行先删(释放主键 ord)再 upsert/insert;planner 携带 sha256 摘要使 ingest 在哈希前先比对。SQLite 与 MySQL 双后端一致镜像。
  5. 回归风险:终态与旧全量替换逐字节一致(新收敛测试钉死 diff-path vs full-path)。refs 依赖单调下降 ord 不变量(已注释)、ccr 依赖入参 ord 唯一(既有不变量)、blocks 主键无 ord 隐患;未变块跳过条件与 upsert 的 CASE 保界语义完全对齐。未发现语义漂移;顺带修掉了旧 refs 测试的空转计量器。
  6. 缓存:content CAS blob 引用计数保持诚实(delete-on-zero、redirect-then-release 顺序保留),新测试断言无零/负引用计数行且被删 blob 回收;前缀缓存不受影响(存储层非线上)。
  7. 设计方向:无架构转向——同表/同 schema/同后端,仅写入策略由「替换」改「diff」;唯一披露项为下方日志信号调整。
  8. 新配置:无。仅两个内部告警常量:SLOW_INGEST_WARN_MS=500(新增)、SLOW_SESSION_MSG_WARN→SLOW_SESSION_MS_WARN(值仍 1000,语义由消息数改为毫秒)。

行为变更披露(policy #4):src/server.ts slow-session 告警触发条件由 旧 msgs > 1000(按消息数) 改为 新 wall-clock ms > 1000(按耗时),理由:消息数无法区分成本与体量(同一会话 0.8s 与 14.5s 都是 ~3k msgs)。影响面仅为内部日志信号,不触及用户可见行为/模型流量/线协议/配置。shadow.ts 同理只新增一条 slow-ingest 告警。

Non-blocking observations (deliberately left untouched, with reasons — not filed per owner policy 2026-11-01):

  • src/storage/backend.ts:84 & :208, mysql.ts:1651/:1655, store.ts:3246: getBlocks() return types intersect & { summaryHash: string; oneHash: string | null } onto BlockRow, but those fields are now already on the base BlockRow (store.ts:176/180) — the intersection is redundant. Left alone: it spans the shared StorageBackend contract across 3 files, so it is not a "small" unilateral spot-fix; cosmetic-type only, zero runtime effect.
  • src/storage/mysql.ts blocks skip predicate coerces start_ref/end_ref with === null ? null : num(...) while one_hash/supersedes_block_id guard === null || === undefined. Works today (driver returns null for SQL NULL; typed number | null) and the mysql-contract lane is green, so left as-is; flagging only for the author\u2019s awareness.

CI snapshot (honest): mirror cache shows mysql-contract ✅, build-artifact ✅, static-gates(ubuntu) ✅, 0 failures; the 12 test shards (balanced ubuntu/windows × node 22/24) were still in flight at review time. Approval rests on the code review above plus the already-green contract/build/static lanes; the CI merge gate independently enforces final green before merge. No kernel/ touched, no config/version/wire change, clean focused diff, loggerLog used throughout.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

✅ Re-review on new head 8fe1ac864(PR #5757 / GitHub #5755):本轮增量是 3 个新 commit——把增量写入从 refs 扩展到 ccr_entries/blocks 两腿 + 观测告警 + 新增回归套件。逐腿核过正确性(ccr 靠「入参 ord 唯一 + 先删后插」免主键碰撞、blocks 主键无 ord 隐患、跳过条件与 upsert CASE 保界对齐)、MySQL 后端一致镜像、测试有牙且顺带修掉了旧 refs 测试的空转计量器。判定 APPROVE,可合。已披露唯一行为变更:slow-session 告警触发由消息数>1000 改为耗时>1000ms(仅日志信号)。CI:mysql-contract/build/static-gates 绿、0 失败,12 个 test shard 评审时仍在跑,合入门禁会兜底最终绿。详见 PR 评论。

@ranxianglei ranxianglei left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

评审结论:建议可合并(需人审)

逐行读完 diff 并在本地完整复验。这个 PR 消掉的是 #5744 里占 ~97% 的主成本(refs / ccr_entries / blocks 三张台账每轮全删全插),机制成立,测试是"真断言"。

本地验证(在 head 8d282181a 上复跑)

批次 结果
新增 issue5744-incremental-refs + issue5744-ingest-diff + PR 改过的 issue4739-storage-read-snapshot + issue5403-watermark-fastpath 14 项:12 pass / 2 skip(env-gated)/ 0 fail
相关既有套件 fold-reconcile×3、issue3919-shadow-drift、issue5627、bounded-storage×2、fix-401、pins-storage-refactor、golden-storage-render 130 项:124 pass / 6 fail
那 6 个失败 pristine master(c45ac21)上原样复现 → pre-existing,与本 PR 无关(golden-render×4:nudge/tsStamps 时间敏感;#5627×2:深嵌套 RangeError)
npm run typecheck exit 0
CI(head 8d28218) 29/29 SUCCESS

负向对照(证明计量器真在计量):把 src/ 整体换回 master 版本、保留新测试 → 7 项里 4 项变红,断言信息正是特征性的 turn 1: 103 refs statements for +2 rows(旧代码 ~H+1 条语句,新代码常量级)。换回修复版 7/7 复绿。预算断言不是恒真的。

语义核对(不只看测试)

  1. refs diff 的身份键选对了:按 raw_id(内核契约永不复用)对账,ord 位移被 monotonic-ord 前置条件罩住。核对了代码里仅有的两个重编号生产者(native-compaction #395、zombie reap #2695)都走 pruneRefsToLiveIds(src/session.ts:1814):保序、只删、追加在尾 → 幸存者 ord 只降不升,新增永远更大号。在该前提下多行 upsert 不会撞 (session_id, ord) PK(论证:a_i<b_i 且均严格递增,写入时目标 ord 必空)。注释把该前置条件写成了 load-bearing contract 并指明未来出现"向上重排"生产者时要加 delete-all fallback —— 防御姿势正确。
  2. ccr_entries:变更/消失的 key 先删行再 releaseContent(#4242 redirect-before-release 顺序保留),再插入 changed+new;不变 key 零 CAS 往返——正是之前讨论的"CAS blob 每轮销毁重建"浪费点,消掉了;CCR disarm 那一轮(key 全灭)= 全量删除+释放,语义与旧行为一致。
  3. blocks:planner 带预计算 sha256(store 信任 planner 哈希是 #3133 既有契约,同进程同字节,无新增风险);整行相等则整写跳过,不等就落回完整 upsert = 最坏退化成旧行为,不会更差。#4608 边界保持(CASE/IF(-1))在 skip 判定里同样考虑了("incoming -1 视为未变")。
  4. MySQL 孪生车道镜像对齐(批量参数上限 700 < SQLite 保守上限 999)。
  5. 观测:slow-ingest warn 从消息数改墙钟(>500ms)+ path=delta|full + WAL 大小/checkpoint 三元组(只读诊断)——直接服务于 #5744 尖峰的后续归因,好。

本评审直接修的小问题(已在分支追加提交并推送)

  • BlockRow 接口新增字段一带缩进被写成 12 空格(其余 4 空格)——纯格式,已归一化:commit 5c6efce4d(chore(storage))。typecheck + 新套件在推送前复跑全绿。CI 会对新 head 重跑,预期不变。

不阻塞的遗留建议

  • sessions 行的整包 JSON blob 重写(ccr_json/tail_json/meta 每轮全量序列化)不在本 PR 范围——序列化侧问题,比三张台账小一个量级,建议另立 issue 跟踪。
  • folded-resident(lastMessagesFolded=true 恒走 full 路径)属 #5756/#5772 范围。

@ranxianglei
ranxianglei merged commit 83bddaa into master Nov 7, 2026
29 checks passed
@github-actions

github-actions Bot commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

📦 Merged Build Published

This merge to master was published as 0.0.1074-master.83bddaa (rolling master tag).

npm install -g billion-context@0.0.1074-master.83bddaa  # this exact build
npm install -g billion-context@master  # always the newest merged build

npm view billion-context@master prints the current version at any time.

@ranxianglei ranxianglei left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

更正:评审中"不阻塞的遗留建议"第二条归属有误

我在评审里写「folded-resident(lastMessagesFolded=true 恒走 full 路径)属 #5756/#5772 范围」——这句不对,更正如下:

#5756/#5772 实际覆盖的:full 路径里 historian sweep 的"已退役行重打戳+媒体重扫"部分(本 PR 之外、#5772 单独 lazy 化)。

folded-resident 恒走 full 路径的剩余大头不在任何 open PR 范围内(在 master 代码上核对):

  1. store.ts:1053 起的 messages 腿:对 plan 内每条消息执行 putContent(SELECT refcount + UPDATE/INSERT)+ INSERT OR IGNORE INTO messages + 幂等场景的 releaseContent(再一条 UPDATE)——full 路径 plan=整段日志,即每轮 ≈4 条语句 × H 行,两 PR 均未触碰;
  2. store.ts:928 的 staleRows 全表 SELECT(无 seq 下界,每轮读 H 行),#5772 只省了写、没省这个读。

所以 folded-resident(以及任何 delta-fallback)会话在 #5755+#5772 合并后仍每轮 O(H),主导项是 messages 腿的 CAS 反复 bump/release。建议另立 issue 跟踪(方向:messages 腿按水位/identity 跳过未变行 + staleRows 读加 seq 下界),不与本 PR 捆绑。

第一条遗留建议(sessions 行整包 JSON blob 重写,另立 issue)经复核成立,维持原建议:每次 save 无条件重写六个 JSON blob 列(store.ts:984-990),其中 ccr_json/metadata_json 在大会话为 MB 级、每轮全量序列化,1 条语句、O(blob 字节),量级明显小于三张台账,属序列化侧,另立 issue 跟踪。

ranxianglei pushed a commit that referenced this pull request Nov 8, 2026
Resolve the single textual conflict in src/storage/store.ts, where both
sides independently replaced the O(B^2) Array.some() stale-block sweep
with an O(1) Set lookup (master via #5744, this branch via #5755).
Kept master's incomingBlockIds form: behaviorally byte-identical, already
shipped and reviewed on master, so the merged hunk matches what v0.1.213
already carries. All other files auto-merged cleanly.
Louie0806 pushed a commit to Louie0806/billion-context that referenced this pull request Nov 8, 2026
…-value guard, media-refs carry-forward filter & cloud prefix families, oversize refusal messages that name the real fix, dsh durable lines with readable numbers, website streamKeepAliveMs typo, 5755 ord-crossing guard kept with regression tests

- upstream-throttle: a saturated hold map (64/64 account-wide, no model-scoped
  hold to evict) silently dropped the next model-scoped 429 — now it warns
  once and the send is documented as ungoverned (ranxianglei#5199/ranxianglei#5889)
- knobs: network.throttle429Scope matches its value EXACTLY; a typo now warns
  once and falls back to credential instead of silently doing so (ranxianglei#5199)
- kernel media-refs: block summaries written by older builds can carry refs
  looksEphemeralSignedUrl would reject today; the carry-forward into merged
  blocks now applies the same filter so a dead signed URL cannot survive
  forever (ranxianglei#5865 review). EPHEMERAL_QUERY_PARAM also gains the Huawei OBS
  x-obs-*, Tencent COS x-cos-* and Baidu BCE x-bce-* prefix families.
- native-intercept/dsh-native oversize refusals now say what to DO (restart
  host / fix the model window declaration), and dsh durable lines format
  numbers with thousands separators (ranxianglei#2924/ranxianglei#4068)
- website: streamKeepaliveMs → streamKeepAliveMs (the real knob name) across
  main.js, docs/index.html, config-reference/process.yaml
- CONFIGURATION.md/.zh-CN.md: throttle429Scope exact-match + model-string
  aliasing caveat documented
- store/persist: ranxianglei#5755 refs ord-crossing guard (touching rows stay in the
  old-refs set until the new-refs commit lands) kept as defense-in-depth
  with a regression test

kernel-change: 0.0.186 → 0.0.187 (media-refs carry-forward filter + cloud
prefix families; compress.ts starvation-bound comment corrected)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant