release: v0.3.2 — 质量加固 + SQL扩展 + 表达式 + 并发同步
v0.2.6 质量加固: - 修复 AriaEngine 二级索引 SSTable 互相覆盖(命名空间隔离) - 修复 LSM 多版本读取顺序错误 + MergeIterator 取最新来源 - 重写 LZ4 压缩器(往返一致性 + 缓冲区溢出) - sstableCache LRU 上限 + 预加载兜底(BufferPool 配置生效) - 修复 React/Vue 集成 import type 运行时 bug + exports 子路径 - 新增 38 个测试(LZ4往返/Crypto/集成), 删除伪测试 v0.3.0 SQL 功能扩展: - 多语句 parseAll + 事务语句 BEGIN/COMMIT/ROLLBACK - INSERT INTO ... SELECT + UNION/UNION ALL + EXISTS 关联子查询 - CREATE/DROP INDEX 五引擎实现 + 别名 WHERE 修复 - benchmark 页面 + 36 个新测试 v0.3.1 表达式与性能: - CASE WHEN 表达式(SELECT 列/WHERE/聚合) - JOIN + 关联子查询逐行绑定 - WAL 批量组提交(写放大 O(N)→O(1)) - 修复 pending frozen 可见性 + flush 缓存竞争 v0.3.2 并发: - CASE WHEN 用于 WHERE/聚合 + JOIN 哈希连接 - 多标签页同步(multiTabSync + BroadcastChannel) - IndexedDB schema 持久化(reopen 后表结构恢复) - 修复 where-matcher 顶层 $not - 修复 CJS 产物 .js 被 ESM 解析(exports 空) — .cjs 后缀 + exports 修正 - 836 测试 / 44 套件 / 81.0% 覆盖率
This commit is contained in:
+273
-273
@@ -1,273 +1,273 @@
|
||||
/**
|
||||
* AriaEngine Buffer Pool + Eviction 单元测试
|
||||
*/
|
||||
import { BufferPool, type PageIO } from '../../src/engine/aria/buffer/pool';
|
||||
import { LRUList, EvictionManager } from '../../src/engine/aria/buffer/eviction';
|
||||
import { PageType, PAGE_SIZE } from '../../src/engine/aria/types';
|
||||
import { initPageHeader } from '../../src/engine/aria/page/header';
|
||||
|
||||
// ===================================================================
|
||||
// LRUList
|
||||
// ===================================================================
|
||||
describe('AriaEngine — LRUList', () => {
|
||||
function makePage(id: number) {
|
||||
const data = new ArrayBuffer(PAGE_SIZE);
|
||||
initPageHeader(data, id, PageType.DATA);
|
||||
return { pageId: id, type: PageType.DATA, data, dirty: false, pins: 0, prev: null, next: null, lastAccess: Date.now() };
|
||||
}
|
||||
|
||||
it('moveToHead — 单元素', () => {
|
||||
const list = new LRUList();
|
||||
const p = makePage(1);
|
||||
list.moveToHead(p);
|
||||
expect(list.size).toBe(1);
|
||||
});
|
||||
|
||||
it('moveToHead — 多元素保持 MRU 顺序', () => {
|
||||
const list = new LRUList();
|
||||
const a = makePage(1);
|
||||
const b = makePage(2);
|
||||
const c = makePage(3);
|
||||
list.moveToHead(a);
|
||||
list.moveToHead(b);
|
||||
list.moveToHead(c);
|
||||
expect(list.size).toBe(3);
|
||||
// c 是最新的
|
||||
});
|
||||
|
||||
it('getLRU 返回最久未使用', () => {
|
||||
const list = new LRUList();
|
||||
const a = makePage(1);
|
||||
const b = makePage(2);
|
||||
list.moveToHead(a);
|
||||
list.moveToHead(b);
|
||||
expect(list.getLRU()!.pageId).toBe(1);
|
||||
});
|
||||
|
||||
it('popLRU 移除并返回最久未使用', () => {
|
||||
const list = new LRUList();
|
||||
const a = makePage(1);
|
||||
const b = makePage(2);
|
||||
list.moveToHead(a);
|
||||
list.moveToHead(b);
|
||||
const popped = list.popLRU();
|
||||
expect(popped!.pageId).toBe(1);
|
||||
expect(list.size).toBe(1);
|
||||
});
|
||||
|
||||
it('remove — 从中间移除', () => {
|
||||
const list = new LRUList();
|
||||
const a = makePage(1);
|
||||
const b = makePage(2);
|
||||
const c = makePage(3);
|
||||
list.moveToHead(a);
|
||||
list.moveToHead(b);
|
||||
list.moveToHead(c);
|
||||
list.remove(b);
|
||||
expect(list.size).toBe(2);
|
||||
});
|
||||
|
||||
it('clear 清空', () => {
|
||||
const list = new LRUList();
|
||||
list.moveToHead(makePage(1));
|
||||
list.moveToHead(makePage(2));
|
||||
list.clear();
|
||||
expect(list.size).toBe(0);
|
||||
});
|
||||
|
||||
it('getAllPages 返回所有页面', () => {
|
||||
const list = new LRUList();
|
||||
list.moveToHead(makePage(1));
|
||||
list.moveToHead(makePage(2));
|
||||
expect(list.getAllPages()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('空 LRU getLRU 返回 null', () => {
|
||||
const list = new LRUList();
|
||||
expect(list.getLRU()).toBeNull();
|
||||
});
|
||||
|
||||
it('空 LRU popLRU 返回 null', () => {
|
||||
const list = new LRUList();
|
||||
expect(list.popLRU()).toBeNull();
|
||||
});
|
||||
|
||||
it('moveToHead 同元素不移重复', () => {
|
||||
const list = new LRUList();
|
||||
const p = makePage(1);
|
||||
list.moveToHead(p);
|
||||
list.moveToHead(p);
|
||||
list.moveToHead(p);
|
||||
expect(list.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// EvictionManager
|
||||
// ===================================================================
|
||||
describe('AriaEngine — EvictionManager', () => {
|
||||
function makePage(id: number, dirty = false) {
|
||||
const data = new ArrayBuffer(PAGE_SIZE);
|
||||
initPageHeader(data, id, PageType.DATA);
|
||||
return { pageId: id, type: PageType.DATA, data, dirty, pins: 0, prev: null, next: null, lastAccess: Date.now() };
|
||||
}
|
||||
|
||||
it('access 更新 LRU', async () => {
|
||||
let evicted = -1;
|
||||
const em = new EvictionManager(3, async (p) => { evicted = p.pageId; });
|
||||
const p = makePage(1);
|
||||
em.add(p);
|
||||
em.access(p);
|
||||
expect(em.getSize()).toBe(1);
|
||||
});
|
||||
|
||||
it('add 不超过容量不触发驱逐', async () => {
|
||||
const evictedPages: number[] = [];
|
||||
const em = new EvictionManager(4, async (p) => { evictedPages.push(p.pageId); });
|
||||
em.add(makePage(1));
|
||||
em.add(makePage(2));
|
||||
em.add(makePage(3));
|
||||
await em.evictIfNeeded(1);
|
||||
expect(evictedPages).toHaveLength(0);
|
||||
expect(em.getSize()).toBe(3);
|
||||
});
|
||||
|
||||
it('evictIfNeeded 超容量触发驱逐', async () => {
|
||||
const evictedPages: number[] = [];
|
||||
const em = new EvictionManager(2, async (p) => { evictedPages.push(p.pageId); });
|
||||
em.add(makePage(1));
|
||||
em.add(makePage(2));
|
||||
em.add(makePage(3)); // 超容量
|
||||
await em.evictIfNeeded(0);
|
||||
// 驱逐后 size 应 <= 2
|
||||
expect(em.getSize()).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('remove 减少 size', () => {
|
||||
const em = new EvictionManager(4, async () => {});
|
||||
const p = makePage(1);
|
||||
em.add(p);
|
||||
em.add(makePage(2));
|
||||
em.remove(p);
|
||||
expect(em.getSize()).toBe(1);
|
||||
});
|
||||
|
||||
it('getCapacity 返回配置容量', () => {
|
||||
const em = new EvictionManager(128, async () => {});
|
||||
expect(em.getCapacity()).toBe(128);
|
||||
});
|
||||
|
||||
it('clear 清空', () => {
|
||||
const em = new EvictionManager(4, async () => {});
|
||||
em.add(makePage(1));
|
||||
em.add(makePage(2));
|
||||
em.clear();
|
||||
expect(em.getSize()).toBe(0);
|
||||
});
|
||||
|
||||
it('脏页驱逐前调用 onEvict 回调', async () => {
|
||||
let flushed = 0;
|
||||
const em = new EvictionManager(2, async (_p) => { flushed++; });
|
||||
em.add(makePage(1, true)); // dirty page
|
||||
em.add(makePage(2));
|
||||
await em.evictIfNeeded(1); // need space → evict page 1
|
||||
expect(flushed).toBeGreaterThanOrEqual(0); // May or may not evict
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// BufferPool (with Mock PageIO)
|
||||
// ===================================================================
|
||||
describe('AriaEngine — BufferPool', () => {
|
||||
class MockPageIO implements PageIO {
|
||||
store = new Map<number, ArrayBuffer>();
|
||||
nextId = 1;
|
||||
reads = 0;
|
||||
writes = 0;
|
||||
|
||||
async readPage(pageId: number) { this.reads++; return this.store.get(pageId) ?? null; }
|
||||
async writePage(pageId: number, data: ArrayBuffer) { this.writes++; this.store.set(pageId, data); }
|
||||
async allocatePageId() { return this.nextId++; }
|
||||
async freePageId(_pageId: number) {}
|
||||
}
|
||||
|
||||
it('newPage 创建页面并 pin', async () => {
|
||||
const io = new MockPageIO();
|
||||
const pool = new BufferPool(io, 4);
|
||||
const page = await pool.newPage();
|
||||
expect(page.pageId).toBe(1);
|
||||
expect(page.pins).toBe(1);
|
||||
expect(page.type).toBe(PageType.DATA);
|
||||
pool.unpin(page);
|
||||
});
|
||||
|
||||
it('getPage — 池中已存在则 pin++', async () => {
|
||||
const io = new MockPageIO();
|
||||
const pool = new BufferPool(io, 4);
|
||||
const p1 = await pool.newPage();
|
||||
pool.unpin(p1);
|
||||
|
||||
const p2 = await pool.getPage(p1.pageId);
|
||||
expect(p2!.pageId).toBe(p1.pageId);
|
||||
expect(p2!.pins).toBe(1);
|
||||
pool.unpin(p2!);
|
||||
});
|
||||
|
||||
it('markDirty + flushPage 写回', async () => {
|
||||
const io = new MockPageIO();
|
||||
const pool = new BufferPool(io, 4);
|
||||
const page = await pool.newPage();
|
||||
new Uint8Array(page.data)[100] = 42;
|
||||
pool.markDirty(page);
|
||||
pool.unpin(page);
|
||||
|
||||
await pool.flushPage(page.pageId);
|
||||
expect(io.writes).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('flushAll 刷新所有脏页', async () => {
|
||||
const io = new MockPageIO();
|
||||
const pool = new BufferPool(io, 8);
|
||||
const p1 = await pool.newPage();
|
||||
const p2 = await pool.newPage();
|
||||
pool.markDirty(p1);
|
||||
pool.markDirty(p2);
|
||||
pool.unpin(p1);
|
||||
pool.unpin(p2);
|
||||
|
||||
await pool.flushAll();
|
||||
expect(io.writes).toBe(2);
|
||||
});
|
||||
|
||||
it('getCachedPageCount 返回缓存数', async () => {
|
||||
const io = new MockPageIO();
|
||||
const pool = new BufferPool(io, 4);
|
||||
await pool.newPage();
|
||||
await pool.newPage();
|
||||
expect(pool.getCachedPageCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('getDirtyPageCount 返回脏页数', async () => {
|
||||
const io = new MockPageIO();
|
||||
const pool = new BufferPool(io, 4);
|
||||
const p = await pool.newPage();
|
||||
pool.markDirty(p);
|
||||
pool.unpin(p);
|
||||
expect(pool.getDirtyPageCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('getCapacity 返回容量', async () => {
|
||||
const io = new MockPageIO();
|
||||
const pool = new BufferPool(io, 64);
|
||||
expect(pool.getCapacity()).toBe(64);
|
||||
});
|
||||
|
||||
it('removePage 移除缓存', async () => {
|
||||
const io = new MockPageIO();
|
||||
const pool = new BufferPool(io, 4);
|
||||
const p = await pool.newPage();
|
||||
pool.unpin(p);
|
||||
pool.removePage(p.pageId);
|
||||
expect(pool.getCachedPageCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
/**
|
||||
* AriaEngine Buffer Pool + Eviction 单元测试
|
||||
*/
|
||||
import { BufferPool, type PageIO } from '../../src/engine/aria/buffer/pool';
|
||||
import { LRUList, EvictionManager } from '../../src/engine/aria/buffer/eviction';
|
||||
import { PageType, PAGE_SIZE } from '../../src/engine/aria/types';
|
||||
import { initPageHeader } from '../../src/engine/aria/page/header';
|
||||
|
||||
// ===================================================================
|
||||
// LRUList
|
||||
// ===================================================================
|
||||
describe('AriaEngine — LRUList', () => {
|
||||
function makePage(id: number) {
|
||||
const data = new ArrayBuffer(PAGE_SIZE);
|
||||
initPageHeader(data, id, PageType.DATA);
|
||||
return { pageId: id, type: PageType.DATA, data, dirty: false, pins: 0, prev: null, next: null, lastAccess: Date.now() };
|
||||
}
|
||||
|
||||
it('moveToHead — 单元素', () => {
|
||||
const list = new LRUList();
|
||||
const p = makePage(1);
|
||||
list.moveToHead(p);
|
||||
expect(list.size).toBe(1);
|
||||
});
|
||||
|
||||
it('moveToHead — 多元素保持 MRU 顺序', () => {
|
||||
const list = new LRUList();
|
||||
const a = makePage(1);
|
||||
const b = makePage(2);
|
||||
const c = makePage(3);
|
||||
list.moveToHead(a);
|
||||
list.moveToHead(b);
|
||||
list.moveToHead(c);
|
||||
expect(list.size).toBe(3);
|
||||
// c 是最新的
|
||||
});
|
||||
|
||||
it('getLRU 返回最久未使用', () => {
|
||||
const list = new LRUList();
|
||||
const a = makePage(1);
|
||||
const b = makePage(2);
|
||||
list.moveToHead(a);
|
||||
list.moveToHead(b);
|
||||
expect(list.getLRU()!.pageId).toBe(1);
|
||||
});
|
||||
|
||||
it('popLRU 移除并返回最久未使用', () => {
|
||||
const list = new LRUList();
|
||||
const a = makePage(1);
|
||||
const b = makePage(2);
|
||||
list.moveToHead(a);
|
||||
list.moveToHead(b);
|
||||
const popped = list.popLRU();
|
||||
expect(popped!.pageId).toBe(1);
|
||||
expect(list.size).toBe(1);
|
||||
});
|
||||
|
||||
it('remove — 从中间移除', () => {
|
||||
const list = new LRUList();
|
||||
const a = makePage(1);
|
||||
const b = makePage(2);
|
||||
const c = makePage(3);
|
||||
list.moveToHead(a);
|
||||
list.moveToHead(b);
|
||||
list.moveToHead(c);
|
||||
list.remove(b);
|
||||
expect(list.size).toBe(2);
|
||||
});
|
||||
|
||||
it('clear 清空', () => {
|
||||
const list = new LRUList();
|
||||
list.moveToHead(makePage(1));
|
||||
list.moveToHead(makePage(2));
|
||||
list.clear();
|
||||
expect(list.size).toBe(0);
|
||||
});
|
||||
|
||||
it('getAllPages 返回所有页面', () => {
|
||||
const list = new LRUList();
|
||||
list.moveToHead(makePage(1));
|
||||
list.moveToHead(makePage(2));
|
||||
expect(list.getAllPages()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('空 LRU getLRU 返回 null', () => {
|
||||
const list = new LRUList();
|
||||
expect(list.getLRU()).toBeNull();
|
||||
});
|
||||
|
||||
it('空 LRU popLRU 返回 null', () => {
|
||||
const list = new LRUList();
|
||||
expect(list.popLRU()).toBeNull();
|
||||
});
|
||||
|
||||
it('moveToHead 同元素不移重复', () => {
|
||||
const list = new LRUList();
|
||||
const p = makePage(1);
|
||||
list.moveToHead(p);
|
||||
list.moveToHead(p);
|
||||
list.moveToHead(p);
|
||||
expect(list.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// EvictionManager
|
||||
// ===================================================================
|
||||
describe('AriaEngine — EvictionManager', () => {
|
||||
function makePage(id: number, dirty = false) {
|
||||
const data = new ArrayBuffer(PAGE_SIZE);
|
||||
initPageHeader(data, id, PageType.DATA);
|
||||
return { pageId: id, type: PageType.DATA, data, dirty, pins: 0, prev: null, next: null, lastAccess: Date.now() };
|
||||
}
|
||||
|
||||
it('access 更新 LRU', async () => {
|
||||
let evicted = -1;
|
||||
const em = new EvictionManager(3, async (p) => { evicted = p.pageId; });
|
||||
const p = makePage(1);
|
||||
em.add(p);
|
||||
em.access(p);
|
||||
expect(em.getSize()).toBe(1);
|
||||
});
|
||||
|
||||
it('add 不超过容量不触发驱逐', async () => {
|
||||
const evictedPages: number[] = [];
|
||||
const em = new EvictionManager(4, async (p) => { evictedPages.push(p.pageId); });
|
||||
em.add(makePage(1));
|
||||
em.add(makePage(2));
|
||||
em.add(makePage(3));
|
||||
await em.evictIfNeeded(1);
|
||||
expect(evictedPages).toHaveLength(0);
|
||||
expect(em.getSize()).toBe(3);
|
||||
});
|
||||
|
||||
it('evictIfNeeded 超容量触发驱逐', async () => {
|
||||
const evictedPages: number[] = [];
|
||||
const em = new EvictionManager(2, async (p) => { evictedPages.push(p.pageId); });
|
||||
em.add(makePage(1));
|
||||
em.add(makePage(2));
|
||||
em.add(makePage(3)); // 超容量
|
||||
await em.evictIfNeeded(0);
|
||||
// 驱逐后 size 应 <= 2
|
||||
expect(em.getSize()).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('remove 减少 size', () => {
|
||||
const em = new EvictionManager(4, async () => {});
|
||||
const p = makePage(1);
|
||||
em.add(p);
|
||||
em.add(makePage(2));
|
||||
em.remove(p);
|
||||
expect(em.getSize()).toBe(1);
|
||||
});
|
||||
|
||||
it('getCapacity 返回配置容量', () => {
|
||||
const em = new EvictionManager(128, async () => {});
|
||||
expect(em.getCapacity()).toBe(128);
|
||||
});
|
||||
|
||||
it('clear 清空', () => {
|
||||
const em = new EvictionManager(4, async () => {});
|
||||
em.add(makePage(1));
|
||||
em.add(makePage(2));
|
||||
em.clear();
|
||||
expect(em.getSize()).toBe(0);
|
||||
});
|
||||
|
||||
it('脏页驱逐前调用 onEvict 回调', async () => {
|
||||
let flushed = 0;
|
||||
const em = new EvictionManager(2, async (_p) => { flushed++; });
|
||||
em.add(makePage(1, true)); // dirty page
|
||||
em.add(makePage(2));
|
||||
await em.evictIfNeeded(1); // need space → evict page 1
|
||||
expect(flushed).toBeGreaterThanOrEqual(0); // May or may not evict
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// BufferPool (with Mock PageIO)
|
||||
// ===================================================================
|
||||
describe('AriaEngine — BufferPool', () => {
|
||||
class MockPageIO implements PageIO {
|
||||
store = new Map<number, ArrayBuffer>();
|
||||
nextId = 1;
|
||||
reads = 0;
|
||||
writes = 0;
|
||||
|
||||
async readPage(pageId: number) { this.reads++; return this.store.get(pageId) ?? null; }
|
||||
async writePage(pageId: number, data: ArrayBuffer) { this.writes++; this.store.set(pageId, data); }
|
||||
async allocatePageId() { return this.nextId++; }
|
||||
async freePageId(_pageId: number) {}
|
||||
}
|
||||
|
||||
it('newPage 创建页面并 pin', async () => {
|
||||
const io = new MockPageIO();
|
||||
const pool = new BufferPool(io, 4);
|
||||
const page = await pool.newPage();
|
||||
expect(page.pageId).toBe(1);
|
||||
expect(page.pins).toBe(1);
|
||||
expect(page.type).toBe(PageType.DATA);
|
||||
pool.unpin(page);
|
||||
});
|
||||
|
||||
it('getPage — 池中已存在则 pin++', async () => {
|
||||
const io = new MockPageIO();
|
||||
const pool = new BufferPool(io, 4);
|
||||
const p1 = await pool.newPage();
|
||||
pool.unpin(p1);
|
||||
|
||||
const p2 = await pool.getPage(p1.pageId);
|
||||
expect(p2!.pageId).toBe(p1.pageId);
|
||||
expect(p2!.pins).toBe(1);
|
||||
pool.unpin(p2!);
|
||||
});
|
||||
|
||||
it('markDirty + flushPage 写回', async () => {
|
||||
const io = new MockPageIO();
|
||||
const pool = new BufferPool(io, 4);
|
||||
const page = await pool.newPage();
|
||||
new Uint8Array(page.data)[100] = 42;
|
||||
pool.markDirty(page);
|
||||
pool.unpin(page);
|
||||
|
||||
await pool.flushPage(page.pageId);
|
||||
expect(io.writes).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('flushAll 刷新所有脏页', async () => {
|
||||
const io = new MockPageIO();
|
||||
const pool = new BufferPool(io, 8);
|
||||
const p1 = await pool.newPage();
|
||||
const p2 = await pool.newPage();
|
||||
pool.markDirty(p1);
|
||||
pool.markDirty(p2);
|
||||
pool.unpin(p1);
|
||||
pool.unpin(p2);
|
||||
|
||||
await pool.flushAll();
|
||||
expect(io.writes).toBe(2);
|
||||
});
|
||||
|
||||
it('getCachedPageCount 返回缓存数', async () => {
|
||||
const io = new MockPageIO();
|
||||
const pool = new BufferPool(io, 4);
|
||||
await pool.newPage();
|
||||
await pool.newPage();
|
||||
expect(pool.getCachedPageCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('getDirtyPageCount 返回脏页数', async () => {
|
||||
const io = new MockPageIO();
|
||||
const pool = new BufferPool(io, 4);
|
||||
const p = await pool.newPage();
|
||||
pool.markDirty(p);
|
||||
pool.unpin(p);
|
||||
expect(pool.getDirtyPageCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('getCapacity 返回容量', async () => {
|
||||
const io = new MockPageIO();
|
||||
const pool = new BufferPool(io, 64);
|
||||
expect(pool.getCapacity()).toBe(64);
|
||||
});
|
||||
|
||||
it('removePage 移除缓存', async () => {
|
||||
const io = new MockPageIO();
|
||||
const pool = new BufferPool(io, 4);
|
||||
const p = await pool.newPage();
|
||||
pool.unpin(p);
|
||||
pool.removePage(p.pageId);
|
||||
expect(pool.getCachedPageCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* AriaEngine SSTable 缓存内存上限测试
|
||||
* @module tests/engine/aria-cache
|
||||
*
|
||||
* 验证 v0.2.6 修复:
|
||||
* 1. SSTable 缓存受 cacheLimitBytes 上限约束(LRU 裁剪)
|
||||
* 2. 缓存驱逐后所有读取路径(全表/范围/PK/索引)仍返回完整数据(prefetch 兜底)
|
||||
* 3. 写入路径不会导致缓存无限增长
|
||||
*/
|
||||
import { AriaEngine } from '../../src/engine/aria/index';
|
||||
import { createSchema } from '../../src/table/schema';
|
||||
|
||||
/** 构造小缓存 + 小 MemTable 阈值的引擎,快速产生多个 SSTable */
|
||||
function createSmallCacheEngine(bufferPoolPages = 2) {
|
||||
return new AriaEngine({
|
||||
storageBackend: 'memory',
|
||||
memtableSizeThreshold: 2048, // ~2KB 阈值 → 300 行会产生多个 SSTable
|
||||
bufferPoolPages,
|
||||
checkpointInterval: 100000, // 关闭自动 checkpoint,避免干扰
|
||||
walSyncMode: 'none',
|
||||
} as any);
|
||||
}
|
||||
|
||||
function makeRows(count: number): Record<string, unknown>[] {
|
||||
const rows: Record<string, unknown>[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
rows.push({ id: `u${i}`, name: `User${i}`, age: 20 + (i % 30) });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
describe('AriaEngine SSTable 缓存内存上限', () => {
|
||||
test('缓存大小受 cacheLimitBytes 约束', async () => {
|
||||
const engine = createSmallCacheEngine(2); // 2 * 4096 = 8KB 上限
|
||||
await engine.open('cache-limit-test', 1);
|
||||
await engine.createTable(createSchema('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string' },
|
||||
age: { type: 'number', index: true },
|
||||
}));
|
||||
|
||||
await engine.insert('users', makeRows(300));
|
||||
const lsm = (engine as any).lsm as {
|
||||
getCacheSize(): number;
|
||||
getCacheLimit(): number;
|
||||
getStats(): { sstableCount: number };
|
||||
};
|
||||
const stats = lsm.getStats();
|
||||
// 300 行 / 2KB 阈值 → 应产生多个 SSTable
|
||||
expect(stats.sstableCount).toBeGreaterThan(1);
|
||||
|
||||
// 多轮查询后缓存仍受上限约束
|
||||
for (let round = 0; round < 5; round++) {
|
||||
const rows = await engine.find('users', { table: 'users', where: { age: 25 } });
|
||||
expect(rows.length).toBe(10);
|
||||
expect(lsm.getCacheSize()).toBeLessThanOrEqual(lsm.getCacheLimit());
|
||||
}
|
||||
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
test('缓存驱逐后全表扫描仍返回完整数据(prefetch 兜底)', async () => {
|
||||
const engine = createSmallCacheEngine(1); // 4KB 上限,必然触发驱逐
|
||||
await engine.open('cache-evict-fullscan', 1);
|
||||
await engine.createTable(createSchema('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string' },
|
||||
}));
|
||||
|
||||
const rows = makeRows(300);
|
||||
await engine.insert('users', rows);
|
||||
|
||||
const all = await engine.find('users', { table: 'users' });
|
||||
expect(all.length).toBe(300);
|
||||
|
||||
const lsm = (engine as any).lsm;
|
||||
expect(lsm.getCacheSize()).toBeLessThanOrEqual(lsm.getCacheLimit());
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
test('缓存驱逐后 PK 等值查询仍正确(prefetchKeys 兜底)', async () => {
|
||||
const engine = createSmallCacheEngine(1);
|
||||
await engine.open('cache-evict-pk', 1);
|
||||
await engine.createTable(createSchema('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string' },
|
||||
}));
|
||||
|
||||
const rows = makeRows(300);
|
||||
await engine.insert('users', rows);
|
||||
|
||||
// 分散查询多个 PK,每次都会经历 驱逐+重新加载
|
||||
for (let i = 0; i < 300; i += 11) {
|
||||
const found = await engine.find('users', { table: 'users', where: { id: `u${i}` } });
|
||||
expect(found.length).toBe(1);
|
||||
expect(found[0].name).toBe(`User${i}`);
|
||||
}
|
||||
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
test('缓存驱逐后二级索引查询仍正确', async () => {
|
||||
const engine = createSmallCacheEngine(1);
|
||||
await engine.open('cache-evict-idx', 1);
|
||||
await engine.createTable(createSchema('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string' },
|
||||
age: { type: 'number', index: true },
|
||||
}));
|
||||
|
||||
await engine.insert('users', makeRows(300));
|
||||
|
||||
// 索引等值 + 范围查询
|
||||
const eq = await engine.find('users', { table: 'users', where: { age: 25 } });
|
||||
expect(eq.length).toBe(10);
|
||||
|
||||
// age 范围 20-49,每个值 10 行
|
||||
const range = await engine.find('users', { table: 'users', where: { age: { $gte: 40 } } });
|
||||
expect(range.length).toBe(100);
|
||||
|
||||
const range2 = await engine.find('users', { table: 'users', where: { age: { $gt: 45 } } });
|
||||
expect(range2.length).toBe(40);
|
||||
|
||||
const inQuery = await engine.find('users', { table: 'users', where: { age: { $in: [21, 22] } } });
|
||||
expect(inQuery.length).toBe(20);
|
||||
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
test('UPDATE/DELETE 在缓存驱逐后仍作用于全部行', async () => {
|
||||
const engine = createSmallCacheEngine(1);
|
||||
await engine.open('cache-evict-mutate', 1);
|
||||
await engine.createTable(createSchema('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string' },
|
||||
age: { type: 'number' },
|
||||
}));
|
||||
|
||||
await engine.insert('users', makeRows(300));
|
||||
|
||||
// 无条件更新 → 全表更新
|
||||
const updated = await engine.update('users', { table: 'users' }, { name: 'Renamed' });
|
||||
expect(updated).toBe(300);
|
||||
|
||||
// age 20-49 每个值 10 行;$lt 25 → age 20-24 → 50 行
|
||||
const deleted = await engine.delete('users', { table: 'users', where: { age: { $lt: 25 } } });
|
||||
expect(deleted).toBe(50);
|
||||
|
||||
const remaining = await engine.find('users', { table: 'users' });
|
||||
expect(remaining.length).toBe(250);
|
||||
expect(remaining.every((r) => r.name === 'Renamed')).toBe(true);
|
||||
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
test('写入路径不突破缓存上限(flush 后立即裁剪)', async () => {
|
||||
const engine = createSmallCacheEngine(2);
|
||||
await engine.open('cache-write-bound', 1);
|
||||
await engine.createTable(createSchema('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string' },
|
||||
}));
|
||||
|
||||
// 分批写入,每批都触发多次 flush
|
||||
for (let batch = 0; batch < 10; batch++) {
|
||||
await engine.insert('users', makeRows(30).map((r, i) => ({ ...r, id: `b${batch}_u${i}` })));
|
||||
const lsm = (engine as any).lsm;
|
||||
expect(lsm.getCacheSize()).toBeLessThanOrEqual(lsm.getCacheLimit());
|
||||
}
|
||||
|
||||
const all = await engine.find('users', { table: 'users' });
|
||||
expect(all.length).toBe(300);
|
||||
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
test('回归:主 LSM 与二级索引 LSM 的 SSTable 不互相覆盖(命名空间隔离)', async () => {
|
||||
const engine = createSmallCacheEngine(4);
|
||||
await engine.open('regression-ns', 1);
|
||||
await engine.createTable(createSchema('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string', index: true },
|
||||
age: { type: 'number', index: true },
|
||||
}));
|
||||
|
||||
// 小阈值下 insert/update 会同时触发主 LSM 与两个索引 LSM 的多次 flush
|
||||
await engine.insert('users', makeRows(120));
|
||||
await engine.update('users', { table: 'users', where: { age: { $gte: 30 } } }, { name: 'Senior' });
|
||||
|
||||
// 主数据完整且为最新值(age 20-49 每个值出现 4 次;$gte 30 → 20 个值 × 4 = 80 行)
|
||||
const all = await engine.find('users', { table: 'users' });
|
||||
expect(all.length).toBe(120);
|
||||
expect(all.filter((r) => r.name === 'Senior').length).toBe(80);
|
||||
|
||||
// 二级索引等值查找仍正确(索引 LSM 数据未被覆盖)
|
||||
const byName = await engine.find('users', { table: 'users', where: { name: 'Senior' } });
|
||||
expect(byName.length).toBe(80);
|
||||
const byAge = await engine.find('users', { table: 'users', where: { age: 25 } });
|
||||
expect(byAge.length).toBe(4);
|
||||
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
test('回归:同 key 跨多次 flush 更新后读到最新值(多版本语义)', async () => {
|
||||
const engine = createSmallCacheEngine(4);
|
||||
await engine.open('regression-versions', 1);
|
||||
await engine.createTable(createSchema('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
value: { type: 'number' },
|
||||
}));
|
||||
|
||||
await engine.insert('users', [{ id: 'a', value: 1 }]);
|
||||
|
||||
// 连续更新同一行 20 次,每次更新都经历 flush
|
||||
for (let v = 2; v <= 20; v++) {
|
||||
await engine.update('users', { table: 'users', where: { id: 'a' } }, { value: v });
|
||||
}
|
||||
|
||||
const rows = await engine.find('users', { table: 'users', where: { id: 'a' } });
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows[0].value).toBe(20);
|
||||
|
||||
// 全表扫描也应返回最新值
|
||||
const all = await engine.find('users', { table: 'users' });
|
||||
expect(all.length).toBe(1);
|
||||
expect(all[0].value).toBe(20);
|
||||
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
test('回归:删除后 tombstone 跨 flush 仍生效(不残留旧数据)', async () => {
|
||||
const engine = createSmallCacheEngine(4);
|
||||
await engine.open('regression-tombstone', 1);
|
||||
await engine.createTable(createSchema('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
age: { type: 'number', index: true },
|
||||
}));
|
||||
|
||||
await engine.insert('users', makeRows(120));
|
||||
|
||||
// 分批删除,触发多次 flush
|
||||
for (let batch = 0; batch < 4; batch++) {
|
||||
const deleted = await engine.delete('users', { table: 'users', where: { age: { $gte: 20 + batch * 5, $lt: 25 + batch * 5 } } });
|
||||
expect(deleted).toBe(20);
|
||||
}
|
||||
|
||||
const remaining = await engine.find('users', { table: 'users' });
|
||||
expect(remaining.length).toBe(40);
|
||||
|
||||
// 索引查找也不应返回已删除行
|
||||
const ghost = await engine.find('users', { table: 'users', where: { age: 22 } });
|
||||
expect(ghost.length).toBe(0);
|
||||
|
||||
await engine.close();
|
||||
});
|
||||
});
|
||||
+218
-127
@@ -1,127 +1,218 @@
|
||||
/**
|
||||
* AriaEngine LZ4 压缩 + LSM Merge Iterator 单元测试
|
||||
* 注:LZ4 为简化演示实现(默认 compression:false),测试聚焦于「不卡死」
|
||||
*/
|
||||
import { compressLZ4, decompressLZ4 } from '../../src/engine/aria/compression/lz4';
|
||||
import { MergeIterator, ArrayEntrySource } from '../../src/engine/aria/index/merge_iterator';
|
||||
|
||||
// ===================================================================
|
||||
// LZ4 压缩 — 安全烟雾测试(不卡死)
|
||||
// ===================================================================
|
||||
describe('AriaEngine — LZ4 Compression', () => {
|
||||
it('短于 4 字节时原样返回', () => {
|
||||
const input = new Uint8Array([1, 2]);
|
||||
const compressed = compressLZ4(input);
|
||||
expect(compressed).toBe(input);
|
||||
});
|
||||
|
||||
it('简单文本压缩不抛出异常且产生输出', () => {
|
||||
const input = new TextEncoder().encode('hello world hello world hello world');
|
||||
const compressed = compressLZ4(input);
|
||||
expect(compressed).toBeInstanceOf(Uint8Array);
|
||||
expect(compressed.byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('重复数据有压缩效果', () => {
|
||||
const pattern = 'ABCD';
|
||||
const repeated = pattern.repeat(100);
|
||||
const input = new TextEncoder().encode(repeated);
|
||||
const compressed = compressLZ4(input);
|
||||
expect(compressed.byteLength).toBeLessThan(input.byteLength);
|
||||
});
|
||||
|
||||
it('随机不可压缩数据不卡死', () => {
|
||||
const input = new Uint8Array(256);
|
||||
for (let i = 0; i < 256; i++) input[i] = Math.floor(Math.random() * 256);
|
||||
const compressed = compressLZ4(input);
|
||||
expect(compressed).toBeInstanceOf(Uint8Array);
|
||||
expect(compressed.byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('长文本压缩不卡死', () => {
|
||||
const input = new TextEncoder().encode('The quick brown fox jumps over the lazy dog. '.repeat(10));
|
||||
const compressed = compressLZ4(input);
|
||||
expect(compressed).toBeTruthy();
|
||||
expect(compressed.byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('多种长度输入均不卡死', () => {
|
||||
for (const size of [10, 50, 100, 200, 500]) {
|
||||
const input = new Uint8Array(size);
|
||||
for (let i = 0; i < size; i++) input[i] = i % 256;
|
||||
const compressed = compressLZ4(input);
|
||||
expect(compressed.byteLength).toBeLessThanOrEqual(input.byteLength + 16);
|
||||
}
|
||||
});
|
||||
|
||||
it('解压不抛出异常', () => {
|
||||
const input = new TextEncoder().encode('test data for decompression smoke test');
|
||||
const compressed = compressLZ4(input);
|
||||
expect(() => decompressLZ4(compressed, input.byteLength)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// MergeIterator — 归并迭代器单元测试
|
||||
// ===================================================================
|
||||
describe('AriaEngine — MergeIterator', () => {
|
||||
it('单数据源归并', () => {
|
||||
const mi = new MergeIterator();
|
||||
mi.addSource(new ArrayEntrySource([
|
||||
['a', { v: 1 }],
|
||||
['b', { v: 2 }],
|
||||
['c', { v: 3 }],
|
||||
]));
|
||||
const result = mi.drain();
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.map(([k]) => k)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('多数据源归并去重(保留最新)', () => {
|
||||
const mi = new MergeIterator();
|
||||
mi.addSource(new ArrayEntrySource([['a', { v: 'new' }], ['c', { v: 3 }]]));
|
||||
mi.addSource(new ArrayEntrySource([['a', { v: 'old' }], ['b', { v: 2 }]]));
|
||||
const result = mi.drain();
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0][0]).toBe('a');
|
||||
expect(result[0][1].v).toBe('new');
|
||||
expect(result[1][0]).toBe('b');
|
||||
expect(result[2][0]).toBe('c');
|
||||
});
|
||||
|
||||
it('空数据源归并', () => {
|
||||
const mi = new MergeIterator();
|
||||
mi.addSource(new ArrayEntrySource([]));
|
||||
const result = mi.drain();
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('大数量归并', () => {
|
||||
const mi = new MergeIterator();
|
||||
for (let s = 0; s < 5; s++) {
|
||||
const entries: [string, Record<string, unknown>][] = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
entries.push([`src${s}-key-${String(i).padStart(3, '0')}`, { src: s, idx: i }]);
|
||||
}
|
||||
mi.addSource(new ArrayEntrySource(entries));
|
||||
}
|
||||
const result = mi.drain();
|
||||
// 5 sources × 100 unique keys = 500 total (keys are unique per source)
|
||||
expect(result).toHaveLength(500);
|
||||
});
|
||||
|
||||
it('ArrayEntrySource — 迭代器用完返回 null', () => {
|
||||
const src = new ArrayEntrySource([['k', { v: 1 }]]);
|
||||
expect(src.next()).not.toBeNull();
|
||||
expect(src.next()).toBeNull();
|
||||
expect(src.next()).toBeNull();
|
||||
});
|
||||
|
||||
it('ArrayEntrySource — reset 重置', () => {
|
||||
const src = new ArrayEntrySource([['k1', { v: 1 }], ['k2', { v: 2 }]]);
|
||||
src.next();
|
||||
src.reset();
|
||||
const val = src.next();
|
||||
expect(val![0]).toBe('k1');
|
||||
});
|
||||
});
|
||||
/**
|
||||
* AriaEngine LZ4 压缩 + LSM Merge Iterator 单元测试
|
||||
* 注:LZ4 为简化演示实现(默认 compression:false),测试聚焦于「不卡死」
|
||||
*/
|
||||
import { compressLZ4, decompressLZ4 } from '../../src/engine/aria/compression/lz4';
|
||||
import { MergeIterator, ArrayEntrySource } from '../../src/engine/aria/index/merge_iterator';
|
||||
|
||||
// ===================================================================
|
||||
// LZ4 压缩 — 安全烟雾测试(不卡死)
|
||||
// ===================================================================
|
||||
describe('AriaEngine — LZ4 Compression', () => {
|
||||
it('短于 4 字节时压缩为纯字面量 token 且往返一致', () => {
|
||||
const input = new Uint8Array([1, 2]);
|
||||
const compressed = compressLZ4(input);
|
||||
// token(lo=0) + 2 字节字面量
|
||||
expect(compressed.byteLength).toBe(3);
|
||||
expect(compressed[0]).toBe(0x20); // litLen=2, matchField=0
|
||||
const restored = decompressLZ4(compressed, 2);
|
||||
expect(Array.from(restored)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('简单文本压缩不抛出异常且产生输出', () => {
|
||||
const input = new TextEncoder().encode('hello world hello world hello world');
|
||||
const compressed = compressLZ4(input);
|
||||
expect(compressed).toBeInstanceOf(Uint8Array);
|
||||
expect(compressed.byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('重复数据有压缩效果', () => {
|
||||
const pattern = 'ABCD';
|
||||
const repeated = pattern.repeat(100);
|
||||
const input = new TextEncoder().encode(repeated);
|
||||
const compressed = compressLZ4(input);
|
||||
expect(compressed.byteLength).toBeLessThan(input.byteLength);
|
||||
});
|
||||
|
||||
it('随机不可压缩数据不卡死', () => {
|
||||
const input = new Uint8Array(256);
|
||||
for (let i = 0; i < 256; i++) input[i] = Math.floor(Math.random() * 256);
|
||||
const compressed = compressLZ4(input);
|
||||
expect(compressed).toBeInstanceOf(Uint8Array);
|
||||
expect(compressed.byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('长文本压缩不卡死', () => {
|
||||
const input = new TextEncoder().encode('The quick brown fox jumps over the lazy dog. '.repeat(10));
|
||||
const compressed = compressLZ4(input);
|
||||
expect(compressed).toBeTruthy();
|
||||
expect(compressed.byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('多种长度输入均不卡死', () => {
|
||||
for (const size of [10, 50, 100, 200, 500]) {
|
||||
const input = new Uint8Array(size);
|
||||
for (let i = 0; i < size; i++) input[i] = i % 256;
|
||||
const compressed = compressLZ4(input);
|
||||
expect(compressed.byteLength).toBeLessThanOrEqual(input.byteLength + 16);
|
||||
}
|
||||
});
|
||||
|
||||
it('解压不抛出异常', () => {
|
||||
const input = new TextEncoder().encode('test data for decompression smoke test');
|
||||
const compressed = compressLZ4(input);
|
||||
expect(() => decompressLZ4(compressed, input.byteLength)).not.toThrow();
|
||||
});
|
||||
|
||||
// ---- v0.2.6 补强:压缩 → 解压 往返一致性 ----
|
||||
|
||||
it('往返一致:重复模式数据', () => {
|
||||
const input = new TextEncoder().encode('ABCD'.repeat(100));
|
||||
const compressed = compressLZ4(input);
|
||||
const restored = decompressLZ4(compressed, input.byteLength);
|
||||
expect(Array.from(restored)).toEqual(Array.from(input));
|
||||
});
|
||||
|
||||
it('往返一致:自然文本数据', () => {
|
||||
const input = new TextEncoder().encode(
|
||||
'The quick brown fox jumps over the lazy dog. '.repeat(10),
|
||||
);
|
||||
const compressed = compressLZ4(input);
|
||||
const restored = decompressLZ4(compressed, input.byteLength);
|
||||
expect(Array.from(restored)).toEqual(Array.from(input));
|
||||
});
|
||||
|
||||
it('往返一致:随机不可压缩数据', () => {
|
||||
const input = new Uint8Array(512);
|
||||
for (let i = 0; i < 512; i++) input[i] = Math.floor(Math.random() * 256);
|
||||
const compressed = compressLZ4(input);
|
||||
const restored = decompressLZ4(compressed, input.byteLength);
|
||||
expect(Array.from(restored)).toEqual(Array.from(input));
|
||||
});
|
||||
|
||||
it('往返一致:多种长度与字节模式', () => {
|
||||
for (const size of [4, 5, 15, 16, 17, 50, 100, 300, 1000]) {
|
||||
const input = new Uint8Array(size);
|
||||
for (let i = 0; i < size; i++) input[i] = i % 7 === 0 ? i % 256 : 0x41;
|
||||
const compressed = compressLZ4(input);
|
||||
const restored = decompressLZ4(compressed, input.byteLength);
|
||||
expect(Array.from(restored)).toEqual(Array.from(input));
|
||||
}
|
||||
});
|
||||
|
||||
it('往返一致:恰好 15 字节字面量边界', () => {
|
||||
// 字面量长度恰好 15(token 上限)时不应丢字节
|
||||
const input = new Uint8Array(15);
|
||||
for (let i = 0; i < 15; i++) input[i] = i;
|
||||
const compressed = compressLZ4(input);
|
||||
const restored = decompressLZ4(compressed, input.byteLength);
|
||||
expect(Array.from(restored)).toEqual(Array.from(input));
|
||||
});
|
||||
|
||||
it('往返一致:超过 15 字节的连续匹配', () => {
|
||||
const input = new TextEncoder().encode('X'.repeat(200) + 'Y' + 'X'.repeat(60));
|
||||
const compressed = compressLZ4(input);
|
||||
const restored = decompressLZ4(compressed, input.byteLength);
|
||||
expect(Array.from(restored)).toEqual(Array.from(input));
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// MergeIterator — 归并迭代器单元测试
|
||||
// ===================================================================
|
||||
describe('AriaEngine — MergeIterator', () => {
|
||||
it('单数据源归并', () => {
|
||||
const mi = new MergeIterator();
|
||||
mi.addSource(new ArrayEntrySource([
|
||||
['a', { v: 1 }],
|
||||
['b', { v: 2 }],
|
||||
['c', { v: 3 }],
|
||||
]));
|
||||
const result = mi.drain();
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.map(([k]) => k)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('多数据源归并去重(保留最新)', () => {
|
||||
const mi = new MergeIterator();
|
||||
mi.addSource(new ArrayEntrySource([['a', { v: 'new' }], ['c', { v: 3 }]]));
|
||||
mi.addSource(new ArrayEntrySource([['a', { v: 'old' }], ['b', { v: 2 }]]));
|
||||
const result = mi.drain();
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0][0]).toBe('a');
|
||||
expect(result[0][1].v).toBe('new');
|
||||
expect(result[1][0]).toBe('b');
|
||||
expect(result[2][0]).toBe('c');
|
||||
});
|
||||
|
||||
// ---- v0.2.6 回归:同 key 多来源时保留 sourceIndex 最小(最新)的条目 ----
|
||||
it('回归:同 key 出现在多个来源时返回 sourceIndex 最小(最新来源)的值', () => {
|
||||
const mi = new MergeIterator();
|
||||
// 语义:sourceIndex 越小越新(memtable=0 < immutable=1 < sstable=2+)
|
||||
mi.addSource(new ArrayEntrySource([['a', { v: 'source0' }]])); // 最新来源
|
||||
mi.addSource(new ArrayEntrySource([['a', { v: 'source1' }]]));
|
||||
mi.addSource(new ArrayEntrySource([['a', { v: 'source2' }]])); // 最旧来源
|
||||
const result = mi.drain();
|
||||
expect(result).toHaveLength(1);
|
||||
// 取的是 sourceIndex 最小(最新来源)的条目,而非堆序决定的任意条目
|
||||
expect(result[0][1].v).toBe('source0');
|
||||
});
|
||||
|
||||
it('回归:最新来源的值位于中间 sourceIndex 时仍取 sourceIndex 最小者', () => {
|
||||
const mi = new MergeIterator();
|
||||
mi.addSource(new ArrayEntrySource([['a', { v: 'middle' }]])); // index 0 = 最新来源
|
||||
mi.addSource(new ArrayEntrySource([['a', { v: 'newest' }]])); // index 1
|
||||
mi.addSource(new ArrayEntrySource([['a', { v: 'oldest' }]])); // index 2
|
||||
const result = mi.drain();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0][1].v).toBe('middle'); // index 0 的条目胜出
|
||||
});
|
||||
|
||||
it('回归:多个同 key 来源 + 其他独立 key 混合', () => {
|
||||
const mi = new MergeIterator();
|
||||
mi.addSource(new ArrayEntrySource([['a', { v: 1 }], ['b', { v: 10 }]]));
|
||||
mi.addSource(new ArrayEntrySource([['a', { v: 2 }]]));
|
||||
mi.addSource(new ArrayEntrySource([['a', { v: 3 }], ['c', { v: 30 }]]));
|
||||
const result = mi.drain();
|
||||
expect(result.map(([k]) => k)).toEqual(['a', 'b', 'c']);
|
||||
expect(result[0][1].v).toBe(1); // sourceIndex 0 = 最新
|
||||
expect(result[1][1].v).toBe(10);
|
||||
expect(result[2][1].v).toBe(30);
|
||||
});
|
||||
|
||||
it('空数据源归并', () => {
|
||||
const mi = new MergeIterator();
|
||||
mi.addSource(new ArrayEntrySource([]));
|
||||
const result = mi.drain();
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('大数量归并', () => {
|
||||
const mi = new MergeIterator();
|
||||
for (let s = 0; s < 5; s++) {
|
||||
const entries: [string, Record<string, unknown>][] = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
entries.push([`src${s}-key-${String(i).padStart(3, '0')}`, { src: s, idx: i }]);
|
||||
}
|
||||
mi.addSource(new ArrayEntrySource(entries));
|
||||
}
|
||||
const result = mi.drain();
|
||||
// 5 sources × 100 unique keys = 500 total (keys are unique per source)
|
||||
expect(result).toHaveLength(500);
|
||||
});
|
||||
|
||||
it('ArrayEntrySource — 迭代器用完返回 null', () => {
|
||||
const src = new ArrayEntrySource([['k', { v: 1 }]]);
|
||||
expect(src.next()).not.toBeNull();
|
||||
expect(src.next()).toBeNull();
|
||||
expect(src.next()).toBeNull();
|
||||
});
|
||||
|
||||
it('ArrayEntrySource — reset 重置', () => {
|
||||
const src = new ArrayEntrySource([['k1', { v: 1 }], ['k2', { v: 2 }]]);
|
||||
src.next();
|
||||
src.reset();
|
||||
const val = src.next();
|
||||
expect(val![0]).toBe('k1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* AriaEngine CryptoManager 加解密测试
|
||||
* @module tests/engine/aria-crypto
|
||||
*
|
||||
* v0.2.6 补强:此前仅验证实例化,现在验证真实的加解密往返一致性。
|
||||
*/
|
||||
import {
|
||||
CryptoManager,
|
||||
initCrypto,
|
||||
encryptPage,
|
||||
decryptPage,
|
||||
closeCrypto,
|
||||
} from '../../src/engine/aria/crypto';
|
||||
|
||||
function toBytes(data: ArrayBuffer): number[] {
|
||||
return Array.from(new Uint8Array(data));
|
||||
}
|
||||
|
||||
describe('AriaEngine — CryptoManager', () => {
|
||||
test('加解密往返一致', async () => {
|
||||
const cm = new CryptoManager();
|
||||
await cm.init('test-password');
|
||||
expect(cm.enabled).toBe(true);
|
||||
|
||||
const original = new TextEncoder().encode('sensitive row data').buffer;
|
||||
const { iv, data } = await cm.encryptPage(original);
|
||||
// 密文应为乱码(与原文不同)
|
||||
expect(toBytes(data)).not.toEqual(toBytes(original));
|
||||
|
||||
const decrypted = await cm.decryptPage(iv, data);
|
||||
expect(toBytes(decrypted)).toEqual(toBytes(original));
|
||||
cm.close();
|
||||
expect(cm.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test('错误密码解密失败(密钥不同)', async () => {
|
||||
const cm1 = new CryptoManager();
|
||||
await cm1.init('correct-password');
|
||||
const original = new TextEncoder().encode('top secret').buffer;
|
||||
const { iv, data } = await cm1.encryptPage(original);
|
||||
|
||||
const cm2 = new CryptoManager();
|
||||
await cm2.init('wrong-password');
|
||||
await expect(cm2.decryptPage(iv, data)).rejects.toThrow();
|
||||
|
||||
cm1.close();
|
||||
cm2.close();
|
||||
});
|
||||
|
||||
test('不同 salt 派生不同密钥,解密互相失败', async () => {
|
||||
const cm1 = new CryptoManager();
|
||||
await cm1.init('pwd', new Uint8Array(16).fill(1));
|
||||
const cm2 = new CryptoManager();
|
||||
await cm2.init('pwd', new Uint8Array(16).fill(2));
|
||||
|
||||
const original = new TextEncoder().encode('salt matters').buffer;
|
||||
const { iv, data } = await cm1.encryptPage(original);
|
||||
await expect(cm2.decryptPage(iv, data)).rejects.toThrow();
|
||||
|
||||
cm1.close();
|
||||
cm2.close();
|
||||
});
|
||||
|
||||
test('未初始化时加密抛错', async () => {
|
||||
const cm = new CryptoManager();
|
||||
expect(cm.enabled).toBe(false);
|
||||
const data = new TextEncoder().encode('x').buffer;
|
||||
await expect(cm.encryptPage(data)).rejects.toThrow(/not initialized/);
|
||||
});
|
||||
|
||||
test('不同实例互不影响(独立密钥状态)', async () => {
|
||||
const cm1 = new CryptoManager();
|
||||
await cm1.init('pwd-a');
|
||||
const cm2 = new CryptoManager();
|
||||
await cm2.init('pwd-b');
|
||||
|
||||
const original = new TextEncoder().encode('instance isolation').buffer;
|
||||
const { iv, data } = await cm1.encryptPage(original);
|
||||
await expect(cm2.decryptPage(iv, data)).rejects.toThrow();
|
||||
|
||||
// 各自解密自己的数据
|
||||
const dec2orig = new TextEncoder().encode('two').buffer;
|
||||
const enc2 = await cm2.encryptPage(dec2orig);
|
||||
const dec2 = await cm2.decryptPage(enc2.iv, enc2.data);
|
||||
expect(toBytes(dec2)).toEqual(toBytes(dec2orig));
|
||||
|
||||
cm1.close();
|
||||
cm2.close();
|
||||
});
|
||||
|
||||
test('全局兼容层往返一致', async () => {
|
||||
await initCrypto('global-password');
|
||||
const original = new TextEncoder().encode('global compat layer').buffer;
|
||||
const { iv, data } = await encryptPage(original);
|
||||
const decrypted = await decryptPage(iv, data);
|
||||
expect(toBytes(decrypted)).toEqual(toBytes(original));
|
||||
closeCrypto();
|
||||
});
|
||||
|
||||
test('大块数据(接近页面大小)往返一致', async () => {
|
||||
const cm = new CryptoManager();
|
||||
await cm.init('page-size-test');
|
||||
// 4KB 页面数据
|
||||
const original = new Uint8Array(4096);
|
||||
for (let i = 0; i < 4096; i++) original[i] = i % 251;
|
||||
const { iv, data } = await cm.encryptPage(original.buffer);
|
||||
const decrypted = await cm.decryptPage(iv, data);
|
||||
expect(toBytes(decrypted)).toEqual(toBytes(original.buffer));
|
||||
cm.close();
|
||||
});
|
||||
});
|
||||
+216
-216
@@ -1,216 +1,216 @@
|
||||
/**
|
||||
* AriaEngine Bloom Filter + MemTable 单元测试
|
||||
*/
|
||||
import { BloomFilter } from '../../src/engine/aria/index/bloom';
|
||||
import { MemTable } from '../../src/engine/aria/index/memtable';
|
||||
|
||||
// ===================================================================
|
||||
// BloomFilter
|
||||
// ===================================================================
|
||||
describe('AriaEngine — BloomFilter', () => {
|
||||
it('插入后 mayContain 返回 true', () => {
|
||||
const bf = new BloomFilter(100);
|
||||
bf.insert('hello');
|
||||
expect(bf.mayContain('hello')).toBe(true);
|
||||
});
|
||||
|
||||
it('未插入的 key mayContain 返回 false', () => {
|
||||
const bf = new BloomFilter(100);
|
||||
bf.insert('hello');
|
||||
expect(bf.mayContain('world')).toBe(false);
|
||||
});
|
||||
|
||||
it('批量插入后所有 key 都判定存在', () => {
|
||||
const bf = new BloomFilter(500);
|
||||
const keys: string[] = [];
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const k = `key-${i}`;
|
||||
keys.push(k);
|
||||
bf.insert(k);
|
||||
}
|
||||
for (const k of keys) {
|
||||
expect(bf.mayContain(k)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('False positive 率可控', () => {
|
||||
const n = 500;
|
||||
const bf = new BloomFilter(n, 10);
|
||||
for (let i = 0; i < n; i++) {
|
||||
bf.insert(`present-${i}`);
|
||||
}
|
||||
let fp = 0;
|
||||
for (let i = 0; i < 500; i++) {
|
||||
if (bf.mayContain(`absent-${i}`)) fp++;
|
||||
}
|
||||
expect(fp).toBeLessThan(25);
|
||||
});
|
||||
|
||||
it('getBitSize 返回正确位数', () => {
|
||||
const bf = new BloomFilter(100, 10);
|
||||
expect(bf.getBitSize()).toBeGreaterThanOrEqual(64);
|
||||
});
|
||||
|
||||
it('getInsertedCount 追踪插入数', () => {
|
||||
const bf = new BloomFilter(100);
|
||||
bf.insert('a');
|
||||
bf.insert('b');
|
||||
bf.insert('c');
|
||||
expect(bf.getInsertedCount()).toBe(3);
|
||||
});
|
||||
|
||||
it('getHashCount 返回哈希函数数量', () => {
|
||||
const bf = new BloomFilter(1000, 10);
|
||||
expect(bf.getHashCount()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('serialize + fromData 往返', () => {
|
||||
const bf1 = new BloomFilter(100);
|
||||
bf1.insert('a');
|
||||
bf1.insert('b');
|
||||
const data = bf1.serialize();
|
||||
|
||||
const bf2 = BloomFilter.fromData(data, bf1.getHashCount());
|
||||
expect(bf2.mayContain('a')).toBe(true);
|
||||
expect(bf2.mayContain('b')).toBe(true);
|
||||
expect(bf2.mayContain('c')).toBe(false);
|
||||
});
|
||||
|
||||
it('空过滤器 mayContain 返回 false', () => {
|
||||
const bf = new BloomFilter(100);
|
||||
expect(bf.mayContain('anything')).toBe(false);
|
||||
});
|
||||
|
||||
it('最少 1 个哈希函数', () => {
|
||||
const bf = new BloomFilter(10, 1);
|
||||
expect(bf.getHashCount()).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// MemTable
|
||||
// ===================================================================
|
||||
describe('AriaEngine — MemTable', () => {
|
||||
let mt: MemTable;
|
||||
|
||||
beforeEach(() => {
|
||||
mt = new MemTable(4 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it('put + get 往返', () => {
|
||||
mt.put('key1', { name: 'Alice', age: 30 });
|
||||
const val = mt.get('key1');
|
||||
expect(val).not.toBeNull();
|
||||
expect(val!.name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('get — 不存在的 key 返回 null', () => {
|
||||
expect(mt.get('nonexistent')).toBeNull();
|
||||
});
|
||||
|
||||
it('put 更新已存在的 key', () => {
|
||||
mt.put('k', { v: 1 });
|
||||
mt.put('k', { v: 2 });
|
||||
expect(mt.get('k')!.v).toBe(2);
|
||||
});
|
||||
|
||||
it('delete 删除成功', () => {
|
||||
mt.put('k', { v: 1 });
|
||||
expect(mt.delete('k')).toBe(true);
|
||||
expect(mt.get('k')).toBeNull();
|
||||
});
|
||||
|
||||
it('delete — 不存在的 key 返回 false', () => {
|
||||
expect(mt.delete('ghost')).toBe(false);
|
||||
});
|
||||
|
||||
it('getAllEntries 返回所有条目(有序)', () => {
|
||||
mt.put('c', { v: 3 });
|
||||
mt.put('a', { v: 1 });
|
||||
mt.put('b', { v: 2 });
|
||||
const entries = mt.getAllEntries();
|
||||
expect(entries).toHaveLength(3);
|
||||
expect(entries[0][0]).toBe('a');
|
||||
expect(entries[1][0]).toBe('b');
|
||||
expect(entries[2][0]).toBe('c');
|
||||
});
|
||||
|
||||
it('rangeScan — 范围查询', () => {
|
||||
mt.put('a', { v: 1 });
|
||||
mt.put('b', { v: 2 });
|
||||
mt.put('c', { v: 3 });
|
||||
mt.put('d', { v: 4 });
|
||||
const results = mt.rangeScan('b', 'c');
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0][0]).toBe('b');
|
||||
expect(results[1][0]).toBe('c');
|
||||
});
|
||||
|
||||
it('rangeScan — 空结果', () => {
|
||||
mt.put('a', { v: 1 });
|
||||
const results = mt.rangeScan('z', 'zz');
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('getEntryCount 正确计数', () => {
|
||||
expect(mt.getEntryCount()).toBe(0);
|
||||
mt.put('a', { v: 1 });
|
||||
mt.put('b', { v: 2 });
|
||||
expect(mt.getEntryCount()).toBe(2);
|
||||
mt.delete('a');
|
||||
expect(mt.getEntryCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('contains 检查存在性', () => {
|
||||
mt.put('x', { v: 1 });
|
||||
expect(mt.contains('x')).toBe(true);
|
||||
expect(mt.contains('y')).toBe(false);
|
||||
});
|
||||
|
||||
it('shouldFlush — 未达阈值返回 false', () => {
|
||||
expect(mt.shouldFlush()).toBe(false);
|
||||
});
|
||||
|
||||
it('clear 清空所有数据', () => {
|
||||
mt.put('a', { v: 1 });
|
||||
mt.put('b', { v: 2 });
|
||||
mt.clear();
|
||||
expect(mt.getEntryCount()).toBe(0);
|
||||
expect(mt.get('a')).toBeNull();
|
||||
});
|
||||
|
||||
it('getEstimatedSize 返回合理估计值', () => {
|
||||
expect(mt.getEstimatedSize()).toBe(0);
|
||||
mt.put('hello', { name: 'world', count: 42 });
|
||||
expect(mt.getEstimatedSize()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('大量数据插入保持有序', () => {
|
||||
const count = 100;
|
||||
for (let i = count - 1; i >= 0; i--) {
|
||||
mt.put(`key-${String(i).padStart(3, '0')}`, { idx: i });
|
||||
}
|
||||
const entries = mt.getAllEntries();
|
||||
expect(entries).toHaveLength(count);
|
||||
for (let i = 0; i < count; i++) {
|
||||
expect(entries[i][1].idx).toBe(i);
|
||||
}
|
||||
});
|
||||
|
||||
it('删除后重新插入', () => {
|
||||
mt.put('k', { v: 1 });
|
||||
mt.delete('k');
|
||||
mt.put('k', { v: 2 });
|
||||
expect(mt.get('k')!.v).toBe(2);
|
||||
});
|
||||
|
||||
it('范围扫描包含边界', () => {
|
||||
mt.put('aa', { v: 1 });
|
||||
mt.put('ab', { v: 2 });
|
||||
mt.put('ac', { v: 3 });
|
||||
const results = mt.rangeScan('aa', 'ab');
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0][0]).toBe('aa');
|
||||
expect(results[1][0]).toBe('ab');
|
||||
});
|
||||
});
|
||||
/**
|
||||
* AriaEngine Bloom Filter + MemTable 单元测试
|
||||
*/
|
||||
import { BloomFilter } from '../../src/engine/aria/index/bloom';
|
||||
import { MemTable } from '../../src/engine/aria/index/memtable';
|
||||
|
||||
// ===================================================================
|
||||
// BloomFilter
|
||||
// ===================================================================
|
||||
describe('AriaEngine — BloomFilter', () => {
|
||||
it('插入后 mayContain 返回 true', () => {
|
||||
const bf = new BloomFilter(100);
|
||||
bf.insert('hello');
|
||||
expect(bf.mayContain('hello')).toBe(true);
|
||||
});
|
||||
|
||||
it('未插入的 key mayContain 返回 false', () => {
|
||||
const bf = new BloomFilter(100);
|
||||
bf.insert('hello');
|
||||
expect(bf.mayContain('world')).toBe(false);
|
||||
});
|
||||
|
||||
it('批量插入后所有 key 都判定存在', () => {
|
||||
const bf = new BloomFilter(500);
|
||||
const keys: string[] = [];
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const k = `key-${i}`;
|
||||
keys.push(k);
|
||||
bf.insert(k);
|
||||
}
|
||||
for (const k of keys) {
|
||||
expect(bf.mayContain(k)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('False positive 率可控', () => {
|
||||
const n = 500;
|
||||
const bf = new BloomFilter(n, 10);
|
||||
for (let i = 0; i < n; i++) {
|
||||
bf.insert(`present-${i}`);
|
||||
}
|
||||
let fp = 0;
|
||||
for (let i = 0; i < 500; i++) {
|
||||
if (bf.mayContain(`absent-${i}`)) fp++;
|
||||
}
|
||||
expect(fp).toBeLessThan(25);
|
||||
});
|
||||
|
||||
it('getBitSize 返回正确位数', () => {
|
||||
const bf = new BloomFilter(100, 10);
|
||||
expect(bf.getBitSize()).toBeGreaterThanOrEqual(64);
|
||||
});
|
||||
|
||||
it('getInsertedCount 追踪插入数', () => {
|
||||
const bf = new BloomFilter(100);
|
||||
bf.insert('a');
|
||||
bf.insert('b');
|
||||
bf.insert('c');
|
||||
expect(bf.getInsertedCount()).toBe(3);
|
||||
});
|
||||
|
||||
it('getHashCount 返回哈希函数数量', () => {
|
||||
const bf = new BloomFilter(1000, 10);
|
||||
expect(bf.getHashCount()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('serialize + fromData 往返', () => {
|
||||
const bf1 = new BloomFilter(100);
|
||||
bf1.insert('a');
|
||||
bf1.insert('b');
|
||||
const data = bf1.serialize();
|
||||
|
||||
const bf2 = BloomFilter.fromData(data, bf1.getHashCount());
|
||||
expect(bf2.mayContain('a')).toBe(true);
|
||||
expect(bf2.mayContain('b')).toBe(true);
|
||||
expect(bf2.mayContain('c')).toBe(false);
|
||||
});
|
||||
|
||||
it('空过滤器 mayContain 返回 false', () => {
|
||||
const bf = new BloomFilter(100);
|
||||
expect(bf.mayContain('anything')).toBe(false);
|
||||
});
|
||||
|
||||
it('最少 1 个哈希函数', () => {
|
||||
const bf = new BloomFilter(10, 1);
|
||||
expect(bf.getHashCount()).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// MemTable
|
||||
// ===================================================================
|
||||
describe('AriaEngine — MemTable', () => {
|
||||
let mt: MemTable;
|
||||
|
||||
beforeEach(() => {
|
||||
mt = new MemTable(4 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it('put + get 往返', () => {
|
||||
mt.put('key1', { name: 'Alice', age: 30 });
|
||||
const val = mt.get('key1');
|
||||
expect(val).not.toBeNull();
|
||||
expect(val!.name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('get — 不存在的 key 返回 null', () => {
|
||||
expect(mt.get('nonexistent')).toBeNull();
|
||||
});
|
||||
|
||||
it('put 更新已存在的 key', () => {
|
||||
mt.put('k', { v: 1 });
|
||||
mt.put('k', { v: 2 });
|
||||
expect(mt.get('k')!.v).toBe(2);
|
||||
});
|
||||
|
||||
it('delete 删除成功', () => {
|
||||
mt.put('k', { v: 1 });
|
||||
expect(mt.delete('k')).toBe(true);
|
||||
expect(mt.get('k')).toBeNull();
|
||||
});
|
||||
|
||||
it('delete — 不存在的 key 返回 false', () => {
|
||||
expect(mt.delete('ghost')).toBe(false);
|
||||
});
|
||||
|
||||
it('getAllEntries 返回所有条目(有序)', () => {
|
||||
mt.put('c', { v: 3 });
|
||||
mt.put('a', { v: 1 });
|
||||
mt.put('b', { v: 2 });
|
||||
const entries = mt.getAllEntries();
|
||||
expect(entries).toHaveLength(3);
|
||||
expect(entries[0][0]).toBe('a');
|
||||
expect(entries[1][0]).toBe('b');
|
||||
expect(entries[2][0]).toBe('c');
|
||||
});
|
||||
|
||||
it('rangeScan — 范围查询', () => {
|
||||
mt.put('a', { v: 1 });
|
||||
mt.put('b', { v: 2 });
|
||||
mt.put('c', { v: 3 });
|
||||
mt.put('d', { v: 4 });
|
||||
const results = mt.rangeScan('b', 'c');
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0][0]).toBe('b');
|
||||
expect(results[1][0]).toBe('c');
|
||||
});
|
||||
|
||||
it('rangeScan — 空结果', () => {
|
||||
mt.put('a', { v: 1 });
|
||||
const results = mt.rangeScan('z', 'zz');
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('getEntryCount 正确计数', () => {
|
||||
expect(mt.getEntryCount()).toBe(0);
|
||||
mt.put('a', { v: 1 });
|
||||
mt.put('b', { v: 2 });
|
||||
expect(mt.getEntryCount()).toBe(2);
|
||||
mt.delete('a');
|
||||
expect(mt.getEntryCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('contains 检查存在性', () => {
|
||||
mt.put('x', { v: 1 });
|
||||
expect(mt.contains('x')).toBe(true);
|
||||
expect(mt.contains('y')).toBe(false);
|
||||
});
|
||||
|
||||
it('shouldFlush — 未达阈值返回 false', () => {
|
||||
expect(mt.shouldFlush()).toBe(false);
|
||||
});
|
||||
|
||||
it('clear 清空所有数据', () => {
|
||||
mt.put('a', { v: 1 });
|
||||
mt.put('b', { v: 2 });
|
||||
mt.clear();
|
||||
expect(mt.getEntryCount()).toBe(0);
|
||||
expect(mt.get('a')).toBeNull();
|
||||
});
|
||||
|
||||
it('getEstimatedSize 返回合理估计值', () => {
|
||||
expect(mt.getEstimatedSize()).toBe(0);
|
||||
mt.put('hello', { name: 'world', count: 42 });
|
||||
expect(mt.getEstimatedSize()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('大量数据插入保持有序', () => {
|
||||
const count = 100;
|
||||
for (let i = count - 1; i >= 0; i--) {
|
||||
mt.put(`key-${String(i).padStart(3, '0')}`, { idx: i });
|
||||
}
|
||||
const entries = mt.getAllEntries();
|
||||
expect(entries).toHaveLength(count);
|
||||
for (let i = 0; i < count; i++) {
|
||||
expect(entries[i][1].idx).toBe(i);
|
||||
}
|
||||
});
|
||||
|
||||
it('删除后重新插入', () => {
|
||||
mt.put('k', { v: 1 });
|
||||
mt.delete('k');
|
||||
mt.put('k', { v: 2 });
|
||||
expect(mt.get('k')!.v).toBe(2);
|
||||
});
|
||||
|
||||
it('范围扫描包含边界', () => {
|
||||
mt.put('aa', { v: 1 });
|
||||
mt.put('ab', { v: 2 });
|
||||
mt.put('ac', { v: 3 });
|
||||
const results = mt.rangeScan('aa', 'ab');
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0][0]).toBe('aa');
|
||||
expect(results[1][0]).toBe('ab');
|
||||
});
|
||||
});
|
||||
|
||||
+337
-337
@@ -1,337 +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);
|
||||
});
|
||||
});
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
|
||||
+129
-129
@@ -1,129 +1,129 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
|
||||
it('getIndexBlockCount 返回索引块数', () => {
|
||||
const builder = new SSTableBuilder(256); // 小 block size 触发多个 block
|
||||
for (let i = 0; i < 100; i++) {
|
||||
builder.add(`k-${i}`, { data: 'x'.repeat(50) });
|
||||
}
|
||||
const { sstableData } = builder.build();
|
||||
|
||||
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
|
||||
expect(reader.getIndexBlockCount()).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
|
||||
it('getIndexBlockCount 返回索引块数', () => {
|
||||
const builder = new SSTableBuilder(256); // 小 block size 触发多个 block
|
||||
for (let i = 0; i < 100; i++) {
|
||||
builder.add(`k-${i}`, { data: 'x'.repeat(50) });
|
||||
}
|
||||
const { sstableData } = builder.build();
|
||||
|
||||
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
|
||||
expect(reader.getIndexBlockCount()).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
+289
-289
@@ -1,289 +1,289 @@
|
||||
/**
|
||||
* AriaEngine WAL + MVCC 单元测试
|
||||
*/
|
||||
import { WAL, type WALStore } from '../../src/engine/aria/wal/log';
|
||||
import { WALRecordType, type WALRecord } from '../../src/engine/aria/types';
|
||||
import { CheckpointManager, type Flushable } from '../../src/engine/aria/wal/checkpoint';
|
||||
import { MVCCManager } from '../../src/engine/aria/transaction/mvcc';
|
||||
|
||||
// ===================================================================
|
||||
// WAL 存储 Mock
|
||||
// ===================================================================
|
||||
class MockWALStore implements WALStore {
|
||||
chunks: Uint8Array[] = [];
|
||||
async append(data: Uint8Array) { 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() { this.chunks = []; }
|
||||
async exists() { return this.chunks.length > 0; }
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// WAL 测试
|
||||
// ===================================================================
|
||||
describe('AriaEngine — WAL', () => {
|
||||
it('append 记录后可恢复', async () => {
|
||||
const store = new MockWALStore();
|
||||
const wal = new WAL(store, true, 'full');
|
||||
|
||||
wal.append({ type: WALRecordType.INSERT, txnId: 1, tableName: 'users', key: '1', data: { name: 'Alice' } });
|
||||
wal.append({ type: WALRecordType.INSERT, txnId: 1, tableName: 'users', key: '2', data: { name: 'Bob' } });
|
||||
|
||||
const records: WALRecord[] = [];
|
||||
await wal.recover((r) => records.push(r));
|
||||
expect(records).toHaveLength(2);
|
||||
expect(records[0].tableName).toBe('users');
|
||||
expect(records[0].key).toBe('1');
|
||||
});
|
||||
|
||||
it('batch 模式缓冲后 flush', async () => {
|
||||
const store = new MockWALStore();
|
||||
const wal = new WAL(store, true, 'batch');
|
||||
|
||||
wal.append({ type: WALRecordType.UPDATE, txnId: 2, tableName: 'items', key: 'a', data: { v: 1 } });
|
||||
wal.append({ type: WALRecordType.DELETE, txnId: 2, tableName: 'items', key: 'b' });
|
||||
|
||||
// 未 flush 前无法恢复
|
||||
let records: WALRecord[] = [];
|
||||
await wal.recover((r) => records.push(r));
|
||||
expect(records).toHaveLength(0);
|
||||
|
||||
await wal.flush();
|
||||
records = [];
|
||||
await wal.recover((r) => records.push(r));
|
||||
expect(records).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('none 模式不记录', async () => {
|
||||
const store = new MockWALStore();
|
||||
const wal = new WAL(store, false, 'none');
|
||||
wal.append({ type: WALRecordType.INSERT, txnId: 3, tableName: 'x', key: 'y', data: {} });
|
||||
expect(store.chunks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('checkpoint 清空 WAL', async () => {
|
||||
const store = new MockWALStore();
|
||||
const wal = new WAL(store, true, 'full');
|
||||
|
||||
wal.append({ type: WALRecordType.CREATE_TABLE, txnId: 0, tableName: 't', key: '' });
|
||||
expect(await store.exists()).toBe(true);
|
||||
|
||||
await wal.checkpoint();
|
||||
expect(await store.exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('getLSN 跟踪序列号', () => {
|
||||
const store = new MockWALStore();
|
||||
const wal = new WAL(store, true, 'full');
|
||||
expect(wal.getLSN()).toBe(0);
|
||||
wal.append({ type: WALRecordType.BEGIN, txnId: 10, tableName: '', key: '' });
|
||||
expect(wal.getLSN()).toBe(1);
|
||||
wal.append({ type: WALRecordType.COMMIT, txnId: 10, tableName: '', key: '' });
|
||||
expect(wal.getLSN()).toBe(2);
|
||||
});
|
||||
|
||||
it('isEnabled 反映配置', () => {
|
||||
expect(new WAL(new MockWALStore(), true).isEnabled()).toBe(true);
|
||||
expect(new WAL(new MockWALStore(), false).isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('多种记录类型编解码', async () => {
|
||||
const store = new MockWALStore();
|
||||
const wal = new WAL(store, true, 'full');
|
||||
|
||||
wal.append({ type: WALRecordType.BEGIN, txnId: 100, tableName: '', key: '' });
|
||||
wal.append({ type: WALRecordType.INSERT, txnId: 100, tableName: 'users', key: '1', data: { x: 'hello' } });
|
||||
wal.append({ type: WALRecordType.UPDATE, txnId: 100, tableName: 'users', key: '1', data: { x: 'world' } });
|
||||
wal.append({ type: WALRecordType.COMMIT, txnId: 100, tableName: '', key: '' });
|
||||
|
||||
const records: WALRecord[] = [];
|
||||
await wal.recover((r) => records.push(r));
|
||||
expect(records).toHaveLength(4);
|
||||
expect(records[0].type).toBe(WALRecordType.BEGIN);
|
||||
expect(records[1].type).toBe(WALRecordType.INSERT);
|
||||
expect(records[2].type).toBe(WALRecordType.UPDATE);
|
||||
expect(records[3].type).toBe(WALRecordType.COMMIT);
|
||||
});
|
||||
|
||||
it('getBufferedCount 返回缓冲数', () => {
|
||||
const wal = new WAL(new MockWALStore(), true, 'batch');
|
||||
expect(wal.getBufferedCount()).toBe(0);
|
||||
wal.append({ type: WALRecordType.INSERT, txnId: 0, tableName: 't', key: 'k' });
|
||||
expect(wal.getBufferedCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// Checkpoint 测试(使用安全 Mock,避免 null 引用导致 CI 卡死)
|
||||
// ===================================================================
|
||||
describe('AriaEngine — CheckpointManager', () => {
|
||||
class MockFlushable implements Flushable { flushed = false; async flushAll() { this.flushed = true; } }
|
||||
class MockLSM { flushed = false; async flush() { this.flushed = true; } }
|
||||
class MockWAL { checkpointed = false; async checkpoint() { this.checkpointed = true; } async flush() {} }
|
||||
|
||||
it('tick 未达间隔不触发 checkpoint', async () => {
|
||||
const lsm = new MockLSM();
|
||||
const wal = new MockWAL();
|
||||
const flushable = new MockFlushable();
|
||||
const cm = new CheckpointManager(lsm as any, wal as any, flushable, 100);
|
||||
await cm.tick();
|
||||
await cm.tick();
|
||||
expect(cm.getOpCount()).toBe(2);
|
||||
expect(flushable.flushed).toBe(false);
|
||||
expect(lsm.flushed).toBe(false);
|
||||
});
|
||||
|
||||
it('setInterval 修改间隔后 tick 触发 checkpoint', async () => {
|
||||
const lsm = new MockLSM();
|
||||
const wal = new MockWAL();
|
||||
const cm = new CheckpointManager(lsm as any, wal as any, null, 1000);
|
||||
cm.setInterval(2);
|
||||
await cm.tick();
|
||||
await cm.tick();
|
||||
expect(cm.getOpCount()).toBe(0); // reset after checkpoint
|
||||
expect(lsm.flushed).toBe(true);
|
||||
expect(wal.checkpointed).toBe(true);
|
||||
});
|
||||
|
||||
it('forceCheckpoint 强制触发', async () => {
|
||||
const lsm = new MockLSM();
|
||||
const wal = new MockWAL();
|
||||
const cm = new CheckpointManager(lsm as any, wal as any, null, 100);
|
||||
await cm.forceCheckpoint();
|
||||
expect(cm.getOpCount()).toBe(0);
|
||||
expect(lsm.flushed).toBe(true);
|
||||
expect(wal.checkpointed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// MVCC 测试
|
||||
// ===================================================================
|
||||
describe('AriaEngine — MVCC', () => {
|
||||
let mvcc: MVCCManager;
|
||||
|
||||
beforeEach(() => { mvcc = new MVCCManager(); });
|
||||
|
||||
it('beginTransaction 分配唯一 ID', () => {
|
||||
const id1 = mvcc.beginTransaction();
|
||||
const id2 = mvcc.beginTransaction();
|
||||
expect(id1).not.toBe(id2);
|
||||
expect(mvcc.isActive(id1)).toBe(true);
|
||||
expect(mvcc.isActive(id2)).toBe(true);
|
||||
});
|
||||
|
||||
it('commit 后 isActive 返回 false', () => {
|
||||
const txnId = mvcc.beginTransaction();
|
||||
mvcc.commitTransaction(txnId);
|
||||
expect(mvcc.isActive(txnId)).toBe(false);
|
||||
});
|
||||
|
||||
it('rollback 后 isActive 返回 false', () => {
|
||||
const txnId = mvcc.beginTransaction();
|
||||
mvcc.rollbackTransaction(txnId);
|
||||
expect(mvcc.isActive(txnId)).toBe(false);
|
||||
});
|
||||
|
||||
it('writeVersion + readVersion 往返', () => {
|
||||
const txnId = mvcc.beginTransaction();
|
||||
mvcc.writeVersion('users', '1', { name: 'Alice', age: 30 }, txnId);
|
||||
const val = mvcc.readVersion('users', '1', txnId);
|
||||
expect(val).not.toBeNull();
|
||||
expect(val!.name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('未提交版本对其他事务不可见', () => {
|
||||
const txn1 = mvcc.beginTransaction();
|
||||
mvcc.writeVersion('users', '1', { name: 'Alice' }, txn1);
|
||||
|
||||
const txn2 = mvcc.beginTransaction();
|
||||
const val = mvcc.readVersion('users', '1', txn2);
|
||||
expect(val).toBeNull(); // txn1's write not yet committed
|
||||
});
|
||||
|
||||
it('commit 后新事务可见', () => {
|
||||
const txn1 = mvcc.beginTransaction();
|
||||
mvcc.writeVersion('users', '1', { name: 'Alice' }, txn1);
|
||||
mvcc.commitTransaction(txn1);
|
||||
|
||||
const txn2 = mvcc.beginTransaction();
|
||||
const val = mvcc.readVersion('users', '1', txn2);
|
||||
expect(val).not.toBeNull();
|
||||
expect(val!.name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('rollback 移除写入的版本', () => {
|
||||
const txnId = mvcc.beginTransaction();
|
||||
mvcc.writeVersion('users', '1', { name: 'Temp' }, txnId);
|
||||
mvcc.rollbackTransaction(txnId);
|
||||
|
||||
const txn2 = mvcc.beginTransaction();
|
||||
expect(mvcc.readVersion('users', '1', txn2)).toBeNull();
|
||||
});
|
||||
|
||||
it('deleteVersion 创建墓碑', () => {
|
||||
const txnId = mvcc.beginTransaction();
|
||||
mvcc.writeVersion('users', '1', { name: 'Alice' }, txnId);
|
||||
mvcc.commitTransaction(txnId);
|
||||
// delete
|
||||
const txn2 = mvcc.beginTransaction();
|
||||
mvcc.deleteVersion('users', '1', txn2);
|
||||
mvcc.commitTransaction(txn2);
|
||||
// 删除后读取
|
||||
const txn3 = mvcc.beginTransaction();
|
||||
const val = mvcc.readVersion('users', '1', txn3);
|
||||
expect(val).not.toBeNull();
|
||||
expect((val! as any).__mvcc_tombstone).toBe(true);
|
||||
});
|
||||
|
||||
it('getLatestCommittedVersions 返回最新已提交', () => {
|
||||
const txnId = mvcc.beginTransaction();
|
||||
mvcc.writeVersion('users', '1', { name: 'Alice' }, txnId);
|
||||
mvcc.writeVersion('users', '2', { name: 'Bob' }, txnId);
|
||||
mvcc.commitTransaction(txnId);
|
||||
|
||||
const result = mvcc.getLatestCommittedVersions('users');
|
||||
expect(result['1'].name).toBe('Alice');
|
||||
expect(result['2'].name).toBe('Bob');
|
||||
});
|
||||
|
||||
it('clearTable 清理指定表', () => {
|
||||
const txnId = mvcc.beginTransaction();
|
||||
mvcc.writeVersion('users', '1', { name: 'A' }, txnId);
|
||||
mvcc.writeVersion('users', '2', { name: 'B' }, txnId);
|
||||
mvcc.writeVersion('orders', 'o1', { amt: 100 }, txnId);
|
||||
mvcc.commitTransaction(txnId);
|
||||
|
||||
mvcc.clearTable('users');
|
||||
const txn2 = mvcc.beginTransaction();
|
||||
expect(mvcc.readVersion('users', '1', txn2)).toBeNull();
|
||||
expect(mvcc.readVersion('users', '2', txn2)).toBeNull();
|
||||
expect(mvcc.readVersion('orders', 'o1', txn2)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('getActiveTxnCount 返回活跃事务数', () => {
|
||||
expect(mvcc.getActiveTxnCount()).toBe(0);
|
||||
mvcc.beginTransaction();
|
||||
mvcc.beginTransaction();
|
||||
expect(mvcc.getActiveTxnCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('gc 清理过旧版本', () => {
|
||||
// 创建很多版本后 gc
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const txnId = mvcc.beginTransaction();
|
||||
mvcc.writeVersion('users', '1', { ver: i }, txnId);
|
||||
mvcc.commitTransaction(txnId);
|
||||
}
|
||||
mvcc.gc(100);
|
||||
// gc 后应可继续操作
|
||||
const txnId = mvcc.beginTransaction();
|
||||
const val = mvcc.readVersion('users', '1', txnId);
|
||||
expect(val).not.toBeNull();
|
||||
});
|
||||
});
|
||||
/**
|
||||
* AriaEngine WAL + MVCC 单元测试
|
||||
*/
|
||||
import { WAL, type WALStore } from '../../src/engine/aria/wal/log';
|
||||
import { WALRecordType, type WALRecord } from '../../src/engine/aria/types';
|
||||
import { CheckpointManager, type Flushable } from '../../src/engine/aria/wal/checkpoint';
|
||||
import { MVCCManager } from '../../src/engine/aria/transaction/mvcc';
|
||||
|
||||
// ===================================================================
|
||||
// WAL 存储 Mock
|
||||
// ===================================================================
|
||||
class MockWALStore implements WALStore {
|
||||
chunks: Uint8Array[] = [];
|
||||
async append(data: Uint8Array) { 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() { this.chunks = []; }
|
||||
async exists() { return this.chunks.length > 0; }
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// WAL 测试
|
||||
// ===================================================================
|
||||
describe('AriaEngine — WAL', () => {
|
||||
it('append 记录后可恢复', async () => {
|
||||
const store = new MockWALStore();
|
||||
const wal = new WAL(store, true, 'full');
|
||||
|
||||
wal.append({ type: WALRecordType.INSERT, txnId: 1, tableName: 'users', key: '1', data: { name: 'Alice' } });
|
||||
wal.append({ type: WALRecordType.INSERT, txnId: 1, tableName: 'users', key: '2', data: { name: 'Bob' } });
|
||||
|
||||
const records: WALRecord[] = [];
|
||||
await wal.recover((r) => records.push(r));
|
||||
expect(records).toHaveLength(2);
|
||||
expect(records[0].tableName).toBe('users');
|
||||
expect(records[0].key).toBe('1');
|
||||
});
|
||||
|
||||
it('batch 模式缓冲后 flush', async () => {
|
||||
const store = new MockWALStore();
|
||||
const wal = new WAL(store, true, 'batch');
|
||||
|
||||
wal.append({ type: WALRecordType.UPDATE, txnId: 2, tableName: 'items', key: 'a', data: { v: 1 } });
|
||||
wal.append({ type: WALRecordType.DELETE, txnId: 2, tableName: 'items', key: 'b' });
|
||||
|
||||
// 未 flush 前无法恢复
|
||||
let records: WALRecord[] = [];
|
||||
await wal.recover((r) => records.push(r));
|
||||
expect(records).toHaveLength(0);
|
||||
|
||||
await wal.flush();
|
||||
records = [];
|
||||
await wal.recover((r) => records.push(r));
|
||||
expect(records).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('none 模式不记录', async () => {
|
||||
const store = new MockWALStore();
|
||||
const wal = new WAL(store, false, 'none');
|
||||
wal.append({ type: WALRecordType.INSERT, txnId: 3, tableName: 'x', key: 'y', data: {} });
|
||||
expect(store.chunks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('checkpoint 清空 WAL', async () => {
|
||||
const store = new MockWALStore();
|
||||
const wal = new WAL(store, true, 'full');
|
||||
|
||||
wal.append({ type: WALRecordType.CREATE_TABLE, txnId: 0, tableName: 't', key: '' });
|
||||
expect(await store.exists()).toBe(true);
|
||||
|
||||
await wal.checkpoint();
|
||||
expect(await store.exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('getLSN 跟踪序列号', () => {
|
||||
const store = new MockWALStore();
|
||||
const wal = new WAL(store, true, 'full');
|
||||
expect(wal.getLSN()).toBe(0);
|
||||
wal.append({ type: WALRecordType.BEGIN, txnId: 10, tableName: '', key: '' });
|
||||
expect(wal.getLSN()).toBe(1);
|
||||
wal.append({ type: WALRecordType.COMMIT, txnId: 10, tableName: '', key: '' });
|
||||
expect(wal.getLSN()).toBe(2);
|
||||
});
|
||||
|
||||
it('isEnabled 反映配置', () => {
|
||||
expect(new WAL(new MockWALStore(), true).isEnabled()).toBe(true);
|
||||
expect(new WAL(new MockWALStore(), false).isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('多种记录类型编解码', async () => {
|
||||
const store = new MockWALStore();
|
||||
const wal = new WAL(store, true, 'full');
|
||||
|
||||
wal.append({ type: WALRecordType.BEGIN, txnId: 100, tableName: '', key: '' });
|
||||
wal.append({ type: WALRecordType.INSERT, txnId: 100, tableName: 'users', key: '1', data: { x: 'hello' } });
|
||||
wal.append({ type: WALRecordType.UPDATE, txnId: 100, tableName: 'users', key: '1', data: { x: 'world' } });
|
||||
wal.append({ type: WALRecordType.COMMIT, txnId: 100, tableName: '', key: '' });
|
||||
|
||||
const records: WALRecord[] = [];
|
||||
await wal.recover((r) => records.push(r));
|
||||
expect(records).toHaveLength(4);
|
||||
expect(records[0].type).toBe(WALRecordType.BEGIN);
|
||||
expect(records[1].type).toBe(WALRecordType.INSERT);
|
||||
expect(records[2].type).toBe(WALRecordType.UPDATE);
|
||||
expect(records[3].type).toBe(WALRecordType.COMMIT);
|
||||
});
|
||||
|
||||
it('getBufferedCount 返回缓冲数', () => {
|
||||
const wal = new WAL(new MockWALStore(), true, 'batch');
|
||||
expect(wal.getBufferedCount()).toBe(0);
|
||||
wal.append({ type: WALRecordType.INSERT, txnId: 0, tableName: 't', key: 'k' });
|
||||
expect(wal.getBufferedCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// Checkpoint 测试(使用安全 Mock,避免 null 引用导致 CI 卡死)
|
||||
// ===================================================================
|
||||
describe('AriaEngine — CheckpointManager', () => {
|
||||
class MockFlushable implements Flushable { flushed = false; async flushAll() { this.flushed = true; } }
|
||||
class MockLSM { flushed = false; async flush() { this.flushed = true; } }
|
||||
class MockWAL { checkpointed = false; async checkpoint() { this.checkpointed = true; } async flush() {} }
|
||||
|
||||
it('tick 未达间隔不触发 checkpoint', async () => {
|
||||
const lsm = new MockLSM();
|
||||
const wal = new MockWAL();
|
||||
const flushable = new MockFlushable();
|
||||
const cm = new CheckpointManager(lsm as any, wal as any, flushable, 100);
|
||||
await cm.tick();
|
||||
await cm.tick();
|
||||
expect(cm.getOpCount()).toBe(2);
|
||||
expect(flushable.flushed).toBe(false);
|
||||
expect(lsm.flushed).toBe(false);
|
||||
});
|
||||
|
||||
it('setInterval 修改间隔后 tick 触发 checkpoint', async () => {
|
||||
const lsm = new MockLSM();
|
||||
const wal = new MockWAL();
|
||||
const cm = new CheckpointManager(lsm as any, wal as any, null, 1000);
|
||||
cm.setInterval(2);
|
||||
await cm.tick();
|
||||
await cm.tick();
|
||||
expect(cm.getOpCount()).toBe(0); // reset after checkpoint
|
||||
expect(lsm.flushed).toBe(true);
|
||||
expect(wal.checkpointed).toBe(true);
|
||||
});
|
||||
|
||||
it('forceCheckpoint 强制触发', async () => {
|
||||
const lsm = new MockLSM();
|
||||
const wal = new MockWAL();
|
||||
const cm = new CheckpointManager(lsm as any, wal as any, null, 100);
|
||||
await cm.forceCheckpoint();
|
||||
expect(cm.getOpCount()).toBe(0);
|
||||
expect(lsm.flushed).toBe(true);
|
||||
expect(wal.checkpointed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// MVCC 测试
|
||||
// ===================================================================
|
||||
describe('AriaEngine — MVCC', () => {
|
||||
let mvcc: MVCCManager;
|
||||
|
||||
beforeEach(() => { mvcc = new MVCCManager(); });
|
||||
|
||||
it('beginTransaction 分配唯一 ID', () => {
|
||||
const id1 = mvcc.beginTransaction();
|
||||
const id2 = mvcc.beginTransaction();
|
||||
expect(id1).not.toBe(id2);
|
||||
expect(mvcc.isActive(id1)).toBe(true);
|
||||
expect(mvcc.isActive(id2)).toBe(true);
|
||||
});
|
||||
|
||||
it('commit 后 isActive 返回 false', () => {
|
||||
const txnId = mvcc.beginTransaction();
|
||||
mvcc.commitTransaction(txnId);
|
||||
expect(mvcc.isActive(txnId)).toBe(false);
|
||||
});
|
||||
|
||||
it('rollback 后 isActive 返回 false', () => {
|
||||
const txnId = mvcc.beginTransaction();
|
||||
mvcc.rollbackTransaction(txnId);
|
||||
expect(mvcc.isActive(txnId)).toBe(false);
|
||||
});
|
||||
|
||||
it('writeVersion + readVersion 往返', () => {
|
||||
const txnId = mvcc.beginTransaction();
|
||||
mvcc.writeVersion('users', '1', { name: 'Alice', age: 30 }, txnId);
|
||||
const val = mvcc.readVersion('users', '1', txnId);
|
||||
expect(val).not.toBeNull();
|
||||
expect(val!.name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('未提交版本对其他事务不可见', () => {
|
||||
const txn1 = mvcc.beginTransaction();
|
||||
mvcc.writeVersion('users', '1', { name: 'Alice' }, txn1);
|
||||
|
||||
const txn2 = mvcc.beginTransaction();
|
||||
const val = mvcc.readVersion('users', '1', txn2);
|
||||
expect(val).toBeNull(); // txn1's write not yet committed
|
||||
});
|
||||
|
||||
it('commit 后新事务可见', () => {
|
||||
const txn1 = mvcc.beginTransaction();
|
||||
mvcc.writeVersion('users', '1', { name: 'Alice' }, txn1);
|
||||
mvcc.commitTransaction(txn1);
|
||||
|
||||
const txn2 = mvcc.beginTransaction();
|
||||
const val = mvcc.readVersion('users', '1', txn2);
|
||||
expect(val).not.toBeNull();
|
||||
expect(val!.name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('rollback 移除写入的版本', () => {
|
||||
const txnId = mvcc.beginTransaction();
|
||||
mvcc.writeVersion('users', '1', { name: 'Temp' }, txnId);
|
||||
mvcc.rollbackTransaction(txnId);
|
||||
|
||||
const txn2 = mvcc.beginTransaction();
|
||||
expect(mvcc.readVersion('users', '1', txn2)).toBeNull();
|
||||
});
|
||||
|
||||
it('deleteVersion 创建墓碑', () => {
|
||||
const txnId = mvcc.beginTransaction();
|
||||
mvcc.writeVersion('users', '1', { name: 'Alice' }, txnId);
|
||||
mvcc.commitTransaction(txnId);
|
||||
// delete
|
||||
const txn2 = mvcc.beginTransaction();
|
||||
mvcc.deleteVersion('users', '1', txn2);
|
||||
mvcc.commitTransaction(txn2);
|
||||
// 删除后读取
|
||||
const txn3 = mvcc.beginTransaction();
|
||||
const val = mvcc.readVersion('users', '1', txn3);
|
||||
expect(val).not.toBeNull();
|
||||
expect((val! as any).__mvcc_tombstone).toBe(true);
|
||||
});
|
||||
|
||||
it('getLatestCommittedVersions 返回最新已提交', () => {
|
||||
const txnId = mvcc.beginTransaction();
|
||||
mvcc.writeVersion('users', '1', { name: 'Alice' }, txnId);
|
||||
mvcc.writeVersion('users', '2', { name: 'Bob' }, txnId);
|
||||
mvcc.commitTransaction(txnId);
|
||||
|
||||
const result = mvcc.getLatestCommittedVersions('users');
|
||||
expect(result['1'].name).toBe('Alice');
|
||||
expect(result['2'].name).toBe('Bob');
|
||||
});
|
||||
|
||||
it('clearTable 清理指定表', () => {
|
||||
const txnId = mvcc.beginTransaction();
|
||||
mvcc.writeVersion('users', '1', { name: 'A' }, txnId);
|
||||
mvcc.writeVersion('users', '2', { name: 'B' }, txnId);
|
||||
mvcc.writeVersion('orders', 'o1', { amt: 100 }, txnId);
|
||||
mvcc.commitTransaction(txnId);
|
||||
|
||||
mvcc.clearTable('users');
|
||||
const txn2 = mvcc.beginTransaction();
|
||||
expect(mvcc.readVersion('users', '1', txn2)).toBeNull();
|
||||
expect(mvcc.readVersion('users', '2', txn2)).toBeNull();
|
||||
expect(mvcc.readVersion('orders', 'o1', txn2)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('getActiveTxnCount 返回活跃事务数', () => {
|
||||
expect(mvcc.getActiveTxnCount()).toBe(0);
|
||||
mvcc.beginTransaction();
|
||||
mvcc.beginTransaction();
|
||||
expect(mvcc.getActiveTxnCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('gc 清理过旧版本', () => {
|
||||
// 创建很多版本后 gc
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const txnId = mvcc.beginTransaction();
|
||||
mvcc.writeVersion('users', '1', { ver: i }, txnId);
|
||||
mvcc.commitTransaction(txnId);
|
||||
}
|
||||
mvcc.gc(100);
|
||||
// gc 后应可继续操作
|
||||
const txnId = mvcc.beginTransaction();
|
||||
const val = mvcc.readVersion('users', '1', txnId);
|
||||
expect(val).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+932
-932
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user