diff --git a/src/engine/kvstore/index.ts b/src/engine/kvstore/index.ts index e76cc1c..0962078 100644 --- a/src/engine/kvstore/index.ts +++ b/src/engine/kvstore/index.ts @@ -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.0(B-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 { + 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 { try { await this.medium.write(LOG_KEY, new ArrayBuffer(0)); @@ -398,6 +431,27 @@ export class KVStore { this.logBytes = 0; } + /** + * v0.8.0(B-6):把日志截断到 `keepBytes` 长度(保留有效前缀)。 + * + * 与 `truncateLog()` 的区别:checkpoint 后日志内容已被快照覆盖,可以清空; + * 而崩溃恢复时日志里**前面的记录是唯一的数据来源**(快照可能落后很多个 + * checkpoint),只能丢弃损坏的尾部。两者语义完全不同,因此是两个方法。 + */ + private async truncateLogTo(keepBytes: number): Promise { + 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) { diff --git a/tests/v080-kvstore-commit-point.test.ts b/tests/v080-kvstore-commit-point.test.ts new file mode 100644 index 0000000..5bd26fa --- /dev/null +++ b/tests/v080-kvstore-commit-point.test.ts @@ -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'); + }); +});