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
+8 -5
View File
@@ -9,7 +9,7 @@
import { AriaEngine } from '../../src/engine/aria/index';
import { createSchema } from '../../src/table/schema';
import { resetOPFSMock } from '../helpers/storage-harness';
import { resetOPFSMock, readManifestNamespace } from '../helpers/storage-harness';
beforeEach(() => { resetOPFSMock(); });
@@ -41,12 +41,15 @@ async function listSSTKeys(engine: AriaEngine): Promise<string[]> {
return keys.filter((k) => k.startsWith('pg_'));
}
/** 读取主 LSM 的 SSTable meta 列表 */
/**
* 读取主 LSM 的 SSTable meta 列表(v0.8.0 B-6:改读 manifest)。
*
* 元数据不再是独立的裸 JSON:它随 manifest 原子提交(带 CRC + 世代号)。
* 旧布局下 `JSON.parse` 失败会被当成"没有文件",即元数据损坏 = 静默空库。
*/
async function listSSTMetas(engine: AriaEngine): Promise<{ id: number; pageIds?: number[] }[]> {
const backend = (engine as any).backend;
const raw = await backend.read('__aria_lsm_meta');
if (!raw) return [];
return JSON.parse(new TextDecoder().decode(raw)) as { id: number; pageIds?: number[] }[];
return readManifestNamespace(backend, 'main');
}
describe('AriaEngine — SSTable CRC 损坏检测(集成)', () => {
+11 -1
View File
@@ -58,7 +58,17 @@ async function writeAndMeasure(opts: {
await engine.createTable(SCHEMA() as never);
for (let i = 0; i < rows; i++) await engine.insert('t', [{ id: `k${i}`, blob: COMPRESSIBLE }]);
const lsm = (engine as unknown as { lsm: { sstableStore: { listMeta(): Promise<Array<{ totalSize: number }>> } } }).lsm;
const lsm = (engine as unknown as {
lsm: { flush(): Promise<void>; sstableStore: { listMeta(): Promise<Array<{ totalSize: number }>> } };
}).lsm;
// v0.8.0B-6):**测量前显式 flush**。
//
// 此前这里直接读 meta 求和,于是"落盘字节数"取决于测量瞬间有多少数据恰好在
// SSTable 里 —— 而 manifest 提交(单一提交点)让每次 flush 多一次原子提交,
// 后台 flush 的进度随之变化,两次实验的"已落盘比例"不再相同,比值就变成在
// 测时序而不是测压缩(实测:未 flush 时 off=19040/on=5130flush 后
// off=58570/on=10096 —— 后者才是同一份数据的真实压缩率)。
await lsm.flush();
const metas = await lsm.sstableStore.listMeta();
const storedBytes = metas.reduce((sum, m) => sum + (m.totalSize ?? 0), 0);
+9 -8
View File
@@ -9,7 +9,7 @@ import { AriaEngine } from '../../src/engine/aria/index';
import { createSchema } from '../../src/table/schema';
import { MetonaSqlark } from '../../src/core';
import { resetOPFSMock } from '../helpers/storage-harness';
import { resetOPFSMock, readManifestState } from '../helpers/storage-harness';
beforeEach(() => { resetOPFSMock(); });
@@ -455,13 +455,14 @@ describe('AriaEngine — Schema 持久化 (Memory Backend)', () => {
name: { type: 'string', required: true },
}));
// 直接通过 backend 验证 Schema JSON 已写入
const raw = await (engine as any).backend.read('__aria_schemas');
expect(raw).not.toBeNull();
const json = new TextDecoder().decode(raw);
const data = JSON.parse(json);
expect(data.users).toBeDefined();
expect(data.users.id.primaryKey).toBe(true);
// v0.8.0(B-6):表结构不再是独立的裸 JSON__aria_schemas),
// 而是随 manifest 一起**原子提交**(单一提交点)。测试改读 manifest ——
// 它才是落盘结构的权威来源(带 CRC 与世代号)。
// 旧布局的问题:坏 JSON 会被 `readMetaList()` 当成 `[]`,元数据损坏 = 静默空库。
const manifest = await readManifestState((engine as any).backend);
expect(manifest).not.toBeNull();
expect(manifest!.schemas.users).toBeDefined();
expect(manifest!.schemas.users.id.primaryKey).toBe(true);
await engine.close();
});
+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 ?? [];
}
+5 -7
View File
@@ -20,7 +20,7 @@ import { OPFSBackend } from '../src/engine/aria/store/opfs_backend';
import { KVStoreEngine } from '../src/engine/kvstore_engine';
import type { SSTableMeta } from '../src/engine/aria/types';
import { resetOPFSMock } from './helpers/storage-harness';
import { resetOPFSMock, readManifestNamespace } from './helpers/storage-harness';
beforeEach(() => { resetOPFSMock(); });
@@ -153,8 +153,8 @@ describe('P0-1b — AriaEngine 打开时完整性校验', () => {
// 篡改存储:把第一个 SSTable 的第一个页面写成残缺内容(meta 仍引用它)
const backend = new OPFSBackend();
await backend.open(dbName);
const metas = JSON.parse(new TextDecoder().decode(
await backend.read('__aria_lsm_meta') as ArrayBuffer)) as { id: number; pageIds: number[] }[];
// v0.8.0B-6):SSTable meta 随 manifest 原子提交(不再是裸 JSON key)
const metas = await readManifestNamespace(backend, 'main') as { id: number; pageIds: number[] }[];
expect(metas.length).toBeGreaterThan(0);
const pageId = metas[0].pageIds[0];
await backend.write(`pg_${pageId}`, new TextEncoder().encode('truncated-garbage').buffer);
@@ -174,8 +174,7 @@ describe('P0-1b — AriaEngine 打开时完整性校验', () => {
const backend2 = new OPFSBackend();
await backend2.open(dbName);
const remaining = (await backend2.listKeys()).filter((k) => k.startsWith('pg_'));
const metaRaw = await backend2.read('__aria_lsm_meta');
const metaList = JSON.parse(new TextDecoder().decode(metaRaw ?? new Uint8Array())) as { pageIds?: number[] }[];
const metaList = await readManifestNamespace(backend2, 'main');
const livePages = new Set<number>();
for (const m of metaList) if (m.pageIds) for (const pid of m.pageIds) livePages.add(pid);
const orphan = remaining.filter((k) => !livePages.has(Number(k.slice(3))));
@@ -414,8 +413,7 @@ describe('P2-9 — 统一自愈接口 repair / clearAll', () => {
// 篡改一个 SSTable 的页面文件
const backend = new OPFSBackend();
await backend.open(dbName);
const metas = JSON.parse(new TextDecoder().decode(
await backend.read('__aria_lsm_meta') as ArrayBuffer)) as { id: number; pageIds: number[] }[];
const metas = await readManifestNamespace(backend, 'main') as { id: number; pageIds: number[] }[];
expect(metas.length).toBeGreaterThan(0);
const victimPage = metas[0].pageIds[0];
const raw = new Uint8Array(await backend.read(`pg_${victimPage}`) as ArrayBuffer);
+3 -3
View File
@@ -116,7 +116,8 @@ describe('P1-A — 二级索引恢复', () => {
const idxLsm = (engine2 as any).secondaryIndexes.get('users:idx:email');
expect(idxLsm).toBeDefined();
expect(idxLsm.getStats().sstableCount).toBeGreaterThan(0);
await idxLsm.prefetchRange('', '\uffff');
// 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' } });
@@ -147,8 +148,7 @@ describe('P1-A — 二级索引恢复', () => {
// 强断言:索引 LSM 已恢复且包含 WAL 回放的行(崩溃前索引未更新,恢复后必须重建)
const idxLsm = (engine2 as any).secondaryIndexes.get('users:idx:city');
expect(idxLsm).toBeDefined();
await idxLsm.prefetchRange('', '\uffff');
// v0.8.0: LSM.rangeScan 改为 async
// 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' } });
+14 -2
View File
@@ -120,8 +120,20 @@ describe('P0 — flush 报告后台失败', () => {
await new Promise((r) => setTimeout(r, 50));
// flush 必须报告后台失败(修复前静默吞错)
await expect(lsm.flush()).rejects.toMatchObject({ code: 'ARIA_BACKGROUND_ERROR' });
// 再次 flush:错误已消费,正常完成
await expect(lsm.flush()).resolves.toBeUndefined();
// v0.8.0B-6/44+45)契约变更:第二次 flush **同样必须失败**。
//
// 修复前的第二行断言是 `resolves.toBeUndefined()` —— 它编码的语义是
// "错误已消费 → 这次 flush 算成功"。但 `FailingStore.save` 是**永远**失败,
// 那份数据此时仍然只在内存里(WAL 之外没有任何副本):报告"成功"等于告诉
// 调用方"已落盘",而崩溃就会丢。这属于"静默成功",与本次根治的目标正好相反。
//
// 现在的语义:flush 只有在**真的把数据落盘**后才 resolve;失败可以重试
// (冻结表会被重新入链,见下一条用例),但重试仍失败就必须继续报错。
await expect(lsm.flush()).rejects.toMatchObject({ code: 'ARIA_BACKGROUND_ERROR' });
// 冻结表仍在(可读、可重试),数据没有凭空消失
expect(lsm.getStats().frozenTables).toBeGreaterThan(0);
expect(await lsm.get('k0')).toEqual({ v: 0 });
});
it('后台 compaction 失败后 flush() 报告(链不卡死)', async () => {
+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);
File diff suppressed because it is too large Load Diff