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:
@@ -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-6:repair() 与 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user