Skip to content

[PR] fix(storage): persist a schema reader floor + unblock the notice-store migration - #6347

Open
ranxianglei wants to merge 3 commits into
masterfrom
2026-11-12_storage-reader-floor
Open

ranxianglei wants to merge 3 commits into
masterfrom
2026-11-12_storage-reader-floor

Conversation

@ranxianglei

Copy link
Copy Markdown
Owner

Root-cause batch for the [根因] storage aggregation (mirror #6267). One PR, two root causes, both in the storage / notice-persistence layer:

#5761 — notice-store hydration race (cherry-picked from #5768)

src/notice-store.ts: a dismiss landing between process start and cli-boot hydration bailed the whole-process hydration (the cache !== null gate), skipping the one-time legacy JSON→kv migration and hiding sibling lanes' kv rows in-process. The fix unions the hydrate into whatever lazy cache exists instead of bailing, with a distinct hydrated latch and a live-cache snapshot just before publish (publish only on success — a failed hydrate leaves the legacy-file fallback live and retries next call).

Commits (cherry-picked with -x, original authors preserved):

  • 13e7f93d26 ci-check — the fix
  • 6ef363c9fe ework-review — comment alignment

#5788 — schema reader floor

The schema stamp only says "how far a store was migrated" — nothing about whether an OLDER build can still read the result. An older build opening a newer-migrated store would no-op (sqlite) or re-run additive DDL (mysql) and then silently write rows it cannot read back.

The fix persists, per store, the oldest schema version the writing build can still read (STORAGE_MIN_READER_VERSION / MYSQL_MIN_READER_VERSION, both 8 — v8 is the only non-additive migration, the identity backfill):

A missing or malformed floor is UNKNOWN and passes, so existing (pre-feature) stores never false-refuse. The floor and the stamp move atomically (a torn pair would under-report the floor and let a too-old build in).

Behavior change (disclosed): opening a store whose persisted reader floor is above this build's schema version now REFUSES TO START where it previously opened (and silently wrote). This is the intended fix — the previous open-and-write was the data-corruption footgun. Pre-feature stores (no floor row) and additive-bump stores (floor ≤ this build) are unaffected.

Tests: tests/storage-reader-floor.test.ts (sqlite hermetic: invariant, atomic floor write, refuse arm, pass arms; mysql hermetic via a typed driver fake: refuse arm, pass arms, stamp+floor in one txn). tests/notice-unified-store.test.ts carries the #5761 regression cases.

Relationship to PR #5768

#5768 fixes #5761 alone. This batch PR cherry-picks its two commits (authorship preserved) and adds the #5788 reader floor. When this merges, #5768 is superseded — please close whichever loses. If #5768 merges first, I'll rebase this onto it.

ci-check and others added 3 commits November 12, 2026 10:34
… legacy→kv migration (#5761)

A dismissal landing before the cli-boot hydration lazily created the receipts
cache, which made hydrateNoticeReceipts bail on `cache !== null` — skipping
the one-time legacy JSON→kv migration for that process's lifetime (re-import
churn on every boot) and hiding sibling lanes' kv rows until restart; a
dismissal landing inside the hydrate's await gap was clobbered by the final
cache assignment.

Twin of the host-plugin-notice fix that landed in the #5757 line (commit
4d74613), ported onto master's API surface (unifiedKvActive/getStore; the
kvStore() probe from that line is not merged yet):

- hydrate unions into the existing lazy cache instead of bailing; a new
  hydrated latch (distinct from lazy-init) gates re-entry
- the live-cache snapshot moves to just before the publish with no awaits in
  between, so mid-hydration dismissals survive the final assignment
- publish only on success: a failed listKvAsync now leaves the cache null
  (legacy-file fallback stays live, next call retries) instead of clobbering
  it with an empty/partial map for the process lifetime

Tests: two regression cases mirroring the #5757 shapes — both fail on the
pre-fix code and pass with it; adjacent notice suites green.

(cherry picked from commit cf85588)
…s a store it cannot read (#5788)

An older build opening a store a newer build migrated with a NON-ADDITIVE
change would silently write rows it cannot read back (rolling upgrade /
rollback). Persist, per store, the oldest schema version the writing build
can still read (STORAGE_MIN_READER_VERSION / MYSQL_MIN_READER_VERSION, both
8 — v8 is the only non-additive migration, the identity backfill). The
floor and the stamp move in ONE transaction (a torn pair would under-report
the floor and let a too-old build in). On open, a build below the stored
floor REFUSES TO START (same bucket as CANTOPEN/readonly — the data is
newer, not corrupt; degrading would split-brain, #3923). A missing or
malformed floor is unknown and passes, so existing stores never
false-refuse.

sqlite: kv_store(storage, schema_reader_floor) + user_version in one txn;
gate in the from>=version no-op branch.
mysql: bili_meta schema_reader_floor + schema_version in one txn; gate in
init().
shadow.ts: dedicated refusal branches naming the real remediation (upgrade
bili / restore a pre-upgrade backup) instead of the generic tail, whose
"move index.db aside to force a fresh rebuild" would destroy the newer
build's data.

Tests: tests/storage-reader-floor.test.ts — sqlite hermetic (invariant,
migration writes floor atomically, refuse arm, pass arms: current / missing
row / additive future bump) + mysql hermetic (typed driver fake: refuse arm,
pass arms, migration writes stamp+floor in one txn).
@ranxianglei ranxianglei added bug Something isn't working p2 scope/storage labels Nov 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📦 Built Package Artifact

Branch: 2026-11-12_storage-reader-floor (fba61ee)

Latest build — install this exact version

npm install -g billion-context@0.0.9250-pr.6347.738e7a4

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
fba61ee billion-context@0.0.9250-pr.6347.738e7a4 build

Download artifact

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

Install instructions are refreshed on each push.

@ranxianglei ranxianglei changed the title fix(storage): persist a schema reader floor + unblock the notice-store migration [PR] fix(storage): persist a schema reader floor + unblock the notice-store migration Nov 12, 2026
@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

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

查重: 不重复 — #5768 是本 PR cherry-pick #5761 一半的来源 PR(patch 逐字节一致、作者信息保留),不是并行工作;本 PR 合入后 #5768 被取代,owner 关闭败方。
标签: bug / scope/storage / p2(理由:#5761 是活跃缺陷但有重启绕行 = p2;#5788 的拒绝在发版时休眠——只有未来非加性迁移上线后才会真正触发,属潜在防护而非活跃数据丢失)。

方向关(①): 两个根因都真实存在且定位正确。#5761 根因是 hydrate 重入门用 cache !== null(懒缓存已创建)当「水合完成」信号——两个概念混用;#5788 根因是「读者地板」(还能读此 store 的最老 build)从未持久化,stamp 只记录迁移推进到哪。两个修复都打到根因,不是堵症状。

回归审查(②): 无无意回归。触碰路径既有行为逐项核验:

  • dismissedCache null→file 回退保持;旧代码 cache = merged 在失败路径也执行(用空 map 覆盖懒缓存)——该覆盖本身是潜在 bug,一并修掉;
  • legacy-only 纯文件路径(unifiedReceiptsActive=false)不变;迁移 key 集合不变(file 中已在 kv 的 key 不重复迁移);
  • sqlite no-op 分支只加 floor 门,缺 floor 行 = 通过(存量 store 永不假拒);
  • mysql stamp GREATEST 永不降级语义保持,stamp+floor 现在同一事务——中途失败回滚,下次 open 幂等重迁;
  • shadow 两个新 floor 分支位于 generic refusal 尾部之前——正确,因为 generic 尾部的「移开 index.db」建议会毁掉新 build 的数据;
  • 错误流验证:StorageSchemaFloorError 不匹配 sqliteCorruptionDetail 的损坏标记(SQLITE_CORRUPT/NOTADB),不会被误分类进「无索引启动」损坏路径,正确到达拒绝分支。

代码审查(③): 两后端门等价性成立(sqlite: floor > version → 拒绝;mysql: current > version && floor > version,功能等价);原子性论证成立(floor ≤ 写入 build 的 stamp,故 floor > runningVersion 蕴含 stamp 高于 runningVersion);torn pair 会低报 floor,所以 stamp+floor 必须同事务——已实现。

测试有效性(④): 真测试、有牙,#3617 双向门——sqlite 6 条(不变量 floor≤version;迁移写 floor+stamp;拒绝臂精确错误字段;3 个通过臂:当前版本+已知 floor / 无 floor 行 / 加性未来 bump),mysql 5 条(typed driver fake;拒绝臂;通过臂含「无迁移时不重写 floor」;迁移臂断言 stamp+floor 在同一 begin..commit 窗口),notice-store 2 条回归测试在旧代码上会失败(预水合 dismissal 阻塞迁移;水合中 dismissal 被最终发布覆盖)。

diff 整洁度(⑤): 6 文件 +591/−35,每行都在范围内;无 CHANGELOG 条目——与近期 fix PR(如 #6326)惯例一致,不阻塞。

八问:

  1. 是什么问题: 存储层两个独立根因——① 启动水合完成前落下的 dismissal 会阻塞一次性 notice 收据 file→kv 迁移,并让兄弟车道的 kv 收据在本进程内不可见(通知重复弹出直到重启);② 老 build 打开被新 build 迁移过的 store 时 no-op/重跑加性 DDL,然后静默写入自己读不回的行(共享索引拓扑的滚动升级陷阱)。
  2. 什么原因导致的: ① 重入门把「懒缓存已创建」与「水合完成」混为一谈;② 读者地板与迁移 stamp 是两条信息,只持久化了后者。
  3. 影响范围与严重程度: ① 任何有 legacy 收据文件且预水合 dismissal 落下的统一存储实例——churn + 通知重复弹直到重启,p2(重启绕行);② 共享索引/滚动升级拓扑——潜在数据损坏类,发版时休眠(已发布 build 中 v8 之后无非加性迁移),下一次非加性迁移上线后转为活跃。
  4. 怎么修复的: ① 独立 hydrated 闩 + 并入既有懒缓存 + 仅成功时发布(失败水合保持 file 回退存活);② 每 store 持久化读者地板,与 stamp 同事务原子迁移,open 时 floor > 本 build 版本即 REFUSE TO START(与 CANTOPEN/readonly 同桶,给真实修复建议);缺/坏 floor = 未知 = 通过。
  5. 是否有回归问题: 无无意回归(逐项清单见上)。披露的行为变更:持久化 floor 高于本 build 版本的 store 现在 REFUSE TO START,此前会打开并静默写——这是预期修复,发版时休眠。
  6. 是否破坏缓存: 不影响前缀缓存/重试/去重键。floor 行每次迁移写一次;当前版本的存量 store 不动(no-op 分支,不写 floor)。
  7. 是否改了设计方向: 无架构变更——加性持久化扩展(一行 kv / 一行 meta)+ 启动门。
  8. 是否引入了新的配置: 无(既有 BILI_STORAGE_UNIFIED=legacy 仍是显式退出)。

CI: 12 绿 + 16 跳过,0 失败;2 个 test shard 仍在跑(ubuntu-24 shard 1、ubuntu-22 shard 3)——全部 windows shard、mysql-contract 真驱动车道、static gates 已绿。
判定: 可合(先 rebase 到当前 master——落后 5 个提交(#6326),无文件重叠,预期干净;+ 2 个在途 shard 转绿)。不符合 auto-merge 门(§7.4:持久化格式扩展 + fallback/行为产品决策)→ 人工合并。合入后 #5768 被取代,请关闭。
一句话观察:PR 正文「retries next call」措辞略不准——hydrateNoticeReceipts 全仓唯一调用点在 src/cli.ts:970(每进程一次),准确表述是「file 回退在本进程余下时间存活,下次进程启动重试」。措辞 nit,非代码缺陷。

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

Labels

bug Something isn't working p2 scope/storage

Projects

None yet

1 participant