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
+106 -14
View File
@@ -4133,6 +4133,19 @@ var WALRecordType;
* SAVEPOINT_ROLLBACK 之后的记录
*/
WALRecordType[WALRecordType["SAVEPOINT_ROLLBACK"] = 9] = "SAVEPOINT_ROLLBACK";
/**
* v0.8.0A41ALTER TABLE **意图**记录
*
* 此前 `alterTable` 完全不写 WAL它先改内存 schema必要时建索引最后才
* `persistSchemas()`中间任何一步抛错"ALTER ADD UNIQUE 撞存量重复值"
* 都会留下**内存已变磁盘未变**的分裂状态 同进程里 `getTableSchema`
* 看到新列重开后新列又消失用户视角ALTER 时好时坏结果取决于是否重启
* 加上意图记录后恢复可以按记录把"内存里已经生效"的结构变更补齐
*
* 记录语义`data.schema` 为变更后的完整 schema JSON回放时**覆盖**该表 schema
*ALTER 是结构权威描述不是增量并对新增的 index/unique 列重建索引
*/
WALRecordType[WALRecordType["ALTER_TABLE"] = 10] = "ALTER_TABLE";
})(WALRecordType || (WALRecordType = {}));
// =============================================================================
// MVCC
@@ -4164,6 +4177,7 @@ const DEFAULT_ARIA_CONFIG = {
maxMemoryMB: 64,
encryption: undefined,
pageStorage: undefined,
testBackend: undefined,
};
/**
@@ -8165,7 +8179,12 @@ class AriaEngine {
}
// 1. 存储后端(可选全库加密包装)
let baseBackend;
if (this.config.storageBackend === 'opfs') {
// v0.8.0:测试可注入后端(见 AriaEngineConfig.testBackend 的说明)——
// 崩溃语义必须让 WAL/FileManager/SSTableStore 都走同一个被测后端。
if (this.config.testBackend) {
baseBackend = this.config.testBackend;
}
else if (this.config.storageBackend === 'opfs') {
baseBackend = new OPFSBackend();
}
else if (this.config.storageBackend === 'kv') {
@@ -8514,6 +8533,24 @@ class AriaEngine {
if (this.schemas.has(schema.name)) {
throw new DatabaseError(`Table "${schema.name}" already exists`, 'TABLE_EXISTS');
}
// v0.8.0A41):**先写 WAL 意图,再改内存/落盘**。
//
// 此前顺序是"改内存 → persistSchemas → 追加 WAL",中间任何一步失败或崩溃,
// 这次 DDL 都只留下一半状态。DROP 侧的顺序问题已实测确认:
// dropTable 删完 LSM 与 schema、但 WAL 记录未写成时崩溃 →
// 重开后表**又回来了**(数据也还在),DROP 被静默撤销。
//
// WAL 是权威来源,且回放是幂等的(`applyWALRecord` 对已存在的表跳过、
// `applyDropTableRecovery` 对不存在的表是空操作),因此"先写 WAL"总能收敛:
// - 崩溃于 WAL 之后、生效之前 → 恢复时重放,DDL 生效 ✓
// - 崩溃于生效之后 → 恢复时重放,幂等 ✓
await this.appendDDLRecord({
type: WALRecordType.CREATE_TABLE,
txnId: 0,
tableName: schema.name,
key: '',
data: { schema: JSON.stringify(schema) },
});
this.schemas.set(schema.name, schema);
this.tablePKs.set(schema.name, this.getPK(schema));
// 为索引列创建二级索引 LSM(每个索引使用独立命名空间的 SSTableStore,避免 id/meta 冲突)
@@ -8536,18 +8573,34 @@ class AriaEngine {
}
}
await this.persistSchemas();
await this.wal.append({
type: WALRecordType.CREATE_TABLE,
txnId: 0,
tableName: schema.name,
key: '',
data: { schema: JSON.stringify(schema) },
});
}
/**
* v0.8.0A41追加一条 DDL 意图记录并立即刷盘
*
* 为什么独立成函数两条 DDL 路径必须共用同一套顺序与刷盘策略
* 否则将来只改一处又会漂移 这正是本项目反复出现的缺陷模式
*
* DDL 不参与事务`ensureNoDDLInTransaction` 已保证因此 txnId 恒为 0
* 不需要提交/回滚语义**必须先于生效**写入否则崩溃会静默丢失 DDL
* DDL 是低频操作这里同步刷盘避免"崩溃丢失 DDL"的窗口过大
*/
async appendDDLRecord(record) {
await this.wal.append(record);
await this.wal.flush();
}
async dropTable(tableName) {
this.ensureOpen();
this.ensureNoDDLInTransaction('DROP TABLE');
this.ensureTable(tableName);
// v0.8.0A41):同 createTable —— **先写 WAL 意图**。
// 实测修复前:dropTable('other') 之后崩溃 → 重开 `tables = ["t","other"]`
// 且 other 的行数据完好,DROP 被静默撤销(用户以为删掉了)。
await this.appendDDLRecord({
type: WALRecordType.DROP_TABLE,
txnId: 0,
tableName,
key: '',
});
// 删除表中所有行
const rows = await this.getAllRows(tableName);
for (const row of rows) {
@@ -8566,12 +8619,6 @@ class AriaEngine {
this.schemas.delete(tableName);
this.tablePKs.delete(tableName);
await this.persistSchemas();
await this.wal.append({
type: WALRecordType.DROP_TABLE,
txnId: 0,
tableName,
key: '',
});
}
/**
* v0.4.2-fix: 清理指定表的全部二级索引 LSM内存 + 存储文件 + meta
@@ -9314,6 +9361,24 @@ class AriaEngine {
if (schema.columns[column.name]) {
throw new DatabaseError(`Column "${column.name}" already exists in table "${tableName}"`, 'COLUMN_EXISTS');
}
// v0.8.0A41):先写 ALTER 意图(含**变更后**的完整 schema),再改内存/索引。
//
// 此前完全不写 WAL:先改内存 schema → 建索引(可能因存量重复值抛错)→
// persistSchemas。中间抛错就留下"内存已加列、磁盘没加"的分裂状态 ——
// 同进程 `getTableSchema` 看到新列,重开后新列消失,用户看到的是
// "ALTER 有时生效有时不生效,取决于是否重启"。
// 有了意图记录,崩溃/失败后恢复会按它把结构补齐(幂等覆盖)。
const intendedSchema = {
name: schema.name,
columns: { ...schema.columns, [column.name]: column },
};
await this.appendDDLRecord({
type: WALRecordType.ALTER_TABLE,
txnId: 0,
tableName,
key: column.name,
data: { schema: JSON.stringify(intendedSchema), action: 'ADD' },
});
schema.columns[column.name] = column;
// v0.8.0 根治:ALTER ADD 的索引/唯一列必须真正建立索引 LSM 并回填。
//
@@ -9346,6 +9411,20 @@ class AriaEngine {
this.secondaryIndexes.delete(idxKey);
}
}
// v0.8.0A41):DROP 同样先写意图(变更后的完整 schema)
{
const intendedSchema = {
name: schema.name,
columns: Object.fromEntries(Object.entries(schema.columns).filter(([col]) => col !== column.name)),
};
await this.appendDDLRecord({
type: WALRecordType.ALTER_TABLE,
txnId: 0,
tableName,
key: column.name,
data: { schema: JSON.stringify(intendedSchema), action: 'DROP' },
});
}
delete schema.columns[column.name];
await this.persistSchemas();
// 重写主 LSM:移除所有行的该列键(find 副本无法就地删除,必须重写存储)
@@ -9911,6 +9990,19 @@ class AriaEngine {
catch { /* skip */ }
}
break;
case WALRecordType.ALTER_TABLE:
// v0.8.0A41):ALTER 是结构权威描述 → **覆盖**该表 schema(不是增量合并)。
// 幂等:重复回放同一记录结果相同。索引列的重建在恢复末尾由
// reindexTableInternal 统一完成(与 CREATE_TABLE 的处理一致)。
if (record.data?.schema) {
try {
const s = JSON.parse(record.data.schema);
this.schemas.set(s.name, s);
this.tablePKs.set(s.name, this.getPK(s));
}
catch { /* skip */ }
}
break;
case WALRecordType.COMMIT:
case WALRecordType.ROLLBACK:
case WALRecordType.BEGIN: