fix(A38/A39): 页面化路径真正压缩 + compressLZ4 去除二次复杂度(含测试介质目录语义修正)
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 已重建。
This commit is contained in:
@@ -297,18 +297,27 @@ export function resetOPFSMock(dbName = 'mock'): InstalledOPFSMock {
|
||||
|
||||
export interface InstalledOPFSMock {
|
||||
store: TransactionalFileStore;
|
||||
/** 当前 mock 看到的所有文件名 */
|
||||
listKeys(): Promise<string[]>;
|
||||
/**
|
||||
* 直接创建(或覆盖)一个文件 —— 用于制造"崩溃残留临时文件"等场景,
|
||||
* 替代旧 API 暴露内部 dir/files 的做法。
|
||||
* v0.8.0:取**某个库目录**的句柄 —— 测试与生产代码(`OPFSBackend.open`)
|
||||
* 使用同一个 API,因此"文件放在哪个目录"不可能出现两套理解。
|
||||
*
|
||||
* 与之配套的 `listKeys/createFile/...` 是**根目录**视图(`dbName/文件` 形态),
|
||||
* 只适合"制造残留文件""看整体布局"这类场景;判断某个文件是否存在,
|
||||
* 应当用 `dir(dbName).hasFile(...)`。
|
||||
*/
|
||||
dir(dbName: string): {
|
||||
listKeys(): Promise<string[]>;
|
||||
createFile(name: string, content?: ArrayBuffer): Promise<void>;
|
||||
hasFile(name: string): Promise<boolean>;
|
||||
readFile(name: string): Promise<ArrayBuffer | null>;
|
||||
removeFile(name: string): Promise<void>;
|
||||
};
|
||||
/** 根目录下的所有文件(含库名前缀) */
|
||||
listKeys(): Promise<string[]>;
|
||||
/** 在**根目录**直接创建文件 —— 用于制造"崩溃残留临时文件"等场景 */
|
||||
createFile(name: string, content?: ArrayBuffer): Promise<void>;
|
||||
/** 文件是否存在 */
|
||||
hasFile(name: string): Promise<boolean>;
|
||||
/** 读取文件内容(返回副本) */
|
||||
readFile(name: string): Promise<ArrayBuffer | null>;
|
||||
/** 删除文件 */
|
||||
removeFile(name: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -320,77 +329,136 @@ export interface InstalledOPFSMock {
|
||||
export function installOPFSMock(dbName = 'mock'): InstalledOPFSMock {
|
||||
const store = getStore(dbName);
|
||||
|
||||
const makeFileHandle = async (name: string, opts?: { create?: boolean }) => {
|
||||
if (!(await store.has(name)) && !opts?.create) {
|
||||
throw new Error(`NotFoundError: ${name}`);
|
||||
}
|
||||
/** 文件路径 = `${目录名}/${文件名}`(root 目录的目录名为空串) */
|
||||
const joinPath = (dirName: string, fileName: string): string =>
|
||||
dirName ? `${dirName}/${fileName}` : fileName;
|
||||
|
||||
/**
|
||||
* 构造某个目录的 FileSystemDirectoryHandle 视图。
|
||||
*
|
||||
* v0.8.0:**目录名必须真正参与文件路径**。
|
||||
*
|
||||
* `OPFSBackend.open(name)` 会 `root.getDirectoryHandle(name, {create:true})`
|
||||
* 并把返回的目录当作该库的根 —— 真实 OPFS 因此天然按库名隔离文件。
|
||||
* 而此前的 mock 把这个 `name` **丢掉**(`async (_name) => dir`),
|
||||
* 所有库共用同一个扁平名字空间。实测(未修复时):
|
||||
* aroma.open('db-alpha') 建表 alpha_only → aroma.open('db-beta')
|
||||
* → `getTableNames()` 返回 ["alpha_only"](beta 看到了 alpha 的表)
|
||||
* 影响面:所有基于本 mock 的"多库/多租户/重启换名"场景都跑在错误语义上。
|
||||
*/
|
||||
const makeDir = (dirName: string) => {
|
||||
const makeFileHandle = async (name: string, opts?: { create?: boolean }) => {
|
||||
const path = joinPath(dirName, name);
|
||||
if (!(await store.has(path)) && !opts?.create) {
|
||||
throw new Error(`NotFoundError: ${path}`);
|
||||
}
|
||||
return {
|
||||
/**
|
||||
* 真实 OPFS 的 getFile() 返回 File,读的是**已提交**内容(size 与 arrayBuffer 一致)。
|
||||
* append 路径会读 `existing.size` 来定位追加位置,因此这里保持二者同源。
|
||||
*/
|
||||
getFile: async () => {
|
||||
const content = (await store.read(path)) ?? new ArrayBuffer(0);
|
||||
return {
|
||||
size: content.byteLength,
|
||||
arrayBuffer: async () => content,
|
||||
};
|
||||
},
|
||||
createWritable: async (wOpts?: { keepExistingData?: boolean }) => {
|
||||
const keepExisting = wOpts?.keepExistingData ?? false;
|
||||
// OPFS 写语义:keepExistingData:false 时从空缓冲开始(旧 mock 会保留旧字节)
|
||||
let buffer = keepExisting ? (await store.read(path)) ?? new ArrayBuffer(0) : new ArrayBuffer(0);
|
||||
return {
|
||||
write: async (arg: ArrayBuffer | { type: string; position: number; data: ArrayBuffer }) => {
|
||||
// 注意:不能用 `arg instanceof ArrayBuffer` 判别 —— 跨 realm / 跨 Buffer 实现时
|
||||
// 会失效(jsdom 与 Node 的 ArrayBuffer 可能不是同一个构造函数),
|
||||
// 从而把 ArrayBuffer 误当成 {position,data} 分支。改用结构判别。
|
||||
if (!isPositionedWrite(arg)) {
|
||||
buffer = copyBuffer(arg as ArrayBuffer);
|
||||
return;
|
||||
}
|
||||
const chunk = arg;
|
||||
const end = chunk.position + chunk.data.byteLength;
|
||||
const merged = new Uint8Array(Math.max(end, buffer.byteLength));
|
||||
merged.set(new Uint8Array(buffer), 0);
|
||||
merged.set(new Uint8Array(chunk.data), chunk.position);
|
||||
buffer = merged.buffer as ArrayBuffer;
|
||||
},
|
||||
close: async () => {
|
||||
// 仅在 close 时提交 —— 未 close 的写入对读不可见(旧 mock 会立即可见)
|
||||
await store.write(path, buffer);
|
||||
store.commitAll();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
/**
|
||||
* 真实 OPFS 的 getFile() 返回 File,读的是**已提交**内容(size 与 arrayBuffer 一致)。
|
||||
* append 路径会读 `existing.size` 来定位追加位置,因此这里保持二者同源。
|
||||
*/
|
||||
getFile: async () => {
|
||||
const content = (await store.read(name)) ?? new ArrayBuffer(0);
|
||||
return {
|
||||
size: content.byteLength,
|
||||
arrayBuffer: async () => content,
|
||||
};
|
||||
getFileHandle: makeFileHandle,
|
||||
entries: async function* () {
|
||||
const prefix = dirName ? `${dirName}/` : '';
|
||||
for (const key of await store.listKeys()) {
|
||||
if (!key.startsWith(prefix)) continue;
|
||||
const rest = key.slice(prefix.length);
|
||||
// 只列出本目录的**直接**子项(真实 OPFS 的 entries 语义)
|
||||
if (rest.includes('/')) continue;
|
||||
yield [rest];
|
||||
}
|
||||
},
|
||||
createWritable: async (wOpts?: { keepExistingData?: boolean }) => {
|
||||
const keepExisting = wOpts?.keepExistingData ?? false;
|
||||
// OPFS 写语义:keepExistingData:false 时从空缓冲开始(旧 mock 会保留旧字节)
|
||||
let buffer = keepExisting ? (await store.read(name)) ?? new ArrayBuffer(0) : new ArrayBuffer(0);
|
||||
return {
|
||||
write: async (arg: ArrayBuffer | { type: string; position: number; data: ArrayBuffer }) => {
|
||||
// 注意:不能用 `arg instanceof ArrayBuffer` 判别 —— 跨 realm / 跨 Buffer 实现时
|
||||
// 会失效(jsdom 与 Node 的 ArrayBuffer 可能不是同一个构造函数),
|
||||
// 从而把 ArrayBuffer 误当成 {position,data} 分支。改用结构判别。
|
||||
if (!isPositionedWrite(arg)) {
|
||||
buffer = copyBuffer(arg as ArrayBuffer);
|
||||
return;
|
||||
}
|
||||
const chunk = arg;
|
||||
const end = chunk.position + chunk.data.byteLength;
|
||||
const merged = new Uint8Array(Math.max(end, buffer.byteLength));
|
||||
merged.set(new Uint8Array(buffer), 0);
|
||||
merged.set(new Uint8Array(chunk.data), chunk.position);
|
||||
buffer = merged.buffer as ArrayBuffer;
|
||||
},
|
||||
close: async () => {
|
||||
// 仅在 close 时提交 —— 未 close 的写入对读不可见(旧 mock 会立即可见)
|
||||
await store.write(name, buffer);
|
||||
store.commitAll();
|
||||
},
|
||||
};
|
||||
removeEntry: async (name: string) => {
|
||||
await store.delete(joinPath(dirName, name));
|
||||
store.commitAll();
|
||||
},
|
||||
/** 子目录:`OPFSBackend.open(name)` 走这里 */
|
||||
getDirectoryHandle: async (name: string, _opts?: unknown) => makeDir(joinPath(dirName, name)),
|
||||
};
|
||||
};
|
||||
|
||||
const dir = {
|
||||
getFileHandle: makeFileHandle,
|
||||
entries: async function* () {
|
||||
for (const key of await store.listKeys()) yield [key];
|
||||
},
|
||||
removeEntry: async (name: string) => {
|
||||
await store.delete(name);
|
||||
store.commitAll();
|
||||
},
|
||||
};
|
||||
const rootDir = makeDir('');
|
||||
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: {
|
||||
storage: {
|
||||
getDirectory: async () => ({
|
||||
getDirectoryHandle: async (_name: string, _opts?: unknown) => dir,
|
||||
}),
|
||||
getDirectory: async () => rootDir,
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* 构造"某个库目录"的测试视图。
|
||||
*
|
||||
* 与生产代码同路径:`OPFSBackend.open(name)` 拿到的是
|
||||
* `root.getDirectoryHandle(name)`,本函数走同一个入口。
|
||||
*/
|
||||
const dirView = (name: string) => {
|
||||
const dirName = joinPath('', name);
|
||||
return {
|
||||
listKeys: async (): Promise<string[]> => {
|
||||
const prefix = `${dirName}/`;
|
||||
return (await store.listKeys())
|
||||
.filter((k) => k.startsWith(prefix) && !k.slice(prefix.length).includes('/'))
|
||||
.map((k) => k.slice(prefix.length));
|
||||
},
|
||||
createFile: async (fileName: string, content?: ArrayBuffer): Promise<void> => {
|
||||
await store.write(joinPath(dirName, fileName), content ?? new ArrayBuffer(0));
|
||||
store.commitAll();
|
||||
},
|
||||
hasFile: (fileName: string) => store.has(joinPath(dirName, fileName)),
|
||||
readFile: (fileName: string) => store.read(joinPath(dirName, fileName)),
|
||||
removeFile: async (fileName: string): Promise<void> => {
|
||||
await store.delete(joinPath(dirName, fileName));
|
||||
store.commitAll();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
store,
|
||||
dir: dirView,
|
||||
// 辅助方法面向**根目录**(调用方给出的名字即完整相对路径,便于制造残留文件)
|
||||
listKeys: () => store.listKeys(),
|
||||
createFile: async (name, content) => {
|
||||
await store.write(name, content ?? new ArrayBuffer(0));
|
||||
|
||||
Reference in New Issue
Block a user