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
+270
View File
@@ -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('createTableWAL 先于 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('dropTableWAL 先于 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 ADDWAL 先于 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 DROPWAL 先于 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' });
});
});