219 lines
8.1 KiB
TypeScript
219 lines
8.1 KiB
TypeScript
/**
|
||
* AriaEngine WAL — CRC-32 校验升级测试
|
||
*
|
||
* 覆盖:
|
||
* 1. 新算法记录往返(append → recover)
|
||
* 2. 篡改记录字节 → CRC 不匹配跳过
|
||
* 3. 旧版弱校验记录兼容(双算法探测)
|
||
* 4. appendBatch 组提交记录往返
|
||
* 5. 损坏记录跳过不影响后续完整记录
|
||
*/
|
||
import { WAL } from '../../src/engine/aria/wal/log';
|
||
import { WALRecordType } from '../../src/engine/aria/types';
|
||
|
||
// ===================================================================
|
||
// WAL 内存 Store(模拟 IStorageBackend 单记录语义)
|
||
// ===================================================================
|
||
class MemoryWALStore {
|
||
chunks: Uint8Array[] = [];
|
||
truncated = false;
|
||
|
||
async append(data: Uint8Array): Promise<void> {
|
||
this.chunks.push(data);
|
||
}
|
||
|
||
async readAll(): Promise<Uint8Array> {
|
||
const total = this.chunks.reduce((s, c) => s + c.byteLength, 0);
|
||
const combined = new Uint8Array(total);
|
||
let off = 0;
|
||
for (const c of this.chunks) { combined.set(c, off); off += c.byteLength; }
|
||
return combined;
|
||
}
|
||
|
||
async truncate(): Promise<void> {
|
||
this.chunks = [];
|
||
this.truncated = true;
|
||
}
|
||
|
||
async exists(): Promise<boolean> {
|
||
return this.chunks.length > 0;
|
||
}
|
||
}
|
||
|
||
function makeRecord(over: Partial<import('../../src/engine/aria/types').WALRecord> = {}) {
|
||
return {
|
||
type: WALRecordType.INSERT,
|
||
txnId: 0,
|
||
tableName: 'users',
|
||
key: 'u-1',
|
||
data: { id: 'u-1', name: 'Alice' },
|
||
...over,
|
||
};
|
||
}
|
||
|
||
describe('AriaEngine — WAL CRC-32 校验', () => {
|
||
it('新算法记录 append → recover 往返完整', async () => {
|
||
const store = new MemoryWALStore();
|
||
const wal = new WAL(store as never, true, 'full');
|
||
|
||
await wal.append(makeRecord({ key: 'u-1' }));
|
||
await wal.append(makeRecord({ key: 'u-2', data: { id: 'u-2', name: 'Bob' } }));
|
||
|
||
const records: import('../../src/engine/aria/types').WALRecord[] = [];
|
||
await wal.recover((r) => records.push(r));
|
||
expect(records).toHaveLength(2);
|
||
expect(records[0].key).toBe('u-1');
|
||
expect(records[1].data).toEqual({ id: 'u-2', name: 'Bob' });
|
||
});
|
||
|
||
it('appendBatch 组提交记录往返完整', async () => {
|
||
const store = new MemoryWALStore();
|
||
const wal = new WAL(store as never, true, 'full');
|
||
|
||
await wal.appendBatch([
|
||
makeRecord({ key: 'a' }),
|
||
makeRecord({ key: 'b' }),
|
||
makeRecord({ key: 'c' }),
|
||
]);
|
||
|
||
const records: import('../../src/engine/aria/types').WALRecord[] = [];
|
||
await wal.recover((r) => records.push(r));
|
||
expect(records.map((r) => r.key)).toEqual(['a', 'b', 'c']);
|
||
expect(records.map((r) => r.lsn)).toEqual([1, 2, 3]);
|
||
});
|
||
|
||
it('篡改记录数据 → CRC 不匹配跳过', async () => {
|
||
const store = new MemoryWALStore();
|
||
const wal = new WAL(store as never, true, 'full');
|
||
|
||
await wal.append(makeRecord({ key: 'good-1' }));
|
||
await wal.append(makeRecord({ key: 'evil' }));
|
||
await wal.append(makeRecord({ key: 'good-2' }));
|
||
|
||
// 篡改第二条记录的 data 字节(LSN=2 的记录)
|
||
const raw = await store.readAll();
|
||
const view = new DataView(raw.buffer, raw.byteOffset, raw.byteLength);
|
||
// 定位第二条记录起始:按记录格式顺序扫描直到 LSN=2
|
||
// [lsn:4][type:1][txnId:4][tblLen:2][table][keyLen:2][key][jsonLen:4][json][crc:4]
|
||
let offset = 0;
|
||
while (offset + 4 <= raw.byteLength && view.getUint32(offset, false) !== 2) {
|
||
const tblLen = view.getUint16(offset + 9, false);
|
||
const keyLenOff = offset + 11 + tblLen;
|
||
const keyLen = view.getUint16(keyLenOff, false);
|
||
const jsonLenOff = keyLenOff + 2 + keyLen;
|
||
const jsonLen = view.getUint32(jsonLenOff, false);
|
||
offset += 4 + 1 + 4 + 2 + tblLen + 2 + keyLen + 4 + jsonLen + 4;
|
||
}
|
||
expect(view.getUint32(offset, false)).toBe(2);
|
||
// 篡改 data 区(json 起始处第 4 字节)
|
||
const tblLen2 = view.getUint16(offset + 9, false);
|
||
const jsonStart = offset + 4 + 1 + 4 + 2 + tblLen2 + 2 + view.getUint16(offset + 11 + tblLen2, false) + 4;
|
||
raw[jsonStart + 4] ^= 0x01;
|
||
// 写回 store(readAll 返回的是合并副本)
|
||
store.chunks = [raw];
|
||
|
||
// 恢复:坏记录被跳过,前后好记录保留
|
||
const records: import('../../src/engine/aria/types').WALRecord[] = [];
|
||
await wal.recover((r) => records.push(r));
|
||
expect(records.map((r) => r.key)).toEqual(['good-1', 'good-2']);
|
||
});
|
||
|
||
it('旧版弱校验记录兼容(双算法探测)', async () => {
|
||
const store = new MemoryWALStore();
|
||
const wal = new WAL(store as never, true, 'full');
|
||
|
||
// 手工构造一条"旧算法"记录:与 encodeRecord 布局一致但 CRC 用旧算法计算
|
||
const record = {
|
||
lsn: 1,
|
||
type: WALRecordType.INSERT as number,
|
||
txnId: 0,
|
||
tableName: 'legacy',
|
||
key: 'old',
|
||
data: { id: 'old', v: 1 },
|
||
};
|
||
const encoder = new TextEncoder();
|
||
const tblB = encoder.encode(record.tableName);
|
||
const keyB = encoder.encode(record.key);
|
||
const jsonB = encoder.encode(JSON.stringify(record.data));
|
||
const size = 4 + 1 + 4 + 2 + tblB.length + 2 + keyB.length + 4 + jsonB.length + 4;
|
||
const buf = new ArrayBuffer(size);
|
||
const v = new DataView(buf);
|
||
let o = 0;
|
||
v.setUint32(o, record.lsn, false); o += 4;
|
||
v.setUint8(o, record.type); o += 1;
|
||
v.setUint32(o, record.txnId, false); o += 4;
|
||
v.setUint16(o, tblB.length, false); o += 2;
|
||
new Uint8Array(buf).set(tblB, o); o += tblB.length;
|
||
v.setUint16(o, keyB.length, false); o += 2;
|
||
new Uint8Array(buf).set(keyB, o); o += keyB.length;
|
||
v.setUint32(o, jsonB.length, false); o += 4;
|
||
new Uint8Array(buf).set(jsonB, o); o += jsonB.length;
|
||
// 旧算法 CRC
|
||
let crc = 0;
|
||
const u8 = new Uint8Array(buf, 0, o);
|
||
for (let i = 0; i < u8.length; i++) crc = ((crc << 5) - crc + u8[i]) | 0;
|
||
v.setUint32(o, crc >>> 0, false);
|
||
|
||
store.chunks.push(new Uint8Array(buf));
|
||
|
||
const records: import('../../src/engine/aria/types').WALRecord[] = [];
|
||
await wal.recover((r) => records.push(r));
|
||
expect(records).toHaveLength(1);
|
||
expect(records[0].tableName).toBe('legacy');
|
||
expect(records[0].data).toEqual({ id: 'old', v: 1 });
|
||
});
|
||
|
||
it('旧算法记录 + 新算法记录混合 → 全部正确恢复', async () => {
|
||
const store = new MemoryWALStore();
|
||
const wal = new WAL(store as never, true, 'full');
|
||
|
||
// 先放一条旧算法记录
|
||
const record = {
|
||
lsn: 1,
|
||
type: WALRecordType.DELETE as number,
|
||
txnId: 0,
|
||
tableName: 't',
|
||
key: 'old-key',
|
||
data: undefined as Record<string, unknown> | undefined,
|
||
};
|
||
const encoder = new TextEncoder();
|
||
const tblB = encoder.encode(record.tableName);
|
||
const keyB = encoder.encode(record.key);
|
||
const size = 4 + 1 + 4 + 2 + tblB.length + 2 + keyB.length + 4 + 4;
|
||
const buf = new ArrayBuffer(size);
|
||
const v = new DataView(buf);
|
||
let o = 0;
|
||
v.setUint32(o, record.lsn, false); o += 4;
|
||
v.setUint8(o, record.type); o += 1;
|
||
v.setUint32(o, record.txnId, false); o += 4;
|
||
v.setUint16(o, tblB.length, false); o += 2;
|
||
new Uint8Array(buf).set(tblB, o); o += tblB.length;
|
||
v.setUint16(o, keyB.length, false); o += 2;
|
||
new Uint8Array(buf).set(keyB, o); o += keyB.length;
|
||
v.setUint32(o, 0, false); o += 4; // jsonLen=0
|
||
let crc = 0;
|
||
const u8 = new Uint8Array(buf, 0, o);
|
||
for (let i = 0; i < u8.length; i++) crc = ((crc << 5) - crc + u8[i]) | 0;
|
||
v.setUint32(o, crc >>> 0, false);
|
||
store.chunks.push(new Uint8Array(buf));
|
||
|
||
// 再追加一条新算法记录(LSN=2)
|
||
await wal.append(makeRecord({ key: 'new-key', lsn: 2 } as never));
|
||
|
||
const records: import('../../src/engine/aria/types').WALRecord[] = [];
|
||
await wal.recover((r) => records.push(r));
|
||
expect(records.map((r) => r.key)).toEqual(['old-key', 'new-key']);
|
||
expect(records[0].type).toBe(WALRecordType.DELETE);
|
||
});
|
||
|
||
it('checkpoint 截断后 exists 返回 false', async () => {
|
||
const store = new MemoryWALStore();
|
||
const wal = new WAL(store as never, true, 'full');
|
||
await wal.append(makeRecord({ key: 'x' }));
|
||
expect(await store.exists()).toBe(true);
|
||
await wal.checkpoint();
|
||
expect(store.truncated).toBe(true);
|
||
expect(await store.exists()).toBe(false);
|
||
});
|
||
});
|