Files
MetonaSqlark/tests/engine/aria-compression.test.ts
T
thzxx c5694b1d23 feat(B-6): 存储层单一提交点(__aria_manifest)+ LSM 结构根治
按 PLAN-v0.7.5.md §B-6 的**完整规格**实施(此前只落地了"降级选项"里的五处止血):
B-6 要求的是 `__aria_manifest` 单一提交点 + LSM 单项改造。完整记录见方案附录 H。

一、单一提交点
  - 新增 `src/engine/aria/store/manifest.ts`:`__aria_manifest_<generation>`
    (magic + formatVersion + generation + 头部 CRC + 载荷 CRC;先写后验;保留两代)。
    载荷 = 页面水位 + 各命名空间 SSTable 元数据 + 表结构 + WAL 起始位置 + 待落盘冻结表意图。
  - 顺序固定:**数据落盘 → manifest 提交 → 才允许截断 WAL / 删除旧文件 / 删除旧 SSTable**。
  - 恢复只认最后一份 CRC 通过的世代;全部世代无效 → `ARIA_MANIFEST_CORRUPT`
    (修复前:裸 JSON meta 解析失败 → `[]` → 静默空库,随后 repair 还会删光活页)。
  - 旧格式(__aria_lsm_meta/__aria_schemas/__aria_meta)首次打开自动迁移,旧键保留;
    迁移遇到损坏 → `ARIA_LEGACY_META_CORRUPT`。
  - 陈旧实例保护(STALE_INSTANCE):认领时一次跨过 MANIFEST_TAKEOVER_STRIDE 个世代,
    杜绝"旧实例在途提交落在同一世代号上"(实测第二个实例 open 直接失败)。

二、LSM
  - 44 冻结表成为一等状态:失败保留 + 可重试(修复前失败即永久失去落盘机会)。
  - 45 `flush()` 先入链再报告后台错误(修复前一次后台失败会让之后每次 flush 直接抛错、
       数据永远等不到落盘);被重试修复的失败进 `getBackgroundWarnings()`(可见但不误报失败)。
  - 47 `MergeIterator` 胜出来源的补充推迟到下一次 `next()`:提前终止不再多算一条。
  - 49 `compacting` 由单 boolean 改为按层集合(跨层触发不再被静默丢弃)。
  - 50 compaction 不再"先 splice 整层再合并"(窗口内该层对读者可见);
       被取代的 SSTable 进"退休表" + 读者 epoch,等更早读者退出才物理删除。
  - 51 底部层原地合并回收墓碑(删除密集场景空间不再无界增长);"整层只剩墓碑" 有专门分支
       (修复前会读 `merged[0][0]` 抛 TypeError,compaction 永久失败)。
  - 55 flush 与 compaction 拆成两条链,checkpoint 只落 memtable;删除引擎层全部
       `prefetch*`/`drainChain` 依赖,改为"快照 + 结构版本乐观重试"
       (版本号同时覆盖 levels 与前台 memtable/frozen 的变化)。
  - 读路径自洽:介质读故障抛 `ARIA_SSTABLE_READ_FAILED`,不再折叠成"文件不存在"误删元数据。

三、WAL
  - LSN 全库单调(manifest 记高水位);按水位删除旧分片(`planKeepFrom` → 提交 → 再删除)。
  - **分片号只增不减**:修复前全量截断后重置为 0,会与 manifest 记录的 startSegment 错位,
    实测造成两个方向的损坏(删掉的行复活 / 已确认写入丢失,见随机压力套件)。
  - 分片空洞(含前缀缺失)显式报 `ARIA_WAL_GAP`,不再静默丢弃尾部。

四、其它
  - `sstable.ts` 三份解析循环合并为 `iterEntries()`,越界策略统一。
  - `vacuum()` 返回真实压缩层数(修复前硬编码 6 且底部层永不压缩)。
  - `close()` 加 try/finally(落盘失败也必须释放后端/锁并复位状态)。
  - `getRecoveryReport()`:{droppedSSTables, dataLossSuspected, walGaps, legacyImported,
    manifestFallback} —— "自愈了什么、有没有真丢数据"成为可读返回值。

五、验证
  - 新增 `tests/v080-b6-single-commit-point.test.ts`(63 项,含 manifest 严格校验表驱动 25 例)。
  - 新增 `scripts/mutation-b6.py`:22 项变异验证(把每个修复回退到修复前行为,对应用例必须失败),
    全部被拦住 —— 这批用例不是陪跑。
  - 常规套件 1935 通过 / 91 套件;覆盖率 90.34 / 82.16 / 94.06 / 93.23(阈值 90/82/94/93);
    e2e 14/14;重型套件 4 套件 27 项全绿。
2026-09-15 10:29:03 +08:00

205 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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: { flush(): Promise<void>; sstableStore: { listMeta(): Promise<Array<{ totalSize: number }>> } };
}).lsm;
// v0.8.0B-6):**测量前显式 flush**。
//
// 此前这里直接读 meta 求和,于是"落盘字节数"取决于测量瞬间有多少数据恰好在
// SSTable 里 —— 而 manifest 提交(单一提交点)让每次 flush 多一次原子提交,
// 后台 flush 的进度随之变化,两次实验的"已落盘比例"不再相同,比值就变成在
// 测时序而不是测压缩(实测:未 flush 时 off=19040/on=5130flush 后
// off=58570/on=10096 —— 后者才是同一份数据的真实压缩率)。
await lsm.flush();
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);
});
});
// ---------------------------------------------------------------------------
// A39compressLZ4 复杂度与格式兼容
// ---------------------------------------------------------------------------
/** 确定性伪随机(避免测试本身依赖随机源) */
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);
});
});