按 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 项全绿。
149 lines
5.4 KiB
TypeScript
149 lines
5.4 KiB
TypeScript
/**
|
||
* AriaEngine File Manager — 页面文件管理 + PageIO 实现
|
||
* @module engine/aria/store/file_manager
|
||
*
|
||
* 负责管理页面文件的生命周期:分配/释放页面 ID,读写页面。
|
||
*/
|
||
|
||
import type { IStorageBackend } from './backend';
|
||
import type { PageIO } from '../buffer/pool';
|
||
import { PAGE_SIZE } from '../types';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// FileManager (implements PageIO)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export class FileManager implements PageIO {
|
||
private backend: IStorageBackend;
|
||
private nextPageId = 0;
|
||
private metaLoaded = false;
|
||
private dbName = '';
|
||
|
||
constructor(backend: IStorageBackend) {
|
||
this.backend = backend;
|
||
}
|
||
|
||
/** 初始化:从存储中读取元数据 */
|
||
async init(dbName: string, watermarkFloor: number = 1): Promise<void> {
|
||
this.dbName = dbName;
|
||
const meta = await this.backend.read('__aria_meta');
|
||
let nextPageId = 1;
|
||
if (meta && meta instanceof ArrayBuffer && meta.byteLength >= 4) {
|
||
nextPageId = new DataView(meta).getUint32(0, false);
|
||
}
|
||
// v0.6.1-fix(P0): 崩溃一致性 —— allocatePageIds 的 saveMeta 可能在分配
|
||
// 页面后被崩溃中断(nextPageId 回退)。恢复时若按回退值继续分配,
|
||
// 页面 id 复用会覆盖旧页面;随后 compaction 按 meta.pageIds 删除"旧"SSTable
|
||
// 时误删被复用的新数据 → 崩溃恢复后静默丢数据(kv 后端 2 万行丢 75%)。
|
||
// 修复:以"现存最大页面 id + 1"为准(单调不回退,绝不复用已存在页面)。
|
||
const keys = await this.backend.listKeys();
|
||
for (const k of keys) {
|
||
if (k.startsWith('pg_')) {
|
||
const id = Number.parseInt(k.slice(3), 10);
|
||
if (!Number.isNaN(id) && id + 1 > nextPageId) nextPageId = id + 1;
|
||
}
|
||
}
|
||
// v0.8.0(B-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.0(B-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 ----
|
||
|
||
async readPage(pageId: number): Promise<ArrayBuffer | null> {
|
||
const key = `pg_${pageId}`;
|
||
const data = await this.backend.read(key);
|
||
if (!data) {
|
||
// v0.4.5: 页面总是先分配(allocatePageId 持久化)后写入 —— 读取缺失页面视为损坏
|
||
// (此前返回空页面会静默掩盖页面丢失,页面化 SSTable 依赖 null 触发自愈清理)
|
||
return null;
|
||
}
|
||
|
||
// 确保大小正确
|
||
if (data.byteLength < PAGE_SIZE) {
|
||
const padded = new ArrayBuffer(PAGE_SIZE);
|
||
new Uint8Array(padded).set(new Uint8Array(data));
|
||
return padded;
|
||
}
|
||
|
||
return data;
|
||
}
|
||
|
||
async writePage(pageId: number, data: ArrayBuffer): Promise<void> {
|
||
const key = `pg_${pageId}`;
|
||
await this.backend.write(key, data);
|
||
}
|
||
|
||
async allocatePageId(): Promise<number> {
|
||
const id = this.nextPageId++;
|
||
await this.persistWatermarkHint();
|
||
return id;
|
||
}
|
||
|
||
/** 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.persistWatermarkHint();
|
||
return ids;
|
||
}
|
||
|
||
async freePageId(_pageId: number): Promise<void> {
|
||
// 简化实现:不回收 pageId
|
||
const key = `pg_${_pageId}`;
|
||
await this.backend.delete(key);
|
||
}
|
||
|
||
// ---- 辅助 ----
|
||
|
||
/**
|
||
* v0.8.0(B-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, nextPageId, false);
|
||
await this.backend.write('__aria_meta', buf);
|
||
}
|
||
|
||
/** 清空所有数据 */
|
||
async clearAll(): Promise<void> {
|
||
await this.backend.clear();
|
||
this.nextPageId = 1;
|
||
try {
|
||
await this.writeLegacyWatermark(1);
|
||
} catch { /* 提示写失败不影响正确性 */ }
|
||
}
|
||
}
|