Files
MetonaSqlark/tests/engine/aria-cache.test.ts
T

304 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* AriaEngine SSTable 缓存内存上限测试
* @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 { resetOPFSMock } from '../helpers/storage-harness';
beforeEach(() => { resetOPFSMock(); });
/** 构造小缓存 + 小 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 {
flush(): Promise<void>;
getCacheSize(): number;
getCacheLimit(): number;
getOversizedCount(): 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);
// v0.8.0 契约修正:内存上限只约束**可驱逐条目**。
//
// 单个 SSTable 大于整个缓存上限时,它必须常驻:一旦驱逐,
// `loadSSTableReader` 未命中就会让调用方 `continue` 跳过整个文件 ——
// 那是静默丢数据(审计实测:300 行只能查回 59 行)。
// 因此这里断言的是"数据完整"这一真正重要的不变量,而不是一个
// 在极小缓存下无法成立的字节上限(上限 = cacheLimit + 单个最大 SSTable)。
for (let round = 0; round < 5; round++) {
const rows = await engine.find('users', { table: 'users', where: { age: 25 } });
expect(rows.length).toBe(10);
}
// 若所有 SSTable 都能装进上限,则缓存大小必须受上限约束
const oversizedPinned = lsm.getOversizedCount();
if (oversizedPinned === 0) {
expect(lsm.getCacheSize()).toBeLessThanOrEqual(lsm.getCacheLimit());
}
await engine.close();
});
test('超大 SSTable 常驻缓存(驱逐会导致读取静默跳过整个文件)', async () => {
const engine = createSmallCacheEngine(1); // 4KB 上限,单个 SSTable 必然超过
await engine.open('cache-oversized-pin', 1);
await engine.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
}));
await engine.insert('users', makeRows(300));
const lsm = (engine as any).lsm as {
flush(): Promise<void>;
getCacheSize(): number;
getCacheLimit(): number;
getOversizedCount(): number;
trimCache(): void;
getStats(): { sstableCount: number };
};
await lsm.flush();
expect(lsm.getStats().sstableCount).toBeGreaterThan(0);
// 强制裁剪后,超大文件仍必须可读(数据完整)
lsm.trimCache();
expect(lsm.getOversizedCount()).toBeGreaterThan(0);
const all = await engine.find('users', { table: 'users' });
expect(all.length).toBe(300);
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);
// v0.8.0: 这是最关键的数据完整性断言 —— 缓存上限极小(4KB)而 SSTable 更大时,
// 读取路径必须仍然返回**全部** 300 行(此前会静默少数据)。
const all = await engine.find('users', { table: 'users' });
expect(all.length).toBe(300);
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();
});
});