按 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 项全绿。
199 lines
8.4 KiB
TypeScript
199 lines
8.4 KiB
TypeScript
/**
|
||
* AriaEngine — SSTable CRC-32 损坏检测集成测试
|
||
*
|
||
* 覆盖:
|
||
* 1. 打开时整文件 CRC 校验失败 → 损坏 SSTable 被清理(自愈),打开不阻塞
|
||
* 2. 运行期预加载 CRC 校验失败 → 损坏文件不缓存并被清理
|
||
* 3. 旧版无校验文件(checksum=0)在 LSM 层仍正常加载
|
||
*/
|
||
import { AriaEngine } from '../../src/engine/aria/index';
|
||
import { createSchema } from '../../src/table/schema';
|
||
|
||
import { resetOPFSMock, readManifestNamespace } from '../helpers/storage-harness';
|
||
|
||
beforeEach(() => { resetOPFSMock(); });
|
||
|
||
let idbCounter = 0;
|
||
function uniqueDB(): string {
|
||
return `crc-${Date.now()}-${++idbCounter}-${Math.random().toString(36).slice(2, 8)}`;
|
||
}
|
||
|
||
const SCHEMA = () => createSchema('users', {
|
||
id: { type: 'string', primaryKey: true },
|
||
name: { type: 'string' },
|
||
age: { type: 'number' },
|
||
});
|
||
|
||
/** 篡改指定 pg_ 页面文件的一个字节 */
|
||
async function corruptKey(engine: AriaEngine, key: string, byteOffset: number): Promise<void> {
|
||
const backend = (engine as any).backend;
|
||
const raw = await backend.read(key);
|
||
expect(raw).not.toBeNull();
|
||
const buf = new Uint8Array(raw as ArrayBuffer);
|
||
buf[Math.min(byteOffset, buf.length - 1)] ^= 0xff;
|
||
await backend.write(key, buf.buffer as ArrayBuffer);
|
||
}
|
||
|
||
/** 列出全部 SSTable 的页面文件 key(页面化存储:pg_ 前缀) */
|
||
async function listSSTKeys(engine: AriaEngine): Promise<string[]> {
|
||
const backend = (engine as any).backend;
|
||
const keys = (await backend.listKeys()) as string[];
|
||
return keys.filter((k) => k.startsWith('pg_'));
|
||
}
|
||
|
||
/**
|
||
* 读取主 LSM 的 SSTable meta 列表(v0.8.0 B-6:改读 manifest)。
|
||
*
|
||
* 元数据不再是独立的裸 JSON:它随 manifest 原子提交(带 CRC + 世代号)。
|
||
* 旧布局下 `JSON.parse` 失败会被当成"没有文件",即元数据损坏 = 静默空库。
|
||
*/
|
||
async function listSSTMetas(engine: AriaEngine): Promise<{ id: number; pageIds?: number[] }[]> {
|
||
const backend = (engine as any).backend;
|
||
return readManifestNamespace(backend, 'main');
|
||
}
|
||
|
||
describe('AriaEngine — SSTable CRC 损坏检测(集成)', () => {
|
||
it('打开时 CRC 损坏的 SSTable 被清理,其余数据可读', async () => {
|
||
const engine = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
|
||
const dbName = uniqueDB();
|
||
await engine.open(dbName, 1);
|
||
await engine.createTable(SCHEMA());
|
||
|
||
// 写入 200 行(触发多次 flush → 多个 SSTable)
|
||
for (let batch = 0; batch < 3; batch++) {
|
||
const rows = [] as Record<string, unknown>[];
|
||
for (let i = 0; i < 50; i++) {
|
||
rows.push({ id: `u-${batch * 50 + i}`, name: `User${batch * 50 + i}`, age: batch * 50 + i });
|
||
}
|
||
await engine.insert('users', rows);
|
||
await (engine as any).lsm.flush();
|
||
}
|
||
await engine.close();
|
||
|
||
// 重新打开,篡改第一个 SSTable 文件的数据区
|
||
const engine2 = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
|
||
await engine2.open(dbName, 1);
|
||
const sstKeys = await listSSTKeys(engine2);
|
||
expect(sstKeys.length).toBeGreaterThanOrEqual(2);
|
||
await corruptKey(engine2, sstKeys[0], 64);
|
||
|
||
// 再次打开:损坏文件应被跳过并清理,打开不抛错
|
||
await engine2.close();
|
||
const engine3 = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
|
||
await engine3.open(dbName, 1);
|
||
|
||
// 剩余未损坏文件的数据应可查询
|
||
const remaining = await engine3.find('users', { table: 'users' });
|
||
expect(remaining.length).toBeGreaterThan(0);
|
||
expect(remaining.length).toBeLessThan(200);
|
||
|
||
// 损坏文件已被清理(页面 + meta 移除)
|
||
const afterKeys = await listSSTKeys(engine3);
|
||
expect(afterKeys).not.toContain(sstKeys[0]);
|
||
const metas = await listSSTMetas(engine3);
|
||
// 被篡改页面所属的 SSTable meta 应被移除
|
||
const victimMetas = await listSSTMetas(engine2);
|
||
const victimMeta = victimMetas.find((m) => m.pageIds?.includes(Number(sstKeys[0].slice(3))));
|
||
expect(metas.some((m) => m.id === victimMeta?.id)).toBe(false);
|
||
|
||
await engine3.close();
|
||
});
|
||
|
||
it('打开时损坏全部 SSTable → 库仍可打开,数据为空但不崩溃', async () => {
|
||
const engine = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
|
||
const dbName = uniqueDB();
|
||
await engine.open(dbName, 1);
|
||
await engine.createTable(SCHEMA());
|
||
for (let batch = 0; batch < 2; batch++) {
|
||
const rows = [] as Record<string, unknown>[];
|
||
for (let i = 0; i < 50; i++) {
|
||
rows.push({ id: `u-${batch * 50 + i}`, name: `User${batch * 50 + i}`, age: batch * 50 + i });
|
||
}
|
||
await engine.insert('users', rows);
|
||
await (engine as any).lsm.flush();
|
||
}
|
||
await engine.close();
|
||
|
||
const engine2 = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
|
||
await engine2.open(dbName, 1);
|
||
for (const key of await listSSTKeys(engine2)) {
|
||
await corruptKey(engine2, key, 16);
|
||
}
|
||
await engine2.close();
|
||
|
||
const engine3 = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
|
||
// 不应抛 ARIA_OPEN_ERROR
|
||
await engine3.open(dbName, 1);
|
||
const rows3 = await engine3.find('users', { table: 'users' });
|
||
expect(rows3.length).toBe(0);
|
||
await engine3.close();
|
||
});
|
||
|
||
it('运行期 CRC 损坏 → 预加载不缓存损坏文件并清理(自愈)', async () => {
|
||
const engine = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
|
||
const dbName = uniqueDB();
|
||
await engine.open(dbName, 1);
|
||
await engine.createTable(SCHEMA());
|
||
for (let batch = 0; batch < 2; batch++) {
|
||
const rows = [] as Record<string, unknown>[];
|
||
for (let i = 0; i < 50; i++) {
|
||
rows.push({ id: `u-${batch * 50 + i}`, name: `User${batch * 50 + i}`, age: batch * 50 + i });
|
||
}
|
||
await engine.insert('users', rows);
|
||
await (engine as any).lsm.flush();
|
||
}
|
||
|
||
// 运行期:直接篡改后端中的某个 SSTable 文件(不触发打开校验路径)
|
||
const sstKeys = await listSSTKeys(engine);
|
||
expect(sstKeys.length).toBeGreaterThanOrEqual(2);
|
||
const victim = sstKeys[sstKeys.length - 1]; // 篡改最新(memtable 已 flush 后的文件)
|
||
await corruptKey(engine, victim, 100);
|
||
|
||
// 缓存里已有该文件(flush 时缓存)→ 清 LSM 层与 BufferPool 页面缓存模拟运行期磁盘损坏
|
||
const lsm = (engine as any).lsm;
|
||
lsm.sstableCache.clear();
|
||
await (engine as any).bufferPool.clear();
|
||
|
||
// 查询触发 prefetchRange → preloadSSTable 发现 CRC 失败 → 清理 + 不缓存
|
||
const remaining = await engine.find('users', { table: 'users' });
|
||
expect(remaining.length).toBeLessThan(100);
|
||
expect(lsm.sstableCache.has(Number(victim.slice(3)))).toBe(false);
|
||
const metas = await lsm.sstableStore.listMeta();
|
||
expect(metas.some((m: { id: number }) => m.id === Number(victim.slice(3)))).toBe(false);
|
||
|
||
await engine.close();
|
||
});
|
||
|
||
it('旧版无校验文件(checksum=0)在 LSM 中正常加载', async () => {
|
||
const engine = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
|
||
const dbName = uniqueDB();
|
||
await engine.open(dbName, 1);
|
||
await engine.createTable(SCHEMA());
|
||
await engine.insert('users', [
|
||
{ id: 'a', name: 'Alice', age: 30 },
|
||
{ id: 'b', name: 'Bob', age: 25 },
|
||
]);
|
||
await (engine as any).lsm.flush();
|
||
await engine.close();
|
||
|
||
const engine2 = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
|
||
await engine2.open(dbName, 1);
|
||
const sstKeys = await listSSTKeys(engine2);
|
||
expect(sstKeys.length).toBe(1);
|
||
const backend = (engine2 as any).backend;
|
||
const raw = await backend.read(sstKeys[0]);
|
||
const buf = new Uint8Array(raw as ArrayBuffer);
|
||
new DataView(buf.buffer).setUint32(buf.byteLength - 4, 0, false); // checksum 清零 → 旧版
|
||
await backend.write(sstKeys[0], buf.buffer as ArrayBuffer);
|
||
await engine2.close();
|
||
|
||
// 重开:checksum=0 跳过校验,数据完整可读
|
||
const engine3 = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
|
||
await engine3.open(dbName, 1);
|
||
const rows3 = await engine3.find('users', { table: 'users' });
|
||
expect(rows3.length).toBe(2);
|
||
expect(rows3.map((r) => r.id).sort()).toEqual(['a', 'b']);
|
||
await engine3.close();
|
||
});
|
||
});
|