方法:四个对抗性子代理分头审查(数据正确性 / 文档宣称 vs 实现 / 公共 API 契约 / 测试质量),每条结论要求可复现证据;逐条复核 + 探针确认 + 变异验证(40 项全部 被对应用例拦住)。 P0:事务活跃期间 repair()/close()/周期 checkpoint 推进 WAL 水位 → 已 COMMIT 的 事务整批消失且恢复报告"干净"。根因 hasPendingFlushData()/computeDurableLsn() 不看 txnSnapshot;守卫此前只在 CheckpointManager 两个回调里。修复:守卫下沉到 computeDurableLsn() 与 advanceWalCheckpoint() 入口(唯一实现)。 P1: - WAL 前缀缺失丢弃整段活分片(回退上一代 manifest 时 kept 为空)→ 前缀缺失单独 记录,后缀照常重放;仅 fromLsn === 0 时才算真异常 - 孤儿回收门槛只看引擎层 dataLossSuspected,漏掉 LSM 层被丢的 SSTable → 统一 describeRecoveryDamage() 聚合判定(损坏时绝不删"引用不到"的文件) - vacuum() 逐层压缩绕过维护链 → vacuumLevels() 每层作为维护链任务执行 - reclaimRetiredNow() 无视在途读者(读者把"已退休"读成"文件损坏")→ 有读者时 退化为延迟回收 P2:WAL 记录级 CRC 损坏不计数不上报;旧格式表结构记录形状损坏静默当空库; bloomFilterBitsPerKey 配置被接受却完全不生效(构建器写死默认值,实现缺陷); 幽灵 meta;介质读故障等于文件损坏的语义无用例;manifest 回读校验两条守卫无用例; 文件名≠载荷世代判定无用例;pageIdWatermark 单调性无用例;分片号两条真实不变量 无用例。 覆盖率口径(第二处漏洞):interface.ts 混着三个运行时函数(cloneRow 等)却被 描述为"纯类型、不纳入统计" → 实现搬到 src/engine/row_clone.ts;搬完门禁真的 失败(functions 93.84% < 94%),补测退化路径后通过。 测试质量:3 条空壳用例改值级断言;1 条"全损坏"用例实际只走缓存 → 拆成两条真 用例;5 秒墙钟 race 改门控 + 失败上限;setTimeout 改 whenIdle();<= 收紧为 <。 变异脚本加固:正控(干净基线必须全绿)、编译失败/0 用例单独归类、300s 超时、 逐字节 sha256 恢复校验、O_EXCL 进程锁、锚点唯一性;变异 22 → 40 项。 文档两轮订正(16 + 11 条不成立宣称):MVCC 快照隔离、backup 一致性快照、 "空洞检测截断"、体积(251,109 B / gzip 63,145 B)、测试与覆盖率数字、 "5 种存储引擎"、Tree-shakable、错误码表补 16 个码、恢复报告字段、已知限制 (回退单向 / 多实例依赖 Web Locks / manifest 体积 / 尾部 WAL 分片不可识别)。 验证:常规套件 92 套件 / 1980 用例全绿;覆盖率 90.59 / 82.59 / 94.14 / 93.50 (阈值 90/82/94/93);e2e 14/14(真实 Chromium + OPFS + CDP 崩溃); 重型套件 4 套件 / 27 用例;变异 40/40;lint + 两份 tsc 干净;dist 已重建。
269 lines
9.9 KiB
TypeScript
269 lines
9.9 KiB
TypeScript
/**
|
||
* AriaEngine SSTable Builder — 构建有序字符串表
|
||
* @module engine/aria/index/sstable_builder
|
||
*
|
||
* 将排序后的 key-value 数据写入 SSTable 格式。
|
||
*
|
||
* v0.4.4 格式 v2(magic "SSTC")修复:
|
||
* - 块大小估算改用 UTF-8 字节长度(TextEncoder 预编码),
|
||
* 此前用字符串 .length(UTF-16 码元)估算而实际写入 UTF-8 字节,
|
||
* 中文内容(1 字 3 字节)导致缓冲区低估 → 写入越界崩溃
|
||
* - keyLen/valueLen 从 u16 升级为 u32(此前 >64KB 的 value 长度被截断,
|
||
* 线性格式整体错乱)
|
||
* - 大 value 单条独立成块(切分逻辑基于字节估算)
|
||
*
|
||
* SSTable 文件布局 (v2):
|
||
* ┌──────────────────────────────────────────────┐
|
||
* │ Data Block 0 │
|
||
* │ Data Block 1 │
|
||
* │ ... │
|
||
* │ Index Block (block offset → key range) │
|
||
* │ Bloom Filter │
|
||
* │ Footer (32 bytes) │
|
||
* │ - index_offset (u32) │
|
||
* │ - index_size (u32) │
|
||
* │ - bloom_offset (u32) │
|
||
* │ - bloom_size (u32) │
|
||
* │ - bloom_hash_count (u32) │
|
||
* │ - entry_count (u32) │
|
||
* │ - magic_number (u32, 0x53535443 ="SSTC")│
|
||
* │ - checksum (u32) │
|
||
* └──────────────────────────────────────────────┘
|
||
* 数据块条目: entryCount(u32) + [keyLen(u32) + key + valueLen(u32) + value]
|
||
* 索引块条目: [keyLen(u32) + key + blockOffset(u32) + blockSize(u32)]
|
||
*/
|
||
|
||
import { BloomFilter } from './bloom';
|
||
import { DEFAULT_BLOOM_BITS_PER_KEY } from '../types';
|
||
import { crc32 } from '../crc32';
|
||
import type { IndexEntry } from '../types';
|
||
|
||
/** v1 格式魔数("SSTB",u16 长度字段,兼容旧文件读取) */
|
||
export const SSTABLE_MAGIC_V1 = 0x53535442;
|
||
/** v2 格式魔数("SSTC",u32 长度字段 + 字节精确估算,v0.4.4) */
|
||
export const SSTABLE_MAGIC_V2 = 0x53535443;
|
||
const SSTABLE_FOOTER_SIZE = 32;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SSTableBuilder
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface EncodedEntry {
|
||
key: string;
|
||
keyBytes: Uint8Array;
|
||
valueBytes: Uint8Array;
|
||
}
|
||
|
||
export class SSTableBuilder {
|
||
/** Bloom Filter 每 key 位数(由配置透传) */
|
||
private bloomBitsPerKey: number;
|
||
private entries: [string, Record<string, unknown>][] = [];
|
||
private blockSizeLimit: number;
|
||
|
||
/**
|
||
* @param blockSizeLimit 块大小上限(字节)
|
||
* @param bloomBitsPerKey Bloom Filter 每 key 位数(v0.8.0 审查修复:此前
|
||
* 无论 `bloomFilterBitsPerKey` 配成多少,这里都写死用默认值 ——
|
||
* 配置项被接受却完全不起作用)
|
||
*/
|
||
constructor(
|
||
blockSizeLimit: number = 4096,
|
||
bloomBitsPerKey: number = DEFAULT_BLOOM_BITS_PER_KEY,
|
||
) {
|
||
this.blockSizeLimit = blockSizeLimit;
|
||
this.bloomBitsPerKey = Number.isFinite(bloomBitsPerKey) && bloomBitsPerKey > 0
|
||
? Math.floor(bloomBitsPerKey)
|
||
: DEFAULT_BLOOM_BITS_PER_KEY;
|
||
}
|
||
|
||
/** 添加一个 key-value 条目(必须按键排序添加) */
|
||
add(key: string, value: Record<string, unknown>): void {
|
||
this.entries.push([key, value]);
|
||
}
|
||
|
||
/**
|
||
* 构建 SSTable 文件的二进制数据(v2 格式)。
|
||
* 返回 { data: Uint8Array, indexEntries: IndexEntry[] }
|
||
*/
|
||
build(): { sstableData: Uint8Array; indexEntries: IndexEntry[] } {
|
||
// v0.4.4-fix: 预编码全部条目 — 块大小估算必须基于 UTF-8 字节长度,
|
||
// 字符串 .length 是 UTF-16 码元(中文 1 字 3 字节 vs 1 码元)→ 缓冲区低估越界
|
||
const encoder = new TextEncoder();
|
||
const encoded: EncodedEntry[] = this.entries.map(([key, value]) => ({
|
||
key,
|
||
keyBytes: encoder.encode(key),
|
||
valueBytes: encoder.encode(JSON.stringify(value)),
|
||
}));
|
||
|
||
const blocks = this.splitIntoBlocks(encoded);
|
||
const bloomFilter = new BloomFilter(this.entries.length, this.bloomBitsPerKey);
|
||
|
||
// 预计算总大小(字节)
|
||
let totalSize = 0;
|
||
const blockOffsets: number[] = [];
|
||
for (const block of blocks) {
|
||
blockOffsets.push(totalSize);
|
||
totalSize += this.computeBlockSize(block);
|
||
}
|
||
|
||
// 索引块(块内最后一个 key 作为索引键)
|
||
const indexEntries: IndexEntry[] = [];
|
||
for (let i = 0; i < blocks.length; i++) {
|
||
const block = blocks[i];
|
||
indexEntries.push({
|
||
key: block[block.length - 1].key,
|
||
blockOffset: blockOffsets[i],
|
||
blockSize: this.computeBlockSize(block),
|
||
});
|
||
}
|
||
|
||
const indexBlockSize = this.estimateIndexBlockSize(indexEntries);
|
||
|
||
// 序列化 bloom filter 以获取其大小
|
||
const bloomData = bloomFilter.serialize();
|
||
const bloomSize = bloomData.byteLength;
|
||
|
||
// 写入到 buffer(包含 bloom block)
|
||
const finalSize = totalSize + indexBlockSize + bloomSize + SSTABLE_FOOTER_SIZE;
|
||
const buf = new ArrayBuffer(finalSize);
|
||
const view = new DataView(buf);
|
||
|
||
let offset = 0;
|
||
|
||
// ---- Data Blocks ----
|
||
for (const block of blocks) {
|
||
offset = this.writeDataBlock(view, offset, block, bloomFilter);
|
||
}
|
||
|
||
// ---- Index Block ----
|
||
const indexOffset = offset;
|
||
offset = this.writeIndexBlock(view, offset, indexEntries);
|
||
|
||
// ---- Bloom Filter Block ----
|
||
const bloomOffset = offset;
|
||
new Uint8Array(view.buffer).set(bloomData, offset);
|
||
offset += bloomSize;
|
||
|
||
// ---- Footer ----
|
||
const footerOffset = offset;
|
||
view.setUint32(footerOffset, indexOffset, false); // index_offset
|
||
view.setUint32(footerOffset + 4, indexBlockSize, false); // index_size
|
||
view.setUint32(footerOffset + 8, bloomOffset, false); // bloom_offset
|
||
view.setUint32(footerOffset + 12, bloomSize, false); // bloom_size
|
||
view.setUint32(footerOffset + 16, bloomFilter.getHashCount(), false);
|
||
view.setUint32(footerOffset + 20, this.entries.length, false);
|
||
view.setUint32(footerOffset + 24, SSTABLE_MAGIC_V2, false);
|
||
// checksum 字段先写 0,全部字节就绪后计算整文件 CRC32 再回填
|
||
view.setUint32(footerOffset + 28, 0, false);
|
||
|
||
// v0.4.5: 真实 CRC-32 校验和 — 覆盖除自身(最后 4 字节)外的全部内容。
|
||
// checksum 永远非 0(计算结果为 0 时用 1 代替),读取端以 0 识别旧版无校验文件
|
||
const all = new Uint8Array(buf);
|
||
let checksum = crc32(all.subarray(0, all.byteLength - 4));
|
||
if (checksum === 0) checksum = 1;
|
||
view.setUint32(footerOffset + 28, checksum, false);
|
||
|
||
return {
|
||
sstableData: new Uint8Array(buf),
|
||
indexEntries,
|
||
};
|
||
}
|
||
|
||
/** 获取条目数 */
|
||
getEntryCount(): number {
|
||
return this.entries.length;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 内部
|
||
// -----------------------------------------------------------------------
|
||
|
||
/** 按 UTF-8 字节大小切分数据块;大 value 单条独立成块 */
|
||
private splitIntoBlocks(encoded: EncodedEntry[]): EncodedEntry[][] {
|
||
const blocks: EncodedEntry[][] = [];
|
||
let current: EncodedEntry[] = [];
|
||
|
||
for (const entry of encoded) {
|
||
current.push(entry);
|
||
// v0.4.4-fix: 基于字节估算;单条超大条目(length===1)独立成块不强行切分
|
||
if (this.computeBlockSize(current) >= this.blockSizeLimit && current.length > 1) {
|
||
blocks.push(current.slice(0, -1));
|
||
current = [entry];
|
||
}
|
||
}
|
||
if (current.length > 0) blocks.push(current);
|
||
|
||
return blocks;
|
||
}
|
||
|
||
/** 块字节大小:entryCount(u32) + 每对 [keyLen(u32) + key + valueLen(u32) + value] */
|
||
private computeBlockSize(block: EncodedEntry[]): number {
|
||
let size = 4;
|
||
for (const e of block) {
|
||
size += 4 + e.keyBytes.length + 4 + e.valueBytes.length;
|
||
}
|
||
return size;
|
||
}
|
||
|
||
private writeDataBlock(
|
||
view: DataView,
|
||
offset: number,
|
||
block: EncodedEntry[],
|
||
bloomFilter: BloomFilter,
|
||
): number {
|
||
// entry count
|
||
view.setUint32(offset, block.length, false);
|
||
offset += 4;
|
||
|
||
for (const e of block) {
|
||
// v0.4.4-fix: 长度字段 u32(此前 u16 截断 >64KB 的 value)
|
||
if (e.keyBytes.length > 0xFFFFFFFF || e.valueBytes.length > 0xFFFFFFFF) {
|
||
throw new Error('SSTable entry too large (exceeds u32 length field)');
|
||
}
|
||
view.setUint32(offset, e.keyBytes.length, false);
|
||
offset += 4;
|
||
new Uint8Array(view.buffer).set(e.keyBytes, offset);
|
||
offset += e.keyBytes.length;
|
||
view.setUint32(offset, e.valueBytes.length, false);
|
||
offset += 4;
|
||
new Uint8Array(view.buffer).set(e.valueBytes, offset);
|
||
offset += e.valueBytes.length;
|
||
|
||
// 插入 bloom filter
|
||
bloomFilter.insert(e.key);
|
||
}
|
||
|
||
return offset;
|
||
}
|
||
|
||
private estimateIndexBlockSize(entries: IndexEntry[]): number {
|
||
// entryCount(u32) + each: keyLen(u32)+key+blockOffset(u32)+blockSize(u32)
|
||
let size = 4;
|
||
const encoder = new TextEncoder();
|
||
for (const entry of entries) {
|
||
size += 4 + encoder.encode(entry.key).byteLength + 8;
|
||
}
|
||
return size;
|
||
}
|
||
|
||
private writeIndexBlock(view: DataView, offset: number, entries: IndexEntry[]): number {
|
||
view.setUint32(offset, entries.length, false);
|
||
offset += 4;
|
||
|
||
for (const entry of entries) {
|
||
const encoder = new TextEncoder();
|
||
const keyBytes = encoder.encode(entry.key);
|
||
view.setUint32(offset, keyBytes.length, false);
|
||
offset += 4;
|
||
new Uint8Array(view.buffer).set(keyBytes, offset);
|
||
offset += keyBytes.length;
|
||
view.setUint32(offset, entry.blockOffset, false);
|
||
offset += 4;
|
||
view.setUint32(offset, entry.blockSize, false);
|
||
offset += 4;
|
||
}
|
||
|
||
return offset;
|
||
}
|
||
}
|