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 已重建。
This commit is contained in:
thzxx
2026-09-15 02:20:27 +08:00
parent 2328540779
commit bf93ec5251
11 changed files with 792 additions and 108 deletions
+70 -44
View File
@@ -1,3 +1,47 @@
/**
* AriaEngine Storage Backend — 存储后端抽象层
* @module engine/aria/store/backend
*
* 封装底层浏览器存储 APIIndexedDB / OPFS / Memory 回退),
* 供 Buffer Pool 的 PageIO 和 WAL 的 WALStore 使用。
*/
interface IStorageBackend {
/** 打开存储 */
open(name: string): Promise<void>;
/** 关闭存储 */
close(): Promise<void>;
/** 是否已打开 */
isOpen(): boolean;
/** 读取数据块 */
read(key: string): Promise<ArrayBuffer | null>;
/** 写入数据块 */
write(key: string, data: ArrayBuffer): Promise<void>;
/**
* 追加写入(v0.4.5 WAL 分片用,可选):
* - OPFS 后端实现真追加(createWritable keepExistingData + seekO(chunk)
* - 未实现的后端由调用方回退 read+writeEncryptedBackend 包装时整体重写保正确性)
* 语义:在 key 现有内容末尾追加 data;key 不存在时等同 write。
*/
append?(key: string, data: ArrayBuffer): Promise<void>;
/**
* 批量原子写入(v0.4.2-fix):多个 key 在单个底层事务中提交,
* 中断时整体回滚,不留半写状态。WAL count 与记录同事务保证一致性。
*/
writeMany(entries: Record<string, ArrayBuffer>): Promise<void>;
/** 删除数据块 */
delete(key: string): Promise<void>;
/**
* 批量原子删除(v0.4.2-fix):多个 key 在单个底层事务中提交。
*/
deleteMany(keys: string[]): Promise<void>;
/** 列出所有 key */
listKeys(): Promise<string[]>;
/** 检查 key 是否存在 */
exists(key: string): Promise<boolean>;
/** 清空所有数据 */
clear(): Promise<void>;
}
interface AriaEngineConfig {
/** 页面大小(默认 4096 */
pageSize?: number;
@@ -37,6 +81,17 @@ interface AriaEngineConfig {
* 显式 false 强制关闭(整 value 存储,兼容旧行为)。
*/
pageStorage?: boolean;
/**
* **仅测试使用**:直接注入存储后端(跳过 storageBackend 选择逻辑)。
*
* 为什么需要这个口子:崩溃/撕裂语义的验证必须让引擎**从打开那一刻起**
* 就走被测后端 —— `this.backend` 在 `open()` 里被 WAL、FileManager、
* SSTableStore 一起捕获,事后替换 `engine.backend` 只会替换其中一部分
*(实测:替换后 WAL 仍写旧后端,于是"崩溃"根本没覆盖 WAL 路径,
* 探针得出的是假结论)。缺少这个口子会让所有故障注入只能在孤立后端上
* 验证,而无法验证"引擎整体在崩裂介质上的行为"。
*/
testBackend?: IStorageBackend;
}
/**
@@ -1421,50 +1476,6 @@ declare class MemoryEngine implements IStorageEngine {
private cascadeDelete;
}
/**
* AriaEngine Storage Backend — 存储后端抽象层
* @module engine/aria/store/backend
*
* 封装底层浏览器存储 APIIndexedDB / OPFS / Memory 回退),
* 供 Buffer Pool 的 PageIO 和 WAL 的 WALStore 使用。
*/
interface IStorageBackend {
/** 打开存储 */
open(name: string): Promise<void>;
/** 关闭存储 */
close(): Promise<void>;
/** 是否已打开 */
isOpen(): boolean;
/** 读取数据块 */
read(key: string): Promise<ArrayBuffer | null>;
/** 写入数据块 */
write(key: string, data: ArrayBuffer): Promise<void>;
/**
* 追加写入(v0.4.5 WAL 分片用,可选):
* - OPFS 后端实现真追加(createWritable keepExistingData + seekO(chunk)
* - 未实现的后端由调用方回退 read+writeEncryptedBackend 包装时整体重写保正确性)
* 语义:在 key 现有内容末尾追加 data;key 不存在时等同 write。
*/
append?(key: string, data: ArrayBuffer): Promise<void>;
/**
* 批量原子写入(v0.4.2-fix):多个 key 在单个底层事务中提交,
* 中断时整体回滚,不留半写状态。WAL count 与记录同事务保证一致性。
*/
writeMany(entries: Record<string, ArrayBuffer>): Promise<void>;
/** 删除数据块 */
delete(key: string): Promise<void>;
/**
* 批量原子删除(v0.4.2-fix):多个 key 在单个底层事务中提交。
*/
deleteMany(keys: string[]): Promise<void>;
/** 列出所有 key */
listKeys(): Promise<string[]>;
/** 检查 key 是否存在 */
exists(key: string): Promise<boolean>;
/** 清空所有数据 */
clear(): Promise<void>;
}
declare class KVStoreEngine implements IStorageEngine {
readonly name = "kv";
private kv;
@@ -1552,6 +1563,10 @@ declare class KVStoreEngine implements IStorageEngine {
declare class AriaEngine implements IStorageEngine {
readonly name = "aria";
/**
* 生效配置。`testBackend` 与 `pageStorage`/`encryption` 一样是**可选**的
* (不参与 Required),否则 DEFAULT_ARIA_CONFIG 会被迫提供一个假后端。
*/
private config;
private lsm;
private wal;
@@ -1602,6 +1617,17 @@ declare class AriaEngine implements IStorageEngine {
getMeta(key: string): Promise<string | null>;
setMeta(key: string, value: string): Promise<void>;
createTable(schema: TableSchema): Promise<void>;
/**
* v0.8.0A41):追加一条 DDL 意图记录并立即刷盘。
*
* 为什么独立成函数:两条 DDL 路径必须共用同一套顺序与刷盘策略,
* 否则将来只改一处又会漂移 —— 这正是本项目反复出现的缺陷模式。
*
* DDL 不参与事务(`ensureNoDDLInTransaction` 已保证),因此 txnId 恒为 0,
* 不需要提交/回滚语义;但**必须先于生效**写入,否则崩溃会静默丢失 DDL。
* DDL 是低频操作,这里同步刷盘,避免"崩溃丢失 DDL"的窗口过大。
*/
private appendDDLRecord;
dropTable(tableName: string): Promise<void>;
/**
* v0.4.2-fix: 清理指定表的全部二级索引 LSM(内存 + 存储文件 + meta)。