A38 `compression` 在页面化路径上被静默忽略
压缩只写在"整 value 存一个 backend value"的分支里,而 `save()` 在页面化
分支**提前 return** —— `pageStorage` 默认自动(OPFS 后端下为 true),
于是 `compression: true` 在默认配置下完全无效且无任何提示。
修法:`compression` 传入 `PageSSTableStore`,在**切页之前**整体压缩
(压缩率优于逐页压缩),加载时对称解压。
连带修正一个会静默损坏数据的接口问题:`SSTableMeta.totalSize` 的语义是
"页面里存了多少字节",加载时按它截断 —— 压缩后必须写**压缩长度**。
为此 `SSTableStore.save` 改为返回 `{ storedSize }`,两处 flush 流程与
整 value 路径都用它回填 totalSize(写未压缩长度会让压缩数据被 0 填充撑大)。
为什么此前没被发现:既有测试只断言"压缩后能读回来",而"根本没压缩"
同样能正确读回 —— 断言太弱。新用例改为**结构性断言**:
开启压缩后落盘字节数必须显著下降(>5×),与实现细节无关。
A39 `compressLZ4` 匹配搜索为 O(n²)
旧实现逐字节向前扫描最多 65535 个候选位置、每个位置再逐字节比较 ——
在低压缩率数据上退化为二次复杂度。实测 60KB 伪随机输入耗时 **2345ms**;
而 SSTable 页/日志段正是几百 KB 到几 MB,属于普通写入路径上的真实卡顿。
修法:改为 LZ4 标准的 **4 字节哈希链**(`head[]`/`prev[]`,单点最多
`MAX_CHAIN=32` 次探测)→ 实测 6ms(约 390×)。
**输出格式完全不变**,既有落盘数据无需迁移;旧实现保留为
`compressLZ4LinearReference` 并作为测试对照物(证明两者可互解)。
另加"全字面量"兜底:任何异常都产出合法可解压的流(数据正确性优先于压缩率)。
测试介质修正(同源发现,影响所有 OPFS 多库场景)
`installOPFSMock` 把 `getDirectoryHandle(name)` 的 `name` **丢弃**,
所有库共用一棵扁平文件树。实测:`open('db-alpha')` 建表后
`open('db-beta').getTableNames()` 返回 `["alpha_only"]`。
真实 OPFS 下 `OPFSBackend.open(name)` 是 `root.getDirectoryHandle(name)`,
因此 mock 现在实现真实的**目录语义**,并提供 `dir(dbName)` 视图让测试与
生产代码使用同一个 API(此前的 `listKeys/createFile` 是根目录假 API,
两个依赖它的用例已改为目录视图)。
验证:新增 tests/engine/aria-compression.test.ts(12 项,含 1MB 大输入与
6 组格式兼容用例);两处修复都做**变异验证**:回退 A38 的接线 → 页面化压缩
用例失败;回退 A39 到线性实现 → "60KB < 1s" 用例失败(实测 2397ms)。
全量 89 套件 / 1742 测试通过;typecheck、lint、build 零错误/零告警;dist 已重建。
322 lines
13 KiB
TypeScript
322 lines
13 KiB
TypeScript
/**
|
||
* AriaEngine — 页面化 SSTable 存储测试
|
||
*
|
||
* 覆盖:
|
||
* 1. PageSSTableStore 单元:save/load 往返(页边界、非整数页)、delete 释放页面
|
||
* 2. AriaEngine + OPFS(页面模式):写入 → flush → close → reopen 数据完整
|
||
* 3. 大 SSTable 多页 + pageIds 注入 meta 验证
|
||
* 4. 页面损坏(CRC 校验失败)→ 打开自愈清理
|
||
* 5. 页面模式与非页面模式混合(pageIds 兼容:新页面写 + 旧整 value 读)
|
||
* 6. 页面化 + 加密 + 压缩组合
|
||
*/
|
||
import { AriaEngine } from '../../src/engine/aria/index';
|
||
import { PageSSTableStore } from '../../src/engine/aria/store/page_sstable_store';
|
||
import { FileManager } from '../../src/engine/aria/store/file_manager';
|
||
import { BufferPool } from '../../src/engine/aria/buffer/pool';
|
||
import { MemoryBackend } from '../../src/engine/aria/store/backend';
|
||
import { createSchema } from '../../src/table/schema';
|
||
import { PAGE_SIZE } from '../../src/engine/aria/types';
|
||
|
||
// ===================================================================
|
||
// OPFS mock(共享工具)
|
||
// ===================================================================
|
||
import { resetOPFSMock } from '../helpers/storage-harness';
|
||
import { decode as decodeBytes } from '../helpers/assertions';
|
||
|
||
const SCHEMA = () => createSchema('users', {
|
||
id: { type: 'string', primaryKey: true },
|
||
name: { type: 'string' },
|
||
note: { type: 'string' },
|
||
});
|
||
|
||
// ===================================================================
|
||
// PageSSTableStore 单元
|
||
// ===================================================================
|
||
describe('AriaEngine — PageSSTableStore 单元', () => {
|
||
it('save/load 往返:单页数据', async () => {
|
||
const backend = new MemoryBackend();
|
||
await backend.open('pg-unit-1');
|
||
const fm = new FileManager(backend);
|
||
await fm.init('pg-unit-1');
|
||
const bp = new BufferPool(fm, 64);
|
||
const store = new PageSSTableStore(fm, bp);
|
||
|
||
const data = new Uint8Array(new TextEncoder().encode('hello page store'));
|
||
await store.save(1, data);
|
||
const pageIds = store.getPageIds(1)!;
|
||
expect(pageIds.length).toBe(1);
|
||
|
||
const back = await store.load(1, pageIds, data.byteLength);
|
||
expect(back).not.toBeNull();
|
||
expect(decodeBytes(back)).toBe('hello page store');
|
||
|
||
// 页面已落盘(backend 有 pg_ 键)
|
||
expect(await backend.exists('pg_1')).toBe(true);
|
||
|
||
await store.delete(1, pageIds);
|
||
expect(await backend.exists('pg_1')).toBe(false);
|
||
await backend.close();
|
||
});
|
||
|
||
it('save/load 往返:多页数据(跨 4KB 边界)', async () => {
|
||
const backend = new MemoryBackend();
|
||
await backend.open('pg-unit-2');
|
||
const fm = new FileManager(backend);
|
||
await fm.init('pg-unit-2');
|
||
const bp = new BufferPool(fm, 64);
|
||
const store = new PageSSTableStore(fm, bp);
|
||
|
||
// 3 页:4KB * 2 + 1KB
|
||
const size = PAGE_SIZE * 2 + 1024;
|
||
const data = new Uint8Array(size);
|
||
let x = 42;
|
||
for (let i = 0; i < size; i++) {
|
||
x = (x * 31 + 7) & 0xffff;
|
||
data[i] = x & 0xff;
|
||
}
|
||
await store.save(7, data);
|
||
const pageIds = store.getPageIds(7)!;
|
||
expect(pageIds.length).toBe(3);
|
||
|
||
const back = await store.load(7, pageIds, size);
|
||
expect(back).not.toBeNull();
|
||
expect(back!.byteLength).toBe(size);
|
||
expect(back).toEqual(data);
|
||
await backend.close();
|
||
});
|
||
|
||
it('load 使用 totalSize 截断最后一页的 0 填充', async () => {
|
||
const backend = new MemoryBackend();
|
||
await backend.open('pg-unit-3');
|
||
const fm = new FileManager(backend);
|
||
await fm.init('pg-unit-3');
|
||
const bp = new BufferPool(fm, 64);
|
||
const store = new PageSSTableStore(fm, bp);
|
||
|
||
// 非整数页:4096 + 100 字节
|
||
const real = new Uint8Array(PAGE_SIZE + 100);
|
||
for (let i = 0; i < real.length; i++) real[i] = i & 0xff;
|
||
await store.save(9, real);
|
||
const pageIds = store.getPageIds(9)!;
|
||
expect(pageIds.length).toBe(2);
|
||
|
||
const back = await store.load(9, pageIds, real.length);
|
||
expect(back!.byteLength).toBe(real.length);
|
||
expect(back).toEqual(real);
|
||
await backend.close();
|
||
});
|
||
|
||
it('页面缺失(磁盘文件丢失 + 缓存清空)→ load 返回 null', async () => {
|
||
const backend = new MemoryBackend();
|
||
await backend.open('pg-unit-4');
|
||
const fm = new FileManager(backend);
|
||
await fm.init('pg-unit-4');
|
||
const bp = new BufferPool(fm, 64);
|
||
const store = new PageSSTableStore(fm, bp);
|
||
|
||
await store.save(5, new Uint8Array(100));
|
||
const pageIds = store.getPageIds(5)!;
|
||
// 模拟崩溃重开:磁盘文件丢失 + 页面缓存清空
|
||
await backend.delete('pg_' + pageIds[0]);
|
||
bp.removePage(pageIds[0]);
|
||
|
||
const back = await store.load(5, pageIds, 100);
|
||
expect(back).toBeNull();
|
||
await backend.close();
|
||
});
|
||
});
|
||
|
||
// ===================================================================
|
||
// AriaEngine + OPFS 页面模式集成
|
||
// ===================================================================
|
||
describe('AriaEngine — 页面化 SSTable 集成(OPFS)', () => {
|
||
it('写入 → flush → close → reopen 数据完整(页面模式默认启用)', async () => {
|
||
resetOPFSMock();
|
||
const engine = new AriaEngine({
|
||
storageBackend: 'opfs',
|
||
memtableSizeThreshold: 64 * 1024 * 1024,
|
||
checkpointInterval: 100000,
|
||
});
|
||
await engine.open('page-db-1', 1);
|
||
await engine.createTable(SCHEMA());
|
||
|
||
// 多行 + 大 note → 超过 4KB → 多页
|
||
const rows = [] as Record<string, unknown>[];
|
||
for (let i = 0; i < 30; i++) {
|
||
rows.push({ id: `u-${i}`, name: `User${i}`, note: 'page-store-note-'.repeat(40) });
|
||
}
|
||
await engine.insert('users', rows);
|
||
await (engine as any).lsm.flush();
|
||
|
||
// 页面化落盘:backend 有 pg_ 键,meta 含 pageIds
|
||
const backend = (engine as any).backend;
|
||
const keys = await backend.listKeys();
|
||
const pgKeys = keys.filter((k: string) => k.startsWith('pg_'));
|
||
const sstKeys = keys.filter((k: string) => k.startsWith('sst_'));
|
||
expect(pgKeys.length).toBeGreaterThan(0);
|
||
expect(sstKeys.length).toBe(0); // 页面模式下不写整 value SSTable
|
||
|
||
const metas = await (engine as any).lsm.sstableStore.listMeta();
|
||
expect(metas.length).toBeGreaterThan(0);
|
||
expect(metas[0].pageIds).toBeDefined();
|
||
expect(metas[0].pageIds.length).toBeGreaterThan(0);
|
||
|
||
await engine.close();
|
||
|
||
// reopen:数据完整
|
||
const engine2 = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
|
||
await engine2.open('page-db-1', 1);
|
||
const found = await engine2.find('users', { table: 'users' });
|
||
expect(found).toHaveLength(30);
|
||
expect(found[0].note).toBe('page-store-note-'.repeat(40));
|
||
expect(await engine2.count('users')).toBe(30);
|
||
await engine2.close();
|
||
});
|
||
|
||
it('多级 compaction 后页面化数据仍完整', async () => {
|
||
resetOPFSMock();
|
||
const engine = new AriaEngine({
|
||
storageBackend: 'opfs',
|
||
memtableSizeThreshold: 16 * 1024,
|
||
checkpointInterval: 100000,
|
||
});
|
||
await engine.open('page-db-2', 1);
|
||
await engine.createTable(SCHEMA());
|
||
|
||
// 大量小批量写入 → 多次 flush → 触发 compaction
|
||
for (let batch = 0; batch < 8; batch++) {
|
||
const rows = [] as Record<string, unknown>[];
|
||
for (let i = 0; i < 20; i++) {
|
||
rows.push({ id: `b${batch}-u${i}`, name: `U${i}`, note: 'x'.repeat(200) });
|
||
}
|
||
await engine.insert('users', rows);
|
||
await (engine as any).lsm.flush();
|
||
}
|
||
// 强制压缩
|
||
await (engine as any).lsm.compactLevel(0);
|
||
|
||
await engine.close();
|
||
const engine2 = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
|
||
await engine2.open('page-db-2', 1);
|
||
expect(await engine2.count('users')).toBe(160);
|
||
const one = await engine2.find('users', { table: 'users', where: { id: 'b3-u10' } });
|
||
expect(one).toHaveLength(1);
|
||
await engine2.close();
|
||
});
|
||
|
||
it('页面损坏(篡改 pg_ 文件)→ 打开自愈清理,其余数据可读', async () => {
|
||
resetOPFSMock();
|
||
const engine = new AriaEngine({
|
||
storageBackend: 'opfs',
|
||
memtableSizeThreshold: 64 * 1024 * 1024,
|
||
checkpointInterval: 100000,
|
||
});
|
||
await engine.open('page-db-3', 1);
|
||
await engine.createTable(SCHEMA());
|
||
for (let batch = 0; batch < 2; batch++) {
|
||
const rows = [] as Record<string, unknown>[];
|
||
for (let i = 0; i < 20; i++) {
|
||
rows.push({ id: `b${batch}-u${i}`, name: `U${i}`, note: 'n'.repeat(100) });
|
||
}
|
||
await engine.insert('users', rows);
|
||
await (engine as any).lsm.flush();
|
||
}
|
||
await engine.close();
|
||
|
||
// 篡改第一个 SSTable 的第一页
|
||
const engine2 = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
|
||
await engine2.open('page-db-3', 1);
|
||
const metas = await (engine2 as any).lsm.sstableStore.listMeta();
|
||
expect(metas.length).toBeGreaterThanOrEqual(2);
|
||
const victim = metas[0];
|
||
const backend = (engine2 as any).backend;
|
||
const pageRaw = await backend.read(`pg_${victim.pageIds[0]}`);
|
||
const corrupted = new Uint8Array(pageRaw);
|
||
corrupted[100] ^= 0xff;
|
||
await backend.write(`pg_${victim.pageIds[0]}`, corrupted.buffer as ArrayBuffer);
|
||
await engine2.close();
|
||
|
||
// 重开:损坏页面的 SSTable 被清理,其余数据可读
|
||
const engine3 = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
|
||
await engine3.open('page-db-3', 1);
|
||
const rows3 = await engine3.find('users', { table: 'users' });
|
||
expect(rows3.length).toBeGreaterThan(0);
|
||
expect(rows3.length).toBeLessThan(40);
|
||
await engine3.close();
|
||
});
|
||
|
||
it('页面模式与非页面模式混合兼容(pageIds 缺失 → 整 value 读取)', async () => {
|
||
const opfs = resetOPFSMock();
|
||
// 阶段 1:非页面模式写入(pageStorage: false → 整 value SSTable)
|
||
const engine = new AriaEngine({
|
||
storageBackend: 'opfs',
|
||
pageStorage: false,
|
||
memtableSizeThreshold: 64 * 1024 * 1024,
|
||
checkpointInterval: 100000,
|
||
});
|
||
await engine.open('page-db-4', 1);
|
||
await engine.createTable(SCHEMA());
|
||
await engine.insert('users', [{ id: 'legacy-1', name: 'Old', note: 'whole-value' }]);
|
||
await (engine as any).lsm.flush();
|
||
await engine.close();
|
||
|
||
// v0.8.0:mock 现在是"真实目录语义"(文件在 `${dbName}/` 下),
|
||
// 因此要用与生产代码相同的目录视图查询,而不是根目录列表。
|
||
const backendKeys1 = await opfs.dir('page-db-4').listKeys();
|
||
expect(backendKeys1.some((k) => k.startsWith('sst_'))).toBe(true);
|
||
|
||
// 阶段 2:页面模式打开(默认 opfs → 启用),读旧数据 + 写新数据
|
||
const engine2 = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
|
||
await engine2.open('page-db-4', 1);
|
||
expect(await engine2.count('users')).toBe(1);
|
||
const legacy = await engine2.find('users', { table: 'users', where: { id: 'legacy-1' } });
|
||
expect(legacy[0].note).toBe('whole-value');
|
||
// 新写入 → 页面化
|
||
await engine2.insert('users', [{ id: 'new-1', name: 'New', note: 'paged' }]);
|
||
await (engine2 as any).lsm.flush();
|
||
await engine2.close();
|
||
|
||
// 阶段 3:混合库重开,新旧数据都在
|
||
const engine3 = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
|
||
await engine3.open('page-db-4', 1);
|
||
expect(await engine3.count('users')).toBe(2);
|
||
const both = await engine3.find('users', { table: 'users' });
|
||
const byId = Object.fromEntries(both.map((r) => [r.id, r]));
|
||
expect(byId['legacy-1'].note).toBe('whole-value');
|
||
expect(byId['new-1'].note).toBe('paged');
|
||
await engine3.close();
|
||
});
|
||
|
||
it('页面化 + 加密 + 压缩组合:往返完整', async () => {
|
||
resetOPFSMock();
|
||
const engine = new AriaEngine({
|
||
storageBackend: 'opfs',
|
||
compression: true,
|
||
encryption: { password: 'page-pass' },
|
||
memtableSizeThreshold: 64 * 1024 * 1024,
|
||
checkpointInterval: 100000,
|
||
});
|
||
await engine.open('page-db-5', 1);
|
||
await engine.createTable(SCHEMA());
|
||
const rows = [] as Record<string, unknown>[];
|
||
for (let i = 0; i < 20; i++) {
|
||
rows.push({ id: `e-${i}`, name: `E${i}`, note: '加密的页面化内容'.repeat(30) });
|
||
}
|
||
await engine.insert('users', rows);
|
||
await (engine as any).lsm.flush();
|
||
await engine.close();
|
||
|
||
const engine2 = new AriaEngine({
|
||
storageBackend: 'opfs',
|
||
compression: true,
|
||
encryption: { password: 'page-pass' },
|
||
checkpointInterval: 100000,
|
||
});
|
||
await engine2.open('page-db-5', 1);
|
||
expect(await engine2.count('users')).toBe(20);
|
||
const one = await engine2.find('users', { table: 'users', where: { id: 'e-7' } });
|
||
expect(one[0].note).toBe('加密的页面化内容'.repeat(30));
|
||
await engine2.close();
|
||
});
|
||
});
|