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 个用例):包含"崩溃重放一致性"、
"普通事务不得误伤"、以及"多个保存点回到最早"的语义护栏。
139 lines
6.4 KiB
TypeScript
139 lines
6.4 KiB
TypeScript
/**
|
||
* 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();
|
||
});
|
||
});
|