Skip to content

fix: bound sessions storage growth (#407) - #478

Closed
ranxianglei wants to merge 1 commit into
masterfrom
2026-09-03_sessions-storage-gc
Closed

ranxianglei wants to merge 1 commit into
masterfrom
2026-09-03_sessions-storage-gc

Conversation

@ranxianglei

Copy link
Copy Markdown
Owner

What changed

Fixes the three stacked root causes of unbounded sessions-dir growth:

1. Leaf-block blockContents stored byte-identical one+full twice (~50% of file bytes)

  • cacheBlockContent (src/session.ts): identical views now share one object in memory (consumers are read-only).
  • buildRecord/serializeBlockContents (src/persist.ts): when one.text === full.text && one.count === full.count (all leaf blocks — kernel collectBlockContent with no nested children yields the same message set), only full is written; one is omitted from the record.
  • buildSession: restores one from full on load (independent copy). Old files with both keys load unchanged — backward compatible.

2. Zero GC → new startup GC pre-pass (src/session-gc.ts)

  • Stat-only (readdir + stat + rm; never reads/parses a body), runs BEFORE the load pass in initSessions, so expired files cost zero parse time.
  • Retention: delete records not written within BILI_SESSION_RETENTION_DAYS (default 90, 0 = off). Age ≈ mtime because writes are atomic temp+rename.
  • Budget: while surviving total exceeds BILI_SESSION_MAX_BYTES (default 1 GiB, 0 = off), drop oldest first.
  • A canonical <base>.json and its spill sibling <base>.fb.json form one unit (age = newest mtime, size = sum) so a record and its spill are never separated; .tmp-* in-flight files are untouched.
  • Rationale for deleting: bili session state is derived data — the conversation itself lives on the client (same posture as the existing in-memory LRU eviction).

3. Two full synchronous walks per boot + migration log repeating every boot

MAX_SESSIONS (#407 suggestion 4): kept as the post-load newest-N cap; true stop-parsing-early would need an early-stop hook in the kernel's recursive walk (cross-repo change, acp-kernel must ship first). With GC bounding total bytes at the default 1 GiB, parse cost no longer grows without bound — corpus is bounded by construction.

Tests

  • tests/proxy-persist.test.ts: leaf-block dedup round-trip (identical view stored once, distinct views keep both, ~half serialized size, one restored on load), migration rekey test migrated to loadAllMigrated, migration-log-fires-once test (log capture).
  • tests/session-gc.test.ts (new): retention/budget/disabled/spill-unit/.tmp-*/missing-dir unit tests + env defaults; acceptance integration test — 300 expired + 5 fresh files: one startup removes the expired BEFORE any read (pool size is the read probe: MAX_SESSIONS raised above 305, only the 5 survivors land in memory), disk total stable across boots.

Pre-flight

  • npm run typecheck ✅
  • npm test: 877/878 pass; the 1 failure (launcher.test.ts: resolveClientCommand codex/claude) fails identically on pristine master — environment-dependent (/usr/bin/codex present on this host), unrelated to this change.
  • npm run build ✅
  • E2E (codex) not run: local upstream unreachable here; change does not touch the request pipeline (server.ts / src/loop / adapters / preflight paths).

- store identical one/full block views once: leaf blocks serialize only
  full (one restored on load); in-memory cache shares one object
- startup GC (stat-only, before any read): retention BILI_SESSION_RETENTION_DAYS
  (default 90d) + byte budget BILI_SESSION_MAX_BYTES (default 1GiB), 0 disables;
  canonical+spill (.fb.json) handled as one unit, .tmp-* untouched
- single startup walk: #286 legacy-id migration folded into loadAllMigrated
  (was two full loadAll walks per boot); migration log fires only when a
  rekey actually happens instead of every boot
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📦 Built Package Artifact

Branch: 2026-09-03_sessions-storage-gc (395dba4)

Option A — Install from npm PR tag (recommended)

npm install -g billion-context@pr-478

Each push to this PR publishes a new version under the pr-478 npm tag.

Option B — Download artifact

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

This comment is automatically updated on each push.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 ework agent · qwen3.8-27b

[bot] 🏷 Reviewed #478 (2026-09-03_sessions-storage-gc, 395dba4). I reproduced the pre-flight in an isolated worktree checked out at the exact commit and did a correctness pass on all three changes. Verdict: LGTM to merge — no blocking issues found. Details below.

Pre-flight — reproduced; claims accurate

  • npm run typecheck ✅ (exit 0)
  • npm run build ✅ (self-contained dist/index.js 2.57 MB, acp-kernel inlined)
  • npm test → 877/878 pass, exactly as stated. The single failure is resolveClientCommand: codex/claude resolve to themselves (launcher.test.ts). I confirmed it is not caused by this PR:
    • The diff touches no launcher/client-config code.
    • It fails identically on pristine master (ran launcher.test.ts alone on master: same test red, 112/113).
    • Root cause is environment-specific: /usr/bin/codex and /usr/bin/claude exist in this sandbox, so resolveClientCommand("codex", {PATH:"/usr/bin"}) resolves differently than the test expects. Pre-existing + env-dependent — safe to ignore for this change.

Merge state

  • Branch is 64 commits behind master, but master made zero changes to any file this PR touches (persist.ts, session.ts, logger.ts, proxy-persist.test.ts) since the merge-base. git merge-tree reports a clean merge — no textual or semantic overlap. No rebase strictly required (one would still be tidy for linear history).

Correctness pass

  1. Leaf-block one/full dedup — safe. The guard c.one.text === c.full.text && c.one.count === c.full.count only merges genuinely-identical views (leaf blocks); blocks with nested children keep distinct text and are still stored twice, correctly. I traced every consumer of blockContents (decompress-shared, export, orphan-gc, stream) — all read-only (view.text/view.count, then delete the map entry), so sharing one object between one/full cannot corrupt. buildSession restores one from full as an independent copy when the key is absent, and old two-key files load unchanged — backward compatible.
  2. Startup GC pre-pass (session-gc.ts) — correct ordering and accounting. Stat-only (readdir+stat+rm, never parses a body) and runs before the load pass, so expired files cost zero parse time. Retention (age < cutoff) runs first, then the byte budget drops oldest-first until under cap. Canonical <base>.json + spill <base>.fb.json are keyed as one unit (age = newest mtime, size = sum) so a record and its spill are never separated; .tmp-* in-flight files are skipped. Log fires only when something was actually deleted. The acceptance test (300 expired + 5 fresh, MAX_SESSIONS raised to 1000, _sessionsSizeForTest()===5) directly proves the expired files are removed before any read.
  3. Single walk + log-once — behavior preserved. I specifically checked the risky path: the combined loadAllMigrated() returns an empty map when persistence is disabled, which matches the old flow exactly (old loadAll() had the same if (!this.enabled) return out guard, and migrateLegacyIds was a separate step that no-ops when disabled) — so there is no data-loss regression. Legacy-id rekey collision semantics are unchanged (I traced a 3-way same-label collision → newest savedAt wins, losers dropped + removed). The migration log is now gated on rekeyed > 0 instead of rekeyed || unlabeled, so it fires once ever rather than every boot.

Non-blocking observations

  • Budget edge case: the trim loop deletes oldest-first until total <= maxBytes with no "keep the newest" floor, so a single record larger than the entire budget would trigger a full wipeout. Very unlikely at the default 1 GiB cap, but worth a future guard if the default is ever lowered or single sessions grow huge.
  • The GC walk uses synchronous fs (readdirSync/statSync/rmSync). Steady-state cost stays bounded by construction, but a long-idle host paying its first post-absence boot does a one-time synchronous walk of the whole dir on the event loop. Acceptable trade-off; noting for completeness.
  • E2E (codex) not run — agreed, defensible: the change is confined to persistence/GC/session-init and does not touch the request pipeline (server.ts, src/loop, adapters, preflight).

Compliance: no as any / @ts-ignore / console.* in the new code (uses loggerLog), version untouched (correct for a content branch). Env knobs BILI_SESSION_RETENTION_DAYS (default 90, 0=off) and BILI_SESSION_MAX_BYTES (default 1 GiB, 0=off) match the description.

Ready to merge from my side (merge itself stays human-only).

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 ework agent · qwen3.8-27b

[bot] 🏷 Outcome summary (follow-up to the full review above): LGTM to merge.

  • Reproduced pre-flight at commit 395dba4 in an isolated worktree: typecheck ✅, build ✅, npm test → 877/878. The single failure (resolveClientCommand: codex/claude) is pre-existing + env-dependent — red on pristine master too, unrelated to this change.
  • Correctness pass on all three changes passed; specifically verified the combined load path preserves disabled-persistence behavior (no data-loss regression) and that rekey collision semantics are unchanged.
  • Clean merge into master despite being 64 commits behind (master touched none of the changed files since the merge-base).
  • Non-blocking notes left above (budget-loop wipeout edge case; synchronous boot walk). Merge stays human-only.

Note for the tracker: this eework task is numbered #479, but no GitHub issue/PR #479 exists (the code under review is this PR, #478). Attempts to post on #479 return 404, so this review + summary are posted here on the PR itself.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 ework agent · qwen3.8-27b

[bot] 🏷 三个根因全部实测确认,已按你的四条建议修复 → PR: #479

审核结论(动手前的验证)

  1. RC1 叶块双份正文 — 确认,且比描述更彻底:直接对 acp-kernel 0.0.47 做了探针实验,T1 叶块(directBlockIds: [])的 one/full 两个视图逐字节相同(探针样本各 ~10.3K 字符完全相等)。机制:被消费的子块在创建时即置为 inactive(consumed.active = false),而 one 视图跳过 inactive 子块——所以只要没有"仍活跃的直连子块",两视图恒等;而缓存发生的那一刻这个条件永远成立。你扫出的 728/728 块相同、占 blockContents 50.0% 与之一致。层次判断:这不是 kernel 的 bug(两个视图对含活跃子块的父块确实不同),而是 bili 无差别双存的策略问题,在 applyRanges 去重是打到了正确层次的修法。
  2. RC2 零 GC — 确认:kernel store 自述 "never deletes";bili 侧除 会话身份不稳定:credential/upstream 维度导致长会话状态孤儿化(sessions 缺失) #286 迁移删除与 tmp 清理外无任何保留策略;MAX_SESSIONS=256 在全量 load 之后才截断内存,evict 反而触发写盘。
  3. RC3 两遍全量解析 + 迁移从不落地 — 确认:initSessions 先 migrateLegacyIds()(内部自带一次 loadAll())再 initSessions 自己的 loadAll();且未标注 legacy 文件按设计永久保留、又无完成标记,所以 "one-time migration" 日志每次启动必现——你观察到的 2s 段即两次完整 walk+parse。

修复内容(对应你的建议 1–4)

  1. directBlockIds 为空只存一份:applyRanges(src/stream.ts)在两视图逐字节相等时存 one: null;加载端(src/persist.ts buildSession)把旧文件里"恰好相同"的 one/full 对归一化为同一形态——存量文件无需迁移脚本,下次写入自动塌缩成单份。decompress 端回退 one ?? full,两种视图语义不变。
  2. 启动 GC:SessionStore.boot() 内做保留期清理——超 BILI_SESSIONS_RETENTION_DAYS(默认 90d)的记录删除;总字节超 BILI_SESSIONS_MAX_BYTES(默认 1 GiB)时按 savedAt 从最旧删到预算内。任一设为 0/负数禁用该维度。删除覆盖 canonical/_unknown/flat-name 三类候选路径及其 .fb.json spill 变体。选删除而非归档:bili state 是派生态(会话本体在客户端),归档目录仍会被 kernel 的 walk 扫到,省不了解析开销。
  3. 一遍扫描 + 迁移落地:新增 SessionStore.boot() = 一次 loadAll() → 迁移 → GC → 水合;迁移改为完成标记门控(<sessions>/.bili-migration-286.done,dotfile 对 kernel 的 .json walk 不可见),只跑一次、只记一次日志。migrateLegacyIds() 保持公开(现有测试直接调用),同样受标记门控。
  4. MAX_SESSIONS 语义:保留内存上限语义;GC 封顶语料后,启动解析时间不再随历史无限恶化。真正的"加载预算"(最旧文件连 parse 都跳过)需要 kernel 侧 loadAll 预选/预算 API——bili 自行实现会脆弱复制 kernel 的 canonical/spill 对账逻辑,不可取。已按跨仓规则先提了 acp-kernel#185(纯增量 API,默认行为不变),kernel 发版后再消费。

验收标准对照

  • ✅ 叶块正文只存一份:新压缩持久化 "one": null(测试断言磁盘 JSON 中正文仅一份);旧的重复对加载时归一化
  • ✅ 300 个过期文件:测试用 fs.readFileSync 探针计数,整次 boot 恰好 302 次读 / 302 个文件 = 一遍 walk;300 过期全清、2 新鲜保留,总量稳定
  • ✅ 迁移日志只出现一次:跨两次 boot 捕获日志,断言迁移行恰好一行;解析时间随语料封顶(GC)停止线性恶化

预检

typecheck 干净;npm test 949/950——唯一失败是环境性的既有问题:launcher.test.ts "codex/claude resolve to themselves" 假设 /usr/bin/codex 不存在,本机恰好装了它,未改动的 master 在本机同样失败,与本 diff 无关(未动 launcher)。build 通过。E2E 未跑(server 请求管线未动,仅启动期会话加载变化)。CONFIGURATION.md 已补两个新 env 与 "Storage lifecycle at boot" 小节。

ranxianglei pushed a commit that referenced this pull request Sep 4, 2026
… one-pass startup (#478)

Session registry storage grew unbounded (~387MB / 452 files measured
locally, ~60MB/day) and every boot performed two synchronous full
walk+parse passes over it. Three stacked causes, all addressed:

1. Leaf blocks persisted byte-identical `one` and `full` views — 50% of
   all blockContents bytes were duplicates corpus-wide. Verified
   empirically: the kernel produces identical text for both views when a
   block has no active direct children, which is always true for T1 leaf
   output. applyRanges now stores `one: null` when the two views are
   byte-equal; load normalizes legacy duplicate pairs into the same form
   so existing files collapse on their next write.

2. No retention policy: startup GC prunes records older than
   BILI_SESSIONS_RETENTION_DAYS (default 90d) and oldest-first past
   BILI_SESSIONS_MAX_BYTES (default 1 GiB). Session state is derived —
   the conversation lives client-side — so pruning is safe; pruned
   sessions start fresh.

3. Startup ran migrateLegacyIds() (with its own loadAll) plus
   initSessions' loadAll(): two recursive directory walks and double
   JSON.parse, and the "one-time" migration log line repeated on every
   boot because no completion marker was ever written. New
   SessionStore.boot() does ONE walk: migrate -> gc -> hydrate. Migration
   is now marker-gated (.bili-migration-286.done) so it runs once, ever.

MAX_SESSIONS keeps its memory-cap semantics; a true parse budget needs a
kernel-side loadAll preselection API (follow-up issue filed).

Tests: tests/fix-478-storage.test.ts — one:null round-trip, legacy
duplicate normalization, 300 expired files pruned in a single
readFileSync pass (probe-counted), migration log fires exactly once
across boots, size-budget pruning, disabled-pass escapes.

Pre-flight: typecheck clean; node --test 949/950 (single failure is
environmental: launcher.test.ts "codex/claude resolve to themselves"
assumes /usr/bin/codex is absent; this box has it installed and unmodified
master fails identically); build ok.
ranxianglei added a commit that referenced this pull request Sep 4, 2026
@ranxianglei

Copy link
Copy Markdown
Owner Author

被 #479 取代(其 body 已声明 Closes #478,且经过独立复审 + rebase 到 master,见 https://github.com/ranxianglei/billion-context/pull/479#issuecomment-5537975850)。本初稿分支不再推进。

@ranxianglei ranxianglei closed this Sep 4, 2026
antclt pushed a commit to antclt/billion-context that referenced this pull request Oct 2, 2026
…xianglei#1725)

- acp-kernel 0.0.99 → 0.0.100. Brings four kernel changes:
  * ak#484: matchToolPattern is case-insensitive — fixes ranxianglei#1725
    (opencode "read" vs Claude-style "Read" protectedTools entries
    silently never matched, so their history could be folded).
  * ak#496: ref capacity widened to 9,999,999, hybrid widths (ranxianglei#483) —
    long side-request sessions no longer burn out at m99999.
  * ak#495: language preservation prompt rule, kernel-side opt-in only
    (exposing a bc config knob is a follow-up decision).
  * ak#479: nudge cadence re-anchors when the live count drops below the
    shown reference (ranxianglei#478) — the kernel-side fix for the class bc#1595
    patched around.
- tests/protected-tools.test.ts: new regression — "Read" pattern protects
  opencode's lowercase "read" pair end-to-end (BLOCKED refs, fold carve-out,
  survival). Verified it FAILS on 0.0.99 and PASSES on 0.0.100.
- tests/issue1595-nudge-reanchor.test.ts: with ak#479 the kernel retires
  the phantom reference inside the nudge node before bc's usage-settle
  hook runs, so the bc log line legitimately disappears; the invariant
  under test is now the end state (phantom never survives a real usage
  report), log asserted only when bc is the retiring side.

Local: typecheck ✓ build ✓ full suite 3591/3571/1/19 pre-adaptation (the 1
failure was this test's outdated log expectation), 16/16 after.
ranxianglei pushed a commit that referenced this pull request Oct 3, 2026
…eady path (#1960)

The steady request path (client echoes the round back) silently dropped
redacted_thinking blocks because anthropicToCore had no case for them, so
every later request rebuilt the latest assistant message without the block
and Anthropic answered 400 "cannot be modified" forever — the half of #1960
that kept the session stuck after the compress turn.

- acp-kernel pin 0.0.99 → 0.0.101 (exact), which carries kernel PR #500
  (redacted_thinking sidecar round-trip). Also rides along from v0.0.100+:
  #483 ref capacity widened to 9,999,999, #484 case-insensitive protected
  tool matching, #478 nudge cadence re-anchor, #493 language-preservation
  behind an opt-in flag (default off), and #499 prune/recommend shared
  per-turn index (hundreds of ms saved on ordinary requests in large
  sessions — release-note material).
- tests/issue1960-steady-redacted.test.ts: client-echoed
  [thinking(sig), redacted(data), tool_use] must survive prepare→outbound
  byte-identical in order. Verified failing on 0.0.99, passing on 0.0.101.

Preflight: typecheck clean; targeted loop/thinking/reasoning suites 186/186
green incl. both new #1960 files; build OK.
LoveDoLove pushed a commit to LoveDoLove-Forked-Projects/billion-context that referenced this pull request Oct 19, 2026
)

A long-running session compressed at the 75% band, and the first
post-compression reading landed at a transitional 92106 while the fold
was still settling — the real level 20s later was 44038. The weld pinned
the baseline at 92106, and the only downward channel (kernel: single
sustained drop ≥ one full growth interval; host mirror ranxianglei#1595: same
margin over the whole interval) needed a 50000 drop while this one was
48068 — 1932 short. Growth stayed negative for 13 hours and every
mid-band nudge sat at 'nudge idle' until the next 75% over-limit event.

Fix: all three reference families — lastPerMessageNudgeTokens (baseline),
lastNudgeShownTokens (shown) and lastShownByTier (per-tier cadence
stamps) — become low-water marks: every sample, ref = min(ref, reading).
The reference rides down with any settle, transitional welds and
estimate-grade overshoots (ranxianglei#478 phantom levels) retire on the very next
sample, and no full-interval drop event is needed. Refs never ride UP,
so growth is always measured as the climb from the nearest valley —
exactly what a cadence wants. The post-compression reset stays
untouched: ranxianglei#194's first-sight bypass still keys on baseline === 0.

Host side: the ranxianglei#1595 mirror (reanchorNudgeOnUsageDrop in session.ts,
called from cache-ledger.ts) is retired — the kernel low-water marks
cover its scenario at every grade, including the estimate lane it missed.
Its regression suite moved to the kernel replay test.

Bump acp-kernel 0.0.133 -> 0.0.134 (kernel-guard rule 2).
cskwork pushed a commit to cskwork/billion-context that referenced this pull request Oct 21, 2026
… one-pass startup (ranxianglei#478)

Session registry storage grew unbounded (~387MB / 452 files measured
locally, ~60MB/day) and every boot performed two synchronous full
walk+parse passes over it. Three stacked causes, all addressed:

1. Leaf blocks persisted byte-identical `one` and `full` views — 50% of
   all blockContents bytes were duplicates corpus-wide. Verified
   empirically: the kernel produces identical text for both views when a
   block has no active direct children, which is always true for T1 leaf
   output. applyRanges now stores `one: null` when the two views are
   byte-equal; load normalizes legacy duplicate pairs into the same form
   so existing files collapse on their next write.

2. No retention policy: startup GC prunes records older than
   BILI_SESSIONS_RETENTION_DAYS (default 90d) and oldest-first past
   BILI_SESSIONS_MAX_BYTES (default 1 GiB). Session state is derived —
   the conversation lives client-side — so pruning is safe; pruned
   sessions start fresh.

3. Startup ran migrateLegacyIds() (with its own loadAll) plus
   initSessions' loadAll(): two recursive directory walks and double
   JSON.parse, and the "one-time" migration log line repeated on every
   boot because no completion marker was ever written. New
   SessionStore.boot() does ONE walk: migrate -> gc -> hydrate. Migration
   is now marker-gated (.bili-migration-286.done) so it runs once, ever.

MAX_SESSIONS keeps its memory-cap semantics; a true parse budget needs a
kernel-side loadAll preselection API (follow-up issue filed).

Tests: tests/fix-478-storage.test.ts — one:null round-trip, legacy
duplicate normalization, 300 expired files pruned in a single
readFileSync pass (probe-counted), migration log fires exactly once
across boots, size-budget pruning, disabled-pass escapes.

Pre-flight: typecheck clean; node --test 949/950 (single failure is
environmental: launcher.test.ts "codex/claude resolve to themselves"
assumes /usr/bin/codex is absent; this box has it installed and unmodified
master fails identically); build ok.
cskwork pushed a commit to cskwork/billion-context that referenced this pull request Oct 21, 2026
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.

1 participant