按 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 项全绿。
318 lines
13 KiB
TypeScript
318 lines
13 KiB
TypeScript
/**
|
||
* v0.4.2 深度审计回归测试(第二轮)
|
||
* 覆盖:
|
||
* - P0-A: compaction 不依赖缓存(缓存未命中不得丢数据/数据不可见)
|
||
* - P0-B: flush/compaction 失败不得卡死 flushChain(写路径死锁)
|
||
* - P1-A: 重开后二级索引恢复 + createIndex 幂等重建 + WAL 恢复后索引一致
|
||
* - P1-B: ALTER TABLE 在 IndexedDB/Hybrid 引擎持久化
|
||
* - P1-C: IndexedDB 事务内 DDL(建表/删表)commit 后磁盘一致
|
||
*/
|
||
|
||
import { AriaEngine } from '../src/engine/aria/index';
|
||
import { createSchema } from '../src/table/schema';
|
||
import { MemoryEngine } from '../src/engine/memory';
|
||
import { HybridEngine } from '../src/hybrid/index';
|
||
import { MetonaSqlark } from '../src/core';
|
||
|
||
import { resetOPFSMock } from './helpers/storage-harness';
|
||
|
||
beforeEach(() => { resetOPFSMock(); });
|
||
|
||
let idbCounter = 0;
|
||
function uniqueDB(): string {
|
||
return `audit-${Date.now()}-${++idbCounter}-${Math.random().toString(36).slice(2, 8)}`;
|
||
}
|
||
|
||
// ===================================================================
|
||
// P0-A: compaction 不得依赖 SSTable 缓存
|
||
// ===================================================================
|
||
|
||
describe('P0-A — Compaction 缓存独立性', () => {
|
||
it('缓存上限小于单个 SSTable 时 compaction 不丢数据(此前数据不可见)', async () => {
|
||
const engine = new AriaEngine({
|
||
storageBackend: 'memory',
|
||
// 缓存上限 4 页 = 16KB,memtable 32KB → 每次 flush 的文件都超出缓存上限被驱逐
|
||
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) }]);
|
||
}
|
||
// 等待 flush + compaction 链全部完成
|
||
await (engine as any).lsm.flush();
|
||
await new Promise((r) => setTimeout(r, 100));
|
||
// 修复前:compaction 缓存未命中跳过全部文件并从 levels 移除 → 查询为空
|
||
expect(await engine.count('t')).toBe(400);
|
||
await engine.close();
|
||
});
|
||
|
||
it('多层级 compaction 后数据仍完整且可重开', async () => {
|
||
const dbName = uniqueDB();
|
||
const engine = new AriaEngine({
|
||
storageBackend: 'opfs',
|
||
bufferPoolPages: 8, // 32KB 缓存
|
||
memtableSizeThreshold: 16 * 1024,
|
||
checkpointInterval: 100000,
|
||
});
|
||
await engine.open(dbName, 1);
|
||
await engine.createTable(createSchema('t', {
|
||
id: { type: 'string', primaryKey: true },
|
||
v: { type: 'number' },
|
||
}));
|
||
for (let i = 0; i < 300; i++) {
|
||
await engine.insert('t', [{ id: `k${String(i).padStart(4, '0')}`, v: i }]);
|
||
}
|
||
await (engine as any).lsm.flush();
|
||
await new Promise((r) => setTimeout(r, 150));
|
||
const before = await engine.count('t');
|
||
expect(before).toBe(300);
|
||
await engine.close();
|
||
|
||
// 重开:数据完整(meta/文件未被 compaction 破坏)
|
||
const engine2 = new AriaEngine({
|
||
storageBackend: 'opfs',
|
||
bufferPoolPages: 8,
|
||
memtableSizeThreshold: 16 * 1024,
|
||
checkpointInterval: 100000,
|
||
});
|
||
await engine2.open(dbName, 1);
|
||
expect(await engine2.count('t')).toBe(300);
|
||
await engine2.close();
|
||
});
|
||
});
|
||
|
||
// ===================================================================
|
||
// P1-A: 二级索引跨重启恢复
|
||
// ===================================================================
|
||
|
||
describe('P1-A — 二级索引恢复', () => {
|
||
it('Aria 重开后二级索引可用(索引 LSM 持久化恢复)', async () => {
|
||
const dbName = uniqueDB();
|
||
const engine = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
|
||
await engine.open(dbName, 1);
|
||
await engine.createTable(createSchema('users', {
|
||
id: { type: 'string', primaryKey: true },
|
||
email: { type: 'string', index: true },
|
||
}));
|
||
await engine.insert('users', [
|
||
{ id: '1', email: 'a@x.com' },
|
||
{ id: '2', email: 'b@x.com' },
|
||
{ id: '3', email: 'a@x.com' },
|
||
]);
|
||
await (engine as any).lsm.flush();
|
||
await engine.close();
|
||
|
||
// 重开:二级索引 LSM 应自动恢复(修复前为空 → 索引查询回退全表,createIndex 也静默跳过)
|
||
const engine2 = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
|
||
await engine2.open(dbName, 1);
|
||
// 强断言:索引 LSM 真实存在且数据完整(防止"索引缺失静默回退全表扫描"的假通过)
|
||
const idxLsm = (engine2 as any).secondaryIndexes.get('users:idx:email');
|
||
expect(idxLsm).toBeDefined();
|
||
expect(idxLsm.getStats().sstableCount).toBeGreaterThan(0);
|
||
// v0.8.0(B-6/55):`prefetchRange` 已删除 —— 读取自洽(未命中即回源 + CRC
|
||
// 校验),调用方不再需要"先预加载再读"这条隐式约定。
|
||
// v0.8.0: LSM.rangeScan 改为 async(读取自洽,未命中会回源加载)
|
||
expect(await idxLsm.rangeScan('', '\uffff')).toHaveLength(3);
|
||
const byEmail = await engine2.find('users', { table: 'users', where: { email: 'a@x.com' } });
|
||
expect(byEmail).toHaveLength(2);
|
||
// createIndex 对已持久化的索引列应幂等可重建(不得静默跳过导致索引永久缺失)
|
||
await engine2.createIndex('users', 'email');
|
||
await engine2.close();
|
||
});
|
||
|
||
it('WAL 恢复后二级索引与主数据一致(崩溃前索引未更新)', async () => {
|
||
const dbName = uniqueDB();
|
||
const engine = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
|
||
await engine.open(dbName, 1);
|
||
await engine.createTable(createSchema('users', {
|
||
id: { type: 'string', primaryKey: true },
|
||
city: { type: 'string', index: true },
|
||
}));
|
||
await engine.insert('users', [{ id: '1', city: 'Beijing' }]);
|
||
await (engine as any).lsm.flush();
|
||
// 写入 WAL 但强制不 flush(模拟崩溃:新行只在 WAL,索引 LSM 未更新)
|
||
await engine.insert('users', [{ id: '2', city: 'Shanghai' }]);
|
||
// 模拟异常退出(不 close)
|
||
await (engine as any).backend.close();
|
||
(engine as any).opened = false;
|
||
|
||
const engine2 = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
|
||
await engine2.open(dbName, 1);
|
||
// 强断言:索引 LSM 已恢复且包含 WAL 回放的行(崩溃前索引未更新,恢复后必须重建)
|
||
const idxLsm = (engine2 as any).secondaryIndexes.get('users:idx:city');
|
||
expect(idxLsm).toBeDefined();
|
||
// v0.8.0(B-6/55):prefetchRange 已删除(读取自洽);rangeScan 现为 async
|
||
expect(await idxLsm.rangeScan('', '\uffff')).toHaveLength(2);
|
||
// 索引查询应看到 WAL 恢复的行(修复前索引与主数据不一致 → 丢行)
|
||
const byCity = await engine2.find('users', { table: 'users', where: { city: 'Shanghai' } });
|
||
expect(byCity).toHaveLength(1);
|
||
expect(byCity[0].id).toBe('2');
|
||
await engine2.close();
|
||
});
|
||
|
||
it('createIndex 重开后仍可新建(schema 标记恢复后不阻塞)', async () => {
|
||
const dbName = uniqueDB();
|
||
const engine = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
|
||
await engine.open(dbName, 1);
|
||
await engine.createTable(createSchema('t', {
|
||
id: { type: 'string', primaryKey: true },
|
||
name: { type: 'string' },
|
||
}));
|
||
await engine.insert('t', [{ id: '1', name: 'A' }]);
|
||
await engine.close();
|
||
|
||
const engine2 = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
|
||
await engine2.open(dbName, 1);
|
||
await engine2.createIndex('t', 'name'); // 修复前 schema 无标记时正常;此处验证无标记场景
|
||
const byName = await engine2.find('t', { table: 't', where: { name: 'A' } });
|
||
expect(byName).toHaveLength(1);
|
||
await engine2.close();
|
||
});
|
||
});
|
||
|
||
// ===================================================================
|
||
// P1-B: ALTER TABLE 跨引擎持久化
|
||
// ===================================================================
|
||
|
||
describe('P1-B — ALTER TABLE 持久化', () => {
|
||
it('KVStoreEngine DROP COLUMN 后重启不复活', async () => {
|
||
const dbName = uniqueDB();
|
||
const db = new MetonaSqlark({ name: dbName, mode: 'disk', diskEngine: 'opfs' });
|
||
await db.init();
|
||
await db.defineTable('t', {
|
||
id: { type: 'string', primaryKey: true },
|
||
old_col: { type: 'string' },
|
||
keep: { type: 'string' },
|
||
});
|
||
await db.table('t').insert({ id: '1', old_col: 'x', keep: 'y' });
|
||
await db.query('ALTER TABLE t DROP COLUMN old_col');
|
||
// 行数据中该列已移除
|
||
const rows = await db.table('t').select().execute();
|
||
expect(rows[0].old_col).toBeUndefined();
|
||
await db.close();
|
||
|
||
// 重启:schema 不复活,列定义已持久化
|
||
const db2 = new MetonaSqlark({ name: dbName, mode: 'disk', diskEngine: 'opfs' });
|
||
await db2.init();
|
||
const schema2 = await db2.getEngine().getTableSchema('t');
|
||
expect(schema2!.columns.old_col).toBeUndefined();
|
||
expect(schema2!.columns.keep).toBeDefined();
|
||
await db2.close();
|
||
});
|
||
|
||
it('HybridEngine ADD COLUMN 后重启保留', async () => {
|
||
const dbName = uniqueDB();
|
||
const db = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'opfs' });
|
||
await db.init();
|
||
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
|
||
await db.query('ALTER TABLE t ADD COLUMN phone STRING');
|
||
await db.close();
|
||
|
||
const db2 = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'opfs' });
|
||
await db2.init();
|
||
const schema2 = await db2.getEngine().getTableSchema('t');
|
||
expect(schema2!.columns.phone).toBeDefined();
|
||
await db2.close();
|
||
});
|
||
});
|
||
|
||
// ===================================================================
|
||
// P1-C: KVStore 事务内 DDL
|
||
// ===================================================================
|
||
|
||
describe('P1-C — KVStore 事务内 DDL', () => {
|
||
it('事务内建表 → commit 后磁盘一致(重启表存在且可查)', async () => {
|
||
const dbName = uniqueDB();
|
||
const db = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'opfs' });
|
||
await db.init();
|
||
await db.defineTable('base', { id: { type: 'string', primaryKey: true } });
|
||
await db.table('base').insert({ id: '1' });
|
||
|
||
await db.transaction(async (trx) => {
|
||
await trx.table('base').insert({ id: '2' });
|
||
// 事务内建新表(此前 commit 时 IDB 无对应 store → 事务失败)
|
||
await db.defineTable('created_in_tx', { id: { type: 'string', primaryKey: true } });
|
||
await db.table('created_in_tx').insert({ id: 'tx1' });
|
||
});
|
||
|
||
expect(await db.table('created_in_tx').count()).toBe(1);
|
||
await db.close();
|
||
|
||
const db2 = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'opfs' });
|
||
await db2.init();
|
||
expect(await db2.table('created_in_tx').count()).toBe(1);
|
||
expect(await db2.table('base').count()).toBe(2);
|
||
await db2.close();
|
||
});
|
||
|
||
it('事务内删表 → commit 后磁盘一致(重启无幽灵表)', async () => {
|
||
const dbName = uniqueDB();
|
||
const db = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'opfs' });
|
||
await db.init();
|
||
await db.defineTable('ghost', { id: { type: 'string', primaryKey: true } });
|
||
await db.table('ghost').insert({ id: '1' });
|
||
|
||
await db.transaction(async () => {
|
||
await db.dropTable('ghost');
|
||
});
|
||
|
||
await db.close();
|
||
const db2 = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'opfs' });
|
||
await db2.init();
|
||
const names = await db2.getTableNames();
|
||
expect(names).not.toContain('ghost');
|
||
await db2.close();
|
||
});
|
||
});
|
||
|
||
// ===================================================================
|
||
// 各引擎 clearAll / repair / 元数据 一致性抽查
|
||
// ===================================================================
|
||
|
||
describe('全模式抽查 — 生命周期与元数据', () => {
|
||
it('MemoryEngine clearAll/repair/getMeta/setMeta', async () => {
|
||
const e = new MemoryEngine();
|
||
await e.open('m', 1);
|
||
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true } } });
|
||
await e.insert('t', [{ id: '1' }]);
|
||
await e.setMeta('k', 'v');
|
||
expect(await e.getMeta('k')).toBe('v');
|
||
await e.repair();
|
||
await e.clearAll();
|
||
expect(await e.getTableNames()).toEqual([]);
|
||
await e.close();
|
||
});
|
||
|
||
it('HybridEngine getMeta/setMeta 委托磁盘', async () => {
|
||
const dbName = uniqueDB();
|
||
const e = new HybridEngine('opfs');
|
||
await e.open(dbName, 1);
|
||
await e.setMeta('__metona_version', '7');
|
||
expect(await e.getMeta('__metona_version')).toBe('7');
|
||
await e.close();
|
||
const e2 = new HybridEngine('opfs');
|
||
await e2.open(dbName, 1);
|
||
expect(await e2.getMeta('__metona_version')).toBe('7');
|
||
await e2.close();
|
||
});
|
||
|
||
it('AriaEngine repair 幂等', async () => {
|
||
const dbName = uniqueDB();
|
||
const engine = new AriaEngine({ storageBackend: 'opfs' });
|
||
await engine.open(dbName, 1);
|
||
await engine.createTable(createSchema('t', { id: { type: 'string', primaryKey: true } }));
|
||
await engine.insert('t', [{ id: '1' }]);
|
||
await engine.repair();
|
||
await engine.repair();
|
||
expect(await engine.count('t')).toBe(1);
|
||
await engine.close();
|
||
});
|
||
});
|