Files
MetonaSqlark/tests/v063-fixes.test.ts
thzxx f97c5a6001
CI / test (18.x) (push) Successful in 19m13s
CI / test (20.x) (push) Successful in 18m8s
CI / test (22.x) (push) Successful in 17m18s
CI / test (24.x) (push) Successful in 22m57s
CI / e2e (push) Successful in 9m53s
fix(P2): v0.6.3 原子性/一致性/资源治理 — 8 项修复 + 12 回归
- KVStore 混合写单记录原子:新增 writeBatch(put+delete 同一条日志记录),
  KVStoreEngine 全部混合写路径统一(兑现真原子宣称,崩溃无新旧行并存)
- WAL full 模式写入失败抛错(此前 console.warn 吞错 → 崩溃即丢且无感知)
- MemoryEngine SET NULL 级联索引残留:复用 removeIndexEntries(消除虚假 UNIQUE_VIOLATION)
- delete 级联两阶段:先全量 RESTRICT 预检(沿 CASCADE 链递归)再执行,无部分级联
  (Memory/Aria 对齐)
- BufferPool 驱逐同步清理 pages Map(EvictionManager onRemove 回调,内存预算真实生效)
- MVCC commit 清理已提交版本(版本链仅作事务内 undo,消除行数据双份常驻)
- LSM.flush 重复入链修复(入链即置空 immutable)+ frozenMemtables 可见性时序
- rollbackToSavepoint 重建受影响表二级索引(消除过期索引条目)

测试 1114 → 1126(71 套件);行覆盖率 89.7%;版本 0.6.3
2026-08-13 10:30:39 +08:00

295 lines
12 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.6.3 修复回归测试 — 原子性/一致性/资源治理
*
* 覆盖:
* - B7: KVStore writeBatch 混合 put+delete 单条日志记录(真原子)
* - B8: WAL full 模式写入失败抛错(不再吞错)
* - B6: MemoryEngine SET NULL 级联索引残留(虚假 UNIQUE_VIOLATION
* - B10: delete 级联两阶段(RESTRICT 预检,无部分级联)
* - B13: BufferPool 驱逐同步清理 pages Map(内存预算真实生效)
* - B11: LSM.flush 不再重复入链(无重复 SSTable)
* - B12: rollbackToSavepoint 重建受影响表二级索引
*/
import { MemoryEngine } from '../src/engine/memory';
import { KVStoreEngine } from '../src/engine/kvstore_engine';
import { AriaEngine } from '../src/engine/aria/index';
import { createSchema } from '../src/table/schema';
import { KVStore } from '../src/engine/kvstore/index';
import { SharedMemoryBackend } from '../src/engine/kvstore/shared_memory_medium';
import { parseLogRecords, KVLogOp } from '../src/engine/kvstore/log';
import { WAL } from '../src/engine/aria/wal/log';
import { BufferPool } from '../src/engine/aria/buffer/pool';
function uniqueDB(): string {
return `v063-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
/** 断言 Promise 以指定错误码拒绝 */
async function expectCode(promise: Promise<unknown>, code: string): Promise<void> {
try {
await promise;
} catch (e) {
expect((e as { code?: string }).code).toBe(code);
return;
}
throw new Error(`Expected rejection with code "${code}", but promise resolved`);
}
describe('v0.6.3 — KVStore writeBatch 混合原子(B7', () => {
it('put+delete 编码进同一条日志记录', async () => {
const kv = new KVStore(new SharedMemoryBackend(), 0);
await kv.open(uniqueDB());
await kv.put('keep', new TextEncoder().encode('k').buffer);
await kv.writeBatch(
{ a: new TextEncoder().encode('1').buffer, b: new TextEncoder().encode('2').buffer },
['old1', 'old2'],
);
// 直接读取日志文件字节:解析应为 2 条记录(put keep + 混合记录),
// 混合记录包含 2 PUT + 2 DELETE 条目
const medium = (kv as any).medium as SharedMemoryBackend;
const raw = await medium.read('__kv_log');
expect(raw).not.toBeNull();
const records: { entries: { op: KVLogOp }[] }[] = [];
parseLogRecords(new Uint8Array(raw!), (r) => records.push(r as never));
expect(records).toHaveLength(2);
const mixed = records[1].entries.map((e) => e.op);
expect(mixed.filter((op) => op === KVLogOp.PUT)).toHaveLength(2);
expect(mixed.filter((op) => op === KVLogOp.DELETE)).toHaveLength(2);
await kv.close();
});
it('KVStoreEngine deleteSET NULL 级联)混合写为单记录', async () => {
const name = uniqueDB();
const eng = new KVStoreEngine();
await eng.open(name, 1);
await eng.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
}));
await eng.createTable(createSchema('orders', {
id: { type: 'string', primaryKey: true },
user_id: { type: 'string', references: 'users.id', onDelete: 'SET NULL' },
}));
await eng.insert('users', [{ id: 'u1' }]);
await eng.insert('orders', [{ id: 'o1', user_id: 'u1' }]);
await eng.delete('users', { table: 'users', where: { id: 'u1' } });
// 日志未 checkpoint:最后一条记录应同时含 orders 行重写(PUT)与 users 行删除(DELETE
const kv = (eng as any).kv as KVStore;
const medium = (kv as any).medium as SharedMemoryBackend;
const raw = await medium.read('__kv_log');
const records: { entries: { op: KVLogOp }[] }[] = [];
parseLogRecords(new Uint8Array(raw!), (r) => records.push(r as never));
const last = records[records.length - 1].entries.map((e) => e.op);
expect(last).toContain(KVLogOp.PUT);
expect(last).toContain(KVLogOp.DELETE);
await eng.close();
// 重开后效果完整(SET NULL + 父行删除均生效)
const eng2 = new KVStoreEngine();
await eng2.open(name, 1);
const orders = await eng2.find('orders', { table: 'orders' });
expect(orders).toHaveLength(1);
expect(orders[0].user_id).toBeNull();
expect(await eng2.count('users')).toBe(0);
await eng2.close();
});
});
describe('v0.6.3 — WAL 写入失败传播(B8', () => {
class FailingStore {
async append(_d: Uint8Array): Promise<void> { throw new Error('disk full'); }
async readAll(): Promise<Uint8Array> { return new Uint8Array(0); }
async truncate(): Promise<void> {}
async exists(): Promise<boolean> { return false; }
}
it("full 模式 append 失败 reject(不再 console.warn 吞错)", async () => {
const wal = new WAL(new FailingStore() as never, true, 'full');
await expect(wal.append({
type: 1, txnId: 0, tableName: 't', key: '1', data: { v: 1 },
})).rejects.toThrow('disk full');
await expect(wal.appendBatch([{
type: 1, txnId: 0, tableName: 't', key: '2', data: { v: 2 },
}])).rejects.toThrow('disk full');
});
});
describe('v0.6.3 — MemoryEngine SET NULL 索引清理(B6', () => {
it('级联 SET NULL 后旧值可被复用(不再虚假 UNIQUE_VIOLATION', async () => {
const eng = new MemoryEngine();
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
}));
await eng.createTable(createSchema('orders', {
id: { type: 'string', primaryKey: true },
user_id: { type: 'string', references: 'users.id', onDelete: 'SET NULL', unique: true },
}));
await eng.insert('users', [{ id: 'u1' }]);
await eng.insert('orders', [{ id: 'o1', user_id: 'u1' }]);
await eng.delete('users', { table: 'users', where: { id: 'u1' } });
// 旧值 u1 的索引条目应被完整清理(含空 Set 移除)
await eng.insert('orders', [{ id: 'o2', user_id: 'u1' }]);
const rows = await eng.find('orders', { table: 'orders' });
expect(rows).toHaveLength(2);
// 索引查询旧值返回正确
const byIdx = await eng.find('orders', { table: 'orders', where: { user_id: 'u1' } });
expect(byIdx).toHaveLength(1);
expect(byIdx[0].id).toBe('o2');
});
});
describe('v0.6.3 — delete 级联两阶段(B10', () => {
async function buildMemory(): Promise<MemoryEngine> {
const eng = new MemoryEngine();
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('parents', {
id: { type: 'string', primaryKey: true },
}));
await eng.createTable(createSchema('cascade_child', {
id: { type: 'string', primaryKey: true },
pid: { type: 'string', references: 'parents.id', onDelete: 'CASCADE' },
}));
await eng.createTable(createSchema('restrict_child', {
id: { type: 'string', primaryKey: true },
pid: { type: 'string', references: 'parents.id', onDelete: 'RESTRICT' },
}));
return eng;
}
it('Memory: 多行删除含 RESTRICT 违规则整体拒绝(无部分级联)', async () => {
const eng = await buildMemory();
await eng.insert('parents', [{ id: 'p1' }, { id: 'p2' }]);
await eng.insert('cascade_child', [{ id: 'c1', pid: 'p1' }]);
await eng.insert('restrict_child', [{ id: 'r2', pid: 'p2' }]);
await expectCode(eng.delete('parents', { table: 'parents' }), 'FOREIGN_KEY_VIOLATION');
// p1 的级联子行必须仍在(修复前:p1 子行已删、父行未删)
expect(await eng.count('parents')).toBe(2);
expect(await eng.count('cascade_child')).toBe(1);
expect(await eng.count('restrict_child')).toBe(1);
});
it('Aria: 多行删除含 RESTRICT 违规则整体拒绝(无部分级联)', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('parents', {
id: { type: 'string', primaryKey: true },
}));
await eng.createTable(createSchema('cascade_child', {
id: { type: 'string', primaryKey: true },
pid: { type: 'string', references: 'parents.id', onDelete: 'CASCADE' },
}));
await eng.createTable(createSchema('restrict_child', {
id: { type: 'string', primaryKey: true },
pid: { type: 'string', references: 'parents.id', onDelete: 'RESTRICT' },
}));
await eng.insert('parents', [{ id: 'p1' }, { id: 'p2' }]);
await eng.insert('cascade_child', [{ id: 'c1', pid: 'p1' }]);
await eng.insert('restrict_child', [{ id: 'r2', pid: 'p2' }]);
await expectCode(eng.delete('parents', { table: 'parents' }), 'FOREIGN_KEY_VIOLATION');
expect(await eng.count('parents')).toBe(2);
expect(await eng.count('cascade_child')).toBe(1);
expect(await eng.count('restrict_child')).toBe(1);
await eng.close();
});
it('深层 CASCADE 链末端的 RESTRICT 也被预检拦截', async () => {
const eng = await buildMemory();
await eng.createTable(createSchema('deep_child', {
id: { type: 'string', primaryKey: true },
cid: { type: 'string', references: 'cascade_child.id', onDelete: 'RESTRICT' },
}));
await eng.insert('parents', [{ id: 'p1' }]);
await eng.insert('cascade_child', [{ id: 'c1', pid: 'p1' }]);
await eng.insert('deep_child', [{ id: 'd1', cid: 'c1' }]);
await expectCode(eng.delete('parents', { table: 'parents', where: { id: 'p1' } }), 'FOREIGN_KEY_VIOLATION');
expect(await eng.count('parents')).toBe(1);
expect(await eng.count('cascade_child')).toBe(1);
expect(await eng.count('deep_child')).toBe(1);
});
it('无 RESTRICT 违规时级联删除照常完整执行', async () => {
const eng = await buildMemory();
await eng.insert('parents', [{ id: 'p1' }, { id: 'p2' }]);
await eng.insert('cascade_child', [{ id: 'c1', pid: 'p1' }, { id: 'c2', pid: 'p2' }]);
const count = await eng.delete('parents', { table: 'parents' });
expect(count).toBe(4); // 2 父 + 2 级联子
expect(await eng.count('parents')).toBe(0);
expect(await eng.count('cascade_child')).toBe(0);
});
});
describe('v0.6.3 — BufferPool 内存预算(B13', () => {
it('驱逐同步清理 pages Map(容量约束真实生效)', async () => {
const pageIO = {
readPage: async (id: number) => {
const b = new ArrayBuffer(4096);
new DataView(b).setUint32(0, id);
new DataView(b).setUint8(4, 1);
return b;
},
writePage: async () => {},
allocatePageId: async () => 0,
freePageId: async () => {},
};
const pool = new BufferPool(pageIO, 2);
for (let i = 1; i <= 20; i++) {
const p = await pool.getPage(i);
pool.unpin(p!);
}
expect((pool as any).pages.size).toBeLessThanOrEqual(2);
// 驱逐后再次访问能重新加载(正确性不受影响)
const p = await pool.getPage(20);
expect(p).not.toBeNull();
pool.unpin(p!);
});
});
describe('v0.6.3 — LSM.flush 不重复入链(B11', () => {
it('2 行 + 中间冻结 + flush → 恰好 2 个 SSTable(无重复产物)', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
v: { type: 'string' },
}));
const lsm = (eng as any).lsm;
await eng.insert('t', [{ id: '1', v: 'a' }]);
lsm.freezeMemtable();
await eng.insert('t', [{ id: '2', v: 'b' }]);
await lsm.flush();
const metas = lsm.levels[0] as { id: number; minKey: string; maxKey: string }[];
expect(metas).toHaveLength(2);
const keys = metas.flatMap((m) => [m.minKey, m.maxKey]).sort();
expect(keys).toEqual(['t:1', 't:1', 't:2', 't:2']);
const rows = await eng.find('t', { table: 't' });
expect(rows).toHaveLength(2);
await eng.close();
});
});
describe('v0.6.3 — rollbackToSavepoint 索引重建(B12', () => {
it('savepoint 回滚后索引不含过期条目', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
tag: { type: 'string', index: true },
}));
await eng.insert('t', [{ id: '1', tag: 'x' }]);
await eng.beginTransaction();
await (eng as any).savepoint('sp1');
await eng.insert('t', [{ id: '2', tag: 'x' }]);
await (eng as any).rollbackToSavepoint('sp1');
await eng.commitTransaction();
// 数据只有 1 行
expect(await eng.count('t')).toBe(1);
// 索引查询 x 只返回 id=1savepoint 后插入的 id=2 索引条目已清理)
const rows = await eng.find('t', { table: 't', where: { tag: 'x' } });
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe('1');
await eng.close();
});
});