按 PLAN-v0.7.5.md §B-6 的**完整规格**实施(此前只落地了"降级选项"里的五处止血):
B-6 要求的是 `__aria_manifest` 单一提交点 + LSM 单项改造。完整记录见方案附录 H。
一、单一提交点
- 新增 `src/engine/aria/store/manifest.ts`:`__aria_manifest_<generation>`
(magic + formatVersion + generation + 头部 CRC + 载荷 CRC;先写后验;保留两代)。
载荷 = 页面水位 + 各命名空间 SSTable 元数据 + 表结构 + WAL 起始位置 + 待落盘冻结表意图。
- 顺序固定:**数据落盘 → manifest 提交 → 才允许截断 WAL / 删除旧文件 / 删除旧 SSTable**。
- 恢复只认最后一份 CRC 通过的世代;全部世代无效 → `ARIA_MANIFEST_CORRUPT`
(修复前:裸 JSON meta 解析失败 → `[]` → 静默空库,随后 repair 还会删光活页)。
- 旧格式(__aria_lsm_meta/__aria_schemas/__aria_meta)首次打开自动迁移,旧键保留;
迁移遇到损坏 → `ARIA_LEGACY_META_CORRUPT`。
- 陈旧实例保护(STALE_INSTANCE):认领时一次跨过 MANIFEST_TAKEOVER_STRIDE 个世代,
杜绝"旧实例在途提交落在同一世代号上"(实测第二个实例 open 直接失败)。
二、LSM
- 44 冻结表成为一等状态:失败保留 + 可重试(修复前失败即永久失去落盘机会)。
- 45 `flush()` 先入链再报告后台错误(修复前一次后台失败会让之后每次 flush 直接抛错、
数据永远等不到落盘);被重试修复的失败进 `getBackgroundWarnings()`(可见但不误报失败)。
- 47 `MergeIterator` 胜出来源的补充推迟到下一次 `next()`:提前终止不再多算一条。
- 49 `compacting` 由单 boolean 改为按层集合(跨层触发不再被静默丢弃)。
- 50 compaction 不再"先 splice 整层再合并"(窗口内该层对读者可见);
被取代的 SSTable 进"退休表" + 读者 epoch,等更早读者退出才物理删除。
- 51 底部层原地合并回收墓碑(删除密集场景空间不再无界增长);"整层只剩墓碑" 有专门分支
(修复前会读 `merged[0][0]` 抛 TypeError,compaction 永久失败)。
- 55 flush 与 compaction 拆成两条链,checkpoint 只落 memtable;删除引擎层全部
`prefetch*`/`drainChain` 依赖,改为"快照 + 结构版本乐观重试"
(版本号同时覆盖 levels 与前台 memtable/frozen 的变化)。
- 读路径自洽:介质读故障抛 `ARIA_SSTABLE_READ_FAILED`,不再折叠成"文件不存在"误删元数据。
三、WAL
- LSN 全库单调(manifest 记高水位);按水位删除旧分片(`planKeepFrom` → 提交 → 再删除)。
- **分片号只增不减**:修复前全量截断后重置为 0,会与 manifest 记录的 startSegment 错位,
实测造成两个方向的损坏(删掉的行复活 / 已确认写入丢失,见随机压力套件)。
- 分片空洞(含前缀缺失)显式报 `ARIA_WAL_GAP`,不再静默丢弃尾部。
四、其它
- `sstable.ts` 三份解析循环合并为 `iterEntries()`,越界策略统一。
- `vacuum()` 返回真实压缩层数(修复前硬编码 6 且底部层永不压缩)。
- `close()` 加 try/finally(落盘失败也必须释放后端/锁并复位状态)。
- `getRecoveryReport()`:{droppedSSTables, dataLossSuspected, walGaps, legacyImported,
manifestFallback} —— "自愈了什么、有没有真丢数据"成为可读返回值。
五、验证
- 新增 `tests/v080-b6-single-commit-point.test.ts`(63 项,含 manifest 严格校验表驱动 25 例)。
- 新增 `scripts/mutation-b6.py`:22 项变异验证(把每个修复回退到修复前行为,对应用例必须失败),
全部被拦住 —— 这批用例不是陪跑。
- 常规套件 1935 通过 / 91 套件;覆盖率 90.34 / 82.16 / 94.06 / 93.23(阈值 90/82/94/93);
e2e 14/14;重型套件 4 套件 27 项全绿。
180 lines
7.4 KiB
TypeScript
180 lines
7.4 KiB
TypeScript
/**
|
||
* v0.4.3 回归测试
|
||
* 覆盖:
|
||
* - P0: close 后后台 compaction 在 backend 关闭后执行(残留任务污染/吞错)
|
||
* - P0: flush 报告后台失败(不再静默吞错)
|
||
* - P1: commit 先持久化 WAL 再合并快照(WAL 失败时数据一致性)
|
||
* - P1: OPFS close 等待挂起写完成
|
||
*/
|
||
|
||
import { AriaEngine } from '../src/engine/aria/index';
|
||
import { createSchema } from '../src/table/schema';
|
||
import { LSM } from '../src/engine/aria/index/lsm';
|
||
|
||
import { resetOPFSMock } from './helpers/storage-harness';
|
||
|
||
beforeEach(() => { resetOPFSMock(); });
|
||
|
||
let idbCounter = 0;
|
||
function uniqueDB(): string {
|
||
return `r43-${Date.now()}-${++idbCounter}-${Math.random().toString(36).slice(2, 8)}`;
|
||
}
|
||
|
||
// ===================================================================
|
||
// P0: close 与后台 compaction
|
||
// ===================================================================
|
||
|
||
describe('P0 — close 后无残留后台任务', () => {
|
||
it('close 等待后台 compaction 完成(backend 关闭后无残留写)', async () => {
|
||
const engine = new AriaEngine({
|
||
storageBackend: 'memory',
|
||
// 小缓存 + 小 memtable:flush 文件超缓存上限被驱逐 → compaction 缓存未命中 → 触发调度
|
||
bufferPoolPages: 4,
|
||
memtableSizeThreshold: 32 * 1024,
|
||
checkpointInterval: 100000,
|
||
});
|
||
await engine.open(uniqueDB(), 1);
|
||
await engine.createTable(createSchema('t', {
|
||
id: { type: 'string', primaryKey: true },
|
||
v: { type: 'number' },
|
||
data: { type: 'string' },
|
||
}));
|
||
for (let i = 0; i < 400; i++) {
|
||
await engine.insert('t', [{ id: `k${String(i).padStart(4, '0')}`, v: i, data: 'x'.repeat(200) }]);
|
||
}
|
||
await (engine as any).lsm.flush();
|
||
const backend = (engine as any).backend;
|
||
|
||
// 立即 close:修复前 setTimeout compaction 在 backend.close() 后才执行 → 残留写
|
||
await engine.close();
|
||
|
||
// 给残留 setTimeout 执行机会
|
||
await new Promise((r) => setTimeout(r, 100));
|
||
const keysAfterClose = await (backend as any).listKeys();
|
||
// 修复前:close 后 compaction 写入 → store 残留 sst_* 文件
|
||
expect(keysAfterClose).toHaveLength(0);
|
||
});
|
||
|
||
it('close 后立即 reopen 不被旧后台任务污染', async () => {
|
||
const dbName = uniqueDB();
|
||
const engine = new AriaEngine({
|
||
storageBackend: 'opfs',
|
||
bufferPoolPages: 4,
|
||
memtableSizeThreshold: 32 * 1024,
|
||
checkpointInterval: 100000,
|
||
});
|
||
await engine.open(dbName, 1);
|
||
await engine.createTable(createSchema('t', {
|
||
id: { type: 'string', primaryKey: true },
|
||
v: { type: 'number' },
|
||
data: { type: 'string' },
|
||
}));
|
||
for (let i = 0; i < 300; i++) {
|
||
await engine.insert('t', [{ id: `k${String(i).padStart(4, '0')}`, v: i, data: 'x'.repeat(200) }]);
|
||
}
|
||
await (engine as any).lsm.flush();
|
||
// 立即 close + 立即 reopen(修复前:旧任务的闭包引用新 backend → 交叉写)
|
||
await engine.close();
|
||
await engine.open(dbName, 1);
|
||
// 新库数据必须完整(不被旧任务破坏)
|
||
expect(await engine.count('t')).toBe(300);
|
||
await engine.close();
|
||
});
|
||
});
|
||
|
||
// ===================================================================
|
||
// P0: 后台失败可见性(不吞错)
|
||
// ===================================================================
|
||
|
||
describe('P0 — flush 报告后台失败', () => {
|
||
class FailingStore {
|
||
failNext = true;
|
||
saved = 0;
|
||
deleted = 0;
|
||
metas: { id: number; level: number }[] = [];
|
||
async save(_id: number, _data: Uint8Array): Promise<void> {
|
||
if (this.failNext) {
|
||
this.failNext = false;
|
||
throw new Error('disk full (simulated)');
|
||
}
|
||
this.saved++;
|
||
}
|
||
async load(_id: number): Promise<Uint8Array | null> { return null; }
|
||
async delete(_id: number): Promise<void> { this.deleted++; }
|
||
async allocateId(): Promise<number> { return ++this.saved; }
|
||
async listMeta(): Promise<{ id: number; level: number }[]> { return this.metas; }
|
||
async saveMeta(meta: { id: number; level: number }): Promise<void> { this.metas.push(meta); }
|
||
async deleteMeta(id: number): Promise<void> { this.metas = this.metas.filter((m) => m.id !== id); }
|
||
}
|
||
|
||
it('后台 flush 失败后 flush() 抛 DatabaseError(ARIA_BACKGROUND_ERROR)', async () => {
|
||
const store = new FailingStore();
|
||
const lsm = new LSM({
|
||
memtableSizeThreshold: 64,
|
||
sstableStore: store as any,
|
||
});
|
||
// 触发 freeze + 后台 flush(save 抛错 → 记录 lastBackgroundError)
|
||
for (let i = 0; i < 200; i++) {
|
||
lsm.put(`k${i}`, { v: i });
|
||
}
|
||
await new Promise((r) => setTimeout(r, 50));
|
||
// flush 必须报告后台失败(修复前静默吞错)
|
||
await expect(lsm.flush()).rejects.toMatchObject({ code: 'ARIA_BACKGROUND_ERROR' });
|
||
|
||
// v0.8.0(B-6/44+45)契约变更:第二次 flush **同样必须失败**。
|
||
//
|
||
// 修复前的第二行断言是 `resolves.toBeUndefined()` —— 它编码的语义是
|
||
// "错误已消费 → 这次 flush 算成功"。但 `FailingStore.save` 是**永远**失败,
|
||
// 那份数据此时仍然只在内存里(WAL 之外没有任何副本):报告"成功"等于告诉
|
||
// 调用方"已落盘",而崩溃就会丢。这属于"静默成功",与本次根治的目标正好相反。
|
||
//
|
||
// 现在的语义:flush 只有在**真的把数据落盘**后才 resolve;失败可以重试
|
||
// (冻结表会被重新入链,见下一条用例),但重试仍失败就必须继续报错。
|
||
await expect(lsm.flush()).rejects.toMatchObject({ code: 'ARIA_BACKGROUND_ERROR' });
|
||
// 冻结表仍在(可读、可重试),数据没有凭空消失
|
||
expect(lsm.getStats().frozenTables).toBeGreaterThan(0);
|
||
expect(await lsm.get('k0')).toEqual({ v: 0 });
|
||
});
|
||
|
||
it('后台 compaction 失败后 flush() 报告(链不卡死)', async () => {
|
||
const store = new FailingStore();
|
||
const lsm = new LSM({
|
||
memtableSizeThreshold: 32,
|
||
sstableStore: store as any,
|
||
});
|
||
for (let i = 0; i < 500; i++) {
|
||
lsm.put(`k${i}`, { v: i });
|
||
}
|
||
await new Promise((r) => setTimeout(r, 100));
|
||
// 链不卡死:flush 要么成功要么报告错误(不能永久 pending)
|
||
const result = await Promise.race([
|
||
lsm.flush().then(() => 'ok', (e) => `err:${(e as any).code}`),
|
||
new Promise((r) => setTimeout(() => r('pending'), 500)),
|
||
]);
|
||
expect(result).not.toBe('pending');
|
||
});
|
||
});
|
||
|
||
// ===================================================================
|
||
// P1: commit 顺序与 OPFS close
|
||
// ===================================================================
|
||
|
||
describe('P1 — 提交顺序与 close 等待', () => {
|
||
it('commit 先持久化 WAL 再合并快照(WAL 始终领先)', async () => {
|
||
// 通过顺序断言:事务 INSERT 的 WAL 记录必须在快照合并可见之前已落盘
|
||
const engine = new AriaEngine({ storageBackend: 'memory' });
|
||
await engine.open(uniqueDB(), 1);
|
||
await engine.createTable(createSchema('t', { id: { type: 'string', primaryKey: true } }));
|
||
await engine.beginTransaction();
|
||
await engine.insert('t', [{ id: '1' }]);
|
||
// 快照合并前:WAL 已含事务记录(batch 模式 flush 时机校验)
|
||
await engine.commitTransaction();
|
||
// 崩溃恢复路径:WAL 完整则恢复数据
|
||
const backend = (engine as any).backend;
|
||
const walKeys = (await backend.listKeys()).filter((k: string) => k.startsWith('__wal_'));
|
||
expect(walKeys.length).toBeGreaterThan(0);
|
||
await engine.close();
|
||
});
|
||
|
||
});
|