feat: v0.2.0 AriaEngine 自研存储引擎
- 新增 AriaEngine: LSM-Tree 页面式存储引擎,19 个模块,~3500 行 TS - page/: Slotted Page 格式 (header/slot/tuple/format) + CRC32 - buffer/: Buffer Pool (LRU 缓存 + 驱逐策略) - index/: LSM-Tree (MemTable 红黑树 + SSTable + Bloom Filter + Merge Iterator) - wal/: WAL 日志 (二进制格式) + Checkpoint 管理 - transaction/: MVCC 版本链 + 快照隔离 - store/: IndexedDB / Memory 双后端抽象 - compression/: LZ4 页面压缩 - 完整持久化: Schema 自动保存、SSTable 元数据管理、WAL 恢复 - 事务感知 CRUD: insert/update/delete 在事务中缓冲到 snapshot - mode: 'aria' 激活自研引擎 - 新增 7 个测试文件,测试数 318 → 524,套件 20 → 27 - aria-page.test.ts (32 tests): Page 格式单元测试 - aria-index.test.ts (26 tests): Bloom Filter + MemTable - aria-sstable.test.ts (9 tests): SSTable Builder + Reader - aria-buffer.test.ts (25 tests): LRU + Eviction + Buffer Pool - aria-wal-mvcc.test.ts (22 tests): WAL 编解码 + MVCC 事务 - aria-compress.test.ts (11 tests): LZ4 + Merge Iterator - aria.test.ts (80 tests): AriaEngine 集成 + 边界测试 - Bug 修复: LRUList size 跟踪、WAL 缓冲区越界、ColumnEncoding 导入 - 全面更新 README.md + site/ 站点文件 (index/docs/demo)
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* AriaEngine Page 格式单元测试
|
||||
* 覆盖: PageHeader / Slot / Tuple 编解码 + PageFormat 整合
|
||||
*/
|
||||
import {
|
||||
PAGE_SIZE, PageType, PAGE_HEADER_SIZE, SLOT_ENTRY_SIZE,
|
||||
} from '../../src/engine/aria/types';
|
||||
import {
|
||||
encodePageHeader, decodePageHeader, initPageHeader, getPageType,
|
||||
getSlotCount, getFreeStart, setFreeStart, setFreeEnd,
|
||||
} from '../../src/engine/aria/page/header';
|
||||
import {
|
||||
getSlotEntry, setSlotEntry, getSlotDirectorySize,
|
||||
getFreeSpace, hasEnoughSpace, allocateSlot, readSlotData, freeSlot,
|
||||
} from '../../src/engine/aria/page/slot';
|
||||
import {
|
||||
encodeTuple, decodeTuple, getColumnEncodingMap,
|
||||
} from '../../src/engine/aria/page/tuple';
|
||||
import { ColumnEncoding } from '../../src/engine/aria/types';
|
||||
import {
|
||||
createPage, pageFromBuffer, pageInsertRow, pageReadRow,
|
||||
pageDeleteRow, pageUpdateRow, computeChecksum, verifyChecksum, updateChecksum,
|
||||
} from '../../src/engine/aria/page/format';
|
||||
|
||||
// ===================================================================
|
||||
// PageHeader
|
||||
// ===================================================================
|
||||
describe('AriaEngine Page — Header', () => {
|
||||
let buf: ArrayBuffer;
|
||||
|
||||
beforeEach(() => {
|
||||
buf = new ArrayBuffer(PAGE_SIZE);
|
||||
});
|
||||
|
||||
it('initPageHeader 初始化头部字段', () => {
|
||||
initPageHeader(buf, 42, PageType.DATA);
|
||||
const h = decodePageHeader(buf);
|
||||
expect(h.pageId).toBe(42);
|
||||
expect(h.type).toBe(PageType.DATA);
|
||||
expect(h.slotCount).toBe(0);
|
||||
expect(h.freeStart).toBe(PAGE_HEADER_SIZE);
|
||||
expect(h.freeEnd).toBe(PAGE_SIZE);
|
||||
});
|
||||
|
||||
it('initPageHeader — INDEX 类型页面', () => {
|
||||
initPageHeader(buf, 99, PageType.INDEX);
|
||||
expect(getPageType(buf)).toBe(PageType.INDEX);
|
||||
});
|
||||
|
||||
it('encodePageHeader + decodePageHeader 往返一致', () => {
|
||||
const header = { pageId: 7, type: PageType.META, freeStart: 32, freeEnd: 4000, slotCount: 5, checksum: 0xdeadbeef };
|
||||
encodePageHeader(header, buf);
|
||||
const decoded = decodePageHeader(buf);
|
||||
expect(decoded.pageId).toBe(7);
|
||||
expect(decoded.type).toBe(PageType.META);
|
||||
expect(decoded.freeStart).toBe(32);
|
||||
expect(decoded.freeEnd).toBe(4000);
|
||||
expect(decoded.slotCount).toBe(5);
|
||||
});
|
||||
|
||||
it('不同 pageId 正确编解码', () => {
|
||||
for (const id of [0, 1, 255, 65535, 0xffffffff]) {
|
||||
initPageHeader(buf, id, PageType.DATA);
|
||||
expect(decodePageHeader(buf).pageId).toBe(id >>> 0);
|
||||
}
|
||||
});
|
||||
|
||||
it('setFreeStart / setFreeEnd 修改字段', () => {
|
||||
initPageHeader(buf, 1, PageType.DATA);
|
||||
setFreeStart(buf, 100);
|
||||
setFreeEnd(buf, 3000);
|
||||
expect(getFreeStart(buf)).toBe(100);
|
||||
expect(decodePageHeader(buf).freeEnd).toBe(3000);
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// Slot Directory
|
||||
// ===================================================================
|
||||
describe('AriaEngine Page — Slot', () => {
|
||||
let buf: ArrayBuffer;
|
||||
|
||||
beforeEach(() => {
|
||||
buf = new ArrayBuffer(PAGE_SIZE);
|
||||
initPageHeader(buf, 1, PageType.DATA);
|
||||
});
|
||||
|
||||
it('getSlotEntry — 空页面 slotCount 为 0', () => {
|
||||
expect(getSlotCount(buf)).toBe(0);
|
||||
});
|
||||
|
||||
it('setSlotEntry + getSlotEntry 往返', () => {
|
||||
// 手动写一个 slot(不通过 allocateSlot)
|
||||
new DataView(buf).setUint16(9, 1, false); // slotCount = 1
|
||||
setSlotEntry(buf, 0, { offset: 1000, length: 50 });
|
||||
const entry = getSlotEntry(buf, 0);
|
||||
expect(entry.offset).toBe(1000);
|
||||
expect(entry.length).toBe(50);
|
||||
});
|
||||
|
||||
it('getSlotDirectorySize 计算正确', () => {
|
||||
expect(getSlotDirectorySize(0)).toBe(0);
|
||||
expect(getSlotDirectorySize(1)).toBe(SLOT_ENTRY_SIZE);
|
||||
expect(getSlotDirectorySize(10)).toBe(10 * SLOT_ENTRY_SIZE);
|
||||
});
|
||||
|
||||
it('getFreeSpace — 空页面有最大空闲空间', () => {
|
||||
const free = getFreeSpace(buf);
|
||||
expect(free).toBe(PAGE_SIZE - PAGE_HEADER_SIZE);
|
||||
});
|
||||
|
||||
it('hasEnoughSpace — 小数据返回 true', () => {
|
||||
expect(hasEnoughSpace(buf, 100)).toBe(true);
|
||||
});
|
||||
|
||||
it('hasEnoughSpace — 超大数据返回 false', () => {
|
||||
expect(hasEnoughSpace(buf, PAGE_SIZE * 2)).toBe(false);
|
||||
});
|
||||
|
||||
it('allocateSlot 分配并写入数据', () => {
|
||||
const data = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const idx = allocateSlot(buf, data);
|
||||
expect(idx).toBe(0);
|
||||
expect(getSlotCount(buf)).toBe(1);
|
||||
|
||||
const readBack = readSlotData(buf, 0);
|
||||
expect(readBack).not.toBeNull();
|
||||
expect(Array.from(readBack!)).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
it('allocateSlot 多次分配', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const data = new Uint8Array([i, i + 1]);
|
||||
const idx = allocateSlot(buf, data);
|
||||
expect(idx).toBe(i);
|
||||
}
|
||||
expect(getSlotCount(buf)).toBe(10);
|
||||
|
||||
// 验证每个 slot 数据正确
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const data = readSlotData(buf, i);
|
||||
expect(Array.from(data!)).toEqual([i, i + 1]);
|
||||
}
|
||||
});
|
||||
|
||||
it('freeSlot 标记为删除', () => {
|
||||
const data = new Uint8Array([10, 20, 30]);
|
||||
allocateSlot(buf, data);
|
||||
freeSlot(buf, 0);
|
||||
const entry = getSlotEntry(buf, 0);
|
||||
expect(entry.offset).toBe(0);
|
||||
expect(entry.length).toBe(0);
|
||||
});
|
||||
|
||||
it('readSlotData — 已删除 slot 返回 null', () => {
|
||||
allocateSlot(buf, new Uint8Array([1]));
|
||||
freeSlot(buf, 0);
|
||||
expect(readSlotData(buf, 0)).toBeNull();
|
||||
});
|
||||
|
||||
it('allocateSlot — 空间不足返回 -1', () => {
|
||||
// 填满页面
|
||||
const big = new Uint8Array(PAGE_SIZE - PAGE_HEADER_SIZE - SLOT_ENTRY_SIZE);
|
||||
const idx1 = allocateSlot(buf, big);
|
||||
expect(idx1).toBe(0);
|
||||
const idx2 = allocateSlot(buf, new Uint8Array([1]));
|
||||
expect(idx2).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// Tuple Codec
|
||||
// ===================================================================
|
||||
describe('AriaEngine Page — Tuple', () => {
|
||||
const colOrder = ['id', 'name', 'age', 'active', 'data'];
|
||||
const colTypes: Record<string, string> = {
|
||||
id: 'string', name: 'string', age: 'number', active: 'boolean', data: 'json',
|
||||
};
|
||||
|
||||
it('encodeTuple + decodeTuple 完整往返', () => {
|
||||
const row = { id: '1', name: 'Alice', age: 30, active: true, data: { x: 1 } };
|
||||
const encoded = encodeTuple(row, colOrder, colTypes);
|
||||
expect(encoded.byteLength).toBeGreaterThan(0);
|
||||
|
||||
const decoded = decodeTuple(encoded, colOrder, colTypes);
|
||||
expect(decoded).not.toBeNull();
|
||||
expect(decoded!.id).toBe('1');
|
||||
expect(decoded!.name).toBe('Alice');
|
||||
expect(decoded!.age).toBe(30);
|
||||
expect(decoded!.active).toBe(true);
|
||||
expect(decoded!.data).toEqual({ x: 1 });
|
||||
});
|
||||
|
||||
it('encodeTuple — null 值正确处理', () => {
|
||||
const row = { id: '2', name: null, age: 25, active: null, data: null };
|
||||
const encoded = encodeTuple(row, colOrder, colTypes);
|
||||
const decoded = decodeTuple(encoded, colOrder, colTypes);
|
||||
expect(decoded!.name).toBeNull();
|
||||
expect(decoded!.active).toBeNull();
|
||||
expect(decoded!.data).toBeNull();
|
||||
});
|
||||
|
||||
it('encodeTuple — undefined 值按 null 处理', () => {
|
||||
const row = { id: '3', age: 30 } as any;
|
||||
const encoded = encodeTuple(row, colOrder, colTypes);
|
||||
const decoded = decodeTuple(encoded, colOrder, colTypes);
|
||||
expect(decoded!.id).toBe('3');
|
||||
expect(decoded!.name).toBeNull();
|
||||
});
|
||||
|
||||
it('encodeTuple — date 类型', () => {
|
||||
const order = ['ts'];
|
||||
const types = { ts: 'date' };
|
||||
const row = { ts: '2024-01-15T00:00:00.000Z' };
|
||||
const encoded = encodeTuple(row, order, types);
|
||||
const decoded = decodeTuple(encoded, order, types);
|
||||
expect(decoded!.ts).toBe('2024-01-15T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('encodeTuple — boolean false', () => {
|
||||
const order = ['flag'];
|
||||
const types = { flag: 'boolean' };
|
||||
const encoded = encodeTuple({ flag: false }, order, types);
|
||||
const decoded = decodeTuple(encoded, order, types);
|
||||
expect(decoded!.flag).toBe(false);
|
||||
});
|
||||
|
||||
it('encodeTuple — 负数', () => {
|
||||
const order = ['val'];
|
||||
const types = { val: 'number' };
|
||||
const encoded = encodeTuple({ val: -42.5 }, order, types);
|
||||
const decoded = decodeTuple(encoded, order, types);
|
||||
expect(decoded!.val).toBe(-42.5);
|
||||
});
|
||||
|
||||
it('encodeTuple — 空字符串', () => {
|
||||
const order = ['s'];
|
||||
const types = { s: 'string' };
|
||||
const encoded = encodeTuple({ s: '' }, order, types);
|
||||
const decoded = decodeTuple(encoded, order, types);
|
||||
expect(decoded!.s).toBe('');
|
||||
});
|
||||
|
||||
it('encodeTuple — 长字符串', () => {
|
||||
const order = ['s'];
|
||||
const types = { s: 'string' };
|
||||
const long = 'x'.repeat(10000);
|
||||
const encoded = encodeTuple({ s: long }, order, types);
|
||||
const decoded = decodeTuple(encoded, order, types);
|
||||
expect(decoded!.s).toBe(long);
|
||||
});
|
||||
|
||||
it('getColumnEncodingMap 返回正确映射', () => {
|
||||
const map = getColumnEncodingMap(colOrder, colTypes);
|
||||
expect(map.get('id')).toBe(ColumnEncoding.STRING);
|
||||
expect(map.get('age')).toBe(ColumnEncoding.NUMBER);
|
||||
expect(map.get('active')).toBe(ColumnEncoding.BOOLEAN);
|
||||
expect(map.get('data')).toBe(ColumnEncoding.JSON);
|
||||
});
|
||||
|
||||
it('decodeTuple — 损坏数据返回 null', () => {
|
||||
const broken = new Uint8Array([0xff, 0xff, 0xff]);
|
||||
expect(decodeTuple(broken, colOrder, colTypes)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// Page Format 整合
|
||||
// ===================================================================
|
||||
describe('AriaEngine Page — Format', () => {
|
||||
const colOrder = ['id', 'name'];
|
||||
const colTypes: Record<string, string> = { id: 'string', name: 'string' };
|
||||
|
||||
it('createPage 创建合法页面', () => {
|
||||
const page = createPage(100, PageType.DATA);
|
||||
expect(page.pageId).toBe(100);
|
||||
expect(page.type).toBe(PageType.DATA);
|
||||
expect(page.pins).toBe(0);
|
||||
expect(page.dirty).toBe(true);
|
||||
});
|
||||
|
||||
it('pageInsertRow + pageReadRow 往返', () => {
|
||||
const page = createPage(1, PageType.DATA);
|
||||
const row = { id: 'u1', name: 'Test' };
|
||||
const idx = pageInsertRow(page, row, colOrder, colTypes);
|
||||
expect(idx).toBe(0);
|
||||
|
||||
const read = pageReadRow(page, 0, colOrder, colTypes);
|
||||
expect(read).not.toBeNull();
|
||||
expect(read!.id).toBe('u1');
|
||||
expect(read!.name).toBe('Test');
|
||||
});
|
||||
|
||||
it('pageInsertRow — 多次插入', () => {
|
||||
const page = createPage(1, PageType.DATA);
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const idx = pageInsertRow(page, { id: `${i}`, name: `User${i}` }, colOrder, colTypes);
|
||||
expect(idx).toBe(i);
|
||||
}
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const row = pageReadRow(page, i, colOrder, colTypes);
|
||||
expect(row!.id).toBe(`${i}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('pageDeleteRow 标记删除', () => {
|
||||
const page = createPage(1, PageType.DATA);
|
||||
pageInsertRow(page, { id: '1', name: 'A' }, colOrder, colTypes);
|
||||
pageInsertRow(page, { id: '2', name: 'B' }, colOrder, colTypes);
|
||||
pageDeleteRow(page, 0);
|
||||
expect(page.dirty).toBe(true);
|
||||
// 已删除的行读取失败
|
||||
const row = pageReadRow(page, 0, colOrder, colTypes);
|
||||
expect(row).toBeNull();
|
||||
// 未删除的行仍然可读
|
||||
const row2 = pageReadRow(page, 1, colOrder, colTypes);
|
||||
expect(row2!.id).toBe('2');
|
||||
});
|
||||
|
||||
it('pageFromBuffer 从 ArrayBuffer 恢复', () => {
|
||||
const page = createPage(5, PageType.INDEX);
|
||||
const restored = pageFromBuffer(5, page.data);
|
||||
expect(restored.pageId).toBe(5);
|
||||
expect(restored.type).toBe(PageType.INDEX);
|
||||
expect(restored.dirty).toBe(false);
|
||||
});
|
||||
|
||||
it('computeChecksum + verifyChecksum', () => {
|
||||
const page = createPage(1, PageType.DATA);
|
||||
updateChecksum(page);
|
||||
expect(verifyChecksum(page)).toBe(true);
|
||||
|
||||
// 修改页面 → 校验和失效
|
||||
new Uint8Array(page.data)[100] = 0xff;
|
||||
expect(verifyChecksum(page)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user