Files
MetonaSqlark/tests/v042-hardening.test.ts
T
thzxx c5694b1d23 feat(B-6): 存储层单一提交点(__aria_manifest)+ LSM 结构根治
按 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 项全绿。
2026-09-15 10:29:03 +08:00

318 lines
13 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.
/**
* 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 页 = 16KBmemtable 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.0B-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.0B-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();
});
});