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:
@@ -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.0(B-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);
|
||||
|
||||
Reference in New Issue
Block a user