Skip to content

Bound startup session loading and preserve historical recovery - #2803

Merged
ranxianglei merged 8 commits into
ranxianglei:masterfrom
mariopasquali:2026-10-11_bounded-session-loading
Oct 13, 2026
Merged

ranxianglei merged 8 commits into
ranxianglei:masterfrom
mariopasquali:2026-10-11_bounded-session-loading

Conversation

@mariopasquali

@mariopasquali mariopasquali commented Oct 11, 2026 •

Copy link
Copy Markdown

Startup currently hydrates every persisted session before applying the resident session count limit. An archive larger than the Node heap can therefore prevent the proxy from starting at all. A count limit also misses large idle sessions, and eviction can discard newer mutations after a failed flush if the session had previously been saved.

Fixes #2776.

Real native OpenCode end-to-end validation: 4/4 passed, including actual compression and persisted state. The external Windows runner allows persistence to flush before termination; tracked assertions are unchanged.

Index live and sibling cold archive files one record at a time, warm at most eight sessions within a 64 MiB disk-byte budget, and load older state and originals on demand. Preserve the existing unified SQLite read precedence and legacy migration identities. Bounded server reads fail closed on corrupt originals; legacy direct API callers retain their existing warned fallback. Original-store saves retain the existing unique atomic temporary filenames and prevent state advancement when they fail.

Bound the idle resident cache with an estimated 512 MiB target. Active requests stay pinned and may exceed that target; the existing session-count cap remains the hard admission limit. A regression specifically verifies that an oversized pinned request does not reject another conversation below that count limit. This is an idle-cache target, not a guarantee of total process RSS or a maximum size for one record.

Validation: typecheck, build and distribution import checks pass; the original 56 focused compatibility/recovery checks passed, and the final corruption compatibility/scale run passed 38 tests with one POSIX-only skip. A portable regression creates a 320 MiB synthetic archive, omits the migration marker, boots in a child with a 128 MiB heap, and restores an older exact original. Additional tests cover cold recovery, corrupt live precedence, failed original saves, failed-flush eviction, concurrent admission, and newer SQLite records winning over older JSON.

Final full patched Windows validation: 6,323 tests, 6,294 passed, 24 skipped, five failures, all reproduced in the unmodified baseline: name clearing (issue2322-session-name.test.ts:223) and four Windows SQLite fixture cleanup EPERM errors (storage-engine-proofs.test.ts:214,265,307,380). The baseline timing assertion did not recur. The direct corruption fallback compatibility failure found in the earlier run is corrected without changing the existing test. The pristine baseline had six failures: those same five and one timing assertion. The full suite is not entirely green; no new failures remain relative to that baseline.

No kernel, configuration schema or version changes. Default direct boot() behavior remains compatible; server startup opts into the bounded path. This draft needs storage-owner review for the newly merged unified engine and archive naming policy. It does not prune history, restore old snapshots, or change the current relay deployment.

Config surface (#2803 owner ruling)

Per the owner ruling in the thread (需要配置 / 放到合理位置 json 配置文件里面 / 默认值不能影响正常会话), the three startup budgets are now config-file keys under the existing sessions object (resolved env > file > default through src/knobs.ts):

File key Env twin Default Meaning
sessions.warmMax BILI_SESSION_WARM_MAX 8 Most-recently-saved sessions hydrated at boot before listening; older state reloads on demand. 0 warms none. Capped by sessions.max.
sessions.warmMaxBytes BILI_SESSION_WARM_MAX_BYTES 67108864 (64 MiB) Decoded-byte budget for the boot warm set (zstd archives cannot bypass the RAM bound); a single file over the budget is never warmed.
sessions.residentMaxBytes BILI_SESSION_RESIDENT_MAX_BYTES 536870912 (512 MiB) Soft target for estimated resident session bytes; active/in-flight state stays pinned above it, idle state discharges back to disk.

Why the existing surface could not express these: sessions.max is count-only (#2776 facet F2) and no byte dimension existed anywhere in the schema. Defaults equal the previously hard-coded values, so unconfigured deployments behave exactly as before (no effect on normal sessions — per the owner constraint). Garbage/negative values fall back to defaults; explicit 0 is honored. Documented in CONFIGURATION.md + CONFIGURATION.zh-CN.md (key table, description, env-mapping table, long-form reference). Owner sign-off: the 决策点 1 ruling in this thread.

@ranxianglei

Copy link
Copy Markdown
Owner

ework 接单

ework-agent added 2 commits October 12, 2026 16:37
…anglei#2803 review)

The existsSync gate in loadEnvelope can race the hourly GC sweep (ranxianglei#1082) or an
operator archiving files into the -cold sibling between gate and read; the bare
readFileSync then throws ENOENT and turns an ordinary absence into a 5xx.
ENOENT now degrades to null like any other missed probe; every other error
still fails closed.
…lei#2803 review)

Bounded startup refuses corrupt originals instead of degrading silently, so
the old 'never a crash' wording no longer matches loadContentStore.
@ranxianglei

Copy link
Copy Markdown
Owner

🤖 Powered by ework · qwen3.8-27b

🏷 Review 结论(PR #2803 / Fixes #2776)

判定先说:方向正确、实现质量高、代码层面可合;已顺手推了 2 个 additive 小修到分支(原作者 commit 未动),另有 4 处行为变更需明示确认 + 2 个设计决策点等 owner 拍板。均不阻塞。

验证证据(本地,基于当前 master c60c2d109):

结构化八问:

  1. 是什么问题:SessionStore.boot() 在应用任何上限之前把全部持久化会话整体水化进内存 → 大存档下 OOM 重启循环(事故规模:2159 文件 / 2.26GB,1GiB heap,peak RSS 670MB)。一个机制(启动期 monolithic enumerate+hydrate)五个面:F1 启动无界 / F2 只有数量维度的上限 / F3 首次迁移无界 / F4 损坏记录被静默当空会话 / F5 驱逐守卫半覆盖。

  2. 什么原因导致的:cap 在水化完成之后才生效,天然约束不了启动内存;存储维度只有数量没有字节;损坏与缺失在 loadSync 里同为 null,getSession 于是铸新会话,下一次保存静默覆盖损坏文件。

  3. 影响范围与严重程度:特定条件下完全无法使用——会话存档超过堆大小的部署必现 OOM 循环;F4/F5 两个数据丢失面影响任何遭遇文件损坏或写失败的部署。小规模常规部署除启动驻留策略外无感(见第 5 问)。

  4. 怎么修复的:(a) 启动改为逐文件索引(内存 O(单文件)),按 savedAt 取 top-K 暖集合(disk-byte 预算内),其余走既有 loadSync 懒加载;(b) 权威记录损坏 fail-closed(抛错拒开空历史)而非静默铸新;(c) content-store 保存失败经 scheduleSave/writeNow/flushSync 传播,evictOldest 保留任何 flush 失败的会话;(d) 常驻缓存加软字节预算(对象图估算权重、活跃请求 pin、数量上限仍为硬准入)。无 kernel 改动、无 config schema 改动、无版本号改动。

  5. 是否有回归问题——既有行为清点,4 处有意变更 + 1 处顺带发现:

    • [变更] 启动驻留:旧 = 最多 MAX_SESSIONS(256)个最近活跃会话整体水化 → 新 = ≤ min(MAX_SESSIONS, 8) 个、64MiB disk-byte 预算内预热,硬编码于 initSessions,无配置项。代价:冷会话重启后首次回访付一次同步解析。⚠️ 决策点 1:warm 数量/字节预算要不要做成配置项(我原 triage 建议 opt-in knob,作者选了硬编码,两者皆可辩护,请拍板)。
    • [变更] 损坏 state 文件在所有模式 fail-closed,不止 bounded 路径:loadEnvelope 是共享方法,所以每个 loadSync 调用方(请求路径、ACP 管理、decompress 父链 walk、search_context 跨会话、fork 探测、web/admin)的"损坏→静默空会话"都变成了报错。符合 F4 意图,但超出 PR 正文 "Default direct boot() behavior remains compatible" 的表述(水化部分确实兼容,损坏处理不是)。我认为这是对的(F4 的本意就是 fail-closed),显式披露。
    • [变更] 驱逐保留规则:旧 = 仅当 flush 失败且从未持久化才阻止驱逐;新 = 任何 flush 失败的会话都保留(先前保存不含最新变更)。后果:持续磁盘满 / Windows EPERM([bug] 0.1.63 起 Windows persist EPERM 风暴:会话持久化静默丢数据(4.9k 次/117 会话)+ compress no-valid-ranges 拒绝 #362 AV 锁场景)下,新会话会被 "session pool exhausted (...failed saves prevent safe eviction)" 拒绝,而不是静默丢失未存增量。完整性优先于可用性的取舍,披露。
    • [发现] 会话身份不稳定:credential/upstream 维度导致长会话状态孤儿化(sessions 缺失) #286 legacy 迁移在 bounded 路径不再执行:不写 marker、不重命名,legacy-label 会话靠内存别名懒采纳;采纳后下次保存会写出规范文件、派生文件(+其 content-store 伴生)成为孤儿 → 每个 legacy 会话一次性磁盘重复,server 车道迁移永久不完成(bulk-boot 路径若运行会幂等自愈)。PR 测试刻意断言"保留不动"(非破坏性立场)。决策点 2:要清理语义(master 的破坏性 rename)还是接受非破坏性?若后者,建议补一个测试钉住"重复是预期行为"。
    • 高危模式逐项过:无阈值/时序/输出格式漂移;原子临时文件名保持(CCR content-store companion write is non-atomic — a crash mid-write silently destroys all retrievable originals #2674 语义不变);unified-SQLite 读取优先级保持(有专门测试);storage-render golden 套件绿;wire-contract 套件绿。残留风险两条(非阻塞):boot 预算数的是磁盘字节,zstd 存档解码膨胀后暖集合 RAM 可能超 64MiB(规模回归固定 ZSTD=0,未覆盖 zstd 高压缩比);bounded boot 每次启动解析全部文件(CPU ∝ 存档大小,内存有界)——可接受,记录在案。
  6. 是否破坏缓存:prompt/prefix cache 不受影响(未触碰消息重建与 wire 产物)。会话层:字节预算在 releaseInFlight 时同步 discharge(超预算时在请求完成路径上同步 flush 最老会话——自限,繁忙主机建议观察延迟);权重估算按 revisionEpoch 缓存,大会话每次失效重走对象图(毫秒级量级,自限)。

  7. 是否改了设计方向:无架构转向——同一套 store、同一文件布局、同一身份规则;新增 bounded-boot 索引模式 + 软字节维度,与既有数量上限并存。warm-set 选择器是未来 index.db SOT 替换时的唯一接缝(与 Startup hydrates the entire session archive before applying resident limits, causing an OOM restart loop #2776 triage 的分步策略一致)。

  8. 是否引入了新的配置:无。(硬编码 8 / 64MiB / 512MiB —— 见决策点 1。)

测试有效性:9 个单测 + 规模回归断言有牙——精确字节相等、对 /refusing to replace its originals/ 与 /refusing an empty resume/ 的 assert.throws、损坏文件字节保留断言、真实子进程 --max-old-space-size=128 + 故意省略迁移 marker、cold 目录 live-before-cold 优先级、unified 优先于过期 JSON 副本、以及精确对应 PR 承诺的软字节预算回归(超大 pinned 请求不拒绝计数上限内的并发会话)。缺口:无测试钉住采纳后保存的文件重复(见决策点 2)。

diff 整洁度:4 文件,每一行都在目的之内;无空白 churn、无锁文件/生成物;kernel-guard / version-guard / config-surface-guard 全绿。

收工判定:代码侧我已放行;待 owner 拍板 (a) 硬编码预算 vs 配置项,(b) legacy 迁移非破坏性立场。两个新增 commit 的 CI 转绿后,剩下的门在你手上。

@ranxianglei

Copy link
Copy Markdown
Owner

⚠️ 决策点 1:warm 数量/字节预算要不要做成配置项(我原 triage 建议 opt-in knob,作者选了硬编码,两者皆可辩护,请拍板)。

需要配置 注意放到合理位置 json配置文件里面 默认值不能影响正常会话

另外我看回归问题比较多

@ranxianglei

Copy link
Copy Markdown
Owner

🤖 Powered by ework · qwen3.8-27b

拍板两项都已落实。PR 分支新增提交 740d258ac(纯增量,未动作者任何提交;作者刚把 master merge 进分支 bb7e4d000,我的 commit 已 rebase 到其上)。验证:typecheck clean、定向测试 67 pass / 0 fail / 1 skip(skip 为本地环境性缺省依赖,CI 覆盖)。PR body 已按规范补 Config surface 小节。

① 是什么问题:你拍板的两件事——(a) warm 数量/字节预算硬编码不可调;(b) 我上轮 review 列的回归清单需要逐项处置,不能只披露。

② 什么原因导致的:(a) 作者实现时选了常量,没走 config-surface 流程;(b) 回归项是 PR 与既有行为的语义差,每项要么修掉、要么确认有意并用测试钉住,必须二选一。

③ 影响范围与严重程度:(a) 原来所有部署被迫接受 8/64MiB/512MiB,现在可调;默认值 = 原硬编码值 → 不配置的常规会话零影响(满足你"默认值不能影响正常会话")。(b) 回归清单里 F-H(zstd 归档绕过字节预算)是真实缺陷:高压缩比归档可让实际解码内存数倍于 64MiB 预算——已修;其余为有意设计(见⑤),无隐藏影响面。

④ 怎么修复的:

  • 配置落在既有 sessions 对象下(env > file > default,走 knobs.ts 惯例):
    • sessions.warmMax / BILI_SESSION_WARM_MAX,默认 8(受 sessions.max 封顶;显式 0 = 不预热)
    • sessions.warmMaxBytes / BILI_SESSION_WARM_MAX_BYTES,默认 67108864(64 MiB)
    • sessions.residentMaxBytes / BILI_SESSION_RESIDENT_MAX_BYTES,默认 536870912(512 MiB)
    • 垃圾值/负数回落默认;非法 env 同样回落
  • warm 集计量从磁盘压缩字节改为解码后字节(新增 readRawFileWithBytes):zstd 归档无法绕过 RAM 预算;plain JSON 文件新旧计量恒等,零漂移
  • 删除 initSessions 里 bootBounded 之后不可达的死代码块(F-J)
  • 新测试:knob 三级优先级(file/env/垃圾值/0)、zstd 解码字节预算、"legacy adoption 保留历史文件、canonical 副本并存"钉为有意行为
  • CONFIGURATION.md + zh-CN 四处(key 表/说明/env 对照表/详表)同步

⑤ 是否有回归问题(你点名的回归清单,逐项处置):

  • F-C corrupt 记录全模式 fail-closed:确认有意——这正是要修的 F4(Startup hydrates the entire session archive before applying resident limits, causing an OOM restart loop #2776 "corrupt authoritative records FAIL CLOSED"),不是副作用
  • F-D 启动驻留 256→≤8:现已可调(warmMax/warmMaxBytes),默认保持现行为
  • F-E 逐出保留所有 flush 失败会话:确认有意(integrity-over-availability;持续磁盘满时宁可 pool exhausted 报错也不丢数据)
  • F-G bounded 路径不跑 会话身份不稳定:credential/upstream 维度导致长会话状态孤儿化(sessions 缺失) #286 迁移、adoption 后 orphaned derived 文件:保持非破坏式 + 新测试钉住"canonical 与 derived 并存且 derived 逐字节不变"为有意行为。若要清理那是破坏性变更,请你单独拍板,我不擅自加
  • F-H zstd 绕过预算:已修(解码字节计量)
  • F-I releaseInFlight 同步回写:自限流(仅超预算触发),繁忙宿主建议观察日志,本次不动
  • F-J 死代码:已删
  • admin.ts:378 writeNow throw 被 catch-all 误标 400/413:既有语义延伸、minor(loopback-only 端点),本次不动

⑥ 是否破坏缓存:不涉及。warm 集只决定启动预加载哪些会话;消息内容、ref 编号、prefix cache 均不变。

⑦ 是否改了设计方向:否。仍是"索引 + warm 集 + 懒加载"架构,只是三个常量入配置 + 修计量口径。

⑧ 是否引入了新的配置:有(即你拍板项):上列 3 个 key,默认 8 / 67108864 / 536870912,各有 BILI_ env twin,无需迁移(新字段,旧配置自然回落默认)。

判定:拍板项全部落地并验证;等 CI 对新提交转绿 + 你对 F-G 处置(维持非破坏式 vs 要求清理)的确认。无异议即可合并。

…anglei#2803)

CONFIGURATION.md/.zh-CN.md tables are generated from website/config-reference/*.yaml (tools/gen-config-docs.mjs); add the three ranxianglei#2803 budget keys to the process.yaml seed and regenerate instead of hand-editing the artifacts. Fixes config-surface-guard / docs-coverage / docs-sync on 740d258.
@ranxianglei

Copy link
Copy Markdown
Owner

🤖 Powered by ework · qwen3.8-27b

CI 跟进:740d258ac 上 27 个检查 = 22 绿 / 2 skip / 3 红,红的三个全是文档门禁(config-surface-guard、docs-coverage、docs-sync)——是我上一提交自找的,已修。

原因:CONFIGURATION.md / zh-CN.md 的表格是 website/config-reference/*.yaml 种子经 tools/gen-config-docs.mjs 生成的(#2107 漂移守卫)。我上一轮直接手改了生成产物而没改 YAML 种子 → docs-sync 判漂移、docs-coverage/config-surface-guard 判新 key/env 无种子条目。

修复:三个 key(sessions.warmMax / warmMaxBytes / residentMaxBytes + env twin)补进 process.yaml 种子,重新生成全部产物。本地三门禁全绿(generate i18n ok 232 keys / coverage ok 146 env + 52 keys / surface-guard ok 127 leaves)。新提交 539bbd3eb 已推上分支(纯增量)。

等 CI 对 539bbd3eb 转绿即可;功能与测试面不受此提交影响(docs-only)。

ework-agent added 2 commits October 12, 2026 17:45
…#2803)

The ranxianglei#2816 tripwire requires every process-spawning suite to be listed in scripts/run-ci-tests.mjs before it may ride the c=4 pool (ranxianglei#1248 review). Concurrency assumptions reviewed: isolated mkdtemp dir, child-only env (BILI_PERSIST_ZSTD/BILI_SESSIONS_DIR), no port binding, one blocking spawnSync under a 128 MiB heap cap.
…tripwire gap on master)

Pre-existing master gap, fixed in passing: c81be81 (ranxianglei#2752, 2026-10-11) added a process-spawning suite before the ranxianglei#2816 LIVE_FILES tripwire landed (e6ba45c, 2026-10-12), so every test job has died at the tripwire since then and the full suite never ran in them. Concurrency assumptions reviewed: listen(0) ephemeral ports, BILI_MCP_PROXY env save/restore, stdio transport to the spawned src/mcp.ts child.
@ranxianglei

Copy link
Copy Markdown
Owner

🤖 Powered by ework · qwen3.8-27b

CI 三个 test job(ubuntu 22/24、windows 24)的失败已定位并修复,新提交已推(539bbd3eb..88b1e507e)。

① 是什么问题:CI 测试调度器(#2816)有个 tripwire——所有会 spawn 进程的测试套件必须先登记进 scripts/run-ci-tests.mjs 的 LIVE_FILES(登记前需按 #1248 审查并发假设),漏登记的套件会让整个 test job 在跑任何测试之前直接 fail。本次有两个文件未登记:tests/bounded-storage-scale.test.ts、tests/issue2752-forward-partial-outcome.test.ts。

② 什么原因导致的:(a) scale test 是本 PR 新增文件,作者漏了登记;(b) issue2752 那个是 master 既有缺口——#2752(c81be81,昨天 20:05)先加了 spawn 套件,#2816 的 tripwire(e6ba45c,今天 11:15)后落地时没补上它。后果比看起来严重:tripwire 在测试阶段之前就 die,所以自 tripwire 合入起,master 的 test job 全都在门口挂掉,全量套件实际一天没跑过。

③ 影响范围与严重程度:功能面零影响(纯 CI 调度元数据);但 master 三平台全量回归停摆是真问题,已顺手修掉。

④ 怎么修复的:两个文件各一个独立标注 commit 登记进 LIVE_FILES(6f6a0e038 本 PR scale test;88b1e507e master 既有缺口——一行改动,按"小问题直接修"就地修不另立项)。#1248 要求的并发假设审查:

  • bounded-storage-scale:隔离 mkdtemp 目录、child-only env(BILI_PERSIST_ZSTD/BILI_SESSIONS_DIR)、无端口绑定、单个阻塞 spawnSync 且 128MiB heap 封顶 → c=4 池内安全
  • issue2752:listen(0) 临时端口、BILI_MCP_PROXY env 存/还原、stdio 传输到 spawn 的 src/mcp.ts 子进程 → 与其他已登记 live 套件同类

本地验证:guard 逻辑独立复跑 GUARD-OK(34 条目、0 个未登记 spawner);两个受影响套件定向跑 23/23 pass(issue2752 自 tripwire 落地后 CI 里一次都没真跑过,这次确认它在本分支是绿的)。

⑤ 是否有回归问题:无。仅列表常量加两行,测试内容/断言/调度参数(单阶段 c=4)未动。
⑥ 是否破坏缓存:不涉及。⑦ 是否改了设计方向:否。⑧ 是否引入了新的配置:无。

等 CI 对 88b1e507e 转绿。转绿后唯一遗留开放项:F-G 处置(非破坏式 legacy 迁移维持 vs 要求清理),等你拍板。

@ranxianglei
ranxianglei merged commit c55059c into ranxianglei:master Oct 13, 2026
33 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

📦 Merged Build Published

This merge to master was published as 0.0.347-master.c55059c (rolling master tag).

npm install -g billion-context@0.0.347-master.c55059c  # 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 pushed a commit that referenced this pull request Oct 13, 2026
…#3016 review)

The passthrough gate scans only the in-memory live-session map. Since #2803
(LRU session eviction) landed on master after this PR's base, a
substrate-holding session can be flushed out of memory and become invisible
to the scan. The previous wording ('disk-only sessions are not actively being
compacted' / 'Never allows when the target could have substrate') documented
a guarantee the merged code does not provide. Comments only; no behavior
change.
ranxianglei pushed a commit that referenced this pull request Oct 19, 2026
…ded-byte budget (#3818 review)

The #3135-6 union in bootBounded re-loaded every indexed id, including
envelope-backed ones the file scan had already offered under the
decoded-byte budget (#2803). With the full default (#3818) that bypassed
maxBytes for every legacy file on disk (bounded-storage warm-budget test
red: 2 sessions warmed where <=1 was allowed). Skip ids whose state file
still exists; the union now only warms pure-index (jsonWriter=off era)
ids, which is its stated purpose.
ranxianglei added a commit that referenced this pull request Oct 19, 2026
…ow-ups)

- shadow record/importRecord skip records whose blockContents carry ids
  no state.blocks entry claims: the ingest cannot represent them, so a
  viewFor rebuild silently dropped the orphans on first load. Real kernel
  sessions never trip this (state.blocks keeps consumed blocks); hand-
  built archives and web seeds do. JSON envelope stays the serving copy
  (orphanSkips counter on the stats surface).
- bootBounded union now respects the decoded-byte budget as well as
  maxSessions: index-only (jsonWriter=off) candidates are measured by
  their serialized record — the same proxy readRawFileWithBytes gives the
  file path (#2803: budget is a RAM proxy).
- loadSync organic import serves the freshly parsed envelope view
  (lossless incl. orphans) instead of a viewFor rebuild.
- test expectations for the default flip: identity-registry restores the
  legacy baseline instead of deleting the env var (absent = full now),
  web-sessions stamp legs build per-leg stores (#3135-3: the stamp is
  the live instance's, not the knob at write time) and read the
  encrypted leg back with its key, storage-mode-label absent leg says
  full, stale 'defaults to shadow' comments updated.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Startup hydrates the entire session archive before applying resident limits, causing an OOM restart loop

2 participants