Files
MetonaSqlark/tests/v080-kvstore-commit-point.test.ts
T
thzxx edd9f1dcd9 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 零错误。
2026-09-15 00:21:32 +08:00

242 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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');
});
});