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 项全绿。
This commit is contained in:
thzxx
2026-09-15 10:29:03 +08:00
parent 714e7f98a4
commit c5694b1d23
20 changed files with 4764 additions and 709 deletions
+72 -6
View File
@@ -29,6 +29,13 @@ import { describe, it, expect, beforeEach } from '@jest/globals';
import { AriaEngine } from '../src/engine/aria/index';
import { resetOPFSMock } from './helpers/storage-harness';
import type { IStorageBackend } from '../src/engine/aria/store/backend';
import {
decodeManifest,
encodeManifest,
generationFromKey,
manifestKey,
type AriaManifest,
} from '../src/engine/aria/store/manifest';
beforeEach(() => { resetOPFSMock(); });
@@ -71,16 +78,71 @@ class OrderRecordingBackend implements IStorageBackend {
async exists(k: string): Promise<boolean> { return this.files.has(k); }
async clear(): Promise<void> { this.files.clear(); }
/** 索引:WAL 记录写入发生在 schema 落盘**之前** */
/**
* 索引:WAL 记录写入发生在 schema **持久化之前**。
*
* v0.8.0B-6):schema 不再是独立的 `__aria_schemas` 裸 JSON
* 它随 manifest 原子提交 —— 因此"schema 落盘"的落点变成 `__aria_manifest_*`。
* 断言的**性质**没变(WAL 先于持久化),只是落点换了。
*/
walPrecedesSchema(): boolean {
const wal = this.order.findIndex((k) => k.includes('wal'));
const sch = this.order.findIndex((k) => k.includes('schemas'));
const sch = this.order.findIndex((k) => k.includes('__aria_manifest_'));
return wal >= 0 && sch >= 0 && wal < sch;
}
/** 模拟"该文件没能落盘"(崩溃窗口) */
dropFile(pattern: string): void {
for (const k of [...this.files.keys()]) if (k.includes(pattern)) this.files.delete(k);
}
/**
* 当前 manifest 的**语义快照**(解码后的内容 + 世代号)。
* 用于"DDL 之前"的状态存档:见 `rollbackManifest()`。
*/
snapshotManifest(): { manifest: AriaManifest; generation: number } {
const gens = [...this.files.keys()]
.map((k) => generationFromKey(k))
.filter((g): g is number => g !== null)
.sort((a, b) => b - a);
if (gens.length === 0) throw new Error('no manifest on medium');
const raw = this.files.get(manifestKey(gens[0]))!;
const decoded = decodeManifest(new Uint8Array(raw));
if (!decoded.ok) throw new Error(`manifest decode failed: ${decoded.reason}`);
return { manifest: decoded.manifest, generation: gens[0] };
}
/**
* 把 manifest 回滚到给定快照(写成一份**更新世代号**的内容,并清掉其它世代)。
*
* 语义:模拟"这次 DDL 的 manifest 提交从未落盘"——数据文件与 WAL 保持原样,
* 于是重开时结构只能靠 **WAL 意图回放** 恢复(这正是本套件要验证的兜底)。
*
* 注意:不能简单删掉新世代 —— 世代保留窗口是 2,被回滚到的那一代此时
* 可能已经被正常清理掉了。因此这里用快照内容重新提交一份新世代。
*/
rollbackManifest(snapshot: { manifest: AriaManifest; generation: number }): void {
const gens = [...this.files.keys()]
.map((k) => generationFromKey(k))
.filter((g): g is number => g !== null);
const nextGeneration = Math.max(0, ...gens) + 1;
const rolled: AriaManifest = {
...snapshot.manifest,
generation: nextGeneration,
namespaces: snapshot.manifest.namespaces,
schemas: snapshot.manifest.schemas,
wal: { ...snapshot.manifest.wal },
frozen: [],
owner: { ...snapshot.manifest.owner },
};
const bytes = encodeManifest(rolled);
this.files.set(
manifestKey(nextGeneration),
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer,
);
for (const k of [...this.files.keys()]) {
const gen = generationFromKey(k);
if (gen !== null && gen !== nextGeneration) this.files.delete(k);
}
}
}
/**
@@ -148,13 +210,15 @@ describe('[v0.8.0] A41 DDL 结构变更可崩溃恢复(WAL 兜底)', () => {
const backend = new OrderRecordingBackend();
const engine = openWith(backend);
await engine.open('ddl-alter-crash', 1);
// 记录"DDL 之前"的 manifest 状态:崩溃窗口 = 之后的提交都没落盘
const beforeDdl = backend.snapshotManifest();
await engine.createTable(schema('t'));
await engine.alterTable('t', 'ADD', { name: 'tag', type: 'string' } as never);
await engine.alterTable('t', 'ADD', { name: 'tag2', type: 'string' } as never);
// 崩溃窗口:两次 ALTER 的 schema 都没能落盘(不 close,否则 close 会重试落盘)
backend.dropFile('__aria_schemas');
expect((await backend.listKeys()).some((k) => k.includes('schemas'))).toBe(false);
backend.rollbackManifest(beforeDdl);
expect(backend.snapshotManifest().generation).toBeGreaterThan(beforeDdl.generation);
const engine2 = openWith(backend);
await engine2.open('ddl-alter-crash', 1);
@@ -173,9 +237,10 @@ describe('[v0.8.0] A41 DDL 结构变更可崩溃恢复(WAL 兜底)', () => {
await engine.insert('t', [{ id: 'r1' }]);
await engine.createTable(schema('other'));
await engine.insert('other', [{ id: 'o1' }]);
const beforeDrop = backend.snapshotManifest();
await engine.dropTable('other');
backend.dropFile('__aria_schemas'); // schema 落盘丢失
backend.rollbackManifest(beforeDrop); // DROP 的持久化提交丢失
// 不 close(崩溃语义)
const engine2 = openWith(backend);
@@ -191,9 +256,10 @@ describe('[v0.8.0] A41 DDL 结构变更可崩溃恢复(WAL 兜底)', () => {
const backend = new OrderRecordingBackend();
const engine = openWith(backend);
await engine.open('ddl-create-crash', 1);
const beforeCreate = backend.snapshotManifest();
await engine.createTable(schema('t'));
await engine.insert('t', [{ id: 'r1' }]);
backend.dropFile('__aria_schemas');
backend.rollbackManifest(beforeCreate);
const engine2 = openWith(backend);
await engine2.open('ddl-create-crash', 1);