2 Commits
Author SHA1 Message Date
thzxx c5694b1d23 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 项全绿。
2026-09-15 10:29:03 +08:00
thzxx bf93ec5251 fix(A41): DDL 的 WAL 意图必须先于生效 + ALTER 结构变更可崩溃恢复(A40 未复现,固化为护栏)
实测确认的缺陷:**DDL 的 WAL 意图记录写在生效之后**,而 WAL 是崩溃后唯一能重放
的结构权威 —— 于是它恰恰是最后才写的:
  - `dropTable('other')` 删完 LSM 与 schema 后崩溃(WAL 尚无 DROP 记录)
    → 重开 `tables = ["t","other"]`,且 other 的行数据完好:**DROP 被静默撤销**
    (用户以为删掉了);
  - `alterTable` **完全不写 WAL**:先改内存 schema、必要时建索引(可能因存量
    重复值抛错),最后才 persistSchemas —— 中间抛错就留下"内存已加列、磁盘没加"
    的分裂状态,崩溃则结构变更整体丢失。
    变异验证:去掉 ALTER 回放后,`ADD tag` + `ADD tag2` 两列在重开后**都消失**
    (实测 `after reopen columns: ["id"]`)。

修法(结构上唯一正确的顺序):
  **先写 WAL 意图并刷盘 → 再改内存 → 最后落盘 schema**
WAL 回放是幂等的(CREATE 对已存在的表跳过、DROP 对不存在的表是空操作、
ALTER 用变更后的完整 schema 覆盖),因此先写 WAL 一定能收敛:
崩溃于 WAL 之后生效之前 → 重放生效;崩溃于生效之后 → 重放幂等。
新增 `WALRecordType.ALTER_TABLE = 10`(含变更后的完整 schema)与统一入口
`appendDDLRecord`(两条 DDL 路径共用顺序与刷盘策略,避免再次单点漂移)。

测试可观测性:`AriaEngine` 无法注入存储后端 —— 而 `this.backend` 在 `open()`
里被 WAL、FileManager、SSTableStore 一起捕获,事后替换只会替换一部分
(实测:替换后 WAL 仍写旧后端,于是"崩溃"根本没覆盖 WAL 路径,探针得出假结论)。
新增 `AriaEngineConfig.testBackend`(仅测试用)作为注入点。

关于 A40(审计记录为"close() 截断 WAL 并把未提交写入落盘 → 重开后幽灵行")
经探针**未复现**:事务中 close 后重开只看到已提交行;事务中 DDL 抛
NOT_SUPPORTED;close 后 rollback 抛 TX_NONE;二次 close 幂等。
按项目原则不"修"不存在的问题,而是把这些**已正确**的行为固化为护栏。

验证:新增 tests/v080-aria-ddl-atomicity.test.ts(11 项:4 项顺序不变量、
4 项崩溃恢复、3 项生命周期护栏)。顺序断言用"记录持久化顺序的后端"实现 ——
那是**顺序**性质,OPFS mock 只暴露最终状态,测不出来。
四处修复均做变异验证。全量 89 套件 / 1752 测试通过;typecheck、lint、build
零错误零告警;dist 已重建。
2026-09-15 02:20:27 +08:00