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
+241
View File
@@ -0,0 +1,241 @@
/**
* v0.8.0 回归套件 —— B-6 存储提交点与崩溃语义(KVStore)
* ============================================================================
* 本套件覆盖 PLAN-v0.7.5.md §4「B-6」列出的 KVStore 三处止血,其中两处是
* **P0 级数据丢失**
*
* 1. `open()` 遇到损坏日志尾部会**清空整个日志**(`truncateLog()` 写空文件)。
* 实测后果:
* 写 3 条记录 → 第 4 条撕裂(崩溃)→ 重开后前 3 条可见 →
* **再重开一次,数据全部为空**。
* 即"尾部损坏"被放大成"整库丢失"。修法:截断到最后一条有效记录
* (与 `repair()` 同一口径),而不是清空。
*
* 2. 自动 checkpoint 失败会把异常抛给 `put()`,但那条写入**已经在 WAL 里**
* (持久化成功)。于是用户看到"写入失败"、数据却在盘上 —— 报错与事实相反。
* 修法:已确认写入不因后台失败而报错,失败记录为 `lastBackgroundError`
* 由下一次显式 `checkpoint()` 报告。
*
* 故障注入通过 `FaultyBackend` 完成(真实字节级撕裂/掉电,而非"重开测试")。
*/
import { KVStore } from '../src/engine/kvstore/index';
import { MemoryBackend } from '../src/engine/aria/store/backend';
import { FaultyBackend } from './helpers/faulty-backend';
import { decode } from './helpers/assertions';
const enc = (s: string): ArrayBuffer => new TextEncoder().encode(s).buffer as ArrayBuffer;
// 注意:每个 KVStore 实例都包一个**自己的** FaultyBackend。
// 故障注入状态是"下一次写"这种一次性标记,跨实例共享会互相污染
//(恢复用的实例会意外继承上一步注入的故障)——本套件刻意不提供共享 helper。
describe('[v0.8.0] B-6 ①:损坏日志尾部不得清空整库', () => {
it('尾部撕裂后重开:已确认写入全部保留(且二次重开仍保留)', async () => {
const medium = new MemoryBackend();
const faulty = new FaultyBackend(medium);
const store = new KVStore(faulty, 0);
await store.open('b6-tail');
await store.put('a', enc('1'));
await store.put('b', enc('2'));
await store.put('c', enc('3'));
// 第 4 条记录**只落前 20 字节**(写入过程中掉电 → 撕裂)
faulty.truncateNextAppendTo(20);
await store.put('d', enc('4')).catch(() => { /* 撕裂写入的行为由下层决定 */ });
// ---- 第一次重开:前 3 条必须可见 ----
const reopened = new KVStore(faulty, 0);
await reopened.open('b6-tail');
expect(decode(await reopened.get('a'))).toBe('1');
expect(decode(await reopened.get('b'))).toBe('2');
expect(decode(await reopened.get('c'))).toBe('3');
// ---- 第二次重开:如果第一次重开把日志清空了,这里会全部为空 ----
// 这是本用例的核心断言:恢复动作本身不得破坏尚未进入快照的数据。
const reopened2 = new KVStore(faulty, 0);
await reopened2.open('b6-tail');
expect(decode(await reopened2.get('a'))).toBe('1');
expect(decode(await reopened2.get('b'))).toBe('2');
expect(decode(await reopened2.get('c'))).toBe('3');
// ---- 第三次重开(幂等):结果不变 ----
const reopened3 = new KVStore(faulty, 0);
await reopened3.open('b6-tail');
expect(reopened3.size()).toBe(reopened2.size());
expect(decode(await reopened3.get('b'))).toBe('2');
});
it('尾部 bit-flip 后重开:有效前缀保留,损坏记录丢弃', async () => {
const medium = new MemoryBackend();
const faulty = new FaultyBackend(medium);
const store = new KVStore(faulty, 0);
await store.open('b6-bitflip');
await store.put('k1', enc('v1'));
await store.put('k2', enc('v2'));
// 把最后一条记录载荷里的一个字节改掉(CRC 不再匹配)
faulty.corruptNextWrite(() => 0xff);
await store.put('k3', enc('v3')).catch(() => { /* 允许实现拒绝 */ });
faulty.clearFaults();
const reopened = new KVStore(new FaultyBackend(medium), 0);
await reopened.open('b6-bitflip');
expect(decode(await reopened.get('k1'))).toBe('v1');
expect(decode(await reopened.get('k2'))).toBe('v2');
// 再次重开仍然保留(不是"第一次恢复读到了、第二次被清掉")
const reopened2 = new KVStore(new FaultyBackend(medium), 0);
await reopened2.open('b6-bitflip');
expect(decode(await reopened2.get('k1'))).toBe('v1');
expect(decode(await reopened2.get('k2'))).toBe('v2');
});
it('损坏日志恢复后新写入仍可读(日志未被置为不可用状态)', async () => {
const medium = new MemoryBackend();
const faulty = new FaultyBackend(medium);
const store = new KVStore(faulty, 0);
await store.open('b6-recover-write');
await store.put('before', enc('yes'));
faulty.truncateNextAppendTo(10);
await store.put('torn', enc('no')).catch(() => {});
faulty.clearFaults();
const reopened = new KVStore(new FaultyBackend(medium), 0);
await reopened.open('b6-recover-write');
// 修复后应能继续追加(日志被截断到有效长度,后续写入从干净位置开始)
await reopened.put('after', enc('also-yes'));
const reopened2 = new KVStore(new FaultyBackend(medium), 0);
await reopened2.open('b6-recover-write');
expect(decode(await reopened2.get('before'))).toBe('yes');
expect(decode(await reopened2.get('after'))).toBe('also-yes');
});
});
describe('[v0.8.0] B-6 ②:自动 checkpoint 失败不得让已确认写入报错', () => {
it('自动 checkpoint 的介质写失败:put() 仍然成功,数据在盘上', async () => {
const medium = new MemoryBackend();
const faulty = new FaultyBackend(medium);
// 阈值 1 字节 → 每次写入后都触发自动 checkpoint
const store = new KVStore(faulty, 1);
await store.open('b6-autockpt');
// 快照写失败(checkpoint 的第一步)
faulty.failNextWrite(1);
// 关键:put() 必须**成功** —— WAL 追加已完成,数据已持久化
await expect(store.put('k', enc('v'))).resolves.toBeUndefined();
expect(faulty.injected.write).toBeGreaterThan(0); // 注入确实生效(避免假绿)
// 数据真的在:重开后可见
const reopened = new KVStore(new FaultyBackend(medium), 0);
await reopened.open('b6-autockpt');
expect(decode(await reopened.get('k'))).toBe('v');
});
it('后台失败由下一次显式 checkpoint() 报告(不静默吞掉)', async () => {
const medium = new MemoryBackend();
const faulty = new FaultyBackend(medium);
const store = new KVStore(faulty, 1);
await store.open('b6-bg-error');
faulty.failNextWrite(1);
await store.put('k', enc('v'));
// 显式 checkpoint 是用户主动要求"把数据压实到快照"的时机,
// 此时失败是真实问题,必须报告(KV_BACKGROUND_ERROR
await expect(store.checkpoint()).rejects.toMatchObject({ code: 'KV_BACKGROUND_ERROR' });
});
it('后台失败报告一次后即清除(不重复污染后续 checkpoint', async () => {
const medium = new MemoryBackend();
const faulty = new FaultyBackend(medium);
const store = new KVStore(faulty, 1);
await store.open('b6-bg-once');
faulty.failNextWrite(1);
await store.put('k', enc('v'));
await expect(store.checkpoint()).rejects.toMatchObject({ code: 'KV_BACKGROUND_ERROR' });
// 第二次 checkpoint 应正常完成(错误已被消费)
faulty.clearFaults();
await expect(store.checkpoint()).resolves.toBeUndefined();
});
it('WAL 追加本身失败:写入必须报错且不得进入内存索引', async () => {
const medium = new MemoryBackend();
const faulty = new FaultyBackend(medium);
const store = new KVStore(faulty, 0);
await store.open('b6-wal-fail');
faulty.failNextAppend(1);
await expect(store.put('k', enc('v'))).rejects.toMatchObject({ code: 'KV_LOG_ERROR' });
// 原子性:失败写入不得留在内存索引里
expect(await store.get('k')).toBeNull();
expect(store.size()).toBe(0);
});
it('掉电静默丢弃 append:写入"看起来成功"但重开后确实不在(无幻影)', async () => {
const medium = new MemoryBackend();
const faulty = new FaultyBackend(medium);
const store = new KVStore(faulty, 0);
await store.open('b6-dropped');
await store.put('confirmed', enc('yes'));
// 下一次 append 被介质静默丢弃(掉电丢失,不抛错)
faulty.dropNextAppend(1);
await store.put('lost', enc('no'));
faulty.clearFaults();
const reopened = new KVStore(new FaultyBackend(medium), 0);
await reopened.open('b6-dropped');
expect(decode(await reopened.get('confirmed'))).toBe('yes');
// 这条记录确实丢了(这是"掉电丢失"的定义)——本断言的价值是确认
// **不会复活成脏数据**,且不会影响其它记录
expect(await reopened.get('lost')).toBeNull();
});
});
describe('[v0.8.0] B-6 ③:checkpoint 时序保证任何崩溃窗口不丢数据', () => {
const windows: Array<[string, (f: FaultyBackend) => void]> = [
['快照写失败(meta 未更新)', (f) => f.failNextWrite(1)],
['meta 写失败(快照已写、日志未截断)', (f) => { f.failNextWrite(1); f.failNextWrite(1); }],
];
it.each(windows)('checkpoint 窗口「%s」后重开:已确认写入仍在', async (_label, inject) => {
const medium = new MemoryBackend();
const faulty = new FaultyBackend(medium);
const store = new KVStore(faulty, 0);
await store.open(`b6-window-${_label.length}`);
await store.put('a', enc('1'));
await store.put('b', enc('2'));
inject(faulty);
await store.checkpoint().catch(() => { /* 本用例关注恢复结果,不关注报错形式 */ });
faulty.clearFaults();
const reopened = new KVStore(new FaultyBackend(medium), 0);
await reopened.open(`b6-window-${_label.length}`);
expect(decode(await reopened.get('a'))).toBe('1');
expect(decode(await reopened.get('b'))).toBe('2');
});
});
describe('[v0.8.0] B-6repair() 与 open() 的恢复口径一致', () => {
it('repair() 报告丢弃字节数,且不丢有效前缀', async () => {
const medium = new MemoryBackend();
const faulty = new FaultyBackend(medium);
const store = new KVStore(faulty, 0);
await store.open('b6-repair');
await store.put('keep', enc('kept'));
faulty.truncateNextAppendTo(12);
await store.put('torn', enc('torn')).catch(() => {});
faulty.clearFaults();
const reopened = new KVStore(new FaultyBackend(medium), 0);
await reopened.open('b6-repair');
const discarded = await reopened.repair();
expect(discarded).toBeGreaterThanOrEqual(0);
expect(decode(await reopened.get('keep'))).toBe('kept');
});
});