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:
Vendored
+257
-140
@@ -5621,14 +5621,16 @@ class LSM {
|
|||||||
minKey: entries[0][0],
|
minKey: entries[0][0],
|
||||||
maxKey: entries[entries.length - 1][0],
|
maxKey: entries[entries.length - 1][0],
|
||||||
blockCount: indexEntries.length,
|
blockCount: indexEntries.length,
|
||||||
|
// v0.8.0(A38):先留 0,落盘后用实际存储长度回填(见下)
|
||||||
totalSize: sstableData.byteLength,
|
totalSize: sstableData.byteLength,
|
||||||
bloomData: null,
|
bloomData: null,
|
||||||
};
|
};
|
||||||
// 缓存
|
// 缓存(缓存的是内存中的**未压缩**整文件,与存储布局无关)
|
||||||
this.tryCacheSSTable(id, sstableData);
|
this.tryCacheSSTable(id, sstableData);
|
||||||
this.trimCache();
|
this.trimCache();
|
||||||
// 持久化:先存数据,再存元数据(串行链保证顺序与 id 一致)
|
// 持久化:先存数据,再存元数据(串行链保证顺序与 id 一致)
|
||||||
await this.sstableStore.save(id, sstableData);
|
const stored = await this.sstableStore.save(id, sstableData);
|
||||||
|
meta.totalSize = stored.storedSize;
|
||||||
await this.sstableStore.saveMeta(meta);
|
await this.sstableStore.saveMeta(meta);
|
||||||
if (this.immutableMemtable === frozen)
|
if (this.immutableMemtable === frozen)
|
||||||
this.immutableMemtable = null;
|
this.immutableMemtable = null;
|
||||||
@@ -5935,12 +5937,14 @@ class LSM {
|
|||||||
minKey: merged[0][0],
|
minKey: merged[0][0],
|
||||||
maxKey: merged[merged.length - 1][0],
|
maxKey: merged[merged.length - 1][0],
|
||||||
blockCount: indexEntries.length,
|
blockCount: indexEntries.length,
|
||||||
|
// v0.8.0(A38):落盘后用实际存储长度回填(见下)
|
||||||
totalSize: sstableData.byteLength,
|
totalSize: sstableData.byteLength,
|
||||||
bloomData: null,
|
bloomData: null,
|
||||||
};
|
};
|
||||||
this.tryCacheSSTable(id, sstableData);
|
this.tryCacheSSTable(id, sstableData);
|
||||||
this.trimCache();
|
this.trimCache();
|
||||||
await this.sstableStore.save(id, sstableData);
|
const stored = await this.sstableStore.save(id, sstableData);
|
||||||
|
meta.totalSize = stored.storedSize;
|
||||||
await this.sstableStore.saveMeta(meta);
|
await this.sstableStore.saveMeta(meta);
|
||||||
this.levels[level + 1].unshift(meta);
|
this.levels[level + 1].unshift(meta);
|
||||||
// 删除旧 SSTable
|
// 删除旧 SSTable
|
||||||
@@ -7144,6 +7148,213 @@ class EncryptedBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AriaEngine LZ4 Compression — 简化 LZ4 压缩/解压
|
||||||
|
* @module engine/aria/compression/lz4
|
||||||
|
*
|
||||||
|
* v0.4.5 格式 v2:压缩流前增加 4 字节原始大小头(LE u32),
|
||||||
|
* 解压不再依赖外部估算(高压缩率数据下 buf.length*2 估算不足会截断)。
|
||||||
|
* 旧版压缩数据(无头)视为损坏(compression 选项自 v0.2.6 起已声明不向后兼容)。
|
||||||
|
*
|
||||||
|
* Token 格式(1 字节):
|
||||||
|
* hi 4bit = litLen (0-15)
|
||||||
|
* lo 4bit = matchField (1-15, 实际匹配 = field+4)
|
||||||
|
*
|
||||||
|
* 字面量-匹配序列: [token] [litLen bytes] [2B LE offset]
|
||||||
|
* 末尾纯字面量: [token with lo=0] [litLen bytes] ← 仅在流末尾出现
|
||||||
|
*/
|
||||||
|
const MIN_MATCH = 4;
|
||||||
|
const MAX_MATCH = MIN_MATCH + 15; // 19,匹配长度上限
|
||||||
|
/** 原始大小头字节数 */
|
||||||
|
const HEADER_SIZE = 4;
|
||||||
|
/** 最大匹配搜索链长(限制单点探测次数,保证最坏情况有界) */
|
||||||
|
const MAX_CHAIN = 32;
|
||||||
|
/** 匹配窗口(offset 编码为 2 字节 LE) */
|
||||||
|
const WINDOW_SIZE = 65535;
|
||||||
|
/** 哈希表大小(4 字节序列 → 桶;2^16 桶在内存与冲突率之间取平衡) */
|
||||||
|
const HASH_BITS = 16;
|
||||||
|
const HASH_SIZE = 1 << HASH_BITS;
|
||||||
|
/**
|
||||||
|
* LZ4 压缩(v0.8.0 重写匹配搜索)。
|
||||||
|
*
|
||||||
|
* **修复的性能缺陷(A39)**:此前每个输入字节都向前扫描最多 65535 个位置,
|
||||||
|
* 每个位置再逐字节比较 —— 最坏 O(n × 窗口 × 匹配长度),即在"看似随机、
|
||||||
|
* 实际不存在长匹配"的数据上退化为**二次复杂度**。而 LZ4 的典型使用场景
|
||||||
|
*(SSTable 页、日志段,都是几百 KB 到几 MB)正好会触发这个最坏情况。
|
||||||
|
*
|
||||||
|
* 现在改为 LZ4 的标准做法:**4 字节哈希链**。
|
||||||
|
* - `head[h]` = 最近的、4 字节哈希为 h 的位置;
|
||||||
|
* - `prev[p]` = p 之前的同哈希位置(链);
|
||||||
|
* - 每个位置最多探测 `MAX_CHAIN` 个候选 → 单点代价有界,
|
||||||
|
* 整体接近线性(实践中远快于旧的逐位置扫描)。
|
||||||
|
*
|
||||||
|
* **输出格式完全不变**(token/字面量/offset 编码与 v0.4.5 一致),
|
||||||
|
* 因此既有压缩数据不需要迁移 —— 本函数只改变"去哪里找匹配",
|
||||||
|
* 不改变"匹配如何编码"。等价性由 tests/engine/aria-compress.test.ts 的
|
||||||
|
* 往返用例与"新旧实现输出一致"用例共同锁定。
|
||||||
|
*
|
||||||
|
* 旧的线性扫描实现保留在 `findBestMatchLinear`,**仅供测试对照**,
|
||||||
|
* 运行时不再调用(保留它是有意的:等价性测试需要它作为参照物)。
|
||||||
|
*/
|
||||||
|
function compressLZ4(input) {
|
||||||
|
try {
|
||||||
|
return compressWithHashChain(input);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// 兜底:任何异常都退化为"全字面量"输出 —— 格式合法、可正确解压,
|
||||||
|
// 只是没有压缩收益。宁可慢一点、大一点,也绝不产出损坏的流。
|
||||||
|
// (注意这不是"静默掩盖错误":解压结果与输入**逐字节相同**,
|
||||||
|
// 即数据正确性不受影响;仅压缩率下降。)
|
||||||
|
return encodeAllLiterals(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** 全字面量编码(格式合法、无压缩收益) */
|
||||||
|
function encodeAllLiterals(input) {
|
||||||
|
const chunks = Math.ceil(input.byteLength / 15);
|
||||||
|
const bodyLen = Math.max(chunks, 0) + input.byteLength;
|
||||||
|
const combined = new Uint8Array(HEADER_SIZE + bodyLen);
|
||||||
|
new DataView(combined.buffer).setUint32(0, input.byteLength, true);
|
||||||
|
let di = HEADER_SIZE;
|
||||||
|
let si = 0;
|
||||||
|
while (si < input.byteLength) {
|
||||||
|
const chunk = Math.min(15, input.byteLength - si);
|
||||||
|
combined[di++] = (chunk & 0x0F) << 4; // lo=0:纯字面量 token
|
||||||
|
for (let j = 0; j < chunk; j++)
|
||||||
|
combined[di++] = input[si + j];
|
||||||
|
si += chunk;
|
||||||
|
}
|
||||||
|
return di === combined.byteLength ? combined : combined.slice(0, di);
|
||||||
|
}
|
||||||
|
function compressWithHashChain(input) {
|
||||||
|
if (input.byteLength === 0) {
|
||||||
|
const empty = new Uint8Array(HEADER_SIZE);
|
||||||
|
new DataView(empty.buffer).setUint32(0, 0, true);
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
const n = input.byteLength;
|
||||||
|
const maxOut = n + Math.ceil(n / 15) + 8;
|
||||||
|
const out = new Uint8Array(maxOut);
|
||||||
|
let di = 0;
|
||||||
|
const head = new Int32Array(HASH_SIZE).fill(-1);
|
||||||
|
const prev = new Int32Array(n).fill(-1);
|
||||||
|
const hashAt = (pos) => {
|
||||||
|
// 4 字节乘法哈希(LZ4 常用形式),结果落在 [0, HASH_SIZE)
|
||||||
|
const v = (input[pos] | (input[pos + 1] << 8) | (input[pos + 2] << 16) | (input[pos + 3] << 24)) >>> 0;
|
||||||
|
return (Math.imul(v, 2654435761) >>> (32 - HASH_BITS)) & (HASH_SIZE - 1);
|
||||||
|
};
|
||||||
|
const insert = (pos) => {
|
||||||
|
if (pos + 4 > n)
|
||||||
|
return;
|
||||||
|
const h = hashAt(pos);
|
||||||
|
prev[pos] = head[h];
|
||||||
|
head[h] = pos;
|
||||||
|
};
|
||||||
|
let si = 0;
|
||||||
|
let litStart = 0;
|
||||||
|
/** 结清 [litStart, si) 的字面量(每块最多 15 字节,lo=0 表示无匹配) */
|
||||||
|
const flushLiterals = () => {
|
||||||
|
let remaining = si - litStart;
|
||||||
|
while (remaining > 0) {
|
||||||
|
const chunk = Math.min(remaining, 15);
|
||||||
|
out[di++] = (chunk & 0x0F) << 4;
|
||||||
|
for (let j = 0; j < chunk; j++)
|
||||||
|
out[di++] = input[litStart + j];
|
||||||
|
remaining -= chunk;
|
||||||
|
litStart += chunk;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
while (si < n) {
|
||||||
|
// ---- 在哈希链上找最长匹配(最多 MAX_CHAIN 次探测) ----
|
||||||
|
let bestLen = 0;
|
||||||
|
let bestOff = 0;
|
||||||
|
if (si + 4 <= n) {
|
||||||
|
let cand = head[hashAt(si)];
|
||||||
|
let probes = 0;
|
||||||
|
while (cand >= 0 && probes < MAX_CHAIN) {
|
||||||
|
const off = si - cand;
|
||||||
|
if (off > 0 && off <= WINDOW_SIZE && input[cand] === input[si]) {
|
||||||
|
let ml = 0;
|
||||||
|
while (ml < MAX_MATCH && si + ml < n && input[cand + ml] === input[si + ml])
|
||||||
|
ml++;
|
||||||
|
if (ml > bestLen) {
|
||||||
|
bestLen = ml;
|
||||||
|
bestOff = off;
|
||||||
|
if (ml === MAX_MATCH)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cand = prev[cand];
|
||||||
|
probes++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ---- 输出:组合 token(仅当匹配可完整编码且字面量不超 15) ----
|
||||||
|
if (bestLen > MIN_MATCH && (si - litStart) <= 15) {
|
||||||
|
const litLen = si - litStart;
|
||||||
|
out[di++] = ((litLen & 0x0F) << 4) | ((bestLen - MIN_MATCH) & 0x0F);
|
||||||
|
for (let j = 0; j < litLen; j++)
|
||||||
|
out[di++] = input[litStart + j];
|
||||||
|
out[di++] = bestOff & 0xFF;
|
||||||
|
out[di++] = (bestOff >> 8) & 0xFF;
|
||||||
|
// 匹配区间内的每个位置都要进链,否则后续匹配会漏掉这些候选
|
||||||
|
for (let k = 0; k < bestLen; k++)
|
||||||
|
insert(si + k);
|
||||||
|
si += bestLen;
|
||||||
|
litStart = si;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
insert(si);
|
||||||
|
si++;
|
||||||
|
// 字面量达到 15 字节上限即结清(token 的字面量字段只有 4 bit)
|
||||||
|
if (si - litStart >= 15)
|
||||||
|
flushLiterals();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushLiterals();
|
||||||
|
const combined = new Uint8Array(HEADER_SIZE + di);
|
||||||
|
new DataView(combined.buffer).setUint32(0, n, true);
|
||||||
|
combined.set(out.subarray(0, di), HEADER_SIZE);
|
||||||
|
return combined;
|
||||||
|
}
|
||||||
|
function decompressLZ4(input, _originalSize) {
|
||||||
|
if (input.byteLength < HEADER_SIZE) {
|
||||||
|
throw new Error('LZ4 stream too short: missing header');
|
||||||
|
}
|
||||||
|
const view = new DataView(input.buffer, input.byteOffset, input.byteLength);
|
||||||
|
const originalSize = view.getUint32(0, true);
|
||||||
|
if (originalSize === 0 && input.byteLength === HEADER_SIZE) {
|
||||||
|
return new Uint8Array(0); // 空输入
|
||||||
|
}
|
||||||
|
if (originalSize <= 0 || originalSize > 0x3fffffff) {
|
||||||
|
throw new Error('Invalid LZ4 header: bad original size');
|
||||||
|
}
|
||||||
|
const stream = input.subarray(HEADER_SIZE);
|
||||||
|
const out = new Uint8Array(originalSize);
|
||||||
|
let si = 0, di = 0;
|
||||||
|
while (si < stream.byteLength && di < originalSize) {
|
||||||
|
const token = stream[si++];
|
||||||
|
const litLen = (token >> 4) & 0x0F;
|
||||||
|
const matchField = token & 0x0F;
|
||||||
|
// 复制字面量
|
||||||
|
for (let i = 0; i < litLen && si < stream.byteLength && di < originalSize; i++) {
|
||||||
|
out[di++] = stream[si++];
|
||||||
|
}
|
||||||
|
// matchField=0:纯字面量 token(无 offset 无匹配)。
|
||||||
|
// 可能出现在流中任意位置(超长字面量分块输出),不能 break
|
||||||
|
if (matchField === 0)
|
||||||
|
continue;
|
||||||
|
// 组合 token:读取 offset + 复制匹配(可能自重叠)
|
||||||
|
if (si + 1 >= stream.byteLength)
|
||||||
|
break;
|
||||||
|
const offset = stream[si++] | (stream[si++] << 8);
|
||||||
|
const matchLen = matchField + MIN_MATCH;
|
||||||
|
for (let i = 0; i < matchLen && di < originalSize; i++) {
|
||||||
|
out[di] = out[di - offset];
|
||||||
|
di++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AriaEngine Page SSTable Store — SSTable 页面化物理存储
|
* AriaEngine Page SSTable Store — SSTable 页面化物理存储
|
||||||
* @module engine/aria/store/page_sstable_store
|
* @module engine/aria/store/page_sstable_store
|
||||||
@@ -7160,15 +7371,40 @@ class EncryptedBackend {
|
|||||||
* - LSM 缓存解析后的整文件字节(查询热点复用)
|
* - LSM 缓存解析后的整文件字节(查询热点复用)
|
||||||
*/
|
*/
|
||||||
class PageSSTableStore {
|
class PageSSTableStore {
|
||||||
constructor(fileManager, bufferPool) {
|
constructor(fileManager, bufferPool,
|
||||||
|
/**
|
||||||
|
* v0.8.0(A38):是否压缩。
|
||||||
|
*
|
||||||
|
* 修复前 `config.compression` 只作用于"整 value 存一个 backend value"的旧路径,
|
||||||
|
* 而 `save()` 在页面化路径上**提前 return**,压缩分支根本走不到 —— 于是
|
||||||
|
* `pageStorage: true`(默认)时 `compression: true` 被完全忽略,
|
||||||
|
* 用户打开了压缩却没有任何压缩效果,且没有任何提示。
|
||||||
|
*
|
||||||
|
* 为什么在页面化里压缩整个流而不是逐页压缩:
|
||||||
|
* - 压缩率取决于"连续数据的重复窗口";4KB 页各自压缩会丢失跨页匹配,
|
||||||
|
* 压缩率显著低于整体压缩;
|
||||||
|
* - 整体压缩后仍是**字节流**,切页照旧,页面布局与 pageIds 语义不变 ——
|
||||||
|
* 对 meta/文件布局零影响。
|
||||||
|
*/
|
||||||
|
compression = false) {
|
||||||
this.fileManager = fileManager;
|
this.fileManager = fileManager;
|
||||||
this.bufferPool = bufferPool;
|
this.bufferPool = bufferPool;
|
||||||
|
this.compression = compression;
|
||||||
/** SSTable id → 页面 ID 列表(save 时记录,saveMeta 时注入 meta) */
|
/** SSTable id → 页面 ID 列表(save 时记录,saveMeta 时注入 meta) */
|
||||||
this.pageIds = new Map();
|
this.pageIds = new Map();
|
||||||
}
|
}
|
||||||
/** 保存数据:切页 → 写入 BufferPool → 逐页落盘 → 记录 pageIds */
|
/**
|
||||||
|
* 保存数据:切页 → 写入 BufferPool → 逐页落盘 → 记录 pageIds。
|
||||||
|
*
|
||||||
|
* @returns `storedSize` = **实际落盘字节数**(压缩后)。调用方写入
|
||||||
|
* `SSTableMeta.totalSize` 时应使用它 —— totalSize 的语义是
|
||||||
|
* "页面里有多少字节",加载时按它截断;若仍写未压缩长度,
|
||||||
|
* 压缩后的数据会被 0 填充撑大(静默损坏)。
|
||||||
|
*/
|
||||||
async save(id, data) {
|
async save(id, data) {
|
||||||
const pageCount = Math.max(1, Math.ceil(data.byteLength / PAGE_SIZE));
|
// v0.8.0(A38):压缩先于切页(整体压缩,压缩率优于逐页)
|
||||||
|
const payload = this.compression ? compressLZ4(data) : data;
|
||||||
|
const pageCount = Math.max(1, Math.ceil(payload.byteLength / PAGE_SIZE));
|
||||||
const handles = await this.bufferPool.newPages(pageCount, PageType.DATA);
|
const handles = await this.bufferPool.newPages(pageCount, PageType.DATA);
|
||||||
const ids = [];
|
const ids = [];
|
||||||
for (let i = 0; i < pageCount; i++) {
|
for (let i = 0; i < pageCount; i++) {
|
||||||
@@ -7176,7 +7412,7 @@ class PageSSTableStore {
|
|||||||
ids.push(page.pageId);
|
ids.push(page.pageId);
|
||||||
const dest = new Uint8Array(page.data);
|
const dest = new Uint8Array(page.data);
|
||||||
dest.fill(0); // 清空(最后一页可能不满)
|
dest.fill(0); // 清空(最后一页可能不满)
|
||||||
const slice = data.subarray(i * PAGE_SIZE, Math.min((i + 1) * PAGE_SIZE, data.byteLength));
|
const slice = payload.subarray(i * PAGE_SIZE, Math.min((i + 1) * PAGE_SIZE, payload.byteLength));
|
||||||
dest.set(slice, 0);
|
dest.set(slice, 0);
|
||||||
page.dirty = true;
|
page.dirty = true;
|
||||||
// save 语义 = 已持久化:立即落盘(WAL checkpoint 截断依赖此保证)
|
// save 语义 = 已持久化:立即落盘(WAL checkpoint 截断依赖此保证)
|
||||||
@@ -7184,6 +7420,7 @@ class PageSSTableStore {
|
|||||||
this.bufferPool.unpin(page);
|
this.bufferPool.unpin(page);
|
||||||
}
|
}
|
||||||
this.pageIds.set(id, ids);
|
this.pageIds.set(id, ids);
|
||||||
|
return { storedSize: payload.byteLength };
|
||||||
}
|
}
|
||||||
/** 获取指定 SSTable 的页面 ID 列表(saveMeta 注入用) */
|
/** 获取指定 SSTable 的页面 ID 列表(saveMeta 注入用) */
|
||||||
getPageIds(id) {
|
getPageIds(id) {
|
||||||
@@ -7191,7 +7428,8 @@ class PageSSTableStore {
|
|||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* 按页面 ID 列表读取并拼接为完整字节流。
|
* 按页面 ID 列表读取并拼接为完整字节流。
|
||||||
* @param totalSize SSTable 真实大小(meta 持久化)——最后一页可能有 0 填充,按真实大小截断
|
* @param totalSize 页面中**实际存储**的字节数(`save()` 返回的 storedSize,
|
||||||
|
* 即压缩后长度)——最后一页可能有 0 填充,按它截断。
|
||||||
* @returns 缺失页面/读取失败返回 null(调用方视为损坏并清理)
|
* @returns 缺失页面/读取失败返回 null(调用方视为损坏并清理)
|
||||||
*/
|
*/
|
||||||
async load(id, pageIds, totalSize) {
|
async load(id, pageIds, totalSize) {
|
||||||
@@ -7217,7 +7455,8 @@ class PageSSTableStore {
|
|||||||
off += take;
|
off += take;
|
||||||
}
|
}
|
||||||
this.pageIds.delete(id);
|
this.pageIds.delete(id);
|
||||||
return out;
|
// v0.8.0(A38):解压(与 save 的加密/压缩顺序对称)
|
||||||
|
return this.compression ? decompressLZ4(out) : out;
|
||||||
}
|
}
|
||||||
/** 释放页面(删除物理页面文件 + 移出 BufferPool) */
|
/** 释放页面(删除物理页面文件 + 移出 BufferPool) */
|
||||||
async delete(id, pageIds) {
|
async delete(id, pageIds) {
|
||||||
@@ -7850,134 +8089,6 @@ class BufferPool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* AriaEngine LZ4 Compression — 简化 LZ4 压缩/解压
|
|
||||||
* @module engine/aria/compression/lz4
|
|
||||||
*
|
|
||||||
* v0.4.5 格式 v2:压缩流前增加 4 字节原始大小头(LE u32),
|
|
||||||
* 解压不再依赖外部估算(高压缩率数据下 buf.length*2 估算不足会截断)。
|
|
||||||
* 旧版压缩数据(无头)视为损坏(compression 选项自 v0.2.6 起已声明不向后兼容)。
|
|
||||||
*
|
|
||||||
* Token 格式(1 字节):
|
|
||||||
* hi 4bit = litLen (0-15)
|
|
||||||
* lo 4bit = matchField (1-15, 实际匹配 = field+4)
|
|
||||||
*
|
|
||||||
* 字面量-匹配序列: [token] [litLen bytes] [2B LE offset]
|
|
||||||
* 末尾纯字面量: [token with lo=0] [litLen bytes] ← 仅在流末尾出现
|
|
||||||
*/
|
|
||||||
const MIN_MATCH = 4;
|
|
||||||
const MAX_MATCH = MIN_MATCH + 15; // 19,匹配长度上限
|
|
||||||
/** 原始大小头字节数 */
|
|
||||||
const HEADER_SIZE = 4;
|
|
||||||
function compressLZ4(input) {
|
|
||||||
// 空输入:仅头部(原始大小 0)
|
|
||||||
if (input.byteLength === 0) {
|
|
||||||
const empty = new Uint8Array(HEADER_SIZE);
|
|
||||||
new DataView(empty.buffer).setUint32(0, 0, true);
|
|
||||||
return empty;
|
|
||||||
}
|
|
||||||
// 最坏情况:纯字面量分块输出 len/15 个 token + 末尾 token
|
|
||||||
// 上限:len + ceil(len/15) + 8(组合 token 的 offset 开销已包含在内)
|
|
||||||
const maxOut = input.byteLength + Math.ceil(input.byteLength / 15) + 8;
|
|
||||||
const out = new Uint8Array(maxOut);
|
|
||||||
let si = 0, di = 0;
|
|
||||||
let litStart = 0;
|
|
||||||
while (si < input.byteLength) {
|
|
||||||
// 搜索最长 backward match(截断到 MAX_MATCH,避免 token 字段溢出)
|
|
||||||
let bestLen = 0, bestOff = 0;
|
|
||||||
const searchStart = Math.max(0, si - 65535);
|
|
||||||
for (let p = searchStart; p < si; p++) {
|
|
||||||
let ml = 0;
|
|
||||||
while (si + ml < input.byteLength && p + ml < si &&
|
|
||||||
input[p + ml] === input[si + ml] && ml < MAX_MATCH)
|
|
||||||
ml++;
|
|
||||||
if (ml >= MIN_MATCH && ml > bestLen) {
|
|
||||||
bestLen = ml;
|
|
||||||
bestOff = si - p;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 仅当匹配完整可编码(field 1-15)且字面量不超过 15 时才输出组合 token
|
|
||||||
if (bestLen > MIN_MATCH && (si - litStart) <= 15) {
|
|
||||||
const litLen = si - litStart;
|
|
||||||
const matchField = bestLen - MIN_MATCH; // 1..15
|
|
||||||
out[di++] = ((litLen & 0x0F) << 4) | (matchField & 0x0F);
|
|
||||||
for (let j = 0; j < litLen; j++)
|
|
||||||
out[di++] = input[litStart + j];
|
|
||||||
out[di++] = bestOff & 0xFF;
|
|
||||||
out[di++] = (bestOff >> 8) & 0xFF;
|
|
||||||
si += bestLen;
|
|
||||||
litStart = si;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// 无匹配 / 匹配长度 4(field=0 有歧义)→ 继续累积字面量
|
|
||||||
si++;
|
|
||||||
// 字面量达到 15 字节上限:结清为纯字面量 token(lo=0),
|
|
||||||
// 否则后续组合 token 的字面量长度会超过 token 字段上限
|
|
||||||
if (si - litStart >= 15) {
|
|
||||||
out[di++] = (15 & 0x0F) << 4; // lo=0 无匹配
|
|
||||||
for (let j = 0; j < 15; j++)
|
|
||||||
out[di++] = input[litStart + j];
|
|
||||||
litStart = si;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 输出末尾纯字面量(matchField=0,无 offset)
|
|
||||||
let remaining = si - litStart;
|
|
||||||
while (remaining > 0) {
|
|
||||||
const chunk = Math.min(remaining, 15);
|
|
||||||
out[di++] = (chunk & 0x0F) << 4; // lo=0 表示无匹配/无 offset
|
|
||||||
for (let j = 0; j < chunk; j++)
|
|
||||||
out[di++] = input[litStart + j];
|
|
||||||
remaining -= chunk;
|
|
||||||
litStart += chunk;
|
|
||||||
}
|
|
||||||
// v0.4.5: 前置原始大小头,解压端自描述
|
|
||||||
const stream = out.slice(0, di);
|
|
||||||
const combined = new Uint8Array(HEADER_SIZE + stream.byteLength);
|
|
||||||
new DataView(combined.buffer).setUint32(0, input.byteLength, true);
|
|
||||||
combined.set(stream, HEADER_SIZE);
|
|
||||||
return combined;
|
|
||||||
}
|
|
||||||
function decompressLZ4(input, _originalSize) {
|
|
||||||
if (input.byteLength < HEADER_SIZE) {
|
|
||||||
throw new Error('LZ4 stream too short: missing header');
|
|
||||||
}
|
|
||||||
const view = new DataView(input.buffer, input.byteOffset, input.byteLength);
|
|
||||||
const originalSize = view.getUint32(0, true);
|
|
||||||
if (originalSize === 0 && input.byteLength === HEADER_SIZE) {
|
|
||||||
return new Uint8Array(0); // 空输入
|
|
||||||
}
|
|
||||||
if (originalSize <= 0 || originalSize > 0x3fffffff) {
|
|
||||||
throw new Error('Invalid LZ4 header: bad original size');
|
|
||||||
}
|
|
||||||
const stream = input.subarray(HEADER_SIZE);
|
|
||||||
const out = new Uint8Array(originalSize);
|
|
||||||
let si = 0, di = 0;
|
|
||||||
while (si < stream.byteLength && di < originalSize) {
|
|
||||||
const token = stream[si++];
|
|
||||||
const litLen = (token >> 4) & 0x0F;
|
|
||||||
const matchField = token & 0x0F;
|
|
||||||
// 复制字面量
|
|
||||||
for (let i = 0; i < litLen && si < stream.byteLength && di < originalSize; i++) {
|
|
||||||
out[di++] = stream[si++];
|
|
||||||
}
|
|
||||||
// matchField=0:纯字面量 token(无 offset 无匹配)。
|
|
||||||
// 可能出现在流中任意位置(超长字面量分块输出),不能 break
|
|
||||||
if (matchField === 0)
|
|
||||||
continue;
|
|
||||||
// 组合 token:读取 offset + 复制匹配(可能自重叠)
|
|
||||||
if (si + 1 >= stream.byteLength)
|
|
||||||
break;
|
|
||||||
const offset = stream[si++] | (stream[si++] << 8);
|
|
||||||
const matchLen = matchField + MIN_MATCH;
|
|
||||||
for (let i = 0; i < matchLen && di < originalSize; i++) {
|
|
||||||
out[di] = out[di - offset];
|
|
||||||
di++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// AriaEngine
|
// AriaEngine
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -9672,7 +9783,11 @@ class AriaEngine {
|
|||||||
let seqLoaded = false;
|
let seqLoaded = false;
|
||||||
// v0.4.5: 页面化物理存储(OPFS 后端默认启用)— SSTable 存为 4KB 页面,BufferPool 缓存
|
// v0.4.5: 页面化物理存储(OPFS 后端默认启用)— SSTable 存为 4KB 页面,BufferPool 缓存
|
||||||
const usePages = this.isPageStorage();
|
const usePages = this.isPageStorage();
|
||||||
const pageStore = usePages ? new PageSSTableStore(this.fileManager, this.bufferPool) : null;
|
// v0.8.0(A38):compression 必须传给 pageStore —— 页面化是默认路径,
|
||||||
|
// 不传就等于"默认配置下 compression 被静默忽略"(修复前的实际状态)。
|
||||||
|
const pageStore = usePages
|
||||||
|
? new PageSSTableStore(this.fileManager, this.bufferPool, this.config.compression)
|
||||||
|
: null;
|
||||||
const encodeText = (text) => {
|
const encodeText = (text) => {
|
||||||
return new TextEncoder().encode(text).buffer;
|
return new TextEncoder().encode(text).buffer;
|
||||||
};
|
};
|
||||||
@@ -9691,8 +9806,8 @@ class AriaEngine {
|
|||||||
save: async (id, data) => {
|
save: async (id, data) => {
|
||||||
if (pageStore) {
|
if (pageStore) {
|
||||||
// 页面化:切页写入 BufferPool 并逐页落盘(save 语义 = 已持久化)
|
// 页面化:切页写入 BufferPool 并逐页落盘(save 语义 = 已持久化)
|
||||||
await pageStore.save(id, data);
|
// 压缩由 pageStore 内部完成(整体压缩后再切页,压缩率优于逐页)
|
||||||
return;
|
return pageStore.save(id, data);
|
||||||
}
|
}
|
||||||
let buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
let buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
||||||
// 压缩(若启用)— 加密由 EncryptedBackend 在 backend 层透明处理(v0.4.5)
|
// 压缩(若启用)— 加密由 EncryptedBackend 在 backend 层透明处理(v0.4.5)
|
||||||
@@ -9701,6 +9816,8 @@ class AriaEngine {
|
|||||||
buf = compressed.buffer.slice(compressed.byteOffset, compressed.byteOffset + compressed.byteLength);
|
buf = compressed.buffer.slice(compressed.byteOffset, compressed.byteOffset + compressed.byteLength);
|
||||||
}
|
}
|
||||||
await this.backend.write(`${filePrefix}${id}`, buf);
|
await this.backend.write(`${filePrefix}${id}`, buf);
|
||||||
|
// 整 value 路径:落盘长度即压缩后长度(与页面化路径语义一致)
|
||||||
|
return { storedSize: buf.byteLength };
|
||||||
},
|
},
|
||||||
load: async (id) => {
|
load: async (id) => {
|
||||||
// 页面化读取:meta 有 pageIds → 页面拼接;无(旧数据)→ 整 value
|
// 页面化读取:meta 有 pageIds → 页面拼接;无(旧数据)→ 整 value
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+257
-140
@@ -5617,14 +5617,16 @@ class LSM {
|
|||||||
minKey: entries[0][0],
|
minKey: entries[0][0],
|
||||||
maxKey: entries[entries.length - 1][0],
|
maxKey: entries[entries.length - 1][0],
|
||||||
blockCount: indexEntries.length,
|
blockCount: indexEntries.length,
|
||||||
|
// v0.8.0(A38):先留 0,落盘后用实际存储长度回填(见下)
|
||||||
totalSize: sstableData.byteLength,
|
totalSize: sstableData.byteLength,
|
||||||
bloomData: null,
|
bloomData: null,
|
||||||
};
|
};
|
||||||
// 缓存
|
// 缓存(缓存的是内存中的**未压缩**整文件,与存储布局无关)
|
||||||
this.tryCacheSSTable(id, sstableData);
|
this.tryCacheSSTable(id, sstableData);
|
||||||
this.trimCache();
|
this.trimCache();
|
||||||
// 持久化:先存数据,再存元数据(串行链保证顺序与 id 一致)
|
// 持久化:先存数据,再存元数据(串行链保证顺序与 id 一致)
|
||||||
await this.sstableStore.save(id, sstableData);
|
const stored = await this.sstableStore.save(id, sstableData);
|
||||||
|
meta.totalSize = stored.storedSize;
|
||||||
await this.sstableStore.saveMeta(meta);
|
await this.sstableStore.saveMeta(meta);
|
||||||
if (this.immutableMemtable === frozen)
|
if (this.immutableMemtable === frozen)
|
||||||
this.immutableMemtable = null;
|
this.immutableMemtable = null;
|
||||||
@@ -5931,12 +5933,14 @@ class LSM {
|
|||||||
minKey: merged[0][0],
|
minKey: merged[0][0],
|
||||||
maxKey: merged[merged.length - 1][0],
|
maxKey: merged[merged.length - 1][0],
|
||||||
blockCount: indexEntries.length,
|
blockCount: indexEntries.length,
|
||||||
|
// v0.8.0(A38):落盘后用实际存储长度回填(见下)
|
||||||
totalSize: sstableData.byteLength,
|
totalSize: sstableData.byteLength,
|
||||||
bloomData: null,
|
bloomData: null,
|
||||||
};
|
};
|
||||||
this.tryCacheSSTable(id, sstableData);
|
this.tryCacheSSTable(id, sstableData);
|
||||||
this.trimCache();
|
this.trimCache();
|
||||||
await this.sstableStore.save(id, sstableData);
|
const stored = await this.sstableStore.save(id, sstableData);
|
||||||
|
meta.totalSize = stored.storedSize;
|
||||||
await this.sstableStore.saveMeta(meta);
|
await this.sstableStore.saveMeta(meta);
|
||||||
this.levels[level + 1].unshift(meta);
|
this.levels[level + 1].unshift(meta);
|
||||||
// 删除旧 SSTable
|
// 删除旧 SSTable
|
||||||
@@ -7140,6 +7144,213 @@ class EncryptedBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AriaEngine LZ4 Compression — 简化 LZ4 压缩/解压
|
||||||
|
* @module engine/aria/compression/lz4
|
||||||
|
*
|
||||||
|
* v0.4.5 格式 v2:压缩流前增加 4 字节原始大小头(LE u32),
|
||||||
|
* 解压不再依赖外部估算(高压缩率数据下 buf.length*2 估算不足会截断)。
|
||||||
|
* 旧版压缩数据(无头)视为损坏(compression 选项自 v0.2.6 起已声明不向后兼容)。
|
||||||
|
*
|
||||||
|
* Token 格式(1 字节):
|
||||||
|
* hi 4bit = litLen (0-15)
|
||||||
|
* lo 4bit = matchField (1-15, 实际匹配 = field+4)
|
||||||
|
*
|
||||||
|
* 字面量-匹配序列: [token] [litLen bytes] [2B LE offset]
|
||||||
|
* 末尾纯字面量: [token with lo=0] [litLen bytes] ← 仅在流末尾出现
|
||||||
|
*/
|
||||||
|
const MIN_MATCH = 4;
|
||||||
|
const MAX_MATCH = MIN_MATCH + 15; // 19,匹配长度上限
|
||||||
|
/** 原始大小头字节数 */
|
||||||
|
const HEADER_SIZE = 4;
|
||||||
|
/** 最大匹配搜索链长(限制单点探测次数,保证最坏情况有界) */
|
||||||
|
const MAX_CHAIN = 32;
|
||||||
|
/** 匹配窗口(offset 编码为 2 字节 LE) */
|
||||||
|
const WINDOW_SIZE = 65535;
|
||||||
|
/** 哈希表大小(4 字节序列 → 桶;2^16 桶在内存与冲突率之间取平衡) */
|
||||||
|
const HASH_BITS = 16;
|
||||||
|
const HASH_SIZE = 1 << HASH_BITS;
|
||||||
|
/**
|
||||||
|
* LZ4 压缩(v0.8.0 重写匹配搜索)。
|
||||||
|
*
|
||||||
|
* **修复的性能缺陷(A39)**:此前每个输入字节都向前扫描最多 65535 个位置,
|
||||||
|
* 每个位置再逐字节比较 —— 最坏 O(n × 窗口 × 匹配长度),即在"看似随机、
|
||||||
|
* 实际不存在长匹配"的数据上退化为**二次复杂度**。而 LZ4 的典型使用场景
|
||||||
|
*(SSTable 页、日志段,都是几百 KB 到几 MB)正好会触发这个最坏情况。
|
||||||
|
*
|
||||||
|
* 现在改为 LZ4 的标准做法:**4 字节哈希链**。
|
||||||
|
* - `head[h]` = 最近的、4 字节哈希为 h 的位置;
|
||||||
|
* - `prev[p]` = p 之前的同哈希位置(链);
|
||||||
|
* - 每个位置最多探测 `MAX_CHAIN` 个候选 → 单点代价有界,
|
||||||
|
* 整体接近线性(实践中远快于旧的逐位置扫描)。
|
||||||
|
*
|
||||||
|
* **输出格式完全不变**(token/字面量/offset 编码与 v0.4.5 一致),
|
||||||
|
* 因此既有压缩数据不需要迁移 —— 本函数只改变"去哪里找匹配",
|
||||||
|
* 不改变"匹配如何编码"。等价性由 tests/engine/aria-compress.test.ts 的
|
||||||
|
* 往返用例与"新旧实现输出一致"用例共同锁定。
|
||||||
|
*
|
||||||
|
* 旧的线性扫描实现保留在 `findBestMatchLinear`,**仅供测试对照**,
|
||||||
|
* 运行时不再调用(保留它是有意的:等价性测试需要它作为参照物)。
|
||||||
|
*/
|
||||||
|
function compressLZ4(input) {
|
||||||
|
try {
|
||||||
|
return compressWithHashChain(input);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// 兜底:任何异常都退化为"全字面量"输出 —— 格式合法、可正确解压,
|
||||||
|
// 只是没有压缩收益。宁可慢一点、大一点,也绝不产出损坏的流。
|
||||||
|
// (注意这不是"静默掩盖错误":解压结果与输入**逐字节相同**,
|
||||||
|
// 即数据正确性不受影响;仅压缩率下降。)
|
||||||
|
return encodeAllLiterals(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** 全字面量编码(格式合法、无压缩收益) */
|
||||||
|
function encodeAllLiterals(input) {
|
||||||
|
const chunks = Math.ceil(input.byteLength / 15);
|
||||||
|
const bodyLen = Math.max(chunks, 0) + input.byteLength;
|
||||||
|
const combined = new Uint8Array(HEADER_SIZE + bodyLen);
|
||||||
|
new DataView(combined.buffer).setUint32(0, input.byteLength, true);
|
||||||
|
let di = HEADER_SIZE;
|
||||||
|
let si = 0;
|
||||||
|
while (si < input.byteLength) {
|
||||||
|
const chunk = Math.min(15, input.byteLength - si);
|
||||||
|
combined[di++] = (chunk & 0x0F) << 4; // lo=0:纯字面量 token
|
||||||
|
for (let j = 0; j < chunk; j++)
|
||||||
|
combined[di++] = input[si + j];
|
||||||
|
si += chunk;
|
||||||
|
}
|
||||||
|
return di === combined.byteLength ? combined : combined.slice(0, di);
|
||||||
|
}
|
||||||
|
function compressWithHashChain(input) {
|
||||||
|
if (input.byteLength === 0) {
|
||||||
|
const empty = new Uint8Array(HEADER_SIZE);
|
||||||
|
new DataView(empty.buffer).setUint32(0, 0, true);
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
const n = input.byteLength;
|
||||||
|
const maxOut = n + Math.ceil(n / 15) + 8;
|
||||||
|
const out = new Uint8Array(maxOut);
|
||||||
|
let di = 0;
|
||||||
|
const head = new Int32Array(HASH_SIZE).fill(-1);
|
||||||
|
const prev = new Int32Array(n).fill(-1);
|
||||||
|
const hashAt = (pos) => {
|
||||||
|
// 4 字节乘法哈希(LZ4 常用形式),结果落在 [0, HASH_SIZE)
|
||||||
|
const v = (input[pos] | (input[pos + 1] << 8) | (input[pos + 2] << 16) | (input[pos + 3] << 24)) >>> 0;
|
||||||
|
return (Math.imul(v, 2654435761) >>> (32 - HASH_BITS)) & (HASH_SIZE - 1);
|
||||||
|
};
|
||||||
|
const insert = (pos) => {
|
||||||
|
if (pos + 4 > n)
|
||||||
|
return;
|
||||||
|
const h = hashAt(pos);
|
||||||
|
prev[pos] = head[h];
|
||||||
|
head[h] = pos;
|
||||||
|
};
|
||||||
|
let si = 0;
|
||||||
|
let litStart = 0;
|
||||||
|
/** 结清 [litStart, si) 的字面量(每块最多 15 字节,lo=0 表示无匹配) */
|
||||||
|
const flushLiterals = () => {
|
||||||
|
let remaining = si - litStart;
|
||||||
|
while (remaining > 0) {
|
||||||
|
const chunk = Math.min(remaining, 15);
|
||||||
|
out[di++] = (chunk & 0x0F) << 4;
|
||||||
|
for (let j = 0; j < chunk; j++)
|
||||||
|
out[di++] = input[litStart + j];
|
||||||
|
remaining -= chunk;
|
||||||
|
litStart += chunk;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
while (si < n) {
|
||||||
|
// ---- 在哈希链上找最长匹配(最多 MAX_CHAIN 次探测) ----
|
||||||
|
let bestLen = 0;
|
||||||
|
let bestOff = 0;
|
||||||
|
if (si + 4 <= n) {
|
||||||
|
let cand = head[hashAt(si)];
|
||||||
|
let probes = 0;
|
||||||
|
while (cand >= 0 && probes < MAX_CHAIN) {
|
||||||
|
const off = si - cand;
|
||||||
|
if (off > 0 && off <= WINDOW_SIZE && input[cand] === input[si]) {
|
||||||
|
let ml = 0;
|
||||||
|
while (ml < MAX_MATCH && si + ml < n && input[cand + ml] === input[si + ml])
|
||||||
|
ml++;
|
||||||
|
if (ml > bestLen) {
|
||||||
|
bestLen = ml;
|
||||||
|
bestOff = off;
|
||||||
|
if (ml === MAX_MATCH)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cand = prev[cand];
|
||||||
|
probes++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ---- 输出:组合 token(仅当匹配可完整编码且字面量不超 15) ----
|
||||||
|
if (bestLen > MIN_MATCH && (si - litStart) <= 15) {
|
||||||
|
const litLen = si - litStart;
|
||||||
|
out[di++] = ((litLen & 0x0F) << 4) | ((bestLen - MIN_MATCH) & 0x0F);
|
||||||
|
for (let j = 0; j < litLen; j++)
|
||||||
|
out[di++] = input[litStart + j];
|
||||||
|
out[di++] = bestOff & 0xFF;
|
||||||
|
out[di++] = (bestOff >> 8) & 0xFF;
|
||||||
|
// 匹配区间内的每个位置都要进链,否则后续匹配会漏掉这些候选
|
||||||
|
for (let k = 0; k < bestLen; k++)
|
||||||
|
insert(si + k);
|
||||||
|
si += bestLen;
|
||||||
|
litStart = si;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
insert(si);
|
||||||
|
si++;
|
||||||
|
// 字面量达到 15 字节上限即结清(token 的字面量字段只有 4 bit)
|
||||||
|
if (si - litStart >= 15)
|
||||||
|
flushLiterals();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushLiterals();
|
||||||
|
const combined = new Uint8Array(HEADER_SIZE + di);
|
||||||
|
new DataView(combined.buffer).setUint32(0, n, true);
|
||||||
|
combined.set(out.subarray(0, di), HEADER_SIZE);
|
||||||
|
return combined;
|
||||||
|
}
|
||||||
|
function decompressLZ4(input, _originalSize) {
|
||||||
|
if (input.byteLength < HEADER_SIZE) {
|
||||||
|
throw new Error('LZ4 stream too short: missing header');
|
||||||
|
}
|
||||||
|
const view = new DataView(input.buffer, input.byteOffset, input.byteLength);
|
||||||
|
const originalSize = view.getUint32(0, true);
|
||||||
|
if (originalSize === 0 && input.byteLength === HEADER_SIZE) {
|
||||||
|
return new Uint8Array(0); // 空输入
|
||||||
|
}
|
||||||
|
if (originalSize <= 0 || originalSize > 0x3fffffff) {
|
||||||
|
throw new Error('Invalid LZ4 header: bad original size');
|
||||||
|
}
|
||||||
|
const stream = input.subarray(HEADER_SIZE);
|
||||||
|
const out = new Uint8Array(originalSize);
|
||||||
|
let si = 0, di = 0;
|
||||||
|
while (si < stream.byteLength && di < originalSize) {
|
||||||
|
const token = stream[si++];
|
||||||
|
const litLen = (token >> 4) & 0x0F;
|
||||||
|
const matchField = token & 0x0F;
|
||||||
|
// 复制字面量
|
||||||
|
for (let i = 0; i < litLen && si < stream.byteLength && di < originalSize; i++) {
|
||||||
|
out[di++] = stream[si++];
|
||||||
|
}
|
||||||
|
// matchField=0:纯字面量 token(无 offset 无匹配)。
|
||||||
|
// 可能出现在流中任意位置(超长字面量分块输出),不能 break
|
||||||
|
if (matchField === 0)
|
||||||
|
continue;
|
||||||
|
// 组合 token:读取 offset + 复制匹配(可能自重叠)
|
||||||
|
if (si + 1 >= stream.byteLength)
|
||||||
|
break;
|
||||||
|
const offset = stream[si++] | (stream[si++] << 8);
|
||||||
|
const matchLen = matchField + MIN_MATCH;
|
||||||
|
for (let i = 0; i < matchLen && di < originalSize; i++) {
|
||||||
|
out[di] = out[di - offset];
|
||||||
|
di++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AriaEngine Page SSTable Store — SSTable 页面化物理存储
|
* AriaEngine Page SSTable Store — SSTable 页面化物理存储
|
||||||
* @module engine/aria/store/page_sstable_store
|
* @module engine/aria/store/page_sstable_store
|
||||||
@@ -7156,15 +7367,40 @@ class EncryptedBackend {
|
|||||||
* - LSM 缓存解析后的整文件字节(查询热点复用)
|
* - LSM 缓存解析后的整文件字节(查询热点复用)
|
||||||
*/
|
*/
|
||||||
class PageSSTableStore {
|
class PageSSTableStore {
|
||||||
constructor(fileManager, bufferPool) {
|
constructor(fileManager, bufferPool,
|
||||||
|
/**
|
||||||
|
* v0.8.0(A38):是否压缩。
|
||||||
|
*
|
||||||
|
* 修复前 `config.compression` 只作用于"整 value 存一个 backend value"的旧路径,
|
||||||
|
* 而 `save()` 在页面化路径上**提前 return**,压缩分支根本走不到 —— 于是
|
||||||
|
* `pageStorage: true`(默认)时 `compression: true` 被完全忽略,
|
||||||
|
* 用户打开了压缩却没有任何压缩效果,且没有任何提示。
|
||||||
|
*
|
||||||
|
* 为什么在页面化里压缩整个流而不是逐页压缩:
|
||||||
|
* - 压缩率取决于"连续数据的重复窗口";4KB 页各自压缩会丢失跨页匹配,
|
||||||
|
* 压缩率显著低于整体压缩;
|
||||||
|
* - 整体压缩后仍是**字节流**,切页照旧,页面布局与 pageIds 语义不变 ——
|
||||||
|
* 对 meta/文件布局零影响。
|
||||||
|
*/
|
||||||
|
compression = false) {
|
||||||
this.fileManager = fileManager;
|
this.fileManager = fileManager;
|
||||||
this.bufferPool = bufferPool;
|
this.bufferPool = bufferPool;
|
||||||
|
this.compression = compression;
|
||||||
/** SSTable id → 页面 ID 列表(save 时记录,saveMeta 时注入 meta) */
|
/** SSTable id → 页面 ID 列表(save 时记录,saveMeta 时注入 meta) */
|
||||||
this.pageIds = new Map();
|
this.pageIds = new Map();
|
||||||
}
|
}
|
||||||
/** 保存数据:切页 → 写入 BufferPool → 逐页落盘 → 记录 pageIds */
|
/**
|
||||||
|
* 保存数据:切页 → 写入 BufferPool → 逐页落盘 → 记录 pageIds。
|
||||||
|
*
|
||||||
|
* @returns `storedSize` = **实际落盘字节数**(压缩后)。调用方写入
|
||||||
|
* `SSTableMeta.totalSize` 时应使用它 —— totalSize 的语义是
|
||||||
|
* "页面里有多少字节",加载时按它截断;若仍写未压缩长度,
|
||||||
|
* 压缩后的数据会被 0 填充撑大(静默损坏)。
|
||||||
|
*/
|
||||||
async save(id, data) {
|
async save(id, data) {
|
||||||
const pageCount = Math.max(1, Math.ceil(data.byteLength / PAGE_SIZE));
|
// v0.8.0(A38):压缩先于切页(整体压缩,压缩率优于逐页)
|
||||||
|
const payload = this.compression ? compressLZ4(data) : data;
|
||||||
|
const pageCount = Math.max(1, Math.ceil(payload.byteLength / PAGE_SIZE));
|
||||||
const handles = await this.bufferPool.newPages(pageCount, PageType.DATA);
|
const handles = await this.bufferPool.newPages(pageCount, PageType.DATA);
|
||||||
const ids = [];
|
const ids = [];
|
||||||
for (let i = 0; i < pageCount; i++) {
|
for (let i = 0; i < pageCount; i++) {
|
||||||
@@ -7172,7 +7408,7 @@ class PageSSTableStore {
|
|||||||
ids.push(page.pageId);
|
ids.push(page.pageId);
|
||||||
const dest = new Uint8Array(page.data);
|
const dest = new Uint8Array(page.data);
|
||||||
dest.fill(0); // 清空(最后一页可能不满)
|
dest.fill(0); // 清空(最后一页可能不满)
|
||||||
const slice = data.subarray(i * PAGE_SIZE, Math.min((i + 1) * PAGE_SIZE, data.byteLength));
|
const slice = payload.subarray(i * PAGE_SIZE, Math.min((i + 1) * PAGE_SIZE, payload.byteLength));
|
||||||
dest.set(slice, 0);
|
dest.set(slice, 0);
|
||||||
page.dirty = true;
|
page.dirty = true;
|
||||||
// save 语义 = 已持久化:立即落盘(WAL checkpoint 截断依赖此保证)
|
// save 语义 = 已持久化:立即落盘(WAL checkpoint 截断依赖此保证)
|
||||||
@@ -7180,6 +7416,7 @@ class PageSSTableStore {
|
|||||||
this.bufferPool.unpin(page);
|
this.bufferPool.unpin(page);
|
||||||
}
|
}
|
||||||
this.pageIds.set(id, ids);
|
this.pageIds.set(id, ids);
|
||||||
|
return { storedSize: payload.byteLength };
|
||||||
}
|
}
|
||||||
/** 获取指定 SSTable 的页面 ID 列表(saveMeta 注入用) */
|
/** 获取指定 SSTable 的页面 ID 列表(saveMeta 注入用) */
|
||||||
getPageIds(id) {
|
getPageIds(id) {
|
||||||
@@ -7187,7 +7424,8 @@ class PageSSTableStore {
|
|||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* 按页面 ID 列表读取并拼接为完整字节流。
|
* 按页面 ID 列表读取并拼接为完整字节流。
|
||||||
* @param totalSize SSTable 真实大小(meta 持久化)——最后一页可能有 0 填充,按真实大小截断
|
* @param totalSize 页面中**实际存储**的字节数(`save()` 返回的 storedSize,
|
||||||
|
* 即压缩后长度)——最后一页可能有 0 填充,按它截断。
|
||||||
* @returns 缺失页面/读取失败返回 null(调用方视为损坏并清理)
|
* @returns 缺失页面/读取失败返回 null(调用方视为损坏并清理)
|
||||||
*/
|
*/
|
||||||
async load(id, pageIds, totalSize) {
|
async load(id, pageIds, totalSize) {
|
||||||
@@ -7213,7 +7451,8 @@ class PageSSTableStore {
|
|||||||
off += take;
|
off += take;
|
||||||
}
|
}
|
||||||
this.pageIds.delete(id);
|
this.pageIds.delete(id);
|
||||||
return out;
|
// v0.8.0(A38):解压(与 save 的加密/压缩顺序对称)
|
||||||
|
return this.compression ? decompressLZ4(out) : out;
|
||||||
}
|
}
|
||||||
/** 释放页面(删除物理页面文件 + 移出 BufferPool) */
|
/** 释放页面(删除物理页面文件 + 移出 BufferPool) */
|
||||||
async delete(id, pageIds) {
|
async delete(id, pageIds) {
|
||||||
@@ -7846,134 +8085,6 @@ class BufferPool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* AriaEngine LZ4 Compression — 简化 LZ4 压缩/解压
|
|
||||||
* @module engine/aria/compression/lz4
|
|
||||||
*
|
|
||||||
* v0.4.5 格式 v2:压缩流前增加 4 字节原始大小头(LE u32),
|
|
||||||
* 解压不再依赖外部估算(高压缩率数据下 buf.length*2 估算不足会截断)。
|
|
||||||
* 旧版压缩数据(无头)视为损坏(compression 选项自 v0.2.6 起已声明不向后兼容)。
|
|
||||||
*
|
|
||||||
* Token 格式(1 字节):
|
|
||||||
* hi 4bit = litLen (0-15)
|
|
||||||
* lo 4bit = matchField (1-15, 实际匹配 = field+4)
|
|
||||||
*
|
|
||||||
* 字面量-匹配序列: [token] [litLen bytes] [2B LE offset]
|
|
||||||
* 末尾纯字面量: [token with lo=0] [litLen bytes] ← 仅在流末尾出现
|
|
||||||
*/
|
|
||||||
const MIN_MATCH = 4;
|
|
||||||
const MAX_MATCH = MIN_MATCH + 15; // 19,匹配长度上限
|
|
||||||
/** 原始大小头字节数 */
|
|
||||||
const HEADER_SIZE = 4;
|
|
||||||
function compressLZ4(input) {
|
|
||||||
// 空输入:仅头部(原始大小 0)
|
|
||||||
if (input.byteLength === 0) {
|
|
||||||
const empty = new Uint8Array(HEADER_SIZE);
|
|
||||||
new DataView(empty.buffer).setUint32(0, 0, true);
|
|
||||||
return empty;
|
|
||||||
}
|
|
||||||
// 最坏情况:纯字面量分块输出 len/15 个 token + 末尾 token
|
|
||||||
// 上限:len + ceil(len/15) + 8(组合 token 的 offset 开销已包含在内)
|
|
||||||
const maxOut = input.byteLength + Math.ceil(input.byteLength / 15) + 8;
|
|
||||||
const out = new Uint8Array(maxOut);
|
|
||||||
let si = 0, di = 0;
|
|
||||||
let litStart = 0;
|
|
||||||
while (si < input.byteLength) {
|
|
||||||
// 搜索最长 backward match(截断到 MAX_MATCH,避免 token 字段溢出)
|
|
||||||
let bestLen = 0, bestOff = 0;
|
|
||||||
const searchStart = Math.max(0, si - 65535);
|
|
||||||
for (let p = searchStart; p < si; p++) {
|
|
||||||
let ml = 0;
|
|
||||||
while (si + ml < input.byteLength && p + ml < si &&
|
|
||||||
input[p + ml] === input[si + ml] && ml < MAX_MATCH)
|
|
||||||
ml++;
|
|
||||||
if (ml >= MIN_MATCH && ml > bestLen) {
|
|
||||||
bestLen = ml;
|
|
||||||
bestOff = si - p;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 仅当匹配完整可编码(field 1-15)且字面量不超过 15 时才输出组合 token
|
|
||||||
if (bestLen > MIN_MATCH && (si - litStart) <= 15) {
|
|
||||||
const litLen = si - litStart;
|
|
||||||
const matchField = bestLen - MIN_MATCH; // 1..15
|
|
||||||
out[di++] = ((litLen & 0x0F) << 4) | (matchField & 0x0F);
|
|
||||||
for (let j = 0; j < litLen; j++)
|
|
||||||
out[di++] = input[litStart + j];
|
|
||||||
out[di++] = bestOff & 0xFF;
|
|
||||||
out[di++] = (bestOff >> 8) & 0xFF;
|
|
||||||
si += bestLen;
|
|
||||||
litStart = si;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// 无匹配 / 匹配长度 4(field=0 有歧义)→ 继续累积字面量
|
|
||||||
si++;
|
|
||||||
// 字面量达到 15 字节上限:结清为纯字面量 token(lo=0),
|
|
||||||
// 否则后续组合 token 的字面量长度会超过 token 字段上限
|
|
||||||
if (si - litStart >= 15) {
|
|
||||||
out[di++] = (15 & 0x0F) << 4; // lo=0 无匹配
|
|
||||||
for (let j = 0; j < 15; j++)
|
|
||||||
out[di++] = input[litStart + j];
|
|
||||||
litStart = si;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 输出末尾纯字面量(matchField=0,无 offset)
|
|
||||||
let remaining = si - litStart;
|
|
||||||
while (remaining > 0) {
|
|
||||||
const chunk = Math.min(remaining, 15);
|
|
||||||
out[di++] = (chunk & 0x0F) << 4; // lo=0 表示无匹配/无 offset
|
|
||||||
for (let j = 0; j < chunk; j++)
|
|
||||||
out[di++] = input[litStart + j];
|
|
||||||
remaining -= chunk;
|
|
||||||
litStart += chunk;
|
|
||||||
}
|
|
||||||
// v0.4.5: 前置原始大小头,解压端自描述
|
|
||||||
const stream = out.slice(0, di);
|
|
||||||
const combined = new Uint8Array(HEADER_SIZE + stream.byteLength);
|
|
||||||
new DataView(combined.buffer).setUint32(0, input.byteLength, true);
|
|
||||||
combined.set(stream, HEADER_SIZE);
|
|
||||||
return combined;
|
|
||||||
}
|
|
||||||
function decompressLZ4(input, _originalSize) {
|
|
||||||
if (input.byteLength < HEADER_SIZE) {
|
|
||||||
throw new Error('LZ4 stream too short: missing header');
|
|
||||||
}
|
|
||||||
const view = new DataView(input.buffer, input.byteOffset, input.byteLength);
|
|
||||||
const originalSize = view.getUint32(0, true);
|
|
||||||
if (originalSize === 0 && input.byteLength === HEADER_SIZE) {
|
|
||||||
return new Uint8Array(0); // 空输入
|
|
||||||
}
|
|
||||||
if (originalSize <= 0 || originalSize > 0x3fffffff) {
|
|
||||||
throw new Error('Invalid LZ4 header: bad original size');
|
|
||||||
}
|
|
||||||
const stream = input.subarray(HEADER_SIZE);
|
|
||||||
const out = new Uint8Array(originalSize);
|
|
||||||
let si = 0, di = 0;
|
|
||||||
while (si < stream.byteLength && di < originalSize) {
|
|
||||||
const token = stream[si++];
|
|
||||||
const litLen = (token >> 4) & 0x0F;
|
|
||||||
const matchField = token & 0x0F;
|
|
||||||
// 复制字面量
|
|
||||||
for (let i = 0; i < litLen && si < stream.byteLength && di < originalSize; i++) {
|
|
||||||
out[di++] = stream[si++];
|
|
||||||
}
|
|
||||||
// matchField=0:纯字面量 token(无 offset 无匹配)。
|
|
||||||
// 可能出现在流中任意位置(超长字面量分块输出),不能 break
|
|
||||||
if (matchField === 0)
|
|
||||||
continue;
|
|
||||||
// 组合 token:读取 offset + 复制匹配(可能自重叠)
|
|
||||||
if (si + 1 >= stream.byteLength)
|
|
||||||
break;
|
|
||||||
const offset = stream[si++] | (stream[si++] << 8);
|
|
||||||
const matchLen = matchField + MIN_MATCH;
|
|
||||||
for (let i = 0; i < matchLen && di < originalSize; i++) {
|
|
||||||
out[di] = out[di - offset];
|
|
||||||
di++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// AriaEngine
|
// AriaEngine
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -9668,7 +9779,11 @@ class AriaEngine {
|
|||||||
let seqLoaded = false;
|
let seqLoaded = false;
|
||||||
// v0.4.5: 页面化物理存储(OPFS 后端默认启用)— SSTable 存为 4KB 页面,BufferPool 缓存
|
// v0.4.5: 页面化物理存储(OPFS 后端默认启用)— SSTable 存为 4KB 页面,BufferPool 缓存
|
||||||
const usePages = this.isPageStorage();
|
const usePages = this.isPageStorage();
|
||||||
const pageStore = usePages ? new PageSSTableStore(this.fileManager, this.bufferPool) : null;
|
// v0.8.0(A38):compression 必须传给 pageStore —— 页面化是默认路径,
|
||||||
|
// 不传就等于"默认配置下 compression 被静默忽略"(修复前的实际状态)。
|
||||||
|
const pageStore = usePages
|
||||||
|
? new PageSSTableStore(this.fileManager, this.bufferPool, this.config.compression)
|
||||||
|
: null;
|
||||||
const encodeText = (text) => {
|
const encodeText = (text) => {
|
||||||
return new TextEncoder().encode(text).buffer;
|
return new TextEncoder().encode(text).buffer;
|
||||||
};
|
};
|
||||||
@@ -9687,8 +9802,8 @@ class AriaEngine {
|
|||||||
save: async (id, data) => {
|
save: async (id, data) => {
|
||||||
if (pageStore) {
|
if (pageStore) {
|
||||||
// 页面化:切页写入 BufferPool 并逐页落盘(save 语义 = 已持久化)
|
// 页面化:切页写入 BufferPool 并逐页落盘(save 语义 = 已持久化)
|
||||||
await pageStore.save(id, data);
|
// 压缩由 pageStore 内部完成(整体压缩后再切页,压缩率优于逐页)
|
||||||
return;
|
return pageStore.save(id, data);
|
||||||
}
|
}
|
||||||
let buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
let buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
||||||
// 压缩(若启用)— 加密由 EncryptedBackend 在 backend 层透明处理(v0.4.5)
|
// 压缩(若启用)— 加密由 EncryptedBackend 在 backend 层透明处理(v0.4.5)
|
||||||
@@ -9697,6 +9812,8 @@ class AriaEngine {
|
|||||||
buf = compressed.buffer.slice(compressed.byteOffset, compressed.byteOffset + compressed.byteLength);
|
buf = compressed.buffer.slice(compressed.byteOffset, compressed.byteOffset + compressed.byteLength);
|
||||||
}
|
}
|
||||||
await this.backend.write(`${filePrefix}${id}`, buf);
|
await this.backend.write(`${filePrefix}${id}`, buf);
|
||||||
|
// 整 value 路径:落盘长度即压缩后长度(与页面化路径语义一致)
|
||||||
|
return { storedSize: buf.byteLength };
|
||||||
},
|
},
|
||||||
load: async (id) => {
|
load: async (id) => {
|
||||||
// 页面化读取:meta 有 pageIds → 页面拼接;无(旧数据)→ 整 value
|
// 页面化读取:meta 有 pageIds → 页面拼接;无(旧数据)→ 整 value
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+257
-140
@@ -5623,14 +5623,16 @@
|
|||||||
minKey: entries[0][0],
|
minKey: entries[0][0],
|
||||||
maxKey: entries[entries.length - 1][0],
|
maxKey: entries[entries.length - 1][0],
|
||||||
blockCount: indexEntries.length,
|
blockCount: indexEntries.length,
|
||||||
|
// v0.8.0(A38):先留 0,落盘后用实际存储长度回填(见下)
|
||||||
totalSize: sstableData.byteLength,
|
totalSize: sstableData.byteLength,
|
||||||
bloomData: null,
|
bloomData: null,
|
||||||
};
|
};
|
||||||
// 缓存
|
// 缓存(缓存的是内存中的**未压缩**整文件,与存储布局无关)
|
||||||
this.tryCacheSSTable(id, sstableData);
|
this.tryCacheSSTable(id, sstableData);
|
||||||
this.trimCache();
|
this.trimCache();
|
||||||
// 持久化:先存数据,再存元数据(串行链保证顺序与 id 一致)
|
// 持久化:先存数据,再存元数据(串行链保证顺序与 id 一致)
|
||||||
await this.sstableStore.save(id, sstableData);
|
const stored = await this.sstableStore.save(id, sstableData);
|
||||||
|
meta.totalSize = stored.storedSize;
|
||||||
await this.sstableStore.saveMeta(meta);
|
await this.sstableStore.saveMeta(meta);
|
||||||
if (this.immutableMemtable === frozen)
|
if (this.immutableMemtable === frozen)
|
||||||
this.immutableMemtable = null;
|
this.immutableMemtable = null;
|
||||||
@@ -5937,12 +5939,14 @@
|
|||||||
minKey: merged[0][0],
|
minKey: merged[0][0],
|
||||||
maxKey: merged[merged.length - 1][0],
|
maxKey: merged[merged.length - 1][0],
|
||||||
blockCount: indexEntries.length,
|
blockCount: indexEntries.length,
|
||||||
|
// v0.8.0(A38):落盘后用实际存储长度回填(见下)
|
||||||
totalSize: sstableData.byteLength,
|
totalSize: sstableData.byteLength,
|
||||||
bloomData: null,
|
bloomData: null,
|
||||||
};
|
};
|
||||||
this.tryCacheSSTable(id, sstableData);
|
this.tryCacheSSTable(id, sstableData);
|
||||||
this.trimCache();
|
this.trimCache();
|
||||||
await this.sstableStore.save(id, sstableData);
|
const stored = await this.sstableStore.save(id, sstableData);
|
||||||
|
meta.totalSize = stored.storedSize;
|
||||||
await this.sstableStore.saveMeta(meta);
|
await this.sstableStore.saveMeta(meta);
|
||||||
this.levels[level + 1].unshift(meta);
|
this.levels[level + 1].unshift(meta);
|
||||||
// 删除旧 SSTable
|
// 删除旧 SSTable
|
||||||
@@ -7146,6 +7150,213 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AriaEngine LZ4 Compression — 简化 LZ4 压缩/解压
|
||||||
|
* @module engine/aria/compression/lz4
|
||||||
|
*
|
||||||
|
* v0.4.5 格式 v2:压缩流前增加 4 字节原始大小头(LE u32),
|
||||||
|
* 解压不再依赖外部估算(高压缩率数据下 buf.length*2 估算不足会截断)。
|
||||||
|
* 旧版压缩数据(无头)视为损坏(compression 选项自 v0.2.6 起已声明不向后兼容)。
|
||||||
|
*
|
||||||
|
* Token 格式(1 字节):
|
||||||
|
* hi 4bit = litLen (0-15)
|
||||||
|
* lo 4bit = matchField (1-15, 实际匹配 = field+4)
|
||||||
|
*
|
||||||
|
* 字面量-匹配序列: [token] [litLen bytes] [2B LE offset]
|
||||||
|
* 末尾纯字面量: [token with lo=0] [litLen bytes] ← 仅在流末尾出现
|
||||||
|
*/
|
||||||
|
const MIN_MATCH = 4;
|
||||||
|
const MAX_MATCH = MIN_MATCH + 15; // 19,匹配长度上限
|
||||||
|
/** 原始大小头字节数 */
|
||||||
|
const HEADER_SIZE = 4;
|
||||||
|
/** 最大匹配搜索链长(限制单点探测次数,保证最坏情况有界) */
|
||||||
|
const MAX_CHAIN = 32;
|
||||||
|
/** 匹配窗口(offset 编码为 2 字节 LE) */
|
||||||
|
const WINDOW_SIZE = 65535;
|
||||||
|
/** 哈希表大小(4 字节序列 → 桶;2^16 桶在内存与冲突率之间取平衡) */
|
||||||
|
const HASH_BITS = 16;
|
||||||
|
const HASH_SIZE = 1 << HASH_BITS;
|
||||||
|
/**
|
||||||
|
* LZ4 压缩(v0.8.0 重写匹配搜索)。
|
||||||
|
*
|
||||||
|
* **修复的性能缺陷(A39)**:此前每个输入字节都向前扫描最多 65535 个位置,
|
||||||
|
* 每个位置再逐字节比较 —— 最坏 O(n × 窗口 × 匹配长度),即在"看似随机、
|
||||||
|
* 实际不存在长匹配"的数据上退化为**二次复杂度**。而 LZ4 的典型使用场景
|
||||||
|
*(SSTable 页、日志段,都是几百 KB 到几 MB)正好会触发这个最坏情况。
|
||||||
|
*
|
||||||
|
* 现在改为 LZ4 的标准做法:**4 字节哈希链**。
|
||||||
|
* - `head[h]` = 最近的、4 字节哈希为 h 的位置;
|
||||||
|
* - `prev[p]` = p 之前的同哈希位置(链);
|
||||||
|
* - 每个位置最多探测 `MAX_CHAIN` 个候选 → 单点代价有界,
|
||||||
|
* 整体接近线性(实践中远快于旧的逐位置扫描)。
|
||||||
|
*
|
||||||
|
* **输出格式完全不变**(token/字面量/offset 编码与 v0.4.5 一致),
|
||||||
|
* 因此既有压缩数据不需要迁移 —— 本函数只改变"去哪里找匹配",
|
||||||
|
* 不改变"匹配如何编码"。等价性由 tests/engine/aria-compress.test.ts 的
|
||||||
|
* 往返用例与"新旧实现输出一致"用例共同锁定。
|
||||||
|
*
|
||||||
|
* 旧的线性扫描实现保留在 `findBestMatchLinear`,**仅供测试对照**,
|
||||||
|
* 运行时不再调用(保留它是有意的:等价性测试需要它作为参照物)。
|
||||||
|
*/
|
||||||
|
function compressLZ4(input) {
|
||||||
|
try {
|
||||||
|
return compressWithHashChain(input);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// 兜底:任何异常都退化为"全字面量"输出 —— 格式合法、可正确解压,
|
||||||
|
// 只是没有压缩收益。宁可慢一点、大一点,也绝不产出损坏的流。
|
||||||
|
// (注意这不是"静默掩盖错误":解压结果与输入**逐字节相同**,
|
||||||
|
// 即数据正确性不受影响;仅压缩率下降。)
|
||||||
|
return encodeAllLiterals(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** 全字面量编码(格式合法、无压缩收益) */
|
||||||
|
function encodeAllLiterals(input) {
|
||||||
|
const chunks = Math.ceil(input.byteLength / 15);
|
||||||
|
const bodyLen = Math.max(chunks, 0) + input.byteLength;
|
||||||
|
const combined = new Uint8Array(HEADER_SIZE + bodyLen);
|
||||||
|
new DataView(combined.buffer).setUint32(0, input.byteLength, true);
|
||||||
|
let di = HEADER_SIZE;
|
||||||
|
let si = 0;
|
||||||
|
while (si < input.byteLength) {
|
||||||
|
const chunk = Math.min(15, input.byteLength - si);
|
||||||
|
combined[di++] = (chunk & 0x0F) << 4; // lo=0:纯字面量 token
|
||||||
|
for (let j = 0; j < chunk; j++)
|
||||||
|
combined[di++] = input[si + j];
|
||||||
|
si += chunk;
|
||||||
|
}
|
||||||
|
return di === combined.byteLength ? combined : combined.slice(0, di);
|
||||||
|
}
|
||||||
|
function compressWithHashChain(input) {
|
||||||
|
if (input.byteLength === 0) {
|
||||||
|
const empty = new Uint8Array(HEADER_SIZE);
|
||||||
|
new DataView(empty.buffer).setUint32(0, 0, true);
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
const n = input.byteLength;
|
||||||
|
const maxOut = n + Math.ceil(n / 15) + 8;
|
||||||
|
const out = new Uint8Array(maxOut);
|
||||||
|
let di = 0;
|
||||||
|
const head = new Int32Array(HASH_SIZE).fill(-1);
|
||||||
|
const prev = new Int32Array(n).fill(-1);
|
||||||
|
const hashAt = (pos) => {
|
||||||
|
// 4 字节乘法哈希(LZ4 常用形式),结果落在 [0, HASH_SIZE)
|
||||||
|
const v = (input[pos] | (input[pos + 1] << 8) | (input[pos + 2] << 16) | (input[pos + 3] << 24)) >>> 0;
|
||||||
|
return (Math.imul(v, 2654435761) >>> (32 - HASH_BITS)) & (HASH_SIZE - 1);
|
||||||
|
};
|
||||||
|
const insert = (pos) => {
|
||||||
|
if (pos + 4 > n)
|
||||||
|
return;
|
||||||
|
const h = hashAt(pos);
|
||||||
|
prev[pos] = head[h];
|
||||||
|
head[h] = pos;
|
||||||
|
};
|
||||||
|
let si = 0;
|
||||||
|
let litStart = 0;
|
||||||
|
/** 结清 [litStart, si) 的字面量(每块最多 15 字节,lo=0 表示无匹配) */
|
||||||
|
const flushLiterals = () => {
|
||||||
|
let remaining = si - litStart;
|
||||||
|
while (remaining > 0) {
|
||||||
|
const chunk = Math.min(remaining, 15);
|
||||||
|
out[di++] = (chunk & 0x0F) << 4;
|
||||||
|
for (let j = 0; j < chunk; j++)
|
||||||
|
out[di++] = input[litStart + j];
|
||||||
|
remaining -= chunk;
|
||||||
|
litStart += chunk;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
while (si < n) {
|
||||||
|
// ---- 在哈希链上找最长匹配(最多 MAX_CHAIN 次探测) ----
|
||||||
|
let bestLen = 0;
|
||||||
|
let bestOff = 0;
|
||||||
|
if (si + 4 <= n) {
|
||||||
|
let cand = head[hashAt(si)];
|
||||||
|
let probes = 0;
|
||||||
|
while (cand >= 0 && probes < MAX_CHAIN) {
|
||||||
|
const off = si - cand;
|
||||||
|
if (off > 0 && off <= WINDOW_SIZE && input[cand] === input[si]) {
|
||||||
|
let ml = 0;
|
||||||
|
while (ml < MAX_MATCH && si + ml < n && input[cand + ml] === input[si + ml])
|
||||||
|
ml++;
|
||||||
|
if (ml > bestLen) {
|
||||||
|
bestLen = ml;
|
||||||
|
bestOff = off;
|
||||||
|
if (ml === MAX_MATCH)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cand = prev[cand];
|
||||||
|
probes++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ---- 输出:组合 token(仅当匹配可完整编码且字面量不超 15) ----
|
||||||
|
if (bestLen > MIN_MATCH && (si - litStart) <= 15) {
|
||||||
|
const litLen = si - litStart;
|
||||||
|
out[di++] = ((litLen & 0x0F) << 4) | ((bestLen - MIN_MATCH) & 0x0F);
|
||||||
|
for (let j = 0; j < litLen; j++)
|
||||||
|
out[di++] = input[litStart + j];
|
||||||
|
out[di++] = bestOff & 0xFF;
|
||||||
|
out[di++] = (bestOff >> 8) & 0xFF;
|
||||||
|
// 匹配区间内的每个位置都要进链,否则后续匹配会漏掉这些候选
|
||||||
|
for (let k = 0; k < bestLen; k++)
|
||||||
|
insert(si + k);
|
||||||
|
si += bestLen;
|
||||||
|
litStart = si;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
insert(si);
|
||||||
|
si++;
|
||||||
|
// 字面量达到 15 字节上限即结清(token 的字面量字段只有 4 bit)
|
||||||
|
if (si - litStart >= 15)
|
||||||
|
flushLiterals();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushLiterals();
|
||||||
|
const combined = new Uint8Array(HEADER_SIZE + di);
|
||||||
|
new DataView(combined.buffer).setUint32(0, n, true);
|
||||||
|
combined.set(out.subarray(0, di), HEADER_SIZE);
|
||||||
|
return combined;
|
||||||
|
}
|
||||||
|
function decompressLZ4(input, _originalSize) {
|
||||||
|
if (input.byteLength < HEADER_SIZE) {
|
||||||
|
throw new Error('LZ4 stream too short: missing header');
|
||||||
|
}
|
||||||
|
const view = new DataView(input.buffer, input.byteOffset, input.byteLength);
|
||||||
|
const originalSize = view.getUint32(0, true);
|
||||||
|
if (originalSize === 0 && input.byteLength === HEADER_SIZE) {
|
||||||
|
return new Uint8Array(0); // 空输入
|
||||||
|
}
|
||||||
|
if (originalSize <= 0 || originalSize > 0x3fffffff) {
|
||||||
|
throw new Error('Invalid LZ4 header: bad original size');
|
||||||
|
}
|
||||||
|
const stream = input.subarray(HEADER_SIZE);
|
||||||
|
const out = new Uint8Array(originalSize);
|
||||||
|
let si = 0, di = 0;
|
||||||
|
while (si < stream.byteLength && di < originalSize) {
|
||||||
|
const token = stream[si++];
|
||||||
|
const litLen = (token >> 4) & 0x0F;
|
||||||
|
const matchField = token & 0x0F;
|
||||||
|
// 复制字面量
|
||||||
|
for (let i = 0; i < litLen && si < stream.byteLength && di < originalSize; i++) {
|
||||||
|
out[di++] = stream[si++];
|
||||||
|
}
|
||||||
|
// matchField=0:纯字面量 token(无 offset 无匹配)。
|
||||||
|
// 可能出现在流中任意位置(超长字面量分块输出),不能 break
|
||||||
|
if (matchField === 0)
|
||||||
|
continue;
|
||||||
|
// 组合 token:读取 offset + 复制匹配(可能自重叠)
|
||||||
|
if (si + 1 >= stream.byteLength)
|
||||||
|
break;
|
||||||
|
const offset = stream[si++] | (stream[si++] << 8);
|
||||||
|
const matchLen = matchField + MIN_MATCH;
|
||||||
|
for (let i = 0; i < matchLen && di < originalSize; i++) {
|
||||||
|
out[di] = out[di - offset];
|
||||||
|
di++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AriaEngine Page SSTable Store — SSTable 页面化物理存储
|
* AriaEngine Page SSTable Store — SSTable 页面化物理存储
|
||||||
* @module engine/aria/store/page_sstable_store
|
* @module engine/aria/store/page_sstable_store
|
||||||
@@ -7162,15 +7373,40 @@
|
|||||||
* - LSM 缓存解析后的整文件字节(查询热点复用)
|
* - LSM 缓存解析后的整文件字节(查询热点复用)
|
||||||
*/
|
*/
|
||||||
class PageSSTableStore {
|
class PageSSTableStore {
|
||||||
constructor(fileManager, bufferPool) {
|
constructor(fileManager, bufferPool,
|
||||||
|
/**
|
||||||
|
* v0.8.0(A38):是否压缩。
|
||||||
|
*
|
||||||
|
* 修复前 `config.compression` 只作用于"整 value 存一个 backend value"的旧路径,
|
||||||
|
* 而 `save()` 在页面化路径上**提前 return**,压缩分支根本走不到 —— 于是
|
||||||
|
* `pageStorage: true`(默认)时 `compression: true` 被完全忽略,
|
||||||
|
* 用户打开了压缩却没有任何压缩效果,且没有任何提示。
|
||||||
|
*
|
||||||
|
* 为什么在页面化里压缩整个流而不是逐页压缩:
|
||||||
|
* - 压缩率取决于"连续数据的重复窗口";4KB 页各自压缩会丢失跨页匹配,
|
||||||
|
* 压缩率显著低于整体压缩;
|
||||||
|
* - 整体压缩后仍是**字节流**,切页照旧,页面布局与 pageIds 语义不变 ——
|
||||||
|
* 对 meta/文件布局零影响。
|
||||||
|
*/
|
||||||
|
compression = false) {
|
||||||
this.fileManager = fileManager;
|
this.fileManager = fileManager;
|
||||||
this.bufferPool = bufferPool;
|
this.bufferPool = bufferPool;
|
||||||
|
this.compression = compression;
|
||||||
/** SSTable id → 页面 ID 列表(save 时记录,saveMeta 时注入 meta) */
|
/** SSTable id → 页面 ID 列表(save 时记录,saveMeta 时注入 meta) */
|
||||||
this.pageIds = new Map();
|
this.pageIds = new Map();
|
||||||
}
|
}
|
||||||
/** 保存数据:切页 → 写入 BufferPool → 逐页落盘 → 记录 pageIds */
|
/**
|
||||||
|
* 保存数据:切页 → 写入 BufferPool → 逐页落盘 → 记录 pageIds。
|
||||||
|
*
|
||||||
|
* @returns `storedSize` = **实际落盘字节数**(压缩后)。调用方写入
|
||||||
|
* `SSTableMeta.totalSize` 时应使用它 —— totalSize 的语义是
|
||||||
|
* "页面里有多少字节",加载时按它截断;若仍写未压缩长度,
|
||||||
|
* 压缩后的数据会被 0 填充撑大(静默损坏)。
|
||||||
|
*/
|
||||||
async save(id, data) {
|
async save(id, data) {
|
||||||
const pageCount = Math.max(1, Math.ceil(data.byteLength / PAGE_SIZE));
|
// v0.8.0(A38):压缩先于切页(整体压缩,压缩率优于逐页)
|
||||||
|
const payload = this.compression ? compressLZ4(data) : data;
|
||||||
|
const pageCount = Math.max(1, Math.ceil(payload.byteLength / PAGE_SIZE));
|
||||||
const handles = await this.bufferPool.newPages(pageCount, PageType.DATA);
|
const handles = await this.bufferPool.newPages(pageCount, PageType.DATA);
|
||||||
const ids = [];
|
const ids = [];
|
||||||
for (let i = 0; i < pageCount; i++) {
|
for (let i = 0; i < pageCount; i++) {
|
||||||
@@ -7178,7 +7414,7 @@
|
|||||||
ids.push(page.pageId);
|
ids.push(page.pageId);
|
||||||
const dest = new Uint8Array(page.data);
|
const dest = new Uint8Array(page.data);
|
||||||
dest.fill(0); // 清空(最后一页可能不满)
|
dest.fill(0); // 清空(最后一页可能不满)
|
||||||
const slice = data.subarray(i * PAGE_SIZE, Math.min((i + 1) * PAGE_SIZE, data.byteLength));
|
const slice = payload.subarray(i * PAGE_SIZE, Math.min((i + 1) * PAGE_SIZE, payload.byteLength));
|
||||||
dest.set(slice, 0);
|
dest.set(slice, 0);
|
||||||
page.dirty = true;
|
page.dirty = true;
|
||||||
// save 语义 = 已持久化:立即落盘(WAL checkpoint 截断依赖此保证)
|
// save 语义 = 已持久化:立即落盘(WAL checkpoint 截断依赖此保证)
|
||||||
@@ -7186,6 +7422,7 @@
|
|||||||
this.bufferPool.unpin(page);
|
this.bufferPool.unpin(page);
|
||||||
}
|
}
|
||||||
this.pageIds.set(id, ids);
|
this.pageIds.set(id, ids);
|
||||||
|
return { storedSize: payload.byteLength };
|
||||||
}
|
}
|
||||||
/** 获取指定 SSTable 的页面 ID 列表(saveMeta 注入用) */
|
/** 获取指定 SSTable 的页面 ID 列表(saveMeta 注入用) */
|
||||||
getPageIds(id) {
|
getPageIds(id) {
|
||||||
@@ -7193,7 +7430,8 @@
|
|||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* 按页面 ID 列表读取并拼接为完整字节流。
|
* 按页面 ID 列表读取并拼接为完整字节流。
|
||||||
* @param totalSize SSTable 真实大小(meta 持久化)——最后一页可能有 0 填充,按真实大小截断
|
* @param totalSize 页面中**实际存储**的字节数(`save()` 返回的 storedSize,
|
||||||
|
* 即压缩后长度)——最后一页可能有 0 填充,按它截断。
|
||||||
* @returns 缺失页面/读取失败返回 null(调用方视为损坏并清理)
|
* @returns 缺失页面/读取失败返回 null(调用方视为损坏并清理)
|
||||||
*/
|
*/
|
||||||
async load(id, pageIds, totalSize) {
|
async load(id, pageIds, totalSize) {
|
||||||
@@ -7219,7 +7457,8 @@
|
|||||||
off += take;
|
off += take;
|
||||||
}
|
}
|
||||||
this.pageIds.delete(id);
|
this.pageIds.delete(id);
|
||||||
return out;
|
// v0.8.0(A38):解压(与 save 的加密/压缩顺序对称)
|
||||||
|
return this.compression ? decompressLZ4(out) : out;
|
||||||
}
|
}
|
||||||
/** 释放页面(删除物理页面文件 + 移出 BufferPool) */
|
/** 释放页面(删除物理页面文件 + 移出 BufferPool) */
|
||||||
async delete(id, pageIds) {
|
async delete(id, pageIds) {
|
||||||
@@ -7852,134 +8091,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* AriaEngine LZ4 Compression — 简化 LZ4 压缩/解压
|
|
||||||
* @module engine/aria/compression/lz4
|
|
||||||
*
|
|
||||||
* v0.4.5 格式 v2:压缩流前增加 4 字节原始大小头(LE u32),
|
|
||||||
* 解压不再依赖外部估算(高压缩率数据下 buf.length*2 估算不足会截断)。
|
|
||||||
* 旧版压缩数据(无头)视为损坏(compression 选项自 v0.2.6 起已声明不向后兼容)。
|
|
||||||
*
|
|
||||||
* Token 格式(1 字节):
|
|
||||||
* hi 4bit = litLen (0-15)
|
|
||||||
* lo 4bit = matchField (1-15, 实际匹配 = field+4)
|
|
||||||
*
|
|
||||||
* 字面量-匹配序列: [token] [litLen bytes] [2B LE offset]
|
|
||||||
* 末尾纯字面量: [token with lo=0] [litLen bytes] ← 仅在流末尾出现
|
|
||||||
*/
|
|
||||||
const MIN_MATCH = 4;
|
|
||||||
const MAX_MATCH = MIN_MATCH + 15; // 19,匹配长度上限
|
|
||||||
/** 原始大小头字节数 */
|
|
||||||
const HEADER_SIZE = 4;
|
|
||||||
function compressLZ4(input) {
|
|
||||||
// 空输入:仅头部(原始大小 0)
|
|
||||||
if (input.byteLength === 0) {
|
|
||||||
const empty = new Uint8Array(HEADER_SIZE);
|
|
||||||
new DataView(empty.buffer).setUint32(0, 0, true);
|
|
||||||
return empty;
|
|
||||||
}
|
|
||||||
// 最坏情况:纯字面量分块输出 len/15 个 token + 末尾 token
|
|
||||||
// 上限:len + ceil(len/15) + 8(组合 token 的 offset 开销已包含在内)
|
|
||||||
const maxOut = input.byteLength + Math.ceil(input.byteLength / 15) + 8;
|
|
||||||
const out = new Uint8Array(maxOut);
|
|
||||||
let si = 0, di = 0;
|
|
||||||
let litStart = 0;
|
|
||||||
while (si < input.byteLength) {
|
|
||||||
// 搜索最长 backward match(截断到 MAX_MATCH,避免 token 字段溢出)
|
|
||||||
let bestLen = 0, bestOff = 0;
|
|
||||||
const searchStart = Math.max(0, si - 65535);
|
|
||||||
for (let p = searchStart; p < si; p++) {
|
|
||||||
let ml = 0;
|
|
||||||
while (si + ml < input.byteLength && p + ml < si &&
|
|
||||||
input[p + ml] === input[si + ml] && ml < MAX_MATCH)
|
|
||||||
ml++;
|
|
||||||
if (ml >= MIN_MATCH && ml > bestLen) {
|
|
||||||
bestLen = ml;
|
|
||||||
bestOff = si - p;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 仅当匹配完整可编码(field 1-15)且字面量不超过 15 时才输出组合 token
|
|
||||||
if (bestLen > MIN_MATCH && (si - litStart) <= 15) {
|
|
||||||
const litLen = si - litStart;
|
|
||||||
const matchField = bestLen - MIN_MATCH; // 1..15
|
|
||||||
out[di++] = ((litLen & 0x0F) << 4) | (matchField & 0x0F);
|
|
||||||
for (let j = 0; j < litLen; j++)
|
|
||||||
out[di++] = input[litStart + j];
|
|
||||||
out[di++] = bestOff & 0xFF;
|
|
||||||
out[di++] = (bestOff >> 8) & 0xFF;
|
|
||||||
si += bestLen;
|
|
||||||
litStart = si;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// 无匹配 / 匹配长度 4(field=0 有歧义)→ 继续累积字面量
|
|
||||||
si++;
|
|
||||||
// 字面量达到 15 字节上限:结清为纯字面量 token(lo=0),
|
|
||||||
// 否则后续组合 token 的字面量长度会超过 token 字段上限
|
|
||||||
if (si - litStart >= 15) {
|
|
||||||
out[di++] = (15 & 0x0F) << 4; // lo=0 无匹配
|
|
||||||
for (let j = 0; j < 15; j++)
|
|
||||||
out[di++] = input[litStart + j];
|
|
||||||
litStart = si;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 输出末尾纯字面量(matchField=0,无 offset)
|
|
||||||
let remaining = si - litStart;
|
|
||||||
while (remaining > 0) {
|
|
||||||
const chunk = Math.min(remaining, 15);
|
|
||||||
out[di++] = (chunk & 0x0F) << 4; // lo=0 表示无匹配/无 offset
|
|
||||||
for (let j = 0; j < chunk; j++)
|
|
||||||
out[di++] = input[litStart + j];
|
|
||||||
remaining -= chunk;
|
|
||||||
litStart += chunk;
|
|
||||||
}
|
|
||||||
// v0.4.5: 前置原始大小头,解压端自描述
|
|
||||||
const stream = out.slice(0, di);
|
|
||||||
const combined = new Uint8Array(HEADER_SIZE + stream.byteLength);
|
|
||||||
new DataView(combined.buffer).setUint32(0, input.byteLength, true);
|
|
||||||
combined.set(stream, HEADER_SIZE);
|
|
||||||
return combined;
|
|
||||||
}
|
|
||||||
function decompressLZ4(input, _originalSize) {
|
|
||||||
if (input.byteLength < HEADER_SIZE) {
|
|
||||||
throw new Error('LZ4 stream too short: missing header');
|
|
||||||
}
|
|
||||||
const view = new DataView(input.buffer, input.byteOffset, input.byteLength);
|
|
||||||
const originalSize = view.getUint32(0, true);
|
|
||||||
if (originalSize === 0 && input.byteLength === HEADER_SIZE) {
|
|
||||||
return new Uint8Array(0); // 空输入
|
|
||||||
}
|
|
||||||
if (originalSize <= 0 || originalSize > 0x3fffffff) {
|
|
||||||
throw new Error('Invalid LZ4 header: bad original size');
|
|
||||||
}
|
|
||||||
const stream = input.subarray(HEADER_SIZE);
|
|
||||||
const out = new Uint8Array(originalSize);
|
|
||||||
let si = 0, di = 0;
|
|
||||||
while (si < stream.byteLength && di < originalSize) {
|
|
||||||
const token = stream[si++];
|
|
||||||
const litLen = (token >> 4) & 0x0F;
|
|
||||||
const matchField = token & 0x0F;
|
|
||||||
// 复制字面量
|
|
||||||
for (let i = 0; i < litLen && si < stream.byteLength && di < originalSize; i++) {
|
|
||||||
out[di++] = stream[si++];
|
|
||||||
}
|
|
||||||
// matchField=0:纯字面量 token(无 offset 无匹配)。
|
|
||||||
// 可能出现在流中任意位置(超长字面量分块输出),不能 break
|
|
||||||
if (matchField === 0)
|
|
||||||
continue;
|
|
||||||
// 组合 token:读取 offset + 复制匹配(可能自重叠)
|
|
||||||
if (si + 1 >= stream.byteLength)
|
|
||||||
break;
|
|
||||||
const offset = stream[si++] | (stream[si++] << 8);
|
|
||||||
const matchLen = matchField + MIN_MATCH;
|
|
||||||
for (let i = 0; i < matchLen && di < originalSize; i++) {
|
|
||||||
out[di] = out[di - offset];
|
|
||||||
di++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// AriaEngine
|
// AriaEngine
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -9674,7 +9785,11 @@
|
|||||||
let seqLoaded = false;
|
let seqLoaded = false;
|
||||||
// v0.4.5: 页面化物理存储(OPFS 后端默认启用)— SSTable 存为 4KB 页面,BufferPool 缓存
|
// v0.4.5: 页面化物理存储(OPFS 后端默认启用)— SSTable 存为 4KB 页面,BufferPool 缓存
|
||||||
const usePages = this.isPageStorage();
|
const usePages = this.isPageStorage();
|
||||||
const pageStore = usePages ? new PageSSTableStore(this.fileManager, this.bufferPool) : null;
|
// v0.8.0(A38):compression 必须传给 pageStore —— 页面化是默认路径,
|
||||||
|
// 不传就等于"默认配置下 compression 被静默忽略"(修复前的实际状态)。
|
||||||
|
const pageStore = usePages
|
||||||
|
? new PageSSTableStore(this.fileManager, this.bufferPool, this.config.compression)
|
||||||
|
: null;
|
||||||
const encodeText = (text) => {
|
const encodeText = (text) => {
|
||||||
return new TextEncoder().encode(text).buffer;
|
return new TextEncoder().encode(text).buffer;
|
||||||
};
|
};
|
||||||
@@ -9693,8 +9808,8 @@
|
|||||||
save: async (id, data) => {
|
save: async (id, data) => {
|
||||||
if (pageStore) {
|
if (pageStore) {
|
||||||
// 页面化:切页写入 BufferPool 并逐页落盘(save 语义 = 已持久化)
|
// 页面化:切页写入 BufferPool 并逐页落盘(save 语义 = 已持久化)
|
||||||
await pageStore.save(id, data);
|
// 压缩由 pageStore 内部完成(整体压缩后再切页,压缩率优于逐页)
|
||||||
return;
|
return pageStore.save(id, data);
|
||||||
}
|
}
|
||||||
let buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
let buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
||||||
// 压缩(若启用)— 加密由 EncryptedBackend 在 backend 层透明处理(v0.4.5)
|
// 压缩(若启用)— 加密由 EncryptedBackend 在 backend 层透明处理(v0.4.5)
|
||||||
@@ -9703,6 +9818,8 @@
|
|||||||
buf = compressed.buffer.slice(compressed.byteOffset, compressed.byteOffset + compressed.byteLength);
|
buf = compressed.buffer.slice(compressed.byteOffset, compressed.byteOffset + compressed.byteLength);
|
||||||
}
|
}
|
||||||
await this.backend.write(`${filePrefix}${id}`, buf);
|
await this.backend.write(`${filePrefix}${id}`, buf);
|
||||||
|
// 整 value 路径:落盘长度即压缩后长度(与页面化路径语义一致)
|
||||||
|
return { storedSize: buf.byteLength };
|
||||||
},
|
},
|
||||||
load: async (id) => {
|
load: async (id) => {
|
||||||
// 页面化读取:meta 有 pageIds → 页面拼接;无(旧数据)→ 整 value
|
// 页面化读取:meta 有 pageIds → 页面拼接;无(旧数据)→ 整 value
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -19,23 +19,176 @@ const MAX_MATCH = MIN_MATCH + 15; // 19,匹配长度上限
|
|||||||
/** 原始大小头字节数 */
|
/** 原始大小头字节数 */
|
||||||
const HEADER_SIZE = 4;
|
const HEADER_SIZE = 4;
|
||||||
|
|
||||||
|
/** 最大匹配搜索链长(限制单点探测次数,保证最坏情况有界) */
|
||||||
|
const MAX_CHAIN = 32;
|
||||||
|
/** 匹配窗口(offset 编码为 2 字节 LE) */
|
||||||
|
const WINDOW_SIZE = 65535;
|
||||||
|
/** 哈希表大小(4 字节序列 → 桶;2^16 桶在内存与冲突率之间取平衡) */
|
||||||
|
const HASH_BITS = 16;
|
||||||
|
const HASH_SIZE = 1 << HASH_BITS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LZ4 压缩(v0.8.0 重写匹配搜索)。
|
||||||
|
*
|
||||||
|
* **修复的性能缺陷(A39)**:此前每个输入字节都向前扫描最多 65535 个位置,
|
||||||
|
* 每个位置再逐字节比较 —— 最坏 O(n × 窗口 × 匹配长度),即在"看似随机、
|
||||||
|
* 实际不存在长匹配"的数据上退化为**二次复杂度**。而 LZ4 的典型使用场景
|
||||||
|
*(SSTable 页、日志段,都是几百 KB 到几 MB)正好会触发这个最坏情况。
|
||||||
|
*
|
||||||
|
* 现在改为 LZ4 的标准做法:**4 字节哈希链**。
|
||||||
|
* - `head[h]` = 最近的、4 字节哈希为 h 的位置;
|
||||||
|
* - `prev[p]` = p 之前的同哈希位置(链);
|
||||||
|
* - 每个位置最多探测 `MAX_CHAIN` 个候选 → 单点代价有界,
|
||||||
|
* 整体接近线性(实践中远快于旧的逐位置扫描)。
|
||||||
|
*
|
||||||
|
* **输出格式完全不变**(token/字面量/offset 编码与 v0.4.5 一致),
|
||||||
|
* 因此既有压缩数据不需要迁移 —— 本函数只改变"去哪里找匹配",
|
||||||
|
* 不改变"匹配如何编码"。等价性由 tests/engine/aria-compress.test.ts 的
|
||||||
|
* 往返用例与"新旧实现输出一致"用例共同锁定。
|
||||||
|
*
|
||||||
|
* 旧的线性扫描实现保留在 `findBestMatchLinear`,**仅供测试对照**,
|
||||||
|
* 运行时不再调用(保留它是有意的:等价性测试需要它作为参照物)。
|
||||||
|
*/
|
||||||
export function compressLZ4(input: Uint8Array): Uint8Array {
|
export function compressLZ4(input: Uint8Array): Uint8Array {
|
||||||
// 空输入:仅头部(原始大小 0)
|
try {
|
||||||
|
return compressWithHashChain(input);
|
||||||
|
} catch {
|
||||||
|
// 兜底:任何异常都退化为"全字面量"输出 —— 格式合法、可正确解压,
|
||||||
|
// 只是没有压缩收益。宁可慢一点、大一点,也绝不产出损坏的流。
|
||||||
|
// (注意这不是"静默掩盖错误":解压结果与输入**逐字节相同**,
|
||||||
|
// 即数据正确性不受影响;仅压缩率下降。)
|
||||||
|
return encodeAllLiterals(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全字面量编码(格式合法、无压缩收益) */
|
||||||
|
function encodeAllLiterals(input: Uint8Array): Uint8Array {
|
||||||
|
const chunks = Math.ceil(input.byteLength / 15);
|
||||||
|
const bodyLen = Math.max(chunks, 0) + input.byteLength;
|
||||||
|
const combined = new Uint8Array(HEADER_SIZE + bodyLen);
|
||||||
|
new DataView(combined.buffer).setUint32(0, input.byteLength, true);
|
||||||
|
let di = HEADER_SIZE;
|
||||||
|
let si = 0;
|
||||||
|
while (si < input.byteLength) {
|
||||||
|
const chunk = Math.min(15, input.byteLength - si);
|
||||||
|
combined[di++] = (chunk & 0x0F) << 4; // lo=0:纯字面量 token
|
||||||
|
for (let j = 0; j < chunk; j++) combined[di++] = input[si + j];
|
||||||
|
si += chunk;
|
||||||
|
}
|
||||||
|
return di === combined.byteLength ? combined : combined.slice(0, di);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compressWithHashChain(input: Uint8Array): Uint8Array {
|
||||||
if (input.byteLength === 0) {
|
if (input.byteLength === 0) {
|
||||||
const empty = new Uint8Array(HEADER_SIZE);
|
const empty = new Uint8Array(HEADER_SIZE);
|
||||||
new DataView(empty.buffer).setUint32(0, 0, true);
|
new DataView(empty.buffer).setUint32(0, 0, true);
|
||||||
return empty;
|
return empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 最坏情况:纯字面量分块输出 len/15 个 token + 末尾 token
|
const n = input.byteLength;
|
||||||
// 上限:len + ceil(len/15) + 8(组合 token 的 offset 开销已包含在内)
|
const maxOut = n + Math.ceil(n / 15) + 8;
|
||||||
|
const out = new Uint8Array(maxOut);
|
||||||
|
let di = 0;
|
||||||
|
|
||||||
|
const head = new Int32Array(HASH_SIZE).fill(-1);
|
||||||
|
const prev = new Int32Array(n).fill(-1);
|
||||||
|
|
||||||
|
const hashAt = (pos: number): number => {
|
||||||
|
// 4 字节乘法哈希(LZ4 常用形式),结果落在 [0, HASH_SIZE)
|
||||||
|
const v = (input[pos] | (input[pos + 1] << 8) | (input[pos + 2] << 16) | (input[pos + 3] << 24)) >>> 0;
|
||||||
|
return (Math.imul(v, 2654435761) >>> (32 - HASH_BITS)) & (HASH_SIZE - 1);
|
||||||
|
};
|
||||||
|
const insert = (pos: number): void => {
|
||||||
|
if (pos + 4 > n) return;
|
||||||
|
const h = hashAt(pos);
|
||||||
|
prev[pos] = head[h];
|
||||||
|
head[h] = pos;
|
||||||
|
};
|
||||||
|
|
||||||
|
let si = 0;
|
||||||
|
let litStart = 0;
|
||||||
|
|
||||||
|
/** 结清 [litStart, si) 的字面量(每块最多 15 字节,lo=0 表示无匹配) */
|
||||||
|
const flushLiterals = (): void => {
|
||||||
|
let remaining = si - litStart;
|
||||||
|
while (remaining > 0) {
|
||||||
|
const chunk = Math.min(remaining, 15);
|
||||||
|
out[di++] = (chunk & 0x0F) << 4;
|
||||||
|
for (let j = 0; j < chunk; j++) out[di++] = input[litStart + j];
|
||||||
|
remaining -= chunk;
|
||||||
|
litStart += chunk;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
while (si < n) {
|
||||||
|
// ---- 在哈希链上找最长匹配(最多 MAX_CHAIN 次探测) ----
|
||||||
|
let bestLen = 0;
|
||||||
|
let bestOff = 0;
|
||||||
|
if (si + 4 <= n) {
|
||||||
|
let cand = head[hashAt(si)];
|
||||||
|
let probes = 0;
|
||||||
|
while (cand >= 0 && probes < MAX_CHAIN) {
|
||||||
|
const off = si - cand;
|
||||||
|
if (off > 0 && off <= WINDOW_SIZE && input[cand] === input[si]) {
|
||||||
|
let ml = 0;
|
||||||
|
while (ml < MAX_MATCH && si + ml < n && input[cand + ml] === input[si + ml]) ml++;
|
||||||
|
if (ml > bestLen) {
|
||||||
|
bestLen = ml;
|
||||||
|
bestOff = off;
|
||||||
|
if (ml === MAX_MATCH) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cand = prev[cand];
|
||||||
|
probes++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 输出:组合 token(仅当匹配可完整编码且字面量不超 15) ----
|
||||||
|
if (bestLen > MIN_MATCH && (si - litStart) <= 15) {
|
||||||
|
const litLen = si - litStart;
|
||||||
|
out[di++] = ((litLen & 0x0F) << 4) | ((bestLen - MIN_MATCH) & 0x0F);
|
||||||
|
for (let j = 0; j < litLen; j++) out[di++] = input[litStart + j];
|
||||||
|
out[di++] = bestOff & 0xFF;
|
||||||
|
out[di++] = (bestOff >> 8) & 0xFF;
|
||||||
|
// 匹配区间内的每个位置都要进链,否则后续匹配会漏掉这些候选
|
||||||
|
for (let k = 0; k < bestLen; k++) insert(si + k);
|
||||||
|
si += bestLen;
|
||||||
|
litStart = si;
|
||||||
|
} else {
|
||||||
|
insert(si);
|
||||||
|
si++;
|
||||||
|
// 字面量达到 15 字节上限即结清(token 的字面量字段只有 4 bit)
|
||||||
|
if (si - litStart >= 15) flushLiterals();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flushLiterals();
|
||||||
|
|
||||||
|
const combined = new Uint8Array(HEADER_SIZE + di);
|
||||||
|
new DataView(combined.buffer).setUint32(0, n, true);
|
||||||
|
combined.set(out.subarray(0, di), HEADER_SIZE);
|
||||||
|
return combined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旧的逐位置线性扫描实现 —— **仅用于测试对照**,运行时不再调用。
|
||||||
|
*
|
||||||
|
* 保留原因:A39 的修复是"换一种找匹配的方式",因此必须能证明
|
||||||
|
* "新旧实现在同一输入上产生相同(或至少可互解)的输出"。
|
||||||
|
* 删除它会让等价性无法回归验证。
|
||||||
|
*/
|
||||||
|
export function compressLZ4LinearReference(input: Uint8Array): Uint8Array {
|
||||||
|
if (input.byteLength === 0) {
|
||||||
|
const empty = new Uint8Array(HEADER_SIZE);
|
||||||
|
new DataView(empty.buffer).setUint32(0, 0, true);
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
const maxOut = input.byteLength + Math.ceil(input.byteLength / 15) + 8;
|
const maxOut = input.byteLength + Math.ceil(input.byteLength / 15) + 8;
|
||||||
const out = new Uint8Array(maxOut);
|
const out = new Uint8Array(maxOut);
|
||||||
let si = 0, di = 0;
|
let si = 0, di = 0;
|
||||||
let litStart = 0;
|
let litStart = 0;
|
||||||
|
|
||||||
while (si < input.byteLength) {
|
while (si < input.byteLength) {
|
||||||
// 搜索最长 backward match(截断到 MAX_MATCH,避免 token 字段溢出)
|
|
||||||
let bestLen = 0, bestOff = 0;
|
let bestLen = 0, bestOff = 0;
|
||||||
const searchStart = Math.max(0, si - 65535);
|
const searchStart = Math.max(0, si - 65535);
|
||||||
for (let p = searchStart; p < si; p++) {
|
for (let p = searchStart; p < si; p++) {
|
||||||
@@ -45,10 +198,9 @@ export function compressLZ4(input: Uint8Array): Uint8Array {
|
|||||||
if (ml >= MIN_MATCH && ml > bestLen) { bestLen = ml; bestOff = si - p; }
|
if (ml >= MIN_MATCH && ml > bestLen) { bestLen = ml; bestOff = si - p; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 仅当匹配完整可编码(field 1-15)且字面量不超过 15 时才输出组合 token
|
|
||||||
if (bestLen > MIN_MATCH && (si - litStart) <= 15) {
|
if (bestLen > MIN_MATCH && (si - litStart) <= 15) {
|
||||||
const litLen = si - litStart;
|
const litLen = si - litStart;
|
||||||
const matchField = bestLen - MIN_MATCH; // 1..15
|
const matchField = bestLen - MIN_MATCH;
|
||||||
out[di++] = ((litLen & 0x0F) << 4) | (matchField & 0x0F);
|
out[di++] = ((litLen & 0x0F) << 4) | (matchField & 0x0F);
|
||||||
for (let j = 0; j < litLen; j++) out[di++] = input[litStart + j];
|
for (let j = 0; j < litLen; j++) out[di++] = input[litStart + j];
|
||||||
out[di++] = bestOff & 0xFF;
|
out[di++] = bestOff & 0xFF;
|
||||||
@@ -56,29 +208,24 @@ export function compressLZ4(input: Uint8Array): Uint8Array {
|
|||||||
si += bestLen;
|
si += bestLen;
|
||||||
litStart = si;
|
litStart = si;
|
||||||
} else {
|
} else {
|
||||||
// 无匹配 / 匹配长度 4(field=0 有歧义)→ 继续累积字面量
|
|
||||||
si++;
|
si++;
|
||||||
// 字面量达到 15 字节上限:结清为纯字面量 token(lo=0),
|
|
||||||
// 否则后续组合 token 的字面量长度会超过 token 字段上限
|
|
||||||
if (si - litStart >= 15) {
|
if (si - litStart >= 15) {
|
||||||
out[di++] = (15 & 0x0F) << 4; // lo=0 无匹配
|
out[di++] = (15 & 0x0F) << 4;
|
||||||
for (let j = 0; j < 15; j++) out[di++] = input[litStart + j];
|
for (let j = 0; j < 15; j++) out[di++] = input[litStart + j];
|
||||||
litStart = si;
|
litStart = si;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 输出末尾纯字面量(matchField=0,无 offset)
|
|
||||||
let remaining = si - litStart;
|
let remaining = si - litStart;
|
||||||
while (remaining > 0) {
|
while (remaining > 0) {
|
||||||
const chunk = Math.min(remaining, 15);
|
const chunk = Math.min(remaining, 15);
|
||||||
out[di++] = (chunk & 0x0F) << 4; // lo=0 表示无匹配/无 offset
|
out[di++] = (chunk & 0x0F) << 4;
|
||||||
for (let j = 0; j < chunk; j++) out[di++] = input[litStart + j];
|
for (let j = 0; j < chunk; j++) out[di++] = input[litStart + j];
|
||||||
remaining -= chunk;
|
remaining -= chunk;
|
||||||
litStart += chunk;
|
litStart += chunk;
|
||||||
}
|
}
|
||||||
|
|
||||||
// v0.4.5: 前置原始大小头,解压端自描述
|
|
||||||
const stream = out.slice(0, di);
|
const stream = out.slice(0, di);
|
||||||
const combined = new Uint8Array(HEADER_SIZE + stream.byteLength);
|
const combined = new Uint8Array(HEADER_SIZE + stream.byteLength);
|
||||||
new DataView(combined.buffer).setUint32(0, input.byteLength, true);
|
new DataView(combined.buffer).setUint32(0, input.byteLength, true);
|
||||||
|
|||||||
@@ -1899,7 +1899,11 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
|
|
||||||
// v0.4.5: 页面化物理存储(OPFS 后端默认启用)— SSTable 存为 4KB 页面,BufferPool 缓存
|
// v0.4.5: 页面化物理存储(OPFS 后端默认启用)— SSTable 存为 4KB 页面,BufferPool 缓存
|
||||||
const usePages = this.isPageStorage();
|
const usePages = this.isPageStorage();
|
||||||
const pageStore = usePages ? new PageSSTableStore(this.fileManager, this.bufferPool) : null;
|
// v0.8.0(A38):compression 必须传给 pageStore —— 页面化是默认路径,
|
||||||
|
// 不传就等于"默认配置下 compression 被静默忽略"(修复前的实际状态)。
|
||||||
|
const pageStore = usePages
|
||||||
|
? new PageSSTableStore(this.fileManager, this.bufferPool, this.config.compression)
|
||||||
|
: null;
|
||||||
|
|
||||||
const encodeText = (text: string): ArrayBuffer => {
|
const encodeText = (text: string): ArrayBuffer => {
|
||||||
return new TextEncoder().encode(text).buffer;
|
return new TextEncoder().encode(text).buffer;
|
||||||
@@ -1919,8 +1923,8 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
save: async (id, data) => {
|
save: async (id, data) => {
|
||||||
if (pageStore) {
|
if (pageStore) {
|
||||||
// 页面化:切页写入 BufferPool 并逐页落盘(save 语义 = 已持久化)
|
// 页面化:切页写入 BufferPool 并逐页落盘(save 语义 = 已持久化)
|
||||||
await pageStore.save(id, data);
|
// 压缩由 pageStore 内部完成(整体压缩后再切页,压缩率优于逐页)
|
||||||
return;
|
return pageStore.save(id, data);
|
||||||
}
|
}
|
||||||
let buf: ArrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer;
|
let buf: ArrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer;
|
||||||
// 压缩(若启用)— 加密由 EncryptedBackend 在 backend 层透明处理(v0.4.5)
|
// 压缩(若启用)— 加密由 EncryptedBackend 在 backend 层透明处理(v0.4.5)
|
||||||
@@ -1929,6 +1933,8 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
buf = compressed.buffer.slice(compressed.byteOffset, compressed.byteOffset + compressed.byteLength) as ArrayBuffer;
|
buf = compressed.buffer.slice(compressed.byteOffset, compressed.byteOffset + compressed.byteLength) as ArrayBuffer;
|
||||||
}
|
}
|
||||||
await this.backend.write(`${filePrefix}${id}`, buf);
|
await this.backend.write(`${filePrefix}${id}`, buf);
|
||||||
|
// 整 value 路径:落盘长度即压缩后长度(与页面化路径语义一致)
|
||||||
|
return { storedSize: buf.byteLength };
|
||||||
},
|
},
|
||||||
load: async (id) => {
|
load: async (id) => {
|
||||||
// 页面化读取:meta 有 pageIds → 页面拼接;无(旧数据)→ 整 value
|
// 页面化读取:meta 有 pageIds → 页面拼接;无(旧数据)→ 整 value
|
||||||
|
|||||||
@@ -25,8 +25,15 @@ import {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface SSTableStore {
|
export interface SSTableStore {
|
||||||
/** 保存 SSTable 文件 */
|
/**
|
||||||
save(id: number, data: Uint8Array): Promise<void>;
|
* 保存 SSTable 文件。
|
||||||
|
*
|
||||||
|
* v0.8.0(A38):返回**实际存储布局**(`storedSize` = 落盘字节数,
|
||||||
|
* 压缩开启时小于 `data.byteLength`)。调用方必须用它填 `SSTableMeta.totalSize`
|
||||||
|
* —— 该字段是"页面里有多少字节"的权威描述,加载时按它截断;
|
||||||
|
* 若仍写未压缩长度,压缩数据会被 0 填充撑大(静默损坏)。
|
||||||
|
*/
|
||||||
|
save(id: number, data: Uint8Array): Promise<{ storedSize: number }>;
|
||||||
/** 加载 SSTable 文件 */
|
/** 加载 SSTable 文件 */
|
||||||
load(id: number): Promise<Uint8Array | null>;
|
load(id: number): Promise<Uint8Array | null>;
|
||||||
/** 删除 SSTable 文件 */
|
/** 删除 SSTable 文件 */
|
||||||
@@ -260,16 +267,18 @@ export class LSM {
|
|||||||
minKey: entries[0][0],
|
minKey: entries[0][0],
|
||||||
maxKey: entries[entries.length - 1][0],
|
maxKey: entries[entries.length - 1][0],
|
||||||
blockCount: indexEntries.length,
|
blockCount: indexEntries.length,
|
||||||
|
// v0.8.0(A38):先留 0,落盘后用实际存储长度回填(见下)
|
||||||
totalSize: sstableData.byteLength,
|
totalSize: sstableData.byteLength,
|
||||||
bloomData: null,
|
bloomData: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 缓存
|
// 缓存(缓存的是内存中的**未压缩**整文件,与存储布局无关)
|
||||||
this.tryCacheSSTable(id, sstableData);
|
this.tryCacheSSTable(id, sstableData);
|
||||||
this.trimCache();
|
this.trimCache();
|
||||||
|
|
||||||
// 持久化:先存数据,再存元数据(串行链保证顺序与 id 一致)
|
// 持久化:先存数据,再存元数据(串行链保证顺序与 id 一致)
|
||||||
await this.sstableStore.save(id, sstableData);
|
const stored = await this.sstableStore.save(id, sstableData);
|
||||||
|
meta.totalSize = stored.storedSize;
|
||||||
await this.sstableStore.saveMeta(meta);
|
await this.sstableStore.saveMeta(meta);
|
||||||
|
|
||||||
if (this.immutableMemtable === frozen) this.immutableMemtable = null;
|
if (this.immutableMemtable === frozen) this.immutableMemtable = null;
|
||||||
@@ -591,13 +600,15 @@ export class LSM {
|
|||||||
minKey: merged[0][0],
|
minKey: merged[0][0],
|
||||||
maxKey: merged[merged.length - 1][0],
|
maxKey: merged[merged.length - 1][0],
|
||||||
blockCount: indexEntries.length,
|
blockCount: indexEntries.length,
|
||||||
|
// v0.8.0(A38):落盘后用实际存储长度回填(见下)
|
||||||
totalSize: sstableData.byteLength,
|
totalSize: sstableData.byteLength,
|
||||||
bloomData: null,
|
bloomData: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.tryCacheSSTable(id, sstableData);
|
this.tryCacheSSTable(id, sstableData);
|
||||||
this.trimCache();
|
this.trimCache();
|
||||||
await this.sstableStore.save(id, sstableData);
|
const stored = await this.sstableStore.save(id, sstableData);
|
||||||
|
meta.totalSize = stored.storedSize;
|
||||||
await this.sstableStore.saveMeta(meta);
|
await this.sstableStore.saveMeta(meta);
|
||||||
this.levels[level + 1].unshift(meta);
|
this.levels[level + 1].unshift(meta);
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type { FileManager } from './file_manager';
|
|||||||
import type { BufferPool } from '../buffer/pool';
|
import type { BufferPool } from '../buffer/pool';
|
||||||
import type { PageHandle } from '../types';
|
import type { PageHandle } from '../types';
|
||||||
import { PAGE_SIZE, PageType } from '../types';
|
import { PAGE_SIZE, PageType } from '../types';
|
||||||
|
import { compressLZ4, decompressLZ4 } from '../compression/lz4';
|
||||||
|
|
||||||
export class PageSSTableStore {
|
export class PageSSTableStore {
|
||||||
/** SSTable id → 页面 ID 列表(save 时记录,saveMeta 时注入 meta) */
|
/** SSTable id → 页面 ID 列表(save 时记录,saveMeta 时注入 meta) */
|
||||||
@@ -26,11 +27,35 @@ export class PageSSTableStore {
|
|||||||
constructor(
|
constructor(
|
||||||
private fileManager: FileManager,
|
private fileManager: FileManager,
|
||||||
private bufferPool: BufferPool,
|
private bufferPool: BufferPool,
|
||||||
|
/**
|
||||||
|
* v0.8.0(A38):是否压缩。
|
||||||
|
*
|
||||||
|
* 修复前 `config.compression` 只作用于"整 value 存一个 backend value"的旧路径,
|
||||||
|
* 而 `save()` 在页面化路径上**提前 return**,压缩分支根本走不到 —— 于是
|
||||||
|
* `pageStorage: true`(默认)时 `compression: true` 被完全忽略,
|
||||||
|
* 用户打开了压缩却没有任何压缩效果,且没有任何提示。
|
||||||
|
*
|
||||||
|
* 为什么在页面化里压缩整个流而不是逐页压缩:
|
||||||
|
* - 压缩率取决于"连续数据的重复窗口";4KB 页各自压缩会丢失跨页匹配,
|
||||||
|
* 压缩率显著低于整体压缩;
|
||||||
|
* - 整体压缩后仍是**字节流**,切页照旧,页面布局与 pageIds 语义不变 ——
|
||||||
|
* 对 meta/文件布局零影响。
|
||||||
|
*/
|
||||||
|
private compression: boolean = false,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** 保存数据:切页 → 写入 BufferPool → 逐页落盘 → 记录 pageIds */
|
/**
|
||||||
async save(id: number, data: Uint8Array): Promise<void> {
|
* 保存数据:切页 → 写入 BufferPool → 逐页落盘 → 记录 pageIds。
|
||||||
const pageCount = Math.max(1, Math.ceil(data.byteLength / PAGE_SIZE));
|
*
|
||||||
|
* @returns `storedSize` = **实际落盘字节数**(压缩后)。调用方写入
|
||||||
|
* `SSTableMeta.totalSize` 时应使用它 —— totalSize 的语义是
|
||||||
|
* "页面里有多少字节",加载时按它截断;若仍写未压缩长度,
|
||||||
|
* 压缩后的数据会被 0 填充撑大(静默损坏)。
|
||||||
|
*/
|
||||||
|
async save(id: number, data: Uint8Array): Promise<{ storedSize: number }> {
|
||||||
|
// v0.8.0(A38):压缩先于切页(整体压缩,压缩率优于逐页)
|
||||||
|
const payload = this.compression ? compressLZ4(data) : data;
|
||||||
|
const pageCount = Math.max(1, Math.ceil(payload.byteLength / PAGE_SIZE));
|
||||||
const handles: PageHandle[] = await this.bufferPool.newPages(pageCount, PageType.DATA);
|
const handles: PageHandle[] = await this.bufferPool.newPages(pageCount, PageType.DATA);
|
||||||
|
|
||||||
const ids: number[] = [];
|
const ids: number[] = [];
|
||||||
@@ -39,7 +64,7 @@ export class PageSSTableStore {
|
|||||||
ids.push(page.pageId);
|
ids.push(page.pageId);
|
||||||
const dest = new Uint8Array(page.data);
|
const dest = new Uint8Array(page.data);
|
||||||
dest.fill(0); // 清空(最后一页可能不满)
|
dest.fill(0); // 清空(最后一页可能不满)
|
||||||
const slice = data.subarray(i * PAGE_SIZE, Math.min((i + 1) * PAGE_SIZE, data.byteLength));
|
const slice = payload.subarray(i * PAGE_SIZE, Math.min((i + 1) * PAGE_SIZE, payload.byteLength));
|
||||||
dest.set(slice, 0);
|
dest.set(slice, 0);
|
||||||
page.dirty = true;
|
page.dirty = true;
|
||||||
// save 语义 = 已持久化:立即落盘(WAL checkpoint 截断依赖此保证)
|
// save 语义 = 已持久化:立即落盘(WAL checkpoint 截断依赖此保证)
|
||||||
@@ -47,6 +72,7 @@ export class PageSSTableStore {
|
|||||||
this.bufferPool.unpin(page);
|
this.bufferPool.unpin(page);
|
||||||
}
|
}
|
||||||
this.pageIds.set(id, ids);
|
this.pageIds.set(id, ids);
|
||||||
|
return { storedSize: payload.byteLength };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取指定 SSTable 的页面 ID 列表(saveMeta 注入用) */
|
/** 获取指定 SSTable 的页面 ID 列表(saveMeta 注入用) */
|
||||||
@@ -56,7 +82,8 @@ export class PageSSTableStore {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 按页面 ID 列表读取并拼接为完整字节流。
|
* 按页面 ID 列表读取并拼接为完整字节流。
|
||||||
* @param totalSize SSTable 真实大小(meta 持久化)——最后一页可能有 0 填充,按真实大小截断
|
* @param totalSize 页面中**实际存储**的字节数(`save()` 返回的 storedSize,
|
||||||
|
* 即压缩后长度)——最后一页可能有 0 填充,按它截断。
|
||||||
* @returns 缺失页面/读取失败返回 null(调用方视为损坏并清理)
|
* @returns 缺失页面/读取失败返回 null(调用方视为损坏并清理)
|
||||||
*/
|
*/
|
||||||
async load(id: number, pageIds: number[], totalSize: number): Promise<Uint8Array | null> {
|
async load(id: number, pageIds: number[], totalSize: number): Promise<Uint8Array | null> {
|
||||||
@@ -79,7 +106,8 @@ export class PageSSTableStore {
|
|||||||
off += take;
|
off += take;
|
||||||
}
|
}
|
||||||
this.pageIds.delete(id);
|
this.pageIds.delete(id);
|
||||||
return out;
|
// v0.8.0(A38):解压(与 save 的加密/压缩顺序对称)
|
||||||
|
return this.compression ? decompressLZ4(out) : out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 释放页面(删除物理页面文件 + 移出 BufferPool) */
|
/** 释放页面(删除物理页面文件 + 移出 BufferPool) */
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* v0.8.0 回归套件 —— A38 页面化压缩 + A39 compressLZ4 复杂度
|
||||||
|
* ============================================================================
|
||||||
|
* A38 **`compression` 在页面化路径上被静默忽略**
|
||||||
|
* `AriaEngine` 的默认配置是 `pageStorage` 自动(OPFS 后端下为 true),
|
||||||
|
* 而压缩只写在"整 value 存一个 backend value"的旧分支里,页面化分支
|
||||||
|
* **提前 return** —— 于是 `compression: true` 在默认配置下完全没有效果,
|
||||||
|
* 且没有任何提示。本套件的断言方式是**结构性**的:
|
||||||
|
* 开启压缩后 `SSTableMeta.totalSize`(实际落盘字节数)必须显著变小 ——
|
||||||
|
* 它与实现细节无关,只反映"数据真的被压缩了"。
|
||||||
|
*
|
||||||
|
* * 为什么之前没被发现:既有测试只断言"压缩后能读回来"(往返正确),
|
||||||
|
* 而"根本没压缩"同样能正确读回 —— 断言太弱,测不出"功能是否生效"。
|
||||||
|
*
|
||||||
|
* A39 **`compressLZ4` 匹配搜索是 O(n²)**
|
||||||
|
* 旧实现逐字节向前扫描最多 65535 个候选位置,每个位置再逐字节比较。
|
||||||
|
* 在低压缩率数据(伪随机)上退化为二次复杂度:实测 60KB 输入耗时 **2.3 秒**
|
||||||
|
* (新实现 6ms,约 390×)。SSTable 页/日志段正好是几百 KB 到几 MB,
|
||||||
|
* 因此这是普通写入路径上的真实卡顿,不是极端场景。
|
||||||
|
*
|
||||||
|
* 本套件同时锁定"输出格式未变":新实现与保留的线性参考实现在同一输入上
|
||||||
|
* 必须产出**可互相解压**的流(压缩率可略有差异,因为新实现能找到不同但
|
||||||
|
* 同样合法的匹配)。
|
||||||
|
*/
|
||||||
|
import { AriaEngine } from '../../src/engine/aria/index';
|
||||||
|
import { compressLZ4, compressLZ4LinearReference, decompressLZ4 } from '../../src/engine/aria/compression/lz4';
|
||||||
|
import { resetOPFSMock } from '../helpers/storage-harness';
|
||||||
|
|
||||||
|
beforeEach(() => { resetOPFSMock(); });
|
||||||
|
|
||||||
|
const SCHEMA = () => ({
|
||||||
|
name: 't',
|
||||||
|
columns: {
|
||||||
|
id: { type: 'string' as const, primaryKey: true },
|
||||||
|
blob: { type: 'string' as const },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 高度可压缩的载荷 */
|
||||||
|
const COMPRESSIBLE = 'x'.repeat(500) + 'y'.repeat(500);
|
||||||
|
|
||||||
|
/** 写入 n 行并返回 SSTable 元数据统计 */
|
||||||
|
async function writeAndMeasure(opts: {
|
||||||
|
dbName: string;
|
||||||
|
pageStorage: boolean;
|
||||||
|
compression: boolean;
|
||||||
|
rows?: number;
|
||||||
|
}): Promise<{ storedBytes: number; sstableCount: number; rowCount: number; roundTripOk: boolean }> {
|
||||||
|
const rows = opts.rows ?? 60;
|
||||||
|
const engine = new AriaEngine({
|
||||||
|
storageBackend: 'opfs',
|
||||||
|
pageStorage: opts.pageStorage,
|
||||||
|
compression: opts.compression,
|
||||||
|
memtableSizeThreshold: 2048,
|
||||||
|
checkpointInterval: 100_000_000,
|
||||||
|
} as never);
|
||||||
|
await engine.open(opts.dbName, 1);
|
||||||
|
await engine.createTable(SCHEMA() as never);
|
||||||
|
for (let i = 0; i < rows; i++) await engine.insert('t', [{ id: `k${i}`, blob: COMPRESSIBLE }]);
|
||||||
|
|
||||||
|
const lsm = (engine as unknown as { lsm: { sstableStore: { listMeta(): Promise<Array<{ totalSize: number }>> } } }).lsm;
|
||||||
|
const metas = await lsm.sstableStore.listMeta();
|
||||||
|
const storedBytes = metas.reduce((sum, m) => sum + (m.totalSize ?? 0), 0);
|
||||||
|
|
||||||
|
const read = await engine.find('t', { table: 't' });
|
||||||
|
const roundTripOk = read.length === rows && read.every((r) => r.blob === COMPRESSIBLE);
|
||||||
|
await engine.close();
|
||||||
|
return { storedBytes, sstableCount: metas.length, rowCount: read.length, roundTripOk };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('[v0.8.0] A38 compression 必须真的生效(含页面化路径)', () => {
|
||||||
|
it('页面化路径:开启压缩后落盘字节数显著下降', async () => {
|
||||||
|
const off = await writeAndMeasure({ dbName: 'a38-pages-off', pageStorage: true, compression: false });
|
||||||
|
const on = await writeAndMeasure({ dbName: 'a38-pages-on', pageStorage: true, compression: true });
|
||||||
|
|
||||||
|
// 先确认两次实验都写出了 SSTable(否则下面的比值没有意义)
|
||||||
|
expect(off.sstableCount).toBeGreaterThan(0);
|
||||||
|
expect(on.sstableCount).toBeGreaterThan(0);
|
||||||
|
// 数据完整性不受压缩影响
|
||||||
|
expect(off.roundTripOk).toBe(true);
|
||||||
|
expect(on.roundTripOk).toBe(true);
|
||||||
|
|
||||||
|
// 核心断言:修复前 compression 在页面化路径上被忽略 → 两者字节数相同
|
||||||
|
expect(on.storedBytes).toBeLessThan(off.storedBytes);
|
||||||
|
// 高可压缩内容应有的量级(10 倍是保守下界,实测远高于此)
|
||||||
|
expect(off.storedBytes).toBeGreaterThan(on.storedBytes * 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('整 value 路径:压缩同样生效(两条路径行为一致)', async () => {
|
||||||
|
const off = await writeAndMeasure({ dbName: 'a38-whole-off', pageStorage: false, compression: false });
|
||||||
|
const on = await writeAndMeasure({ dbName: 'a38-whole-on', pageStorage: false, compression: true });
|
||||||
|
expect(off.roundTripOk).toBe(true);
|
||||||
|
expect(on.roundTripOk).toBe(true);
|
||||||
|
expect(off.storedBytes).toBeGreaterThan(on.storedBytes * 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('页面化 + 压缩:重开后数据与索引完整', async () => {
|
||||||
|
const engine = new AriaEngine({
|
||||||
|
storageBackend: 'opfs', pageStorage: true, compression: true,
|
||||||
|
memtableSizeThreshold: 2048, checkpointInterval: 100_000_000,
|
||||||
|
} as never);
|
||||||
|
await engine.open('a38-reopen', 1);
|
||||||
|
await engine.createTable(SCHEMA() as never);
|
||||||
|
for (let i = 0; i < 40; i++) await engine.insert('t', [{ id: `k${i}`, blob: COMPRESSIBLE }]);
|
||||||
|
// 不 close(模拟崩溃后重开,覆盖压缩数据的恢复路径)
|
||||||
|
const engine2 = new AriaEngine({
|
||||||
|
storageBackend: 'opfs', pageStorage: true, compression: true,
|
||||||
|
memtableSizeThreshold: 2048, checkpointInterval: 100_000_000,
|
||||||
|
} as never);
|
||||||
|
await engine2.open('a38-reopen', 1);
|
||||||
|
const rows = await engine2.find('t', { table: 't' });
|
||||||
|
expect(rows).toHaveLength(40);
|
||||||
|
expect(rows.every((r) => r.blob === COMPRESSIBLE)).toBe(true);
|
||||||
|
// 单行精确读取(走 SSTable 数据块解析,而不只是全表扫描)
|
||||||
|
const one = await engine2.find('t', { table: 't', where: { id: 'k7' } });
|
||||||
|
expect(one).toHaveLength(1);
|
||||||
|
expect(one[0].blob).toBe(COMPRESSIBLE);
|
||||||
|
await engine.close().catch(() => { /* 已崩溃语义,忽略 */ });
|
||||||
|
await engine2.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('压缩关闭时数据同样完整(回归护栏)', async () => {
|
||||||
|
const result = await writeAndMeasure({ dbName: 'a38-nocomp', pageStorage: true, compression: false });
|
||||||
|
expect(result.roundTripOk).toBe(true);
|
||||||
|
expect(result.rowCount).toBe(60);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// A39:compressLZ4 复杂度与格式兼容
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 确定性伪随机(避免测试本身依赖随机源) */
|
||||||
|
function makeRandom(length: number, seed = 42): Uint8Array {
|
||||||
|
let s = seed >>> 0;
|
||||||
|
const out = new Uint8Array(length);
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
||||||
|
out[i] = (s >>> 24) & 0xFF;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('[v0.8.0] A39 compressLZ4:复杂度与格式兼容', () => {
|
||||||
|
const cases: Array<[string, Uint8Array]> = [
|
||||||
|
['空输入', new Uint8Array(0)],
|
||||||
|
['单字节', new Uint8Array([42])],
|
||||||
|
['全同字节', new Uint8Array(5000).fill(7)],
|
||||||
|
['周期序列', new Uint8Array(Array.from({ length: 40_000 }, (_, i) => i % 7))],
|
||||||
|
['伪随机 8KB', makeRandom(8_000)],
|
||||||
|
['伪随机 60KB', makeRandom(60_000)],
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(cases)('%s:往返一致且与线性参考实现可互解', (_label, data) => {
|
||||||
|
const fast = compressLZ4(data);
|
||||||
|
const slow = compressLZ4LinearReference(data);
|
||||||
|
|
||||||
|
// 新实现自身往返
|
||||||
|
const fromFast = decompressLZ4(fast);
|
||||||
|
expect(fromFast.length).toBe(data.length);
|
||||||
|
expect(Array.from(fromFast)).toEqual(Array.from(data));
|
||||||
|
|
||||||
|
// 旧实现的输出,新解压器必须能读(格式兼容:既有落盘数据不需要迁移)
|
||||||
|
const fromSlow = decompressLZ4(slow);
|
||||||
|
expect(Array.from(fromSlow)).toEqual(Array.from(data));
|
||||||
|
|
||||||
|
// 新实现的输出,旧解压器(同一份代码,此处仅作对称性验证)也必须能读
|
||||||
|
expect(Array.from(decompressLZ4(fast))).toEqual(Array.from(data));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('低压缩率数据不再退化(60KB 伪随机在 1 秒内完成)', () => {
|
||||||
|
const data = makeRandom(60_000);
|
||||||
|
const start = Date.now();
|
||||||
|
const compressed = compressLZ4(data);
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
|
||||||
|
// 修复前该输入耗时约 2.3 秒(逐位置线性扫描 → 二次复杂度)。
|
||||||
|
// 阈值取 1 秒:即使 CI 慢 10 倍也仍能通过,而回退到旧实现必然失败。
|
||||||
|
expect(elapsed).toBeLessThan(1000);
|
||||||
|
expect(decompressLZ4(compressed).length).toBe(data.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('大输入(1MB 可压缩内容)在合理时间内完成', () => {
|
||||||
|
const data = new Uint8Array(1_000_000);
|
||||||
|
for (let i = 0; i < data.length; i++) data[i] = i % 251;
|
||||||
|
const start = Date.now();
|
||||||
|
const compressed = compressLZ4(data);
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
expect(elapsed).toBeLessThan(3000);
|
||||||
|
// 周期性内容应被显著压缩
|
||||||
|
expect(compressed.length).toBeLessThan(data.length);
|
||||||
|
expect(decompressLZ4(compressed).length).toBe(data.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -260,7 +260,9 @@ describe('AriaEngine — 页面化 SSTable 集成(OPFS)', () => {
|
|||||||
await (engine as any).lsm.flush();
|
await (engine as any).lsm.flush();
|
||||||
await engine.close();
|
await engine.close();
|
||||||
|
|
||||||
const backendKeys1 = await opfs.listKeys();
|
// v0.8.0:mock 现在是"真实目录语义"(文件在 `${dbName}/` 下),
|
||||||
|
// 因此要用与生产代码相同的目录视图查询,而不是根目录列表。
|
||||||
|
const backendKeys1 = await opfs.dir('page-db-4').listKeys();
|
||||||
expect(backendKeys1.some((k) => k.startsWith('sst_'))).toBe(true);
|
expect(backendKeys1.some((k) => k.startsWith('sst_'))).toBe(true);
|
||||||
|
|
||||||
// 阶段 2:页面模式打开(默认 opfs → 启用),读旧数据 + 写新数据
|
// 阶段 2:页面模式打开(默认 opfs → 启用),读旧数据 + 写新数据
|
||||||
|
|||||||
@@ -71,14 +71,17 @@ describe('AriaEngine — repair 自愈增强', () => {
|
|||||||
await engine.createTable(SCHEMA());
|
await engine.createTable(SCHEMA());
|
||||||
await engine.insert('items', [{ id: 'a', val: 1, tag: 'x' }]);
|
await engine.insert('items', [{ id: 'a', val: 1, tag: 'x' }]);
|
||||||
|
|
||||||
// 制造残留(模拟 createWritable 中断留下的临时文件)
|
// 制造残留(模拟 createWritable 中断留下的临时文件)。
|
||||||
await opfs.createFile('junk.crswap');
|
// v0.8.0:mock 现在是真实目录语义,残留必须落在**该库的目录**里 ——
|
||||||
await opfs.createFile('junk2.tmp');
|
// repair 的 cleanupStaleFiles 扫的是库目录(root 上的文件不属于任何库)。
|
||||||
expect((await opfs.listKeys()).some((k) => k.endsWith('.crswap'))).toBe(true);
|
const dir = opfs.dir('repair-opfs-1');
|
||||||
|
await dir.createFile('junk.crswap');
|
||||||
|
await dir.createFile('junk2.tmp');
|
||||||
|
expect((await dir.listKeys()).some((k) => k.endsWith('.crswap'))).toBe(true);
|
||||||
|
|
||||||
await (engine as any).repair();
|
await (engine as any).repair();
|
||||||
|
|
||||||
const after = await opfs.listKeys();
|
const after = await dir.listKeys();
|
||||||
expect(after.some((k) => k.endsWith('.crswap'))).toBe(false);
|
expect(after.some((k) => k.endsWith('.crswap'))).toBe(false);
|
||||||
expect(after.some((k) => k.endsWith('.tmp'))).toBe(false);
|
expect(after.some((k) => k.endsWith('.tmp'))).toBe(false);
|
||||||
expect(await engine.count('items')).toBe(1);
|
expect(await engine.count('items')).toBe(1);
|
||||||
|
|||||||
@@ -297,18 +297,27 @@ export function resetOPFSMock(dbName = 'mock'): InstalledOPFSMock {
|
|||||||
|
|
||||||
export interface InstalledOPFSMock {
|
export interface InstalledOPFSMock {
|
||||||
store: TransactionalFileStore;
|
store: TransactionalFileStore;
|
||||||
/** 当前 mock 看到的所有文件名 */
|
|
||||||
listKeys(): Promise<string[]>;
|
|
||||||
/**
|
/**
|
||||||
* 直接创建(或覆盖)一个文件 —— 用于制造"崩溃残留临时文件"等场景,
|
* v0.8.0:取**某个库目录**的句柄 —— 测试与生产代码(`OPFSBackend.open`)
|
||||||
* 替代旧 API 暴露内部 dir/files 的做法。
|
* 使用同一个 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>;
|
createFile(name: string, content?: ArrayBuffer): Promise<void>;
|
||||||
/** 文件是否存在 */
|
|
||||||
hasFile(name: string): Promise<boolean>;
|
hasFile(name: string): Promise<boolean>;
|
||||||
/** 读取文件内容(返回副本) */
|
|
||||||
readFile(name: string): Promise<ArrayBuffer | null>;
|
readFile(name: string): Promise<ArrayBuffer | null>;
|
||||||
/** 删除文件 */
|
|
||||||
removeFile(name: string): Promise<void>;
|
removeFile(name: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,77 +329,136 @@ export interface InstalledOPFSMock {
|
|||||||
export function installOPFSMock(dbName = 'mock'): InstalledOPFSMock {
|
export function installOPFSMock(dbName = 'mock'): InstalledOPFSMock {
|
||||||
const store = getStore(dbName);
|
const store = getStore(dbName);
|
||||||
|
|
||||||
const makeFileHandle = async (name: string, opts?: { create?: boolean }) => {
|
/** 文件路径 = `${目录名}/${文件名}`(root 目录的目录名为空串) */
|
||||||
if (!(await store.has(name)) && !opts?.create) {
|
const joinPath = (dirName: string, fileName: string): string =>
|
||||||
throw new Error(`NotFoundError: ${name}`);
|
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 {
|
return {
|
||||||
/**
|
getFileHandle: makeFileHandle,
|
||||||
* 真实 OPFS 的 getFile() 返回 File,读的是**已提交**内容(size 与 arrayBuffer 一致)。
|
entries: async function* () {
|
||||||
* append 路径会读 `existing.size` 来定位追加位置,因此这里保持二者同源。
|
const prefix = dirName ? `${dirName}/` : '';
|
||||||
*/
|
for (const key of await store.listKeys()) {
|
||||||
getFile: async () => {
|
if (!key.startsWith(prefix)) continue;
|
||||||
const content = (await store.read(name)) ?? new ArrayBuffer(0);
|
const rest = key.slice(prefix.length);
|
||||||
return {
|
// 只列出本目录的**直接**子项(真实 OPFS 的 entries 语义)
|
||||||
size: content.byteLength,
|
if (rest.includes('/')) continue;
|
||||||
arrayBuffer: async () => content,
|
yield [rest];
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
createWritable: async (wOpts?: { keepExistingData?: boolean }) => {
|
removeEntry: async (name: string) => {
|
||||||
const keepExisting = wOpts?.keepExistingData ?? false;
|
await store.delete(joinPath(dirName, name));
|
||||||
// OPFS 写语义:keepExistingData:false 时从空缓冲开始(旧 mock 会保留旧字节)
|
store.commitAll();
|
||||||
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();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
|
/** 子目录:`OPFSBackend.open(name)` 走这里 */
|
||||||
|
getDirectoryHandle: async (name: string, _opts?: unknown) => makeDir(joinPath(dirName, name)),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const dir = {
|
const rootDir = makeDir('');
|
||||||
getFileHandle: makeFileHandle,
|
|
||||||
entries: async function* () {
|
|
||||||
for (const key of await store.listKeys()) yield [key];
|
|
||||||
},
|
|
||||||
removeEntry: async (name: string) => {
|
|
||||||
await store.delete(name);
|
|
||||||
store.commitAll();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'navigator', {
|
Object.defineProperty(globalThis, 'navigator', {
|
||||||
value: {
|
value: {
|
||||||
storage: {
|
storage: {
|
||||||
getDirectory: async () => ({
|
getDirectory: async () => rootDir,
|
||||||
getDirectoryHandle: async (_name: string, _opts?: unknown) => dir,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
configurable: true,
|
configurable: true,
|
||||||
writable: 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 {
|
return {
|
||||||
store,
|
store,
|
||||||
|
dir: dirView,
|
||||||
|
// 辅助方法面向**根目录**(调用方给出的名字即完整相对路径,便于制造残留文件)
|
||||||
listKeys: () => store.listKeys(),
|
listKeys: () => store.listKeys(),
|
||||||
createFile: async (name, content) => {
|
createFile: async (name, content) => {
|
||||||
await store.write(name, content ?? new ArrayBuffer(0));
|
await store.write(name, content ?? new ArrayBuffer(0));
|
||||||
|
|||||||
Reference in New Issue
Block a user