/** * 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'; import { installOPFSMock } from '../helpers/opfs-mock'; beforeEach(() => { installOPFSMock(new Map()); }); /** 构造小缓存 + 小 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[] { const rows: Record[] = []; 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 { flush(): Promise; getCacheSize(): number; getCacheLimit(): number; getStats(): { sstableCount: number }; }; // v0.6.1-perf: insert 不再隐式排空后台链(逐行 prefetchKeys 已移除), // 显式等待后台 flush 完成后再断言 SSTable 产物 await lsm.flush(); 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.cacheSize).toBeLessThanOrEqual(lsm.cacheLimitBytes); } 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.cacheSize).toBeLessThanOrEqual(lsm.cacheLimitBytes); 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.cacheSize).toBeLessThanOrEqual(lsm.cacheLimitBytes); } 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(); }); });