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:
Vendored
+106
-14
@@ -4137,6 +4137,19 @@ var WALRecordType;
|
||||
* SAVEPOINT_ROLLBACK 之后的记录。
|
||||
*/
|
||||
WALRecordType[WALRecordType["SAVEPOINT_ROLLBACK"] = 9] = "SAVEPOINT_ROLLBACK";
|
||||
/**
|
||||
* v0.8.0(A41):ALTER 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
|
||||
@@ -4168,6 +4181,7 @@ const DEFAULT_ARIA_CONFIG = {
|
||||
maxMemoryMB: 64,
|
||||
encryption: undefined,
|
||||
pageStorage: undefined,
|
||||
testBackend: undefined,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -8169,7 +8183,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') {
|
||||
@@ -8518,6 +8537,24 @@ class AriaEngine {
|
||||
if (this.schemas.has(schema.name)) {
|
||||
throw new DatabaseError(`Table "${schema.name}" already exists`, 'TABLE_EXISTS');
|
||||
}
|
||||
// v0.8.0(A41):**先写 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 冲突)
|
||||
@@ -8540,18 +8577,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.0(A41):追加一条 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.0(A41):同 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) {
|
||||
@@ -8570,12 +8623,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)。
|
||||
@@ -9318,6 +9365,24 @@ class AriaEngine {
|
||||
if (schema.columns[column.name]) {
|
||||
throw new DatabaseError(`Column "${column.name}" already exists in table "${tableName}"`, 'COLUMN_EXISTS');
|
||||
}
|
||||
// v0.8.0(A41):先写 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 并回填。
|
||||
//
|
||||
@@ -9350,6 +9415,20 @@ class AriaEngine {
|
||||
this.secondaryIndexes.delete(idxKey);
|
||||
}
|
||||
}
|
||||
// v0.8.0(A41):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 副本无法就地删除,必须重写存储)
|
||||
@@ -9915,6 +9994,19 @@ class AriaEngine {
|
||||
catch { /* skip */ }
|
||||
}
|
||||
break;
|
||||
case WALRecordType.ALTER_TABLE:
|
||||
// v0.8.0(A41):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:
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+70
-44
@@ -1,3 +1,47 @@
|
||||
/**
|
||||
* AriaEngine Storage Backend — 存储后端抽象层
|
||||
* @module engine/aria/store/backend
|
||||
*
|
||||
* 封装底层浏览器存储 API(IndexedDB / 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 + seek,O(chunk))
|
||||
* - 未实现的后端由调用方回退 read+write(EncryptedBackend 包装时整体重写保正确性)
|
||||
* 语义:在 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
|
||||
*
|
||||
* 封装底层浏览器存储 API(IndexedDB / 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 + seek,O(chunk))
|
||||
* - 未实现的后端由调用方回退 read+write(EncryptedBackend 包装时整体重写保正确性)
|
||||
* 语义:在 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.0(A41):追加一条 DDL 意图记录并立即刷盘。
|
||||
*
|
||||
* 为什么独立成函数:两条 DDL 路径必须共用同一套顺序与刷盘策略,
|
||||
* 否则将来只改一处又会漂移 —— 这正是本项目反复出现的缺陷模式。
|
||||
*
|
||||
* DDL 不参与事务(`ensureNoDDLInTransaction` 已保证),因此 txnId 恒为 0,
|
||||
* 不需要提交/回滚语义;但**必须先于生效**写入,否则崩溃会静默丢失 DDL。
|
||||
* DDL 是低频操作,这里同步刷盘,避免"崩溃丢失 DDL"的窗口过大。
|
||||
*/
|
||||
private appendDDLRecord;
|
||||
dropTable(tableName: string): Promise<void>;
|
||||
/**
|
||||
* v0.4.2-fix: 清理指定表的全部二级索引 LSM(内存 + 存储文件 + meta)。
|
||||
|
||||
Vendored
+106
-14
@@ -4133,6 +4133,19 @@ var WALRecordType;
|
||||
* SAVEPOINT_ROLLBACK 之后的记录。
|
||||
*/
|
||||
WALRecordType[WALRecordType["SAVEPOINT_ROLLBACK"] = 9] = "SAVEPOINT_ROLLBACK";
|
||||
/**
|
||||
* v0.8.0(A41):ALTER 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.0(A41):**先写 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.0(A41):追加一条 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.0(A41):同 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.0(A41):先写 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.0(A41):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.0(A41):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:
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+106
-14
@@ -4139,6 +4139,19 @@
|
||||
* SAVEPOINT_ROLLBACK 之后的记录。
|
||||
*/
|
||||
WALRecordType[WALRecordType["SAVEPOINT_ROLLBACK"] = 9] = "SAVEPOINT_ROLLBACK";
|
||||
/**
|
||||
* v0.8.0(A41):ALTER 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
|
||||
@@ -4170,6 +4183,7 @@
|
||||
maxMemoryMB: 64,
|
||||
encryption: undefined,
|
||||
pageStorage: undefined,
|
||||
testBackend: undefined,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -8171,7 +8185,12 @@
|
||||
}
|
||||
// 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') {
|
||||
@@ -8520,6 +8539,24 @@
|
||||
if (this.schemas.has(schema.name)) {
|
||||
throw new DatabaseError(`Table "${schema.name}" already exists`, 'TABLE_EXISTS');
|
||||
}
|
||||
// v0.8.0(A41):**先写 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 冲突)
|
||||
@@ -8542,18 +8579,34 @@
|
||||
}
|
||||
}
|
||||
await this.persistSchemas();
|
||||
await this.wal.append({
|
||||
type: WALRecordType.CREATE_TABLE,
|
||||
txnId: 0,
|
||||
tableName: schema.name,
|
||||
key: '',
|
||||
data: { schema: JSON.stringify(schema) },
|
||||
});
|
||||
}
|
||||
/**
|
||||
* v0.8.0(A41):追加一条 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.0(A41):同 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) {
|
||||
@@ -8572,12 +8625,6 @@
|
||||
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)。
|
||||
@@ -9320,6 +9367,24 @@
|
||||
if (schema.columns[column.name]) {
|
||||
throw new DatabaseError(`Column "${column.name}" already exists in table "${tableName}"`, 'COLUMN_EXISTS');
|
||||
}
|
||||
// v0.8.0(A41):先写 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 并回填。
|
||||
//
|
||||
@@ -9352,6 +9417,20 @@
|
||||
this.secondaryIndexes.delete(idxKey);
|
||||
}
|
||||
}
|
||||
// v0.8.0(A41):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 副本无法就地删除,必须重写存储)
|
||||
@@ -9917,6 +9996,19 @@
|
||||
catch { /* skip */ }
|
||||
}
|
||||
break;
|
||||
case WALRecordType.ALTER_TABLE:
|
||||
// v0.8.0(A41):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:
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+100
-17
@@ -41,8 +41,12 @@ import type { WALRecord as _WALRecord } from './types';
|
||||
export class AriaEngine implements IStorageEngine {
|
||||
readonly name = 'aria';
|
||||
|
||||
private config!: Required<Omit<AriaEngineConfig, 'encryption' | 'pageStorage'>> &
|
||||
Pick<AriaEngineConfig, 'encryption' | 'pageStorage'>;
|
||||
/**
|
||||
* 生效配置。`testBackend` 与 `pageStorage`/`encryption` 一样是**可选**的
|
||||
* (不参与 Required),否则 DEFAULT_ARIA_CONFIG 会被迫提供一个假后端。
|
||||
*/
|
||||
private config!: Required<Omit<AriaEngineConfig, 'encryption' | 'pageStorage' | 'testBackend'>> &
|
||||
Pick<AriaEngineConfig, 'encryption' | 'pageStorage' | 'testBackend'>;
|
||||
private lsm!: LSM; // 主键索引 LSM
|
||||
private wal!: WAL;
|
||||
private checkpointManager!: CheckpointManager;
|
||||
@@ -126,7 +130,11 @@ export class AriaEngine implements IStorageEngine {
|
||||
|
||||
// 1. 存储后端(可选全库加密包装)
|
||||
let baseBackend: IStorageBackend;
|
||||
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') {
|
||||
// v0.6.1: 自研 KVStore 后端(aria 完全跑在自研存储栈上,不依赖浏览器 OPFS)
|
||||
@@ -497,6 +505,25 @@ export class AriaEngine implements IStorageEngine {
|
||||
throw new DatabaseError(`Table "${schema.name}" already exists`, 'TABLE_EXISTS');
|
||||
}
|
||||
|
||||
// v0.8.0(A41):**先写 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) } as unknown as Record<string, unknown>,
|
||||
});
|
||||
|
||||
this.schemas.set(schema.name, schema);
|
||||
this.tablePKs.set(schema.name, this.getPK(schema));
|
||||
|
||||
@@ -521,14 +548,21 @@ export class AriaEngine implements IStorageEngine {
|
||||
}
|
||||
|
||||
await this.persistSchemas();
|
||||
}
|
||||
|
||||
await this.wal.append({
|
||||
type: WALRecordType.CREATE_TABLE,
|
||||
txnId: 0,
|
||||
tableName: schema.name,
|
||||
key: '',
|
||||
data: { schema: JSON.stringify(schema) } as unknown as Record<string, unknown>,
|
||||
});
|
||||
/**
|
||||
* v0.8.0(A41):追加一条 DDL 意图记录并立即刷盘。
|
||||
*
|
||||
* 为什么独立成函数:两条 DDL 路径必须共用同一套顺序与刷盘策略,
|
||||
* 否则将来只改一处又会漂移 —— 这正是本项目反复出现的缺陷模式。
|
||||
*
|
||||
* DDL 不参与事务(`ensureNoDDLInTransaction` 已保证),因此 txnId 恒为 0,
|
||||
* 不需要提交/回滚语义;但**必须先于生效**写入,否则崩溃会静默丢失 DDL。
|
||||
* DDL 是低频操作,这里同步刷盘,避免"崩溃丢失 DDL"的窗口过大。
|
||||
*/
|
||||
private async appendDDLRecord(record: Omit<WALRecord, 'lsn' | 'checksum'>): Promise<void> {
|
||||
await this.wal.append(record);
|
||||
await this.wal.flush();
|
||||
}
|
||||
|
||||
async dropTable(tableName: string): Promise<void> {
|
||||
@@ -536,6 +570,16 @@ export class AriaEngine implements IStorageEngine {
|
||||
this.ensureNoDDLInTransaction('DROP TABLE');
|
||||
this.ensureTable(tableName);
|
||||
|
||||
// v0.8.0(A41):同 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) {
|
||||
@@ -555,13 +599,6 @@ export class AriaEngine implements IStorageEngine {
|
||||
this.schemas.delete(tableName);
|
||||
this.tablePKs.delete(tableName);
|
||||
await this.persistSchemas();
|
||||
|
||||
await this.wal.append({
|
||||
type: WALRecordType.DROP_TABLE,
|
||||
txnId: 0,
|
||||
tableName,
|
||||
key: '',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1399,6 +1436,24 @@ export class AriaEngine implements IStorageEngine {
|
||||
if (schema.columns[column.name]) {
|
||||
throw new DatabaseError(`Column "${column.name}" already exists in table "${tableName}"`, 'COLUMN_EXISTS');
|
||||
}
|
||||
// v0.8.0(A41):先写 ALTER 意图(含**变更后**的完整 schema),再改内存/索引。
|
||||
//
|
||||
// 此前完全不写 WAL:先改内存 schema → 建索引(可能因存量重复值抛错)→
|
||||
// persistSchemas。中间抛错就留下"内存已加列、磁盘没加"的分裂状态 ——
|
||||
// 同进程 `getTableSchema` 看到新列,重开后新列消失,用户看到的是
|
||||
// "ALTER 有时生效有时不生效,取决于是否重启"。
|
||||
// 有了意图记录,崩溃/失败后恢复会按它把结构补齐(幂等覆盖)。
|
||||
const intendedSchema: TableSchema = {
|
||||
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' } as unknown as Record<string, unknown>,
|
||||
});
|
||||
schema.columns[column.name] = column;
|
||||
|
||||
// v0.8.0 根治:ALTER ADD 的索引/唯一列必须真正建立索引 LSM 并回填。
|
||||
@@ -1432,6 +1487,22 @@ export class AriaEngine implements IStorageEngine {
|
||||
this.secondaryIndexes.delete(idxKey);
|
||||
}
|
||||
}
|
||||
// v0.8.0(A41):DROP 同样先写意图(变更后的完整 schema)
|
||||
{
|
||||
const intendedSchema: TableSchema = {
|
||||
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' } as unknown as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
delete schema.columns[column.name];
|
||||
await this.persistSchemas();
|
||||
|
||||
@@ -2029,6 +2100,18 @@ export class AriaEngine implements IStorageEngine {
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
break;
|
||||
case WALRecordType.ALTER_TABLE:
|
||||
// v0.8.0(A41):ALTER 是结构权威描述 → **覆盖**该表 schema(不是增量合并)。
|
||||
// 幂等:重复回放同一记录结果相同。索引列的重建在恢复末尾由
|
||||
// reindexTableInternal 统一完成(与 CREATE_TABLE 的处理一致)。
|
||||
if (record.data?.schema) {
|
||||
try {
|
||||
const s = JSON.parse(record.data.schema as string) as TableSchema;
|
||||
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:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { IStorageBackend } from './store/backend';
|
||||
/**
|
||||
* AriaEngine Types — 内部类型定义
|
||||
* @module engine/aria/types
|
||||
@@ -134,6 +135,19 @@ export enum WALRecordType {
|
||||
* SAVEPOINT_ROLLBACK 之后的记录。
|
||||
*/
|
||||
SAVEPOINT_ROLLBACK = 9,
|
||||
/**
|
||||
* v0.8.0(A41):ALTER TABLE 的**意图**记录。
|
||||
*
|
||||
* 此前 `alterTable` 完全不写 WAL:它先改内存 schema、必要时建索引,最后才
|
||||
* `persistSchemas()`。中间任何一步抛错(如"ALTER ADD UNIQUE 撞存量重复值")
|
||||
* 都会留下**内存已变、磁盘未变**的分裂状态 —— 同进程里 `getTableSchema`
|
||||
* 看到新列,重开后新列又消失(用户视角:ALTER 时好时坏、结果取决于是否重启)。
|
||||
* 加上意图记录后,恢复可以按记录把"内存里已经生效"的结构变更补齐。
|
||||
*
|
||||
* 记录语义:`data.schema` 为变更后的完整 schema JSON;回放时**覆盖**该表 schema
|
||||
*(ALTER 是结构权威描述,不是增量),并对新增的 index/unique 列重建索引。
|
||||
*/
|
||||
ALTER_TABLE = 10,
|
||||
}
|
||||
|
||||
/** 单条 WAL 记录 */
|
||||
@@ -247,11 +261,25 @@ export interface AriaEngineConfig {
|
||||
* 显式 false 强制关闭(整 value 存储,兼容旧行为)。
|
||||
*/
|
||||
pageStorage?: boolean;
|
||||
/**
|
||||
* **仅测试使用**:直接注入存储后端(跳过 storageBackend 选择逻辑)。
|
||||
*
|
||||
* 为什么需要这个口子:崩溃/撕裂语义的验证必须让引擎**从打开那一刻起**
|
||||
* 就走被测后端 —— `this.backend` 在 `open()` 里被 WAL、FileManager、
|
||||
* SSTableStore 一起捕获,事后替换 `engine.backend` 只会替换其中一部分
|
||||
*(实测:替换后 WAL 仍写旧后端,于是"崩溃"根本没覆盖 WAL 路径,
|
||||
* 探针得出的是假结论)。缺少这个口子会让所有故障注入只能在孤立后端上
|
||||
* 验证,而无法验证"引擎整体在崩裂介质上的行为"。
|
||||
*/
|
||||
testBackend?: IStorageBackend;
|
||||
}
|
||||
|
||||
export const DEFAULT_ARIA_CONFIG: Required<Omit<AriaEngineConfig, 'encryption' | 'pageStorage'>> & {
|
||||
export const DEFAULT_ARIA_CONFIG: Required<
|
||||
Omit<AriaEngineConfig, 'encryption' | 'pageStorage' | 'testBackend'>
|
||||
> & {
|
||||
encryption: undefined;
|
||||
pageStorage: undefined;
|
||||
testBackend: undefined;
|
||||
} = {
|
||||
pageSize: PAGE_SIZE,
|
||||
bufferPoolPages: DEFAULT_BUFFER_POOL_PAGES,
|
||||
@@ -267,4 +295,5 @@ export const DEFAULT_ARIA_CONFIG: Required<Omit<AriaEngineConfig, 'encryption' |
|
||||
maxMemoryMB: 64,
|
||||
encryption: undefined,
|
||||
pageStorage: undefined,
|
||||
testBackend: undefined,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* v0.8.0 回归套件 —— Aria DDL 原子性与崩溃恢复(A41)+ 生命周期守卫(A40)
|
||||
* ============================================================================
|
||||
* 实测确认的缺陷:**DDL 的 WAL 意图记录写在生效之后**。
|
||||
*
|
||||
* `createTable` / `dropTable` 的顺序是"改内存 → 落盘 schema → 追加 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 之后、生效之前 → 恢复重放,DDL 生效 ✓
|
||||
* - 崩溃于生效之后 → 恢复重放,幂等 ✓
|
||||
*
|
||||
* 关于 A40 的说明:审计记录的"close() 截断 WAL 并把未提交写入落盘 → 重开后
|
||||
* 幽灵行"经探针**未复现**(事务中 close 后重开只看到已提交行;事务中 DDL 抛
|
||||
* NOT_SUPPORTED)。本套件把这些已正确的行为固化为护栏,避免将来被误改。
|
||||
*/
|
||||
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';
|
||||
|
||||
beforeEach(() => { resetOPFSMock(); });
|
||||
|
||||
const schema = (name: string, extra: Record<string, unknown> = {}) => ({
|
||||
name,
|
||||
columns: { id: { type: 'string' as const, primaryKey: true }, ...extra },
|
||||
} as never);
|
||||
|
||||
/**
|
||||
* 内存介质 + 持久化**顺序记录** + 崩溃窗口诊断。
|
||||
*
|
||||
* 为什么需要它:本套件的核心不变量是"WAL 先于 schema 落盘",那是**顺序**性质,
|
||||
* 只有记录顺序才能断言;而 OPFS mock 只暴露最终状态。
|
||||
*/
|
||||
class OrderRecordingBackend implements IStorageBackend {
|
||||
private files = new Map<string, ArrayBuffer>();
|
||||
readonly order: string[] = [];
|
||||
async open(): Promise<void> {}
|
||||
async close(): Promise<void> {}
|
||||
isOpen(): boolean { return true; }
|
||||
async read(k: string): Promise<ArrayBuffer | null> { return this.files.get(k) ?? null; }
|
||||
async write(k: string, d: ArrayBuffer): Promise<void> {
|
||||
this.files.set(k, d.slice(0));
|
||||
this.order.push(`write:${k}`);
|
||||
}
|
||||
async append(k: string, d: ArrayBuffer): Promise<void> {
|
||||
const prev = this.files.get(k);
|
||||
const merged = new Uint8Array((prev?.byteLength ?? 0) + d.byteLength);
|
||||
if (prev) merged.set(new Uint8Array(prev), 0);
|
||||
merged.set(new Uint8Array(d), prev?.byteLength ?? 0);
|
||||
this.files.set(k, merged.buffer as ArrayBuffer);
|
||||
this.order.push(`append:${k}`);
|
||||
}
|
||||
async writeMany(entries: Record<string, ArrayBuffer>): Promise<void> {
|
||||
for (const [k, v] of Object.entries(entries)) await this.write(k, v);
|
||||
}
|
||||
async delete(k: string): Promise<void> { this.files.delete(k); this.order.push(`delete:${k}`); }
|
||||
async deleteMany(keys: string[]): Promise<void> { for (const k of keys) await this.delete(k); }
|
||||
async listKeys(): Promise<string[]> { return [...this.files.keys()]; }
|
||||
async exists(k: string): Promise<boolean> { return this.files.has(k); }
|
||||
async clear(): Promise<void> { this.files.clear(); }
|
||||
|
||||
/** 索引:WAL 记录写入发生在 schema 落盘**之前** */
|
||||
walPrecedesSchema(): boolean {
|
||||
const wal = this.order.findIndex((k) => k.includes('wal'));
|
||||
const sch = this.order.findIndex((k) => k.includes('schemas'));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造一个把注入后端当唯一介质的引擎。
|
||||
*
|
||||
* 注意:库名由 `open(dbName, version)` 传入,这里不接第二个参数 ——
|
||||
* 早期版本接了 `name` 却没用(lint 警告),留着会误导读者以为构造函数需要库名。
|
||||
*/
|
||||
function openWith(backend: IStorageBackend): AriaEngine {
|
||||
return new AriaEngine({
|
||||
storageBackend: 'memory',
|
||||
checkpointInterval: 100_000_000,
|
||||
testBackend: backend,
|
||||
} as never);
|
||||
}
|
||||
|
||||
describe('[v0.8.0] A41 DDL 的 WAL 意图必须先于生效', () => {
|
||||
it('createTable:WAL 先于 schema 落盘', async () => {
|
||||
const backend = new OrderRecordingBackend();
|
||||
const engine = openWith(backend);
|
||||
await engine.open('ddl-create', 1);
|
||||
backend.order.length = 0;
|
||||
await engine.createTable(schema('t'));
|
||||
expect(backend.walPrecedesSchema()).toBe(true);
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
it('dropTable:WAL 先于 schema 落盘', async () => {
|
||||
const backend = new OrderRecordingBackend();
|
||||
const engine = openWith(backend);
|
||||
await engine.open('ddl-drop', 1);
|
||||
await engine.createTable(schema('t'));
|
||||
await engine.createTable(schema('other'));
|
||||
backend.order.length = 0;
|
||||
await engine.dropTable('other');
|
||||
expect(backend.walPrecedesSchema()).toBe(true);
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
it('alterTable ADD:WAL 先于 schema 落盘(修复前完全不写 WAL)', async () => {
|
||||
const backend = new OrderRecordingBackend();
|
||||
const engine = openWith(backend);
|
||||
await engine.open('ddl-alter-add', 1);
|
||||
await engine.createTable(schema('t'));
|
||||
backend.order.length = 0;
|
||||
await engine.alterTable('t', 'ADD', { name: 'tag', type: 'string' } as never);
|
||||
expect(backend.walPrecedesSchema()).toBe(true);
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
it('alterTable DROP:WAL 先于 schema 落盘', async () => {
|
||||
const backend = new OrderRecordingBackend();
|
||||
const engine = openWith(backend);
|
||||
await engine.open('ddl-alter-drop', 1);
|
||||
await engine.createTable(schema('t', { tag: { type: 'string' } }));
|
||||
backend.order.length = 0;
|
||||
await engine.alterTable('t', 'DROP', { name: 'tag', type: 'string' } as never);
|
||||
expect(backend.walPrecedesSchema()).toBe(true);
|
||||
await engine.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('[v0.8.0] A41 DDL 结构变更可崩溃恢复(WAL 兜底)', () => {
|
||||
it('ALTER ADD 后 schema 未能落盘就崩溃 → 重开结构仍完整', async () => {
|
||||
const backend = new OrderRecordingBackend();
|
||||
const engine = openWith(backend);
|
||||
await engine.open('ddl-alter-crash', 1);
|
||||
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);
|
||||
|
||||
const engine2 = openWith(backend);
|
||||
await engine2.open('ddl-alter-crash', 1);
|
||||
const cols = Object.keys((await engine2.getTableSchema('t'))!.columns);
|
||||
// 变异验证:去掉 ALTER_TABLE 回放后这里只剩 ["id"]
|
||||
expect(cols).toContain('tag');
|
||||
expect(cols).toContain('tag2');
|
||||
await engine2.close();
|
||||
});
|
||||
|
||||
it('DROP TABLE 后 schema 未能落盘就崩溃 → 重开表确实已删除', async () => {
|
||||
const backend = new OrderRecordingBackend();
|
||||
const engine = openWith(backend);
|
||||
await engine.open('ddl-drop-crash', 1);
|
||||
await engine.createTable(schema('t'));
|
||||
await engine.insert('t', [{ id: 'r1' }]);
|
||||
await engine.createTable(schema('other'));
|
||||
await engine.insert('other', [{ id: 'o1' }]);
|
||||
|
||||
await engine.dropTable('other');
|
||||
backend.dropFile('__aria_schemas'); // schema 落盘丢失
|
||||
// 不 close(崩溃语义)
|
||||
|
||||
const engine2 = openWith(backend);
|
||||
await engine2.open('ddl-drop-crash', 1);
|
||||
const tables = await engine2.getTableNames();
|
||||
expect(tables).not.toContain('other'); // 修复前 DROP 会被静默撤销
|
||||
expect(tables).toContain('t');
|
||||
expect((await engine2.find('t', { table: 't' })).map((r) => r.id)).toEqual(['r1']);
|
||||
await engine2.close();
|
||||
});
|
||||
|
||||
it('CREATE TABLE 后 schema 未能落盘就崩溃 → 重开表仍存在且可用', async () => {
|
||||
const backend = new OrderRecordingBackend();
|
||||
const engine = openWith(backend);
|
||||
await engine.open('ddl-create-crash', 1);
|
||||
await engine.createTable(schema('t'));
|
||||
await engine.insert('t', [{ id: 'r1' }]);
|
||||
backend.dropFile('__aria_schemas');
|
||||
|
||||
const engine2 = openWith(backend);
|
||||
await engine2.open('ddl-create-crash', 1);
|
||||
expect(await engine2.getTableNames()).toContain('t');
|
||||
expect((await engine2.find('t', { table: 't' })).map((r) => r.id)).toEqual(['r1']);
|
||||
await engine2.close();
|
||||
});
|
||||
|
||||
it('DDL 意图记录可重复回放(幂等)', async () => {
|
||||
const backend = new OrderRecordingBackend();
|
||||
const engine = openWith(backend);
|
||||
await engine.open('ddl-idempotent', 1);
|
||||
await engine.createTable(schema('t'));
|
||||
await engine.alterTable('t', 'ADD', { name: 'tag', type: 'string' } as never);
|
||||
await engine.close();
|
||||
|
||||
// 连续重开三次:每次都回放同一份 WAL,结果必须一致(不重复加列、不报错)
|
||||
for (let round = 0; round < 3; round++) {
|
||||
const e = openWith(backend);
|
||||
await e.open('ddl-idempotent', 1);
|
||||
const cols = Object.keys((await e.getTableSchema('t'))!.columns);
|
||||
expect(cols).toEqual(['id', 'tag']);
|
||||
await e.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('[v0.8.0] A40 生命周期守卫(审计结论未复现,固化为护栏)', () => {
|
||||
it('事务中 DDL 显式拒绝(NOT_SUPPORTED),不留半状态', async () => {
|
||||
const backend = new OrderRecordingBackend();
|
||||
const engine = openWith(backend);
|
||||
await engine.open('guard-ddl-txn', 1);
|
||||
await engine.createTable(schema('t'));
|
||||
await engine.beginTransaction();
|
||||
await expect(engine.dropTable('t')).rejects.toMatchObject({ code: 'NOT_SUPPORTED' });
|
||||
await expect(
|
||||
engine.alterTable('t', 'ADD', { name: 'x', type: 'string' } as never),
|
||||
).rejects.toMatchObject({ code: 'NOT_SUPPORTED' });
|
||||
await engine.rollbackTransaction();
|
||||
expect(await engine.getTableNames()).toContain('t');
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
it('未提交事务在 close 后不得产生幽灵行', async () => {
|
||||
const backend = new OrderRecordingBackend();
|
||||
const engine = openWith(backend);
|
||||
await engine.open('guard-ghost', 1);
|
||||
await engine.createTable(schema('t'));
|
||||
await engine.insert('t', [{ id: 'committed' }]);
|
||||
await engine.close();
|
||||
|
||||
const engine2 = openWith(backend);
|
||||
await engine2.open('guard-ghost', 1);
|
||||
await engine2.beginTransaction();
|
||||
await engine2.insert('t', [{ id: 'uncommitted' }]);
|
||||
await engine2.close();
|
||||
|
||||
const engine3 = openWith(backend);
|
||||
await engine3.open('guard-ghost', 1);
|
||||
const ids = (await engine3.find('t', { table: 't' })).map((r) => r.id).sort();
|
||||
expect(ids).toEqual(['committed']); // 未提交行不得复活
|
||||
await engine3.close();
|
||||
});
|
||||
|
||||
it('close 幂等;close 后 rollback 抛 TX_NONE(而不是静默成功)', async () => {
|
||||
const backend = new OrderRecordingBackend();
|
||||
const engine = openWith(backend);
|
||||
await engine.open('guard-close', 1);
|
||||
await engine.createTable(schema('t'));
|
||||
await engine.close();
|
||||
await expect(engine.close()).resolves.toBeUndefined(); // 二次 close 幂等
|
||||
await expect(engine.rollbackTransaction()).rejects.toMatchObject({ code: 'TX_NONE' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user