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
+42 -10
View File
@@ -24,7 +24,7 @@ export class FileManager implements PageIO {
}
/** 初始化:从存储中读取元数据 */
async init(dbName: string): Promise<void> {
async init(dbName: string, watermarkFloor: number = 1): Promise<void> {
this.dbName = dbName;
const meta = await this.backend.read('__aria_meta');
let nextPageId = 1;
@@ -43,11 +43,30 @@ export class FileManager implements PageIO {
if (!Number.isNaN(id) && id + 1 > nextPageId) nextPageId = id + 1;
}
}
this.nextPageId = nextPageId;
if (!meta || !(meta instanceof ArrayBuffer) || meta.byteLength < 4 || nextPageId !== new DataView(meta as ArrayBuffer).getUint32(0, false)) {
await this.saveMeta();
// v0.8.0B-6):manifest 的 pageId 水位是**权威下限**(单调推进、永不复用),
// 与"现存最大页面 id + 1"、"旧 __aria_meta" 三者取最大 —— 任何单一来源被
// 截断/回退都不会导致页面 id 复用。
if (Number.isFinite(watermarkFloor) && watermarkFloor > nextPageId) {
nextPageId = Math.floor(watermarkFloor);
}
this.nextPageId = nextPageId;
this.metaLoaded = true;
// v0.8.0B-6):`__aria_meta` 降级为**兼容/诊断提示**,不再作为提交点:
// 页面水位只由 manifest 提交(单一提交点),这里仅在旧值落后时补写一次,
// 且写入失败不影响引擎(旧版本读到的只是"落后但单调"的提示值)。
const legacyValue = meta && meta instanceof ArrayBuffer && meta.byteLength >= 4
? new DataView(meta as ArrayBuffer).getUint32(0, false)
: -1;
if (legacyValue !== nextPageId) {
try {
await this.writeLegacyWatermark(nextPageId);
} catch { /* 兼容提示写失败不影响正确性 */ }
}
}
/** v0.8.0: 当前页面 id 水位(下一个可分配 id)—— manifest 提交时记录 */
getNextPageId(): number {
return this.nextPageId;
}
// ---- PageIO ----
@@ -78,18 +97,18 @@ export class FileManager implements PageIO {
async allocatePageId(): Promise<number> {
const id = this.nextPageId++;
await this.saveMeta();
await this.persistWatermarkHint();
return id;
}
/** v0.4.5: 批量分配页面 ID(一次 meta 持久化,避免页面化 SSTable 保存时逐页写 meta */
/** v0.4.5: 批量分配页面 ID(一次提示写,避免页面化 SSTable 保存时逐页写 meta */
async allocatePageIds(count: number): Promise<number[]> {
if (count <= 0) return [];
const ids: number[] = [];
const start = this.nextPageId;
this.nextPageId += count;
for (let i = 0; i < count; i++) ids.push(start + i);
await this.saveMeta();
await this.persistWatermarkHint();
return ids;
}
@@ -101,9 +120,20 @@ export class FileManager implements PageIO {
// ---- 辅助 ----
private async saveMeta(): Promise<void> {
/**
* v0.8.0B-6):`__aria_meta` 只是**兼容提示**(旧版本/人工诊断用),
* 失败不抛错 —— 真正的提交点是 manifest 的 `pageIdWatermark`。
*/
private async persistWatermarkHint(): Promise<void> {
if (!this.metaLoaded) return;
try {
await this.writeLegacyWatermark(this.nextPageId);
} catch { /* 提示写失败不影响正确性(manifest 才是权威) */ }
}
private async writeLegacyWatermark(nextPageId: number): Promise<void> {
const buf = new ArrayBuffer(8);
new DataView(buf).setUint32(0, this.nextPageId, false);
new DataView(buf).setUint32(0, nextPageId, false);
await this.backend.write('__aria_meta', buf);
}
@@ -111,6 +141,8 @@ export class FileManager implements PageIO {
async clearAll(): Promise<void> {
await this.backend.clear();
this.nextPageId = 1;
await this.saveMeta();
try {
await this.writeLegacyWatermark(1);
} catch { /* 提示写失败不影响正确性 */ }
}
}