fix(B-6): KVStore 损坏尾部不再清空整库 + 后台 checkpoint 失败语义(两处 P0)

PLAN-v0.7.5.md §4 B-6 的 KVStore 三处止血中最严重的两处,均为**P0 数据丢失**。

① open() 遇损坏日志尾部会清空整个日志
   修复前 `open()` 检测到 corruptOffsets 后调用 `truncateLog()` —— 那是"写空文件",
   用于 checkpoint 之后(此时快照已覆盖全部数据)。用在崩溃恢复路径上,
   等价于把"尾部损坏"放大成"整库丢失"。实测(本提交的新用例锁定):
     写 a/b/c 三条 → 第 4 条撕裂(只落 20 字节)→ 重开:a/b/c 可见
     → **再重开一次:全部为空**
   修法:新增 `truncateLogTo(keepBytes)`,恢复路径截断到
   `findValidLogLength(log)`(与 repair() 同一口径),只丢弃损坏尾部。
   两个方法的语义差异写进注释:checkpoint 后可清空(快照是数据来源),
   恢复时只能截断(日志前缀才是唯一数据来源)。

② 自动 checkpoint 失败会让已确认写入报错
   修复前 `appendRecord` 末尾的自动 checkpoint 没有 try/catch:快照/meta 写失败
   会把异常冒泡到 `put()`,但那条写入**已经在 WAL 里**(WAL 是权威来源,崩溃后
   一定能重放)。用户看到"写入失败"、数据却在盘上 —— 报错与事实相反,
   调用方据此重试会写两次、据此丢弃业务状态会丢数据。
   修法:抽出 `autoCheckpoint()`,失败记录为 `lastBackgroundError` 而不抛出;
   由**下一次显式 `checkpoint()`** 报告(`KV_BACKGROUND_ERROR`)——
   那是用户主动要求压实数据的时机,此时失败才是真实问题。
   WAL 追加本身失败仍然照旧抛 `KV_LOG_ERROR` 且不回滚内存索引(原子性保持)。

验证方式:tests/v080-kvstore-commit-point.test.ts —— 11 项,使用
`FaultyBackend` 做**真实字节级**故障注入(撕裂写 / bit-flip / 掉电丢弃 /
写失败),而非"重开测试"。并做了**变异验证**:把两处修复分别回退到修复前的
行为,对应用例立即失败(①2 项失败、②3 项失败),恢复后全绿 ——
确认这些断言真的能拦住回归,不是假绿。

全量 85 套件 / 1657 测试通过;typecheck(src+tests) 与 lint 零错误。
This commit is contained in:
thzxx
2026-09-15 00:21:32 +08:00
parent b20d47bd93
commit edd9f1dcd9
2 changed files with 298 additions and 3 deletions
+57 -3
View File
@@ -133,8 +133,17 @@ export class KVStore {
this.logBytes = log.byteLength;
}
if (corruptOffsets.length > 0) {
// 损坏尾部:截断日志(丢弃未确认记录),下次 checkpoint 落盘
await this.truncateLog();
// v0.8.0(B-6)根治:损坏尾部只能**截断到最后一条有效记录**,
// 绝不能清空整个日志。
//
// 修复前这里调用 `truncateLog()`(写空文件),于是"尾部一个字节损坏"
// 导致**全部已确认写入消失**:
// 写 3 条记录 → 第 4 条只写了一半(崩溃)→ 重开
// → 前 3 条被上面的重放读到内存,随后被 truncateLog() 从介质上抹掉
// → 再重开一次,数据全部为空。
// 这是本项目最严重的一类缺陷:把"损坏尾部"放大成"整库丢失"。
// 正确做法(与 repair() 一致):保留 [0, validBytes) 前缀。
await this.truncateLogTo(this.findValidLogLength(log));
}
}
@@ -383,14 +392,38 @@ export class KVStore {
// 自动 checkpoint(日志超阈值)
if (this.checkpointThreshold > 0 && this.logBytes >= this.checkpointThreshold) {
await this.autoCheckpoint();
}
}
/**
* v0.8.0B-6)根治:自动 checkpoint 的失败语义。
*
* 此前的顺序是"WAL 追加成功 → 更新内存索引 → 自动 checkpoint",而自动
* checkpoint **没有 try/catch**:快照/元数据写失败会把异常抛出 `appendRecord`
* 一路冒泡到调用方 —— 但那条写入**已经持久化在 WAL 里了**(WAL 是权威来源,
* 崩溃后一定能重放出来)。于是用户看到 `put()` 失败、以为数据没进去,
* 实际数据已经落盘 —— 报错与事实相反,属于最有害的一类不一致
* (调用方据此重试会写入两次,或据"失败"丢弃业务状态)。
*
* 现在的语义(与 PLAN-v0.7.5.md 的 B-6 止血方案一致):
* - 已确认的写入**不得**因为后台失败而报错;
* - 失败被记录为 `lastBackgroundError`,由**下一次** `checkpoint()`
* 显式报告(那是用户主动要求把数据压实到快照的时机,此时失败是真实问题);
* - 内存索引与 WAL 仍然一致(两者都已包含这条写入),不产生半状态。
*/
private async autoCheckpoint(): Promise<void> {
try {
await this.medium.write(SNAPSHOT_KEY, encodeSnapshot(this.seq, this.index).buffer as ArrayBuffer);
const meta: KVStoreMeta = { seq: this.seq };
await this.medium.write(META_KEY, new TextEncoder().encode(JSON.stringify(meta)).buffer);
await this.truncateLog();
} catch (error) {
this.lastBackgroundError = error;
}
}
/** 截断日志(清空文件) */
/** 截断日志(清空文件)—— 仅用于 checkpoint 之后:快照已覆盖全部数据 */
private async truncateLog(): Promise<void> {
try {
await this.medium.write(LOG_KEY, new ArrayBuffer(0));
@@ -398,6 +431,27 @@ export class KVStore {
this.logBytes = 0;
}
/**
* v0.8.0B-6):把日志截断到 `keepBytes` 长度(保留有效前缀)。
*
* 与 `truncateLog()` 的区别:checkpoint 后日志内容已被快照覆盖,可以清空;
* 而崩溃恢复时日志里**前面的记录是唯一的数据来源**(快照可能落后很多个
* checkpoint),只能丢弃损坏的尾部。两者语义完全不同,因此是两个方法。
*/
private async truncateLogTo(keepBytes: number): Promise<void> {
try {
const raw = await this.medium.read(LOG_KEY);
if (!raw) return;
if (keepBytes >= raw.byteLength) return; // 无需截断(损坏判定与读取之间无变化)
const kept = new Uint8Array(raw).subarray(0, keepBytes).slice();
await this.medium.write(LOG_KEY, kept.buffer as ArrayBuffer);
this.logBytes = keepBytes;
} catch {
// 截断失败不影响本次恢复的内存状态:日志文件多出的损坏尾部会在
// 下次 open 时被同样识别并跳过(解析在损坏处停止),因此不会读到脏数据。
}
}
/** 应用记录条目到内存索引 */
private applyRecord(entries: { op: KVLogOp; key: string; value: ArrayBuffer }[]): void {
for (const e of entries) {