Files
MetonaSqlark/tests/v080-savepoint.test.ts
thzxx 89243ef6eb fix(A3/A4): SAVEPOINT 语义根治 —— 已回滚的行不再复活、陈旧保存点不再吞写入
A3 已回滚的行在崩溃重启后复活(静默数据错误)
  `ROLLBACK TO <savepoint>` 只改内存快照、**不写 WAL**,而 COMMIT 会把整个 txnId
  标记为已提交,恢复时按"该事务的全部记录"重放 → 被回滚掉的写入被重新应用。
  实测:事务内插入 a、savepoint、插入 b、回滚到 savepoint、提交 →
  实时只剩 a,崩溃重开变成 a+b。

  根治:新增 WALRecordType.SAVEPOINT_ROLLBACK(走既有 data 字段携带
  { replayFromIndex } 边界,二进制格式不变、旧库记录仍可解析)。
  - 写入侧:savepoint() 记录"该事务当时已追加的记录条数";rollbackToSavepoint()
    先写标记再改内存(与 commit/rollback 的"WAL 领先内存"一致)。
  - 恢复侧:按**事务内**下标计算窗口 —— 保留 [0, keepUpTo),丢弃
    [keepUpTo, 最后一个标记),标记之后的记录照常保留。
    注意不能拿全局下标比较:全局数组里混有 txnId=0 的非事务记录(CREATE_TABLE
    等)与其它事务的记录。这个差一错误在实现过程中被测试抓出并修正。
  - 事务内 WAL 记录计数(txnWalRecordCount)在 5 个 appendBatch 站点与 BEGIN
    处维护,事务开始/结束时归零。

A4 跨事务复用的陈旧 savepoint 静默丢弃当前事务的写入
  savepoints 在 commit/rollback 时**从不清空**,且 rollbackToSavepoint 不校验归属。
  实测:上一个事务遗留 savepoint 名 → 新事务 update 后 ROLLBACK TO 该名 + COMMIT,
  写入凭空消失(v=5 被回退成 v=9)。
  根治:事务结束清空 savepoints 与边界表;rollbackToSavepoint 校验
  sp.txnId === currentTxnId,陈旧保存点抛 SAVEPOINT_NOT_FOUND。

新增 tests/v080-savepoint.test.ts(4 个用例):包含"崩溃重放一致性"、
"普通事务不得误伤"、以及"多个保存点回到最早"的语义护栏。
2026-09-14 21:53:46 +08:00

139 lines
6.4 KiB
TypeScript
Raw Permalink 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 SAVEPOINT 语义根治
*
* 覆盖两个此前会导致**静默数据错误**的缺陷:
*
* A3 已回滚的行在崩溃重启后复活
* `ROLLBACK TO <savepoint>` 此前只改内存快照、不写 WAL,而 COMMIT 会把整个
* txnId 标记为已提交,恢复时按"该事务的全部记录"重放 —— 于是被回滚掉的写入
* 被重新应用。实测:实时只剩 a,崩溃重开变成 a+b。
* 修复:savepoint 回滚写入 SAVEPOINT_ROLLBACK 记录(携带 replayFromIndex
* 边界),恢复时只应用"保存点之后、最后一个标记之前"的写入。
*
* A4 跨事务复用的陈旧 savepoint 静默丢弃当前事务的写入
* savepoints 在 commit/rollback 时从不清空,且 rollbackToSavepoint 不校验归属。
* 实测:新事务 update 后 ROLLBACK TO 上一事务遗留的名字 + COMMIT,写入凭空消失。
* 修复:事务结束清空 savepoint;回滚前校验 txnId 归属。
*/
import { MetonaSqlark } from '../src/core';
import { AriaEngine } from '../src/engine/aria/index';
describe('[v0.8.0] A4 跨事务 savepoint 泄漏', () => {
test('陈旧 savepoint 名不可重用,当前事务写入不被替换', async () => {
const db = await MetonaSqlark.create({ name: 'v080-sp-stale', mode: 'aria', diskEngine: 'memory' } as never);
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
await db.query("INSERT INTO t VALUES ('x', 0)");
await db.query('BEGIN');
await db.query("UPDATE t SET v = 9 WHERE id='x'");
await db.query('SAVEPOINT sp');
await db.query('COMMIT');
expect((await db.query("SELECT v FROM t WHERE id='x'") as Array<{ v: number }>)[0].v).toBe(9);
await db.query('BEGIN');
await db.query("UPDATE t SET v = 5 WHERE id='x'");
// 上一事务的 savepoint 随事务结束失效
await expect(db.query('ROLLBACK TO sp')).rejects.toMatchObject({ code: 'SAVEPOINT_NOT_FOUND' });
await db.query('COMMIT');
// 关键:本事务的写入必须保留(此前会被旧快照静默替换回 9)
expect((await db.query("SELECT v FROM t WHERE id='x'") as Array<{ v: number }>)[0].v).toBe(5);
await db.close();
});
});
describe('[v0.8.0] A3 savepoint 回滚的崩溃一致性', () => {
/** kv 后端介质按库名存于全局注册表且 close 不清数据 → 新引擎 open 同名库 = 崩溃重启 */
const CFG = { storageBackend: 'kv' as const, walSyncMode: 'full' as const, checkpointInterval: 999999 };
test('回滚到保存点后提交:崩溃重放不得复活已回滚的行', async () => {
const eng = new AriaEngine(CFG) as unknown as {
open(n: string, v: number): Promise<void>;
createTable(s: unknown): Promise<void>;
beginTransaction(): Promise<void>;
insert(t: string, r: unknown[]): Promise<unknown>;
savepoint(n: string): Promise<void>;
rollbackToSavepoint(n: string): Promise<void>;
commitTransaction(): Promise<void>;
find(t: string, q: unknown): Promise<Array<Record<string, unknown>>>;
close(): Promise<void>;
};
await eng.open('v080-sp-crash', 1);
await eng.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true } } });
await eng.beginTransaction();
await eng.insert('t', [{ id: 'a' }]);
await eng.savepoint('s');
await eng.insert('t', [{ id: 'b' }]);
await eng.rollbackToSavepoint('s');
await eng.commitTransaction();
const live = (await eng.find('t', { table: 't' })).map((r) => r.id).sort();
// 崩溃:不调用 close()close 会 checkpoint 截断 WAL,掩盖重放路径)
const eng2 = new AriaEngine(CFG) as unknown as typeof eng;
await eng2.open('v080-sp-crash', 1);
const after = (await eng2.find('t', { table: 't' })).map((r) => r.id).sort();
expect(live).toEqual(['a']);
expect(after).toEqual(live); // 不得复活 b
await eng2.close();
});
test('无 savepoint 的普通事务崩溃重放仍然完整(不得误伤)', async () => {
const eng = new AriaEngine(CFG) as unknown as {
open(n: string, v: number): Promise<void>;
createTable(s: unknown): Promise<void>;
beginTransaction(): Promise<void>;
insert(t: string, r: unknown[]): Promise<unknown>;
commitTransaction(): Promise<void>;
find(t: string, q: unknown): Promise<Array<Record<string, unknown>>>;
close(): Promise<void>;
};
await eng.open('v080-sp-crash2', 1);
await eng.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true } } });
await eng.beginTransaction();
await eng.insert('t', [{ id: 'a' }]);
await eng.insert('t', [{ id: 'b' }]);
await eng.commitTransaction();
const live = (await eng.find('t', { table: 't' })).map((r) => r.id).sort();
const eng2 = new AriaEngine(CFG) as unknown as typeof eng;
await eng2.open('v080-sp-crash2', 1);
const after = (await eng2.find('t', { table: 't' })).map((r) => r.id).sort();
expect(live).toEqual(['a', 'b']);
expect(after).toEqual(live);
await eng2.close();
});
test('多个保存点:回到最早保存点,其后写入全部丢弃', async () => {
const eng = new AriaEngine(CFG) as unknown as {
open(n: string, v: number): Promise<void>;
createTable(s: unknown): Promise<void>;
beginTransaction(): Promise<void>;
insert(t: string, r: unknown[]): Promise<unknown>;
savepoint(n: string): Promise<void>;
rollbackToSavepoint(n: string): Promise<void>;
commitTransaction(): Promise<void>;
find(t: string, q: unknown): Promise<Array<Record<string, unknown>>>;
close(): Promise<void>;
};
await eng.open('v080-sp-multi', 1);
await eng.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true } } });
await eng.beginTransaction();
await eng.insert('t', [{ id: 'a' }]);
await eng.savepoint('s1');
await eng.insert('t', [{ id: 'b' }]);
await eng.savepoint('s2');
await eng.insert('t', [{ id: 'c' }]);
await eng.rollbackToSavepoint('s1');
await eng.commitTransaction();
const live = (await eng.find('t', { table: 't' })).map((r) => r.id).sort();
const eng2 = new AriaEngine(CFG) as unknown as typeof eng;
await eng2.open('v080-sp-multi', 1);
const after = (await eng2.find('t', { table: 't' })).map((r) => r.id).sort();
expect(live).toEqual(['a']);
expect(after).toEqual(['a']);
await eng2.close();
});
});