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:
thzxx
2026-09-15 01:52:57 +08:00
parent 85f0f170a4
commit 97b9fa486d
15 changed files with 1327 additions and 517 deletions
+257 -140
View File
@@ -5621,14 +5621,16 @@ class LSM {
minKey: entries[0][0],
maxKey: entries[entries.length - 1][0],
blockCount: indexEntries.length,
// v0.8.0(A38):先留 0,落盘后用实际存储长度回填(见下)
totalSize: sstableData.byteLength,
bloomData: null,
};
// 缓存
// 缓存(缓存的是内存中的**未压缩**整文件,与存储布局无关)
this.tryCacheSSTable(id, sstableData);
this.trimCache();
// 持久化:先存数据,再存元数据(串行链保证顺序与 id 一致)
await this.sstableStore.save(id, sstableData);
const stored = await this.sstableStore.save(id, sstableData);
meta.totalSize = stored.storedSize;
await this.sstableStore.saveMeta(meta);
if (this.immutableMemtable === frozen)
this.immutableMemtable = null;
@@ -5935,12 +5937,14 @@ class LSM {
minKey: merged[0][0],
maxKey: merged[merged.length - 1][0],
blockCount: indexEntries.length,
// v0.8.0(A38):落盘后用实际存储长度回填(见下)
totalSize: sstableData.byteLength,
bloomData: null,
};
this.tryCacheSSTable(id, sstableData);
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);
this.levels[level + 1].unshift(meta);
// 删除旧 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 页面化物理存储
* @module engine/aria/store/page_sstable_store
@@ -7160,15 +7371,40 @@ class EncryptedBackend {
* - LSM 缓存解析后的整文件字节查询热点复用
*/
class PageSSTableStore {
constructor(fileManager, bufferPool) {
constructor(fileManager, bufferPool,
/**
* v0.8.0A38是否压缩
*
* 修复前 `config.compression` 只作用于"整 value 存一个 backend value"的旧路径
* `save()` 在页面化路径上**提前 return**压缩分支根本走不到 于是
* `pageStorage: true`默认 `compression: true` 被完全忽略
* 用户打开了压缩却没有任何压缩效果且没有任何提示
*
* 为什么在页面化里压缩整个流而不是逐页压缩
* - 压缩率取决于"连续数据的重复窗口"4KB 页各自压缩会丢失跨页匹配
* 压缩率显著低于整体压缩
* - 整体压缩后仍是**字节流**切页照旧页面布局与 pageIds 语义不变
* meta/文件布局零影响
*/
compression = false) {
this.fileManager = fileManager;
this.bufferPool = bufferPool;
this.compression = compression;
/** SSTable id → 页面 ID 列表(save 时记录,saveMeta 时注入 meta */
this.pageIds = new Map();
}
/** 保存数据:切页 → 写入 BufferPool → 逐页落盘 → 记录 pageIds */
/**
* 保存数据切页 写入 BufferPool 逐页落盘 记录 pageIds
*
* @returns `storedSize` = **实际落盘字节数**压缩后调用方写入
* `SSTableMeta.totalSize` 时应使用它 totalSize 的语义是
* "页面里有多少字节"加载时按它截断若仍写未压缩长度
* 压缩后的数据会被 0 填充撑大静默损坏
*/
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 ids = [];
for (let i = 0; i < pageCount; i++) {
@@ -7176,7 +7412,7 @@ class PageSSTableStore {
ids.push(page.pageId);
const dest = new Uint8Array(page.data);
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);
page.dirty = true;
// save 语义 = 已持久化:立即落盘(WAL checkpoint 截断依赖此保证)
@@ -7184,6 +7420,7 @@ class PageSSTableStore {
this.bufferPool.unpin(page);
}
this.pageIds.set(id, ids);
return { storedSize: payload.byteLength };
}
/** 获取指定 SSTable 的页面 ID 列表(saveMeta 注入用) */
getPageIds(id) {
@@ -7191,7 +7428,8 @@ class PageSSTableStore {
}
/**
* 按页面 ID 列表读取并拼接为完整字节流
* @param totalSize SSTable 真实大小meta 持久化最后一页可能有 0 填充按真实大小截断
* @param totalSize 页面中**实际存储**的字节数`save()` 返回的 storedSize
* 即压缩后长度最后一页可能有 0 填充按它截断
* @returns 缺失页面/读取失败返回 null调用方视为损坏并清理
*/
async load(id, pageIds, totalSize) {
@@ -7217,7 +7455,8 @@ class PageSSTableStore {
off += take;
}
this.pageIds.delete(id);
return out;
// v0.8.0A38):解压(与 save 的加密/压缩顺序对称)
return this.compression ? decompressLZ4(out) : out;
}
/** 释放页面(删除物理页面文件 + 移出 BufferPool */
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
// ---------------------------------------------------------------------------
@@ -9672,7 +9783,11 @@ class AriaEngine {
let seqLoaded = false;
// v0.4.5: 页面化物理存储(OPFS 后端默认启用)— SSTable 存为 4KB 页面,BufferPool 缓存
const usePages = this.isPageStorage();
const pageStore = usePages ? new PageSSTableStore(this.fileManager, this.bufferPool) : null;
// v0.8.0A38):compression 必须传给 pageStore —— 页面化是默认路径,
// 不传就等于"默认配置下 compression 被静默忽略"(修复前的实际状态)。
const pageStore = usePages
? new PageSSTableStore(this.fileManager, this.bufferPool, this.config.compression)
: null;
const encodeText = (text) => {
return new TextEncoder().encode(text).buffer;
};
@@ -9691,8 +9806,8 @@ class AriaEngine {
save: async (id, data) => {
if (pageStore) {
// 页面化:切页写入 BufferPool 并逐页落盘(save 语义 = 已持久化)
await pageStore.save(id, data);
return;
// 压缩由 pageStore 内部完成(整体压缩后再切页,压缩率优于逐页)
return pageStore.save(id, data);
}
let buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
// 压缩(若启用)— 加密由 EncryptedBackend 在 backend 层透明处理(v0.4.5
@@ -9701,6 +9816,8 @@ class AriaEngine {
buf = compressed.buffer.slice(compressed.byteOffset, compressed.byteOffset + compressed.byteLength);
}
await this.backend.write(`${filePrefix}${id}`, buf);
// 整 value 路径:落盘长度即压缩后长度(与页面化路径语义一致)
return { storedSize: buf.byteLength };
},
load: async (id) => {
// 页面化读取:meta 有 pageIds → 页面拼接;无(旧数据)→ 整 value
+1 -1
View File
File diff suppressed because one or more lines are too long
+257 -140
View File
@@ -5617,14 +5617,16 @@ class LSM {
minKey: entries[0][0],
maxKey: entries[entries.length - 1][0],
blockCount: indexEntries.length,
// v0.8.0(A38):先留 0,落盘后用实际存储长度回填(见下)
totalSize: sstableData.byteLength,
bloomData: null,
};
// 缓存
// 缓存(缓存的是内存中的**未压缩**整文件,与存储布局无关)
this.tryCacheSSTable(id, sstableData);
this.trimCache();
// 持久化:先存数据,再存元数据(串行链保证顺序与 id 一致)
await this.sstableStore.save(id, sstableData);
const stored = await this.sstableStore.save(id, sstableData);
meta.totalSize = stored.storedSize;
await this.sstableStore.saveMeta(meta);
if (this.immutableMemtable === frozen)
this.immutableMemtable = null;
@@ -5931,12 +5933,14 @@ class LSM {
minKey: merged[0][0],
maxKey: merged[merged.length - 1][0],
blockCount: indexEntries.length,
// v0.8.0(A38):落盘后用实际存储长度回填(见下)
totalSize: sstableData.byteLength,
bloomData: null,
};
this.tryCacheSSTable(id, sstableData);
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);
this.levels[level + 1].unshift(meta);
// 删除旧 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 页面化物理存储
* @module engine/aria/store/page_sstable_store
@@ -7156,15 +7367,40 @@ class EncryptedBackend {
* - LSM 缓存解析后的整文件字节查询热点复用
*/
class PageSSTableStore {
constructor(fileManager, bufferPool) {
constructor(fileManager, bufferPool,
/**
* v0.8.0A38是否压缩
*
* 修复前 `config.compression` 只作用于"整 value 存一个 backend value"的旧路径
* `save()` 在页面化路径上**提前 return**压缩分支根本走不到 于是
* `pageStorage: true`默认 `compression: true` 被完全忽略
* 用户打开了压缩却没有任何压缩效果且没有任何提示
*
* 为什么在页面化里压缩整个流而不是逐页压缩
* - 压缩率取决于"连续数据的重复窗口"4KB 页各自压缩会丢失跨页匹配
* 压缩率显著低于整体压缩
* - 整体压缩后仍是**字节流**切页照旧页面布局与 pageIds 语义不变
* meta/文件布局零影响
*/
compression = false) {
this.fileManager = fileManager;
this.bufferPool = bufferPool;
this.compression = compression;
/** SSTable id → 页面 ID 列表(save 时记录,saveMeta 时注入 meta */
this.pageIds = new Map();
}
/** 保存数据:切页 → 写入 BufferPool → 逐页落盘 → 记录 pageIds */
/**
* 保存数据切页 写入 BufferPool 逐页落盘 记录 pageIds
*
* @returns `storedSize` = **实际落盘字节数**压缩后调用方写入
* `SSTableMeta.totalSize` 时应使用它 totalSize 的语义是
* "页面里有多少字节"加载时按它截断若仍写未压缩长度
* 压缩后的数据会被 0 填充撑大静默损坏
*/
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 ids = [];
for (let i = 0; i < pageCount; i++) {
@@ -7172,7 +7408,7 @@ class PageSSTableStore {
ids.push(page.pageId);
const dest = new Uint8Array(page.data);
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);
page.dirty = true;
// save 语义 = 已持久化:立即落盘(WAL checkpoint 截断依赖此保证)
@@ -7180,6 +7416,7 @@ class PageSSTableStore {
this.bufferPool.unpin(page);
}
this.pageIds.set(id, ids);
return { storedSize: payload.byteLength };
}
/** 获取指定 SSTable 的页面 ID 列表(saveMeta 注入用) */
getPageIds(id) {
@@ -7187,7 +7424,8 @@ class PageSSTableStore {
}
/**
* 按页面 ID 列表读取并拼接为完整字节流
* @param totalSize SSTable 真实大小meta 持久化最后一页可能有 0 填充按真实大小截断
* @param totalSize 页面中**实际存储**的字节数`save()` 返回的 storedSize
* 即压缩后长度最后一页可能有 0 填充按它截断
* @returns 缺失页面/读取失败返回 null调用方视为损坏并清理
*/
async load(id, pageIds, totalSize) {
@@ -7213,7 +7451,8 @@ class PageSSTableStore {
off += take;
}
this.pageIds.delete(id);
return out;
// v0.8.0A38):解压(与 save 的加密/压缩顺序对称)
return this.compression ? decompressLZ4(out) : out;
}
/** 释放页面(删除物理页面文件 + 移出 BufferPool */
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
// ---------------------------------------------------------------------------
@@ -9668,7 +9779,11 @@ class AriaEngine {
let seqLoaded = false;
// v0.4.5: 页面化物理存储(OPFS 后端默认启用)— SSTable 存为 4KB 页面,BufferPool 缓存
const usePages = this.isPageStorage();
const pageStore = usePages ? new PageSSTableStore(this.fileManager, this.bufferPool) : null;
// v0.8.0A38):compression 必须传给 pageStore —— 页面化是默认路径,
// 不传就等于"默认配置下 compression 被静默忽略"(修复前的实际状态)。
const pageStore = usePages
? new PageSSTableStore(this.fileManager, this.bufferPool, this.config.compression)
: null;
const encodeText = (text) => {
return new TextEncoder().encode(text).buffer;
};
@@ -9687,8 +9802,8 @@ class AriaEngine {
save: async (id, data) => {
if (pageStore) {
// 页面化:切页写入 BufferPool 并逐页落盘(save 语义 = 已持久化)
await pageStore.save(id, data);
return;
// 压缩由 pageStore 内部完成(整体压缩后再切页,压缩率优于逐页)
return pageStore.save(id, data);
}
let buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
// 压缩(若启用)— 加密由 EncryptedBackend 在 backend 层透明处理(v0.4.5
@@ -9697,6 +9812,8 @@ class AriaEngine {
buf = compressed.buffer.slice(compressed.byteOffset, compressed.byteOffset + compressed.byteLength);
}
await this.backend.write(`${filePrefix}${id}`, buf);
// 整 value 路径:落盘长度即压缩后长度(与页面化路径语义一致)
return { storedSize: buf.byteLength };
},
load: async (id) => {
// 页面化读取:meta 有 pageIds → 页面拼接;无(旧数据)→ 整 value
+1 -1
View File
File diff suppressed because one or more lines are too long
+257 -140
View File
@@ -5623,14 +5623,16 @@
minKey: entries[0][0],
maxKey: entries[entries.length - 1][0],
blockCount: indexEntries.length,
// v0.8.0(A38):先留 0,落盘后用实际存储长度回填(见下)
totalSize: sstableData.byteLength,
bloomData: null,
};
// 缓存
// 缓存(缓存的是内存中的**未压缩**整文件,与存储布局无关)
this.tryCacheSSTable(id, sstableData);
this.trimCache();
// 持久化:先存数据,再存元数据(串行链保证顺序与 id 一致)
await this.sstableStore.save(id, sstableData);
const stored = await this.sstableStore.save(id, sstableData);
meta.totalSize = stored.storedSize;
await this.sstableStore.saveMeta(meta);
if (this.immutableMemtable === frozen)
this.immutableMemtable = null;
@@ -5937,12 +5939,14 @@
minKey: merged[0][0],
maxKey: merged[merged.length - 1][0],
blockCount: indexEntries.length,
// v0.8.0(A38):落盘后用实际存储长度回填(见下)
totalSize: sstableData.byteLength,
bloomData: null,
};
this.tryCacheSSTable(id, sstableData);
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);
this.levels[level + 1].unshift(meta);
// 删除旧 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 页面化物理存储
* @module engine/aria/store/page_sstable_store
@@ -7162,15 +7373,40 @@
* - LSM 缓存解析后的整文件字节查询热点复用
*/
class PageSSTableStore {
constructor(fileManager, bufferPool) {
constructor(fileManager, bufferPool,
/**
* v0.8.0A38是否压缩
*
* 修复前 `config.compression` 只作用于"整 value 存一个 backend value"的旧路径
* `save()` 在页面化路径上**提前 return**压缩分支根本走不到 于是
* `pageStorage: true`默认 `compression: true` 被完全忽略
* 用户打开了压缩却没有任何压缩效果且没有任何提示
*
* 为什么在页面化里压缩整个流而不是逐页压缩
* - 压缩率取决于"连续数据的重复窗口"4KB 页各自压缩会丢失跨页匹配
* 压缩率显著低于整体压缩
* - 整体压缩后仍是**字节流**切页照旧页面布局与 pageIds 语义不变
* meta/文件布局零影响
*/
compression = false) {
this.fileManager = fileManager;
this.bufferPool = bufferPool;
this.compression = compression;
/** SSTable id → 页面 ID 列表(save 时记录,saveMeta 时注入 meta */
this.pageIds = new Map();
}
/** 保存数据:切页 → 写入 BufferPool → 逐页落盘 → 记录 pageIds */
/**
* 保存数据切页 写入 BufferPool 逐页落盘 记录 pageIds
*
* @returns `storedSize` = **实际落盘字节数**压缩后调用方写入
* `SSTableMeta.totalSize` 时应使用它 totalSize 的语义是
* "页面里有多少字节"加载时按它截断若仍写未压缩长度
* 压缩后的数据会被 0 填充撑大静默损坏
*/
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 ids = [];
for (let i = 0; i < pageCount; i++) {
@@ -7178,7 +7414,7 @@
ids.push(page.pageId);
const dest = new Uint8Array(page.data);
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);
page.dirty = true;
// save 语义 = 已持久化:立即落盘(WAL checkpoint 截断依赖此保证)
@@ -7186,6 +7422,7 @@
this.bufferPool.unpin(page);
}
this.pageIds.set(id, ids);
return { storedSize: payload.byteLength };
}
/** 获取指定 SSTable 的页面 ID 列表(saveMeta 注入用) */
getPageIds(id) {
@@ -7193,7 +7430,8 @@
}
/**
* 按页面 ID 列表读取并拼接为完整字节流
* @param totalSize SSTable 真实大小meta 持久化最后一页可能有 0 填充按真实大小截断
* @param totalSize 页面中**实际存储**的字节数`save()` 返回的 storedSize
* 即压缩后长度最后一页可能有 0 填充按它截断
* @returns 缺失页面/读取失败返回 null调用方视为损坏并清理
*/
async load(id, pageIds, totalSize) {
@@ -7219,7 +7457,8 @@
off += take;
}
this.pageIds.delete(id);
return out;
// v0.8.0A38):解压(与 save 的加密/压缩顺序对称)
return this.compression ? decompressLZ4(out) : out;
}
/** 释放页面(删除物理页面文件 + 移出 BufferPool */
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
// ---------------------------------------------------------------------------
@@ -9674,7 +9785,11 @@
let seqLoaded = false;
// v0.4.5: 页面化物理存储(OPFS 后端默认启用)— SSTable 存为 4KB 页面,BufferPool 缓存
const usePages = this.isPageStorage();
const pageStore = usePages ? new PageSSTableStore(this.fileManager, this.bufferPool) : null;
// v0.8.0A38):compression 必须传给 pageStore —— 页面化是默认路径,
// 不传就等于"默认配置下 compression 被静默忽略"(修复前的实际状态)。
const pageStore = usePages
? new PageSSTableStore(this.fileManager, this.bufferPool, this.config.compression)
: null;
const encodeText = (text) => {
return new TextEncoder().encode(text).buffer;
};
@@ -9693,8 +9808,8 @@
save: async (id, data) => {
if (pageStore) {
// 页面化:切页写入 BufferPool 并逐页落盘(save 语义 = 已持久化)
await pageStore.save(id, data);
return;
// 压缩由 pageStore 内部完成(整体压缩后再切页,压缩率优于逐页)
return pageStore.save(id, data);
}
let buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
// 压缩(若启用)— 加密由 EncryptedBackend 在 backend 层透明处理(v0.4.5
@@ -9703,6 +9818,8 @@
buf = compressed.buffer.slice(compressed.byteOffset, compressed.byteOffset + compressed.byteLength);
}
await this.backend.write(`${filePrefix}${id}`, buf);
// 整 value 路径:落盘长度即压缩后长度(与页面化路径语义一致)
return { storedSize: buf.byteLength };
},
load: async (id) => {
// 页面化读取:meta 有 pageIds → 页面拼接;无(旧数据)→ 整 value
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long