fix(session-store): 修复 SQLite 导入孤儿 WAL - #1073
Conversation
ac852cc to
ede2188
Compare
自动评审初步意见(以维护者审阅为准)先说结论:根因判断的方向是对的,修法( 一、缺陷与修复我独立复现了,成立在 Bun 1.4.0 上跑真实生产代码(
同一脚本在 Node 下两种模式都正常 —— 确认是 Bun 特有。
二、🔴 唯一的合入前建议:新增的断言目前没有区分力新测试里那三条
原因是结构性的:vitest 的测试体永远在 Node 下执行,即使用 这意味着:修复本身是真的,但它没有回归护栏 —— 未来任何人把这行改回 WAL,全套测试和 CI 都会放行。 可行的补法(我写了一版,实测在 PR 代码上绿、MUT-A/MUT-B 两组变异都红):显式定位 bun 二进制起子进程跑真实导入路径,并让子进程自报运行时、断言它确实是 bun(否则守卫又会变空转): // 关键:不能用 process.execPath / ts-runner —— 它们继承父运行时(Node)
function findBun(): string | null {
for (const dir of (process.env.PATH || '').split(':')) {
const cand = join(dir, 'bun');
if (dir && existsSync(cand)) return cand;
}
return null;
}
// …spawnSync(bun, ['-e', src]),src 内 import 生产 session-store 并 init/listSessions
expect(res.runtime, 'child must really run under Bun').toBe('bun'); // 自证有牙
expect(res.visible).toBe(40);若不希望测试依赖 bun 存在,退一步也可以只加形态守卫(grep 生产源码断言导入路径不出现 三、🟠 一个 PR 描述外的发现:已被打坏的存量库不会自愈,且会静默报「健康」这条不是本 PR 引入的,但它决定了修复的实际覆盖面,建议一并考虑: 如果某个库已经被旧代码打坏( 即 40 行会话静默变成 0 行,而且 补充两点让严重性更准确:
顺带一提:PR 新增的 post-close 守卫本身是个有效的安全网。我把它和 WAL 组合起来测(WAL + 守卫),它会拒绝发布、保留冻结 JSON 的 40 行,且 四、其它核对结果
五、一个可选的措辞修正描述里说「WAL 本就不适用于会被连同 sidecar 一起 rename 的库」——方向对,但我实测根因更具体一点:并非 WAL 天生不能配 rename,而是 Bun 的
Node 三种写法都正常。这不影响你选 DELETE(对一次性产物来说 DELETE 确实更合适,也更不依赖引擎细节),但如果描述里写准根因,将来读到这段的人不会误以为「WAL + rename」本身是错的。 以上是自动评审的初步意见,最终以维护者审阅为准。第二节(补一个真在 Bun 下跑的回归)是我唯一建议合入前处理的项;第三节更像是可以另开 issue 的既有问题。 |
复审意见(第二位自动评审,以维护者审阅为准)上面这条初审的三个核心结论我已独立复核,全部成立;补充几点校准与实现要求。 1. 缺陷与修复:确认成立Bun 1.4.0 + master 代码跑真实导入路径复现:发布后 2. 「现有断言无区分力」确认;补回归时请满足四点独立证实:
另注:若未来有人改用「WAL + 3. 存量坏库:结论确认,机制可写得更准,窗口其实更宽复现了「rename 后 SIGKILL → 永久损坏:重启
严重性校准(需 crash 才永久坏、线上 fleet 跑 Node 未受影响、风险面是 Bun/编译形态)与初审一致,同意。 4. 根因归因:独立复现成立,建议按此修 PR 描述措辞Bun 1.4.0 四种写法对照(同一 schema + 40 行插入):
Node 2.x 四种全部正常。根因确为「Bun 的 结论同意初审判定:修法正确,唯一合入前建议是补一个真在 Bun 下跑的端到端回归(要求见第 2 节);PR 描述的根因措辞建议一并修正。最终以维护者审阅为准。 |
自动评审补充:可直接取用的回归测试补丁(以维护者审阅为准,不代表合并决定)接前一条意见的第二节。既然「修复是真的、但没有护栏」这条只有在真的跑 Bun 时才能咬住,我把测试写好并验证过了,贴在这里供你直接取用或改写。新增文件 展开补丁import { describe, it, expect } from 'vitest';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readdirSync, rmSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
/**
* The orphan-WAL defect this guards is BUN-ONLY, and a vitest test body always
* runs under NODE — even when the suite is launched with `bun x vitest`
* (verified: the body reports `node v22.21.1`). So an in-process assertion can
* never observe it: reverting the production fix leaves the Node-side suite
* 19/19 green. CI cannot see it either — `bun run test` runs the Node path, and
* the `bun-binary` job's smoke uses an empty `bots.json`, so no import happens.
*
* Therefore this guard spawns a REAL bun binary and drives the real import path.
* Two things keep it from silently degrading into a no-op:
* • the child reports its runtime and `Bun.version`, and we ASSERT both — a
* child that quietly fell back to Node would otherwise pass vacuously;
* • a missing bun FAILS rather than skips (opt out explicitly with
* BOTMUX_ALLOW_NO_BUN=1). A skip would let the guard vanish the next time
* the runner image changes — the same silent-loss failure mode the fix is
* about.
*/
/** `packageManager` pins the Bun the project (and CI's setup-bun) uses. */
function pinnedBunVersion(): string {
const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8')) as { packageManager?: string };
const m = /^bun@(.+)$/.exec(pkg.packageManager ?? '');
if (!m) throw new Error(`package.json packageManager is not a bun pin: ${pkg.packageManager}`);
return m[1];
}
/**
* Resolve bun from the INHERITED PATH. Deliberately not `process.execPath`
* (that is the parent runtime — Node here) and not a login shell: `bash -lc
* 'command -v bun'` exits 1 on a machine where bun lives under an fnm
* node-versions dir, because the login PATH differs from the one vitest runs
* with. Not checking the executable bit — an unusable candidate surfaces as a
* spawn failure, which is louder than a silent skip.
*/
function findBun(): string | undefined {
for (const dir of (process.env.PATH ?? '').split(':')) {
if (!dir) continue;
const candidate = join(dir, 'bun');
if (existsSync(candidate)) return candidate;
}
return undefined;
}
describe('first-start JSON import under Bun', () => {
it('publishes a store a fresh Bun process can read back in full', () => {
const bun = findBun();
if (!bun) {
// Explicit escape hatch only; absence is a failure by default.
if (process.env.BOTMUX_ALLOW_NO_BUN === '1') return;
throw new Error(
'bun not found on PATH — this guard covers a Bun-only defect and must not silently skip. '
+ 'Set BOTMUX_ALLOW_NO_BUN=1 to opt out deliberately.',
);
}
const dataDir = mkdtempSync(join(tmpdir(), 'sqlite-import-bun-'));
// Isolate HOME too: without SESSION_DATA_DIR the store falls back to
// HOME/.botmux, so a regression in the harness would touch real data.
const fakeHome = mkdtempSync(join(tmpdir(), 'sqlite-import-home-'));
try {
const rows: Record<string, unknown> = {};
for (let i = 0; i < 40; i++) {
rows[`s${i}`] = {
sessionId: `s${i}`, chatId: 'oc_chat', rootMessageId: `om_s${i}`, title: `t${i}`,
status: 'active', createdAt: '2026-01-01T00:00:00.000Z', scope: 'topic',
};
}
mkdirSync(dataDir, { recursive: true });
writeFileSync(join(dataDir, 'sessions-appA.json'), JSON.stringify(rows));
const storeModule = join(process.cwd(), 'src', 'services', 'session-store.ts');
const source = `
const store = await import(${JSON.stringify(storeModule)});
store.init('appA');
console.log(JSON.stringify({
runtime: typeof Bun !== 'undefined' ? 'bun' : 'node',
bunVersion: typeof Bun !== 'undefined' ? Bun.version : null,
visible: store.listSessions().length,
}));
`;
const child = spawnSync(bun, ['-e', source], {
encoding: 'utf-8',
env: { ...process.env, HOME: fakeHome, SESSION_DATA_DIR: dataDir },
});
const line = (child.stdout ?? '').split('\n').filter(l => l.trim().startsWith('{')).pop();
expect(line, `bun child produced no result. stderr:\n${child.stderr}`).toBeTruthy();
const result = JSON.parse(line!) as { runtime: string; bunVersion: string | null; visible: number };
// Self-certification: without these the guard passes even if the child
// silently ran under Node, where the defect does not exist.
expect(result.runtime, 'child must actually run under Bun').toBe('bun');
expect(result.bunVersion, 'child bun must match the packageManager pin').toBe(pinnedBunVersion());
// The defect: schema+rows stranded in an orphan WAL, so the published
// .db opens as an empty (or unreadable) database.
expect(result.visible, 'every imported row must be visible under Bun').toBe(40);
// Nothing may remain under the `.tmp` basename after publishing.
const storeDir = join(dataDir, 'session-stores', 'appA');
expect(readdirSync(storeDir).filter(f => f.includes('.tmp'))).toEqual([]);
} finally {
rmSync(dataDir, { recursive: true, force: true });
rmSync(fakeHome, { recursive: true, force: true });
}
});
});我对这版补丁跑过的验收(都是最终版,不是原型)
几个设计取舍,供你判断是否同意
另外, 以上是自动评审的补充材料,是否采用、怎么改都以维护者判断为准;我不会代为合并。 |
导入临时库现用 DELETE journaling,其 sidecar 是 `.tmp-journal`。 将 pre-import 清理、post-close 守卫、error-path 清理三处循环补上 `-journal`,使不变量与实际使用的日志模式一致;回归断言同步校验 `sessions.db.tmp-journal` 不残留。
dcd3bc6 to
19cbb44
Compare
|
已按建议处理,更新在
验证: |
自动评审复审意见(新 commit
|
| 检查 | 结果 |
|---|---|
| 新测试在本分支 | 1/1 绿 |
MUT-A:只把 journal_mode 改回 WAL |
红 expected +0 to be 40 |
MUT-B:session-store.ts 整个回退到 master |
红(同一条) |
| PATH 上没有 bun | 红 bun not found on PATH; cannot run the required Bun-specific regression(fail 而非 skip ✅) |
把 packageManager 改成 bun@9.9.9 |
红 expected '1.4.0' to be '9.9.9' ⟹ 版本 pin 断言有牙 |
bun x tsc --noEmit |
exit 0 |
四个直接相关文件同跑(新测试 + session-store-sqlite + ts-runner-helper + session-store-bwrap) |
27/27 绿 |
| 全量 unit | 8 文件 / 21 用例失败 —— 与我上一轮量到的既有基线逐字一致,且无一与 session-store / sqlite / ts-runner 相关 |
| 真实数据隔离 | store 数 56→56、无 appA、临时目录 finally 清净 ✅ |
三、两处你比我原来的写法更好
accessSync(candidate, X_OK)优于我用的existsSync。我原来特意说"不查可执行位",理由是不可执行的候选会以 spawn 失败暴露。你这样更早失败、也更准。我担心过一件事并实测排除了:root 下X_OK是否会被绕过 —— 结论是不会,对chmod 644的假 bun 仍正确拒绝(Linux 要求至少一个 exec 位)。- Windows 分支(
bun.exe/bun.cmd)是我没考虑的,虽然本仓 daemon 跑 Linux,但共用 helper 里补上是对的。
顺带说明一点,免得后来人误改:新 helper 里硬写 ['-e', source]、没有复用 tsEvalArgs(),这是对的。tsEvalArgs() 按父进程运行时返回参数,父进程是 Node 时会返回 --input-type=module,语义上就不是"给 bun 用的"。(我实测 bun 其实也接受这个 flag,所以复用不会真的坏 —— 但按当前写法语义更清楚。)
四、非阻断的小建议(采不采都行)
BOTMUX_ALLOW_NO_BUN之类的显式豁免被去掉了。默认 fail 我完全赞成(这正是核心);但如果将来有人在没装 bun 的环境跑单测,现在只能改代码。是否留一个显式 env 逃生阀,由你判断 —— CI 上没有这个问题,buildjob 有setup-bun@v2+bun-version: 1.4.0,PATH 找得到。resolveBunExecutable/spawnSyncBunTsEvalWithRepoImports在test/ts-runner-helper.test.ts里没有直连测试。它们已被新测试端到端用到(所以不是死代码),但那个 helper 文件本身的测试覆盖了其它每个导出,补两条会更一致。
五、PR 描述与实现的一致性
描述里「通过统一的 test/helpers/ts-runner.ts 定位并启动 Bun」「子进程自报运行时和版本」「断言没有任何 .tmp-* sidecar」——逐条核对,与代码一致。根因段落也按上一轮的实测改准了(说明是「prepared statement 尚未 finalize 时 close() 不做 checkpoint」,而不是笼统的「WAL 不适用于 rename」)。
「本 PR 阻止新坏库产生,不处理旧版本已经发布的损坏库;存量恢复另行跟进」——这个边界划得清楚且准确,与我实测一致(已损坏的库因为 .db 已存在,不会走 pre-import 清理分支)。
以上是自动评审的复审意见,最终以维护者审阅为准;我不代为合并。
deepcoldy
left a comment
There was a problem hiding this comment.
双审通过(自动评审 + 交叉复审),维护者确认合入。
修复经真跑验证有效,新增回归测试有牙(两组变异均转红),与最新 master 合并干净且护栏不失效。
|
🚀 Released in v3.18.7 |
#1073 只阻止产生孤儿 WAL 坏库,不修存量。被打坏的 store 主库仅 4096 字节空壳、数据全在 sessions.db.tmp-wal 里,而 listSessions() 返回 0 且 listSessionsStrict() 不抛 —— 静默丢掉全部会话且自报健康。 检测只用「.db 存在且 <dbFp>.tmp* 孤儿存在」(quick_check 在坏库上返回 ok,零区分力; 「表不存在」会被 CREATE TABLE IF NOT EXISTS 自掩蔽)。 恢复不就地改名 .tmp-wal(-wal 是替换而非合并语义,实测会把已被写入新会话的库净毁数据), 改为复制到 scratch 让 SQLite 重放后 INSERT OR IGNORE 合并,活行永远赢。 两个独立决策分开判:能否动手需正向作证(孤儿确有重放 / 快照被读到——看解析了哪个源不看行数 / 同 digest receipt);能否毁掉孤儿需 wal_checkpoint(PASSIVE) 真实接受帧数 + 与不挂 WAL 的 同一 shell 整行差分,证不了完整则归档原始字节而非删除。清理顺序把带数据的 .tmp-wal 放最后, 使「只剩 .tmp-shm」不可能产生;跨崩溃收敛用与合并同事务提交的 receipt。 无法证实时设 loadFailure 让 listSessionsStrict() 正常抛错;非 owner 进程不修库。恢复全程 在既有文件锁内。 18 例回归全部在真 Bun 子进程里造坏库并跑生产 load();反变异 14 组逐一转红;session-store 四文件 128/128;全量 19565 passed(5 条失败均为既有环境项或端口竞争,隔离重跑绿)。
变更
首次将 JSON 会话导入 SQLite 时,临时库显式改用 DELETE journaling(不再 WAL):关闭后主库已自包含,单次
renameSync即可完整发布。同时把导入路径三处 sidecar 循环统一到实际日志模式:pre-import 清理、post-close 守卫、error-path 清理都覆盖.tmp-journal,并保留-wal/-shm以清理旧版本崩溃残留。新增真正在 Bun 1.4.0 下运行生产导入路径的回归测试。测试通过统一的
test/helpers/ts-runner.ts定位并启动 Bun,子进程自报运行时和版本,导入 40 行后重新读取,并断言没有任何.tmp-*sidecar。原因
Bun 1.4.0 下,导入路径的 prepared statement 尚未 finalize 时调用
close(),WAL 不会在关闭时完成 checkpoint。随后只重命名主库,-wal/-shm仍保留.tmpbasename,schema 和已提交行被留在孤儿 WAL 中;发布后的sessions.db重开时会报SQLite disk I/O error或呈现为空库。DELETE 模式使一次性构建产物在提交后由主文件自包含,不依赖 WAL checkpoint 和多文件同步 rename。live store 打开后仍按既有逻辑切到 WAL。
影响范围
仅改 JSON 首次导入的 SQLite 初始化路径及其回归测试;其他 CLI、后端及话题/群组会话路径没有改动。本 PR 阻止新坏库产生,不处理旧版本已经发布的损坏库;存量恢复另行跟进。
验证
bun run build:通过bun run test -- test/session-store-sqlite-bun-import.test.ts test/session-store-sqlite.test.ts:2 个文件、20/20 通过0/40行Bun.version与packageManager的bun@1.4.0一致bun run daemon:restart:通过