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
+33
View File
@@ -518,3 +518,36 @@ export class CrashableStoreBackend implements IStorageBackend {
this.store.commitAll();
}
}
// ---------------------------------------------------------------------------
// v0.8.0B-6):manifest 读取辅助
// ---------------------------------------------------------------------------
/**
* 读取存储上**最新有效世代**的 manifest(用生产解码器,不做测试特供路径)。
*
* 为什么测试需要它:v0.8.0 起 SSTable 元数据与表结构不再是各自独立的裸 JSON
* `__aria_lsm_meta*` / `__aria_schemas`),而是随 manifest 一起原子提交。
* 任何"直接读裸 JSON 验证落盘内容"的测试都必须改读 manifest —— 否则它验证的是
* 一个已经不存在(且没有 CRC/世代保护)的存储布局。
*
* @returns manifest 内容;全新库(没有任何 manifest 文件)返回 null
* 文件存在但全部世代无效 → 抛错(与生产恢复语义一致,绝不"失败当空库")
*/
export async function readManifestState(
backend: IStorageBackend,
): Promise<import('../../src/engine/aria/store/manifest').AriaManifest | null> {
const { ManifestStore } = await import('../../src/engine/aria/store/manifest');
const store = new ManifestStore({ backend });
const loaded = await store.load();
return loaded.manifest;
}
/** 读取某个命名空间当前的 SSTable meta 列表(测试断言用) */
export async function readManifestNamespace(
backend: IStorageBackend,
ns: string = 'main',
): Promise<{ id: number; level: number; pageIds?: number[]; minKey: string; maxKey: string }[]> {
const manifest = await readManifestState(backend);
return manifest?.namespaces[ns]?.sstables ?? [];
}