Files
MetonaSqlark/tests/helpers/storage-harness.ts
T
thzxx 97b9fa486d 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 已重建。
2026-09-15 01:52:57 +08:00

521 lines
20 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.
/**
* 测试共享 — OPFS 事务性文件存储(v0.8.0 根治版)
*
* ============================================================================
* 为什么需要重写(审计结论,见 PLAN-v0.7.5.md 工作流 C-1
* ============================================================================
* 旧 mocktests/helpers/opfs-mock.ts)有三处与真实 OPFS 语义不符,会掩盖真实缺陷:
* 1. `read()` 返回内部 ArrayBuffer **引用**(真实 OPFS 返回快照副本)
* → 调用方原地修改会"污染磁盘",而真实环境不会;
* 2. `write()` 中 `if (keepExisting || position > 0)` 会在 keepExistingData:false
* 时也保留旧字节(真实 OPFS 该场景应截断);
* 3. `close()` 是空函数、写入**立即对读可见**(真实 OPFS 是写 swap 文件、
* close 时才原子替换)。因此"提交前可见"这类缺陷在旧 mock 下永远测不出来。
*
* 更关键的是:**没有任何 mock 能表达"半写/撕裂/丢失一次写/部分删除失败"**,
* 而项目所有"崩溃恢复"测试用的都是 `backend.close()`(优雅停机,会把写队列刷完),
* 于是崩溃相关的声称在结构上无法被验证。
*
* ============================================================================
* 本实现的两层设计
* ============================================================================
* 第 1 层 `TransactionalFileStore`
* - 纯数据 + 字节级故障注入 + 崩溃模拟(commit/discard),**不依赖任何浏览器 API**。
* 可直接用于单元测试:`store.write('k', buf); store.crashPending(); store.commitAll();`
* - 忠实实现 OPFS 的写语义:createWritable 后写入进入 pending 区,close 时原子提交;
* crash 丢弃全部 pending,已提交内容保持崩溃前状态。
*
* 第 2 层 `installOPFSMock()`
* - 把 store 包装成 `navigator.storage.getDirectory()` 的 OPFS 门面,供 OPFSBackend 使用。
* - 读返回**副本**(不再泄漏内部引用)、keepExistingData:false 时截断、
* close 前不可见(与真实 OPFS 一致)。
*
* 故障注入 API(供故障矩阵测试使用):
* - `failNextWrite(n)` / `failNextAppend(n)` / `failNextDelete(n)`
* - `truncateNextAppendTo(n)` —— 追加只写入前 n 字节(撕裂写)
* - `crashPending()` —— 丢弃全部未提交写入(模拟进程崩溃)
* - `commitAll()` —— 提交全部 pending(模拟 createWritable.close 全部完成)
*/
import type { IStorageBackend } from '../../src/engine/aria/store/backend';
// ---------------------------------------------------------------------------
// 第 1 层:事务性文件存储(无浏览器依赖,可直接单测)
// ---------------------------------------------------------------------------
export interface TransactionalFileStoreOptions {
/** 库名(仅用于日志/调试) */
dbName?: string;
}
/** 一次写入的故障注入配置 */
interface FaultConfig {
failWrite: number;
failAppend: number;
failDelete: number;
/** 追加写只落盘前 N 字节(撕裂写);-1 表示不启用 */
truncateAppendTo: number;
}
export class TransactionalFileStore {
/** 已提交内容(= 真实"磁盘"状态) */
private committed = new Map<string, ArrayBuffer>();
/** 未提交写入(= OPFS createWritable 的 swap 区) */
private pending = new Map<string, ArrayBuffer | null>();
private readonly dbName: string;
private faults: FaultConfig = { failWrite: 0, failAppend: 0, failDelete: 0, truncateAppendTo: -1 };
/** 统计(供断言"到底发生了几次 IO" */
readonly stats = { writes: 0, appends: 0, deletes: 0, reads: 0, commits: 0, crashes: 0 };
constructor(options: TransactionalFileStoreOptions = {}) {
this.dbName = options.dbName ?? 'mock';
}
get name(): string {
return this.dbName;
}
// ---- 故障注入 ----
/** 接下来 n 次 write() 抛错 */
failNextWrite(n = 1): void { this.faults.failWrite = n; }
/** 接下来 n 次 append() 抛错 */
failNextAppend(n = 1): void { this.faults.failAppend = n; }
/** 接下来 n 次 delete() 抛错(模拟 OPFS removeEntry 失败) */
failNextDelete(n = 1): void { this.faults.failDelete = n; }
/** 下一次 append() 只落盘前 n 字节(撕裂写:模拟写入中途断电) */
truncateNextAppendTo(n: number): void { this.faults.truncateAppendTo = n; }
/** 清空全部故障注入 */
clearFaults(): void {
this.faults = { failWrite: 0, failAppend: 0, failDelete: 0, truncateAppendTo: -1 };
}
// ---- 崩溃 / 提交 ----
/**
* 模拟进程崩溃:丢弃**全部**未提交写入。
* 已提交内容保持崩溃前状态(这正是 OPFS copy-on-write 的保证)。
*/
crashPending(): void {
this.pending.clear();
this.stats.crashes++;
}
/** 模拟所有未完成的 createWritable 正常 close:把 pending 原子提交 */
commitAll(): void {
for (const [key, value] of this.pending) {
if (value === null) this.committed.delete(key);
else this.committed.set(key, value);
}
this.pending.clear();
this.stats.commits++;
}
/** 是否存在未提交写入(测试可断言"崩溃窗口"是否真的存在) */
hasPending(): boolean {
return this.pending.size > 0;
}
// ---- 数据操作(与 IStorageBackend 语义对齐,供 FaultyBackend 复用) ----
async read(key: string): Promise<ArrayBuffer | null> {
this.stats.reads++;
// 读取只看已提交内容 —— 未 close 的写入对读不可见(真实 OPFS 语义)
const buf = this.committed.get(key);
if (!buf) return null;
return copyBuffer(buf); // 返回副本,杜绝调用方原地修改污染"磁盘"
}
async write(key: string, data: ArrayBuffer): Promise<void> {
if (this.faults.failWrite > 0) {
this.faults.failWrite--;
throw new Error(`[OPFS-mock] injected write failure on "${key}"`);
}
this.stats.writes++;
this.pending.set(key, copyBuffer(data));
}
async append(key: string, data: ArrayBuffer): Promise<void> {
if (this.faults.failAppend > 0) {
this.faults.failAppend--;
throw new Error(`[OPFS-mock] injected append failure on "${key}"`);
}
this.stats.appends++;
let chunk = copyBuffer(data);
if (this.faults.truncateAppendTo >= 0) {
// 撕裂写:只落盘前 n 字节
chunk = copyBufferHead(chunk, this.faults.truncateAppendTo);
this.faults.truncateAppendTo = -1;
}
// 追加目标是"已提交内容 + pending 中同 key 的写入"(同一 swap 文件内的续写)
const base = this.pending.has(key)
? (this.pending.get(key) as ArrayBuffer | null)
: this.committed.get(key) ?? null;
if (!base) {
this.pending.set(key, chunk);
return;
}
const merged = new Uint8Array(base.byteLength + chunk.byteLength);
merged.set(new Uint8Array(base), 0);
merged.set(new Uint8Array(chunk), base.byteLength);
this.pending.set(key, merged.buffer as ArrayBuffer);
}
/** 覆盖写(keepExistingData: false,真实 OPFS 会截断) */
async overwrite(key: string, data: ArrayBuffer): Promise<void> {
await this.write(key, data);
}
async delete(key: string): Promise<void> {
if (this.faults.failDelete > 0) {
this.faults.failDelete--;
throw new Error(`[OPFS-mock] injected delete failure on "${key}"`);
}
this.stats.deletes++;
this.pending.set(key, null); // 删除也在提交时才生效
}
async listKeys(): Promise<string[]> {
const keys = new Set<string>(this.committed.keys());
for (const [key, value] of this.pending) {
if (value === null) keys.delete(key);
else keys.add(key);
}
return [...keys];
}
async has(key: string): Promise<boolean> {
if (this.pending.has(key)) return this.pending.get(key) !== null;
return this.committed.has(key);
}
/** 直接查看已提交字节数(不经过 read 的副本语义,供结构断言用) */
committedSize(key: string): number {
return this.committed.get(key)?.byteLength ?? 0;
}
/** 已提交内容的只读快照(供"篡改磁盘"类测试:写坏字节后重开观察恢复) */
snapshotCommitted(): Map<string, ArrayBuffer> {
const out = new Map<string, ArrayBuffer>();
for (const [k, v] of this.committed) out.set(k, copyBuffer(v));
return out;
}
/** 用快照替换已提交内容(模拟外部损坏/离线篡改) */
restoreCommitted(snapshot: Map<string, ArrayBuffer>): void {
this.committed = new Map();
for (const [k, v] of snapshot) this.committed.set(k, copyBuffer(v));
this.pending.clear();
}
/** 就地篡改已提交字节(模拟 bit flip / 部分覆盖),不做副本 */
corruptCommitted(key: string, mutate: (bytes: Uint8Array) => void): boolean {
const buf = this.committed.get(key);
if (!buf) return false;
mutate(new Uint8Array(buf));
return true;
}
clear(): void {
this.committed.clear();
this.pending.clear();
}
}
/**
* 复制 ArrayBuffer(杜绝引用泄漏 —— 旧 mock 的缺陷 1)
*
* 注意:不能用 `buf.slice(0)` —— 在 jsdom 环境下 `ArrayBuffer.prototype.slice` 可能被
* Blob/File 的 slice 语义遮蔽,导致 `slice(0, n)` 的参数被忽略、返回整段内容。
* 这里用 Uint8Array 显式复制,行为在任何环境都确定。
*/
function copyBuffer(buf: ArrayBuffer): ArrayBuffer {
const out = new Uint8Array(buf.byteLength);
out.set(new Uint8Array(buf));
return out.buffer as ArrayBuffer;
}
/** 复制前 n 字节(撕裂写用;同上不使用 ArrayBuffer.slice */
function copyBufferHead(buf: ArrayBuffer, n: number): ArrayBuffer {
const len = Math.max(0, Math.min(n, buf.byteLength));
const out = new Uint8Array(len);
out.set(new Uint8Array(buf, 0, len));
return out.buffer as ArrayBuffer;
}
/**
* 结构判别:是否为 `createWritable().write({type,position,data})` 形式。
* 不用 `instanceof ArrayBuffer` —— 跨 realm 时会误判(见 write 内注释)。
*/
function isPositionedWrite(
arg: ArrayBuffer | { type: string; position: number; data: ArrayBuffer },
): arg is { type: string; position: number; data: ArrayBuffer } {
const anyArg = arg as { type?: unknown; position?: unknown; data?: unknown };
return typeof anyArg?.type === 'string'
&& typeof anyArg?.position === 'number'
&& anyArg?.data != null;
}
// ---------------------------------------------------------------------------
// 第 2 层:OPFS 门面(供 OPFSBackend 使用)
// ---------------------------------------------------------------------------
/** 全局注册表:库名 → store(跨 backend 实例共享 = 持久化语义) */
const registry = new Map<string, TransactionalFileStore>();
/** 取(或创建)某库的共享 store */
export function getStore(dbName: string): TransactionalFileStore {
let store = registry.get(dbName);
if (!store) {
store = new TransactionalFileStore({ dbName });
registry.set(dbName, store);
}
return store;
}
/** 清空全局注册表(测试隔离) */
export function clearRegistry(): void {
registry.clear();
}
/**
* 每次调用都重置的 OPFS 安装(推荐在 `beforeEach` 中使用)。
*
* 等价于 `clearRegistry(); installOPFSMock(dbName);`。
* 命名里强调 reset 是为了避免旧写法的误导:此前测试写 `installOPFSMock(new Map())`
* 传入的 Map 会被 mock **静默忽略**(各测试文件各自传 Map 并不能隔离共享状态)。
*/
export function resetOPFSMock(dbName = 'mock'): InstalledOPFSMock {
clearRegistry();
return installOPFSMock(dbName);
}
export interface InstalledOPFSMock {
store: TransactionalFileStore;
/**
* 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>;
}
/**
* 安装 OPFS mock 到 globalThis.navigator.storage.getDirectory()。
*
* @param dbName 库名(决定共享 store;同名多次安装拿到同一个 store)
*/
export function installOPFSMock(dbName = 'mock'): InstalledOPFSMock {
const store = getStore(dbName);
/** 文件路径 = `${目录名}/${文件名}`(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 {
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];
}
},
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 rootDir = makeDir('');
Object.defineProperty(globalThis, 'navigator', {
value: {
storage: {
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));
store.commitAll();
},
hasFile: (name) => store.has(name),
readFile: (name) => store.read(name),
removeFile: async (name) => {
await store.delete(name);
store.commitAll();
},
};
}
/**
* 直接由 TransactionalFileStore 驱动的可崩溃后端。
*
* 用途:验证"真崩溃"(丢弃未提交写入)而非"优雅停机"(close 刷完队列)。
* OPFSBackend 之上无法表达 pending 语义(它每次 write 都会 close 提交),
* 因此需要这一层直连介质的后端来构造"写入进行中崩溃"的窗口。
*/
export class CrashableStoreBackend implements IStorageBackend {
private readonly store: TransactionalFileStore;
constructor(store: TransactionalFileStore) {
this.store = store;
}
open(_name: string): Promise<void> { return Promise.resolve(); }
close(): Promise<void> { return Promise.resolve(); }
isOpen(): boolean { return true; }
read(key: string) { return this.store.read(key); }
write(key: string, data: ArrayBuffer) { return this.store.write(key, data); }
append(key: string, data: ArrayBuffer) { return this.store.append(key, data); }
writeMany(entries: Record<string, ArrayBuffer>) {
return (async () => {
for (const [k, v] of Object.entries(entries)) await this.store.write(k, v);
})();
}
delete(key: string) { return this.store.delete(key); }
deleteMany(keys: string[]) {
return (async () => {
for (const k of keys) await this.store.delete(k);
})();
}
listKeys() { return this.store.listKeys(); }
exists(key: string) { return this.store.has(key); }
clear(): Promise<void> { this.store.clear(); return Promise.resolve(); }
/** 崩溃:丢弃全部未提交写入 */
simulateCrash(): void {
this.store.crashPending();
}
/** 正常提交(模拟所有 createWritable 完成 close */
commit(): void {
this.store.commitAll();
}
}