Files
MetonaSqlark/tests/engine/aria-wal-segment.test.ts
T
thzxx 0b44620721 fix(v0.8.0): 全量回归审查 —— 1 处 P0 数据丢失 + 4 处 P1 + 9 处 P2 根因修复
方法:四个对抗性子代理分头审查(数据正确性 / 文档宣称 vs 实现 / 公共 API 契约 /
测试质量),每条结论要求可复现证据;逐条复核 + 探针确认 + 变异验证(40 项全部
被对应用例拦住)。

P0:事务活跃期间 repair()/close()/周期 checkpoint 推进 WAL 水位 → 已 COMMIT 的
事务整批消失且恢复报告"干净"。根因 hasPendingFlushData()/computeDurableLsn()
不看 txnSnapshot;守卫此前只在 CheckpointManager 两个回调里。修复:守卫下沉到
computeDurableLsn() 与 advanceWalCheckpoint() 入口(唯一实现)。

P1:
- WAL 前缀缺失丢弃整段活分片(回退上一代 manifest 时 kept 为空)→ 前缀缺失单独
  记录,后缀照常重放;仅 fromLsn === 0 时才算真异常
- 孤儿回收门槛只看引擎层 dataLossSuspected,漏掉 LSM 层被丢的 SSTable →
  统一 describeRecoveryDamage() 聚合判定(损坏时绝不删"引用不到"的文件)
- vacuum() 逐层压缩绕过维护链 → vacuumLevels() 每层作为维护链任务执行
- reclaimRetiredNow() 无视在途读者(读者把"已退休"读成"文件损坏")→ 有读者时
  退化为延迟回收

P2:WAL 记录级 CRC 损坏不计数不上报;旧格式表结构记录形状损坏静默当空库;
bloomFilterBitsPerKey 配置被接受却完全不生效(构建器写死默认值,实现缺陷);
幽灵 meta;介质读故障等于文件损坏的语义无用例;manifest 回读校验两条守卫无用例;
文件名≠载荷世代判定无用例;pageIdWatermark 单调性无用例;分片号两条真实不变量
无用例。

覆盖率口径(第二处漏洞):interface.ts 混着三个运行时函数(cloneRow 等)却被
描述为"纯类型、不纳入统计" → 实现搬到 src/engine/row_clone.ts;搬完门禁真的
失败(functions 93.84% < 94%),补测退化路径后通过。

测试质量:3 条空壳用例改值级断言;1 条"全损坏"用例实际只走缓存 → 拆成两条真
用例;5 秒墙钟 race 改门控 + 失败上限;setTimeout 改 whenIdle();<= 收紧为 <。

变异脚本加固:正控(干净基线必须全绿)、编译失败/0 用例单独归类、300s 超时、
逐字节 sha256 恢复校验、O_EXCL 进程锁、锚点唯一性;变异 22 → 40 项。

文档两轮订正(16 + 11 条不成立宣称):MVCC 快照隔离、backup 一致性快照、
"空洞检测截断"、体积(251,109 B / gzip 63,145 B)、测试与覆盖率数字、
"5 种存储引擎"、Tree-shakable、错误码表补 16 个码、恢复报告字段、已知限制
(回退单向 / 多实例依赖 Web Locks / manifest 体积 / 尾部 WAL 分片不可识别)。

验证:常规套件 92 套件 / 1980 用例全绿;覆盖率 90.59 / 82.59 / 94.14 / 93.50
(阈值 90/82/94/93);e2e 14/14(真实 Chromium + OPFS + CDP 崩溃);
重型套件 4 套件 / 27 用例;变异 40/40;lint + 两份 tsc 干净;dist 已重建。
2026-09-15 16:33:40 +08:00

387 lines
17 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.
/**
* AriaEngine — SegmentedWALStore 分片 WAL 存储测试
*
* 覆盖:
* 1. append → readAll 往返(分片切换、字节顺序)
* 2. 空洞检测:序号不连续 → 空洞后的分片丢弃
* 3. 旧格式 __wal_N + __wal_count 兼容读取
* 4. truncate / exists
* 5. 后端无 append 接口时回退 read+write(正确性一致)
* 6. AriaEngine 集成:高频写入跨重启数据完整(分片 + WAL 重放)
*/
import { SegmentedWALStore } from '../../src/engine/aria/wal/segmented_store';
import { MemoryBackend } from '../../src/engine/aria/store/backend';
import { AriaEngine } from '../../src/engine/aria/index';
import { createSchema } from '../../src/table/schema';
import { resetOPFSMock } from '../helpers/storage-harness';
beforeEach(() => { resetOPFSMock(); });
let idbCounter = 0;
function uniqueDB(): string {
return `seg-${Date.now()}-${++idbCounter}-${Math.random().toString(36).slice(2, 8)}`;
}
const enc = (s: string) => new Uint8Array(new TextEncoder().encode(s));
/** 构造"首 4 字节为大端 LSN"的记录(分片的水位判定依赖它) */
function rec(lsn: number, pad = ''): Uint8Array {
const filler = enc(pad.padEnd(4, '.'));
const out = new Uint8Array(4 + filler.byteLength);
new DataView(out.buffer).setUint32(0, lsn, false);
out.set(filler, 4);
return out;
}
describe('AriaEngine — SegmentedWALStore 单元', () => {
it('append → readAll 往返,多批次字节顺序一致', async () => {
const backend = new MemoryBackend();
await backend.open('seg-unit-1');
const store = new SegmentedWALStore(backend, 1024);
expect(await store.exists()).toBe(false);
await store.append(enc('AAA'));
await store.append(enc('BBB'));
await store.append(enc('CCC'));
expect(await store.exists()).toBe(true);
const all = await store.readAll();
expect(new TextDecoder().decode(all)).toBe('AAABBBCCC');
// 后端是分片文件(单个 key
const keys = await backend.listKeys();
expect(keys).toEqual(['__wal_000000.bin']);
await backend.close();
});
it('超过分片阈值自动切换分片', async () => {
const backend = new MemoryBackend();
await backend.open('seg-unit-2');
const store = new SegmentedWALStore(backend, 16); // 每分片 16 字节
await store.append(enc('AAAAAAAAAA')); // 10B → 分片 0
await store.append(enc('BBBBBBBBBB')); // 10B → 分片 10 已 10B+10B > 16
await store.append(enc('CC')); // 2B → 分片 110+2 <= 16
const all = await store.readAll();
expect(new TextDecoder().decode(all)).toBe('AAAAAAAAAABBBBBBBBBBCC');
const keys = await backend.listKeys().then((ks) => ks.sort());
expect(keys).toEqual(['__wal_000000.bin', '__wal_000001.bin']);
await backend.close();
});
it('尾部分片缺失(无法与更后分片比较)→ 已读到的分片仍然返回', async () => {
const backend = new MemoryBackend();
await backend.open('seg-unit-3');
const store = new SegmentedWALStore(backend, 16);
await store.append(enc('SEG0-SEG0-S')); // 10B → 分片 0
await store.append(enc('SEG1-SEG1-S')); // 10B → 分片 10 已 10B+10B > 16
await store.append(enc('S2')); // 2B → 分片 110+2 <= 16
// 模拟 truncate 部分完成:删除分片 1(空洞)
await backend.delete('__wal_000001.bin');
const all = await store.readAll();
// 介质上只剩分片 0:没有更后的分片作为参照,缺失的"尾部"无法被识别成空洞
//(这是介质信息本身的限制 —— 尾部丢失只能靠 manifest.nextLsn 之外的证据发现)
expect(new TextDecoder().decode(all)).toBe('SEG0-SEG0-S');
await backend.close();
});
it('前缀缺失:水位未推进(fromLsn=0)→ 全部丢弃;水位已推进 → 后缀照常读取', async () => {
const backend = new MemoryBackend();
await backend.open('seg-unit-4');
const store = new SegmentedWALStore(backend, 3); // 2 字节记录 → 每条一个分片
await store.append(enc('X0'));
await store.append(enc('X1'));
await backend.delete('__wal_000000.bin');
// 从未推进过水位:分片本应从 0 连续存在 → 前缀缺失是异常,保守丢弃
const all = await store.readAll();
expect(all.byteLength).toBe(0);
// 水位已推进(例如回退到上一代 manifeststartSegment/startLsn 指向更早的位置):
// 前缀那一段本就被水位跳过,**不能**因此丢弃后面的活分片
const fromLater = await store.readAllFrom(0, 1);
expect(new TextDecoder().decode(fromLater.data)).toBe('X1');
expect(fromLater.missingPrefix).toEqual([0]);
expect(fromLater.gaps).toEqual([]);
await backend.close();
});
it('内部空洞:空洞之后的分片整体丢弃,并把空洞号上报', async () => {
const backend = new MemoryBackend();
await backend.open('seg-unit-4b');
const store = new SegmentedWALStore(backend, 8); // 8 字节记录 → 每条一个分片
await store.append(rec(1, 'aaaa')); // 分片 0
await store.append(rec(2, 'bbbb')); // 分片 1
await store.append(rec(3, 'cccc')); // 分片 2
expect((await store.readAllFrom(0, 0)).segments).toEqual([0, 1, 2]);
await backend.delete('__wal_000001.bin'); // 内部空洞
const result = await store.readAllFrom(0, 0);
expect(result.segments).toEqual([0]); // 只读了空洞之前的分片
expect(result.gaps).toEqual([1]); // 显式上报(不再静默)
expect(result.data.byteLength).toBe(8); // 只保留空洞之前的分片
expect(new DataView(result.data.buffer, result.data.byteOffset).getUint32(0, false)).toBe(1);
await backend.close();
});
it('整体清空后分片号绝不回退(可以复用最后用过的号,但绝不能回到 0)', async () => {
const backend = new MemoryBackend();
await backend.open('seg-unit-4c');
const store = new SegmentedWALStore(backend, 8); // 8 字节记录 → 每条一个分片
await store.append(rec(1, 'aaaa')); // 分片 0
await store.append(rec(2, 'bbbb')); // 分片 1
await store.append(rec(3, 'cccc')); // 分片 2
expect((await store.readAllFrom(0, 0)).segments).toEqual([0, 1, 2]);
await store.truncate();
expect((await backend.listKeys()).length).toBe(0); // 介质上确实没有分片了
await store.append(rec(4, 'dddd'));
const walKeys = (await backend.listKeys()).filter((k) => k.startsWith('__wal_'));
expect(walKeys).toHaveLength(1);
const newSeq = Number(walKeys[0].match(/^__wal_(\d{6,})\.bin$/)![1]);
// 关键不变量:不回退到 0(回退 + manifest.startSegment>0 = 新记录被恢复过滤掉)
expect(newSeq).toBeGreaterThanOrEqual(2);
expect(walKeys).not.toContain('__wal_000000.bin');
// 清空后从新分片号读取仍能读回新记录(该契约不能被破坏)
const after = await store.readAllFrom(newSeq, 0);
expect(after.data.byteLength).toBe(8);
expect(new DataView(after.data.buffer, after.data.byteOffset).getUint32(0, false)).toBe(4);
await backend.close();
});
it('写入永不落到 manifest 水位下限之下(startSegment=K → 新分片号 >= K', async () => {
const backend = new MemoryBackend();
await backend.open('seg-unit-4e');
const store = new SegmentedWALStore(backend, 8);
await store.append(rec(1, 'aaaa')); // 分片 0(首条 LSN=1
await store.append(rec(2, 'bbbb')); // 分片 1(首条 LSN=2
await store.append(rec(3, 'cccc')); // 分片 2(首条 LSN=3
// 模拟 checkpoint:先算水位下限并**提交进 manifest**,再删除前缀分片
const keepFrom = await store.planKeepFrom(3, 3); // 全部记录都已落盘 → 全部可删
expect(keepFrom).toBe(3);
await store.truncateBefore(3, 3);
expect((await backend.listKeys()).filter((k) => k.startsWith('__wal_'))).toEqual([]);
// 之后的新写入必须落在 >= keepFrom 的分片里(否则重开时被 `seq >= startSegment` 过滤 → 静默丢失)
await store.append(rec(4, 'dddd'));
const seqs = (await backend.listKeys())
.filter((k) => k.startsWith('__wal_'))
.map((k) => Number(k.match(/^__wal_(\d{6,})\.bin$/)![1]));
expect(seqs.length).toBeGreaterThan(0);
expect(Math.min(...seqs)).toBeGreaterThanOrEqual(keepFrom);
// 而且从 manifest 的水位下限开始读,这条新记录必须读得到(双向验证)
const reread = await store.readAllFrom(keepFrom, 3);
expect(reread.data.byteLength).toBe(8);
expect(new DataView(reread.data.buffer, reread.data.byteOffset).getUint32(0, false)).toBe(4);
await backend.close();
});
it('活跃区间已被清理(fromSegment > 0,介质上无分片):新写入从 fromSegment 继续', async () => {
const backend = new MemoryBackend();
await backend.open('seg-unit-4d');
const store = new SegmentedWALStore(backend, 8);
await store.append(enc('AAAA'));
await store.append(enc('BBBB')); // 分片 0、1
// 介质上分片被全部清理,manifest 说 startSegment = 5
const later = await store.readAllFrom(5, 100);
// 一个分片都没有:无法判断缺了哪些号 → 不谎报空洞,也不丢已有数据
expect(later.data.byteLength).toBe(0);
expect(later.segments).toEqual([]);
expect(later.gaps).toEqual([]);
await store.append(enc('EEEE'));
const keys = await backend.listKeys();
expect(keys.some((k) => k === '__wal_000005.bin')).toBe(true); // 从 fromSegment 继续
await backend.close();
});
it('旧格式 __wal_N + __wal_count 兼容读取(迁移前数据)', async () => {
const backend = new MemoryBackend();
await backend.open('seg-unit-5');
// 构造旧格式数据
await backend.write('__wal_0', enc('LEGACY0').buffer);
await backend.write('__wal_1', enc('LEGACY1').buffer);
await backend.write('__wal_count', enc('2').buffer);
const store = new SegmentedWALStore(backend, 1024);
expect(await store.exists()).toBe(true);
const all = await store.readAll();
expect(new TextDecoder().decode(all)).toBe('LEGACY0LEGACY1');
// 迁移中追加新格式:新记录写入分片文件(不与旧键冲突)
await store.append(enc('NEW'));
const keys = await backend.listKeys().then((ks) => ks.sort());
expect(keys).toContain('__wal_000000.bin');
expect(keys).toContain('__wal_0');
// truncate 清空新旧全部
await store.truncate();
expect(await store.exists()).toBe(false);
expect((await backend.listKeys()).length).toBe(0);
await backend.close();
});
it('truncate 清空分片并重置状态', async () => {
const backend = new MemoryBackend();
await backend.open('seg-unit-6');
const store = new SegmentedWALStore(backend, 16);
await store.append(enc('AAAAAA'));
await store.append(enc('BBBBBB'));
await store.truncate();
expect(await store.exists()).toBe(false);
expect((await backend.listKeys()).length).toBe(0);
// 截断后继续追加从分片 0 重新开始
await store.append(enc('CCC'));
const all = await store.readAll();
expect(new TextDecoder().decode(all)).toBe('CCC');
await backend.close();
});
it('后端无 append 接口 → 回退 read+write 语义一致', async () => {
// MemoryBackend 没有 append 接口 → 走回退路径
const backend = new MemoryBackend();
await backend.open('seg-unit-7');
const store = new SegmentedWALStore(backend, 1024);
await store.append(enc('PART1'));
await store.append(enc('PART2'));
const all = await store.readAll();
expect(new TextDecoder().decode(all)).toBe('PART1PART2');
await backend.close();
});
});
// ===================================================================
// AriaEngine 集成 — 分片 WAL 跨重启
// ===================================================================
describe('AriaEngine — 分片 WAL 集成', () => {
it('高频写入(多条 WAL 记录)→ close → reopen 数据完整', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({
storageBackend: 'opfs',
checkpointInterval: 100000,
walSyncMode: 'full',
});
await engine.open(dbName, 1);
await engine.createTable(createSchema('logs', {
id: { type: 'string', primaryKey: true },
msg: { type: 'string' },
}));
const total = 300;
for (let i = 0; i < total; i++) {
await engine.insert('logs', [{ id: `log-${i}`, msg: `message ${i}` }]);
}
// 不 flush 不 checkpoint,全部留在 WAL → 模拟崩溃后重放
await (engine as any).backend.close();
(engine as any).opened = false;
const engine2 = new AriaEngine({
storageBackend: 'opfs',
checkpointInterval: 100000,
walSyncMode: 'full',
});
await engine2.open(dbName, 1);
const rows = await engine2.find('logs', { table: 'logs' });
expect(rows).toHaveLength(total);
expect(rows.some((r) => r.id === 'log-299')).toBe(true);
await engine2.close();
});
it('分片结构落盘验证(backend 中是分片文件而非旧单记录键)', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({
storageBackend: 'opfs',
checkpointInterval: 100000,
walSyncMode: 'full',
});
await engine.open(dbName, 1);
await engine.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
}));
await engine.insert('t', [{ id: '1' }, { id: '2' }, { id: '3' }]);
const backend = (engine as any).backend;
const keys = await backend.listKeys();
const walKeys = keys.filter((k: string) => k.startsWith('__wal_'));
// 新格式:__wal_000000.bin;无旧格式单记录键与 count 键
expect(walKeys).toContain('__wal_000000.bin');
expect(walKeys.some((k: string) => /^__wal_\d+$/.test(k) && !k.endsWith('.bin'))).toBe(false);
expect(walKeys).not.toContain('__wal_count');
await engine.close();
});
it('WAL 分片部分残留(空洞)→ 重开恢复已落盘数据,不丢已 checkpoint 数据', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({
storageBackend: 'opfs',
checkpointInterval: 100000,
walSyncMode: 'full',
});
await engine.open(dbName, 1);
await engine.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
}));
// 第一批:flush 落盘 + checkpoint 清 WAL
await engine.insert('t', [{ id: 'a' }, { id: 'b' }]);
await (engine as any).lsm.flush();
await (engine as any).wal.checkpoint();
// 第二批:只进 WAL(不 flush
await engine.insert('t', [{ id: 'c' }, { id: 'd' }]);
// 模拟 WAL 分片文件被外部删掉(空洞场景)
const backend = (engine as any).backend;
await backend.delete('__wal_000000.bin');
await engine.close();
const engine2 = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
await engine2.open(dbName, 1);
// 已落盘的 a/b 必须保留;c/d 在 WAL 中且文件被删 → 恢复不到(可接受的保守丢弃)
const rows = await engine2.find('t', { table: 't' });
expect(rows.some((r) => r.id === 'a')).toBe(true);
expect(rows.some((r) => r.id === 'b')).toBe(true);
await engine2.close();
});
it('旧格式 WAL 升级迁移:旧键重放后 checkpoint 清空', async () => {
const dbName = uniqueDB();
// 手工构造旧格式 WAL 库:直接写旧键(模拟 v0.4.4 库崩溃现场)
const engine = new AriaEngine({
storageBackend: 'opfs',
checkpointInterval: 100000,
walSyncMode: 'full',
});
await engine.open(dbName, 1);
await engine.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
}));
// 写入一批数据但只保留在 WAL(新格式)→ close 前手工转成旧格式
await engine.insert('t', [{ id: 'x' }]);
const backend = (engine as any).backend;
const raw = await backend.read('__wal_000000.bin');
expect(raw).not.toBeNull();
// 清掉新格式,写成旧格式(单记录键)
await backend.delete('__wal_000000.bin');
await backend.write('__wal_0', raw);
await backend.write('__wal_count', new TextEncoder().encode('1').buffer);
// 同时清掉内存里的 WAL 状态,模拟"重启"
await (engine as any).backend.close();
(engine as any).opened = false;
const engine2 = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
await engine2.open(dbName, 1);
const rows = await engine2.find('t', { table: 't' });
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe('x');
// 恢复后 checkpoint 清空旧键
const keys = await (engine2 as any).backend.listKeys();
expect(keys.some((k: string) => k.startsWith('__wal_'))).toBe(false);
await engine2.close();
});
});