Files
MetonaSqlark/tests/engine/aria-sstable.test.ts

340 lines
14 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.
/**
* AriaEngine SSTable Builder + Reader 单元测试
*/
import { SSTableBuilder } from '../../src/engine/aria/index/sstable_builder';
import { SSTableReader } from '../../src/engine/aria/index/sstable';
import type { SSTableMeta } from '../../src/engine/aria/types';
// ===================================================================
// SSTable Builder + Reader
// ===================================================================
describe('AriaEngine — SSTable Builder + Reader', () => {
const makeMeta = (data: Uint8Array): SSTableMeta => ({
id: 1, level: 0, minKey: '', maxKey: '\uffff',
blockCount: 1, totalSize: data.byteLength, bloomData: null,
});
it('构建单条目 SSTable 并精确读取', () => {
const builder = new SSTableBuilder(4096);
builder.add('key1', { name: 'Alice', age: 30 });
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
const result = reader.get('key1');
expect(result).not.toBeNull();
expect(result!.name).toBe('Alice');
expect(result!.age).toBe(30);
});
it('构建多条 SSTable 并全部读取', () => {
const builder = new SSTableBuilder(4096);
const items: [string, Record<string, unknown>][] = [];
for (let i = 0; i < 100; i++) {
const key = `user-${String(i).padStart(3, '0')}`;
const value = { idx: i, name: `User${i}` };
items.push([key, value]);
builder.add(key, value);
}
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
for (const [key, value] of items) {
const result = reader.get(key);
expect(result).not.toBeNull();
expect(result!.idx).toBe(value.idx);
}
});
it('get — 不存在的 key 返回 null', () => {
const builder = new SSTableBuilder(4096);
builder.add('a', { v: 1 });
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
expect(reader.get('nonexistent')).toBeNull();
});
it('rangeScan — 范围查询', () => {
const builder = new SSTableBuilder(4096);
for (let i = 0; i < 20; i++) {
builder.add(`k-${String(i).padStart(2, '0')}`, { v: i });
}
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
const results: [string, Record<string, unknown>][] = [];
reader.rangeScan('k-05', 'k-10', (k, v) => results.push([k, v]));
expect(results).toHaveLength(6);
expect(results[0][0]).toBe('k-05');
expect(results[results.length - 1][0]).toBe('k-10');
});
it('scanAll — 遍历所有条目', () => {
const builder = new SSTableBuilder(4096);
const count = 50;
for (let i = 0; i < count; i++) {
builder.add(`item-${i}`, { idx: i });
}
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
const items: [string, Record<string, unknown>][] = [];
reader.scanAll((k, v) => items.push([k, v]));
expect(items).toHaveLength(count);
});
// v0.6.1-fix(P0): 索引键是"块内最后一个 key"locateBlockLE 排除尾 key 越界的
// 下一块会导致范围扫描漏读尾部数据(二级索引 5 万行查询丢 106~219 条)。
it('v0.6.1-fix — 范围扫描跨前缀边界不漏尾部数据', () => {
// 小块强制多块;t5 数据分散在多块中,最后一个 t5 块之后紧跟 t6 块(尾 key 越界)
const builder = new SSTableBuilder(120);
for (let i = 0; i < 30; i++) {
builder.add(`t5:k${String(i).padStart(5, '0')}`, { pk: `k${i}` });
}
for (let i = 0; i < 30; i++) {
builder.add(`t6:k${String(i).padStart(5, '0')}`, { pk: `k${i}` });
}
for (let i = 0; i < 30; i++) {
builder.add(`t7:k${String(i).padStart(5, '0')}`, { pk: `k${i}` });
}
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
const t5: [string, Record<string, unknown>][] = [];
reader.rangeScan('t5', 't5\uffff', (k, v) => t5.push([k, v]));
expect(t5).toHaveLength(30);
expect(t5[0][0]).toBe('t5:k00000');
expect(t5[t5.length - 1][0]).toBe('t5:k00029');
});
it('v0.6.1-fix — 范围扫描 endKey 恰在块尾(不越界不丢、不多扫错数据)', () => {
const builder = new SSTableBuilder(150);
for (let i = 0; i < 40; i++) {
builder.add(`p-${String(i).padStart(3, '0')}`, { v: i });
}
for (let i = 0; i < 20; i++) {
builder.add(`q-${String(i).padStart(3, '0')}`, { v: i });
}
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
// endKey 恰等于某条存在的 key:结果必须包含它且不含 p 之外的 key
const results: [string, Record<string, unknown>][] = [];
reader.rangeScan('p-000', 'p-039', (k, v) => results.push([k, v]));
expect(results).toHaveLength(40);
expect(results[39][0]).toBe('p-039');
});
it('v0.6.1-fix — 范围扫描 endKey 超出全部数据(返回全量)', () => {
const builder = new SSTableBuilder(100);
for (let i = 0; i < 25; i++) {
builder.add(`z-${String(i).padStart(3, '0')}`, { v: i });
}
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
const results: [string, Record<string, unknown>][] = [];
reader.rangeScan('z-000', '\uffff\uffff', (k, v) => results.push([k, v]));
expect(results).toHaveLength(25);
});
it('v0.6.1-fix — 多前缀数据 rangeScan 与 scanAll 结果一致', () => {
// 模拟索引混合写入:t0..t9 交叉 + 尾部跨块(flush 路径先排序再构建)
const items: [string, Record<string, unknown>][] = [];
for (let i = 0; i < 120; i++) {
const tag = `t${i % 10}`;
items.push([`${tag}:k${String(i).padStart(5, '0')}`, { pk: `k${i}` }]);
}
items.sort((a, b) => (a[0] < b[0] ? -1 : 1));
const builder = new SSTableBuilder(200);
for (const [k, v] of items) builder.add(k, v);
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
for (let t = 0; t < 10; t++) {
const keys: string[] = [];
reader.rangeScan(`t${t}`, `t${t}\uffff`, (k) => keys.push(k));
expect(keys).toHaveLength(12);
}
});
it('边界 — 空 SSTable 不抛异常', () => {
const builder = new SSTableBuilder(4096);
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
expect(reader.get('any')).toBeNull();
const results: [string, Record<string, unknown>][] = [];
reader.scanAll((k, v) => results.push([k, v]));
expect(results).toHaveLength(0);
});
it('带特殊字符的 key', () => {
const builder = new SSTableBuilder(4096);
// 必须按键排序添加(按 ASCII 排序:空格 < 短横 < 点号)
builder.add('key with space', { v: 3 });
builder.add('key-with-dash', { v: 1 });
builder.add('key.with.dot', { v: 2 });
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
expect(reader.get('key-with-dash')!.v).toBe(1);
expect(reader.get('key.with.dot')!.v).toBe(2);
expect(reader.get('key with space')!.v).toBe(3);
});
it('getEntryCount 返回正确条目数', () => {
const builder = new SSTableBuilder(4096);
builder.add('a', { v: 1 });
builder.add('b', { v: 2 });
builder.add('c', { v: 3 });
expect(builder.getEntryCount()).toBe(3);
});
});
// ===================================================================
// v0.4.5: 整文件 CRC-32 校验
// ===================================================================
describe('AriaEngine — SSTable CRC-32 校验', () => {
const makeMeta = (data: Uint8Array): SSTableMeta => ({
id: 1, level: 0, minKey: '', maxKey: '\uffff',
blockCount: 1, totalSize: data.byteLength, bloomData: null,
});
it('新构建的 SSTable 写入真实非零 checksum', () => {
const builder = new SSTableBuilder(4096);
builder.add('key1', { name: 'Alice' });
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
// footer 最后 4 字节为真实 checksum(非 0
const stored = new DataView(sstableData.buffer, sstableData.byteOffset + sstableData.byteLength - 4, 4).getUint32(0, false);
expect(stored).not.toBe(0);
expect(reader.verifyChecksum()).toBe(true);
});
it('正常文件校验通过后可正常读取', () => {
const builder = new SSTableBuilder(4096);
for (let i = 0; i < 50; i++) builder.add(`k-${i}`, { v: i });
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
expect(reader.verifyChecksum()).toBe(true);
expect(reader.get('k-30')).toEqual({ v: 30 });
});
it('数据块字节篡改 → 校验失败', () => {
const builder = new SSTableBuilder(4096);
for (let i = 0; i < 50; i++) builder.add(`k-${i}`, { v: i, pad: 'x'.repeat(64) });
const { sstableData } = builder.build();
// 篡改数据区中部一个字节(非 footer)
const corrupted = new Uint8Array(sstableData);
corrupted[Math.floor(corrupted.length / 2)] ^= 0xff;
const reader = new SSTableReader(corrupted, makeMeta(corrupted));
// 结构仍可解析(长度一致),但 CRC 必须失败
const stored = new DataView(corrupted.buffer, corrupted.byteOffset + corrupted.byteLength - 4, 4).getUint32(0, false);
expect(stored).not.toBe(0);
expect(reader.verifyChecksum()).toBe(false);
});
it('索引块字节篡改 → 校验失败', () => {
const builder = new SSTableBuilder(4096);
for (let i = 0; i < 20; i++) builder.add(`k-${i}`, { v: i });
const { sstableData } = builder.build();
// 从 footer 反推索引块位置并篡改
const footerOffset = sstableData.byteLength - 32;
const view = new DataView(sstableData.buffer, sstableData.byteOffset, sstableData.byteLength);
const indexOffset = view.getUint32(footerOffset, false);
const corrupted = new Uint8Array(sstableData);
corrupted[indexOffset + 8] ^= 0x01;
const reader = new SSTableReader(corrupted, makeMeta(corrupted));
expect(reader.verifyChecksum()).toBe(false);
});
it('旧版文件(checksum=0)跳过校验,保持兼容', () => {
const builder = new SSTableBuilder(4096);
builder.add('legacy', { v: 1 });
const { sstableData } = builder.build();
// 手工把 checksum 字段清零,模拟旧版写入产物
const legacy = new Uint8Array(sstableData);
new DataView(legacy.buffer).setUint32(legacy.byteLength - 4, 0, false);
const reader = new SSTableReader(legacy, makeMeta(legacy));
const stored = new DataView(legacy.buffer, legacy.byteOffset + legacy.byteLength - 4, 4).getUint32(0, false);
expect(stored).toBe(0);
expect(reader.verifyChecksum()).toBe(true); // 跳过校验
expect(reader.get('legacy')).toEqual({ v: 1 }); // 数据仍可读
});
it('v1 旧格式文件(magic SSTBchecksum=0)校验通过且可读', () => {
// 手工构造 v1 格式最小文件:单条记录 + 索引 + footermagic SSTB, checksum 0
const encoder = new TextEncoder();
const keyBytes = encoder.encode('old-key');
const valBytes = encoder.encode(JSON.stringify({ v: 42 }));
// data block: entryCount(u32)=1 + keyLen(u16)+key + valLen(u16)+val
const block = new ArrayBuffer(4 + 2 + keyBytes.length + 2 + valBytes.length);
const bv = new DataView(block);
let off = 0;
bv.setUint32(off, 1, false); off += 4;
bv.setUint16(off, keyBytes.length, false); off += 2;
new Uint8Array(block).set(keyBytes, off); off += keyBytes.length;
bv.setUint16(off, valBytes.length, false); off += 2;
new Uint8Array(block).set(valBytes, off); off += valBytes.length;
// index block: entryCount(u32)=1 + keyLen(u16)+key + blockOffset(u32) + blockSize(u32)
const blockSize = block.byteLength;
const idxKey = encoder.encode('old-key');
const idxBlock = new ArrayBuffer(4 + 2 + idxKey.length + 4 + 4);
const iv = new DataView(idxBlock);
let ioff = 0;
iv.setUint32(ioff, 1, false); ioff += 4;
iv.setUint16(ioff, idxKey.length, false); ioff += 2;
new Uint8Array(idxBlock).set(idxKey, ioff); ioff += idxKey.length;
iv.setUint32(ioff, 0, false); ioff += 4; // blockOffset = 0
iv.setUint32(ioff, blockSize, false); ioff += 4;
const footerSize = 32;
const total = block.byteLength + idxBlock.byteLength + footerSize;
const file = new ArrayBuffer(total);
const fv = new DataView(file);
new Uint8Array(file).set(new Uint8Array(block), 0);
new Uint8Array(file).set(new Uint8Array(idxBlock), block.byteLength);
const fo = block.byteLength + idxBlock.byteLength;
fv.setUint32(fo, block.byteLength, false); // index_offset
fv.setUint32(fo + 4, idxBlock.byteLength, false);
fv.setUint32(fo + 8, 0, false); // bloom_offset
fv.setUint32(fo + 12, 0, false); // bloom_size
fv.setUint32(fo + 16, 0, false); // bloom_hash_count
fv.setUint32(fo + 20, 1, false); // entry_count
fv.setUint32(fo + 24, 0x53535442, false); // magic "SSTB" (v1)
fv.setUint32(fo + 28, 0, false); // checksum = 0(旧版无校验)
const data = new Uint8Array(file);
const reader = new SSTableReader(data, makeMeta(data));
const stored = new DataView(data.buffer, data.byteOffset + data.byteLength - 4, 4).getUint32(0, false);
expect(stored).toBe(0);
expect(reader.verifyChecksum()).toBe(true);
expect(reader.get('old-key')).toEqual({ v: 42 });
});
it('footer checksum 字段自身篡改 → 校验失败', () => {
const builder = new SSTableBuilder(4096);
builder.add('a', { v: 1 });
const { sstableData } = builder.build();
const tampered = new Uint8Array(sstableData);
const view = new DataView(tampered.buffer);
view.setUint32(tampered.byteLength - 4, view.getUint32(tampered.byteLength - 4, false) ^ 0x000000ff, false);
const reader = new SSTableReader(tampered, makeMeta(tampered));
expect(reader.verifyChecksum()).toBe(false);
});
});