Files
MetonaSqlark/tests/v080-aria-ddl-atomicity.test.ts
T
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

271 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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' });
});
});