fix(v0.8.0): 全量回归审查 —— 1 处 P0 数据丢失 + 4 处 P1 + 9 处 P2 根因修复
方法:四个对抗性子代理分头审查(数据正确性 / 文档宣称 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 已重建。
This commit is contained in:
Vendored
+367
-77
@@ -320,12 +320,15 @@ function stripUndefined(updates) {
|
||||
}
|
||||
|
||||
/**
|
||||
* metona-sqlark Engine Interface — 存储引擎抽象接口
|
||||
* @module engine/interface
|
||||
* metona-sqlark — 行所有权(row ownership)工具
|
||||
* @module engine/row_clone
|
||||
*
|
||||
* v0.8.0(审查修复):这三个函数此前放在 `engine/interface.ts`(被文档描述为
|
||||
* "纯类型声明、可执行语句为 0、因此不纳入覆盖率统计")。它们其实是**运行时实现**
|
||||
* 且被 Memory 引擎与 AriaEngine 的读路径调用 —— 放在纯类型文件里等于让真实实现
|
||||
* 代码逃过覆盖率口径(同一类"统计口径虚高"问题正是 G5 门禁要根治的)。
|
||||
* 现在实现搬到本模块,`interface.ts` 回到纯类型。
|
||||
*/
|
||||
// ---------------------------------------------------------------------------
|
||||
// v0.8.0: 行所有权(row ownership)约定
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* 深拷贝一行,使调用方**无法通过修改返回值改写存储**。
|
||||
*
|
||||
@@ -337,12 +340,18 @@ function stripUndefined(updates) {
|
||||
* 即调用方一次无意的原地修改就能让索引与行失配、该行永久查不出来(Aria 因为是
|
||||
* 反序列化路径反而幸免,于是又成了跨引擎行为差异)。
|
||||
*
|
||||
* 约定(写入 interface 文档,所有引擎必须遵守):
|
||||
* 约定(所有引擎必须遵守):
|
||||
* **读出的行是副本,写入接收的行也是副本** —— 引擎不得把内部行对象暴露给外部,
|
||||
* 也不得持有调用方传入的行对象引用。
|
||||
*
|
||||
* 实现说明:结构化克隆可用时优先使用(正确处理 Date/嵌套对象/循环引用);
|
||||
* 存储层写入的行已经过 validateRow 的 JSON 安全性检查,因此退化路径也是安全的。
|
||||
* 实现说明:结构化克隆可用时优先使用;退化路径(`cloneRowFallback`)逐层复制
|
||||
* 普通对象/数组,并额外保留 Date、Uint8Array、ArrayBuffer。
|
||||
*
|
||||
* 前提:存储层写入的行已经过 `validateRow` 的 JSON 安全性检查 —— 例如 `date`
|
||||
* 列要求的是**合法日期字符串**而不是 Date 实例(`table/validation.ts`),因此
|
||||
* 行内永远是 JSON 安全值,"环境提供的 structuredClone 是否保留 Date"不影响
|
||||
* 引擎行为(jsdom 的 structuredClone 是 JSON 化 polyfill,会把 Date 变字符串;
|
||||
* 真实浏览器与 Node 的原生实现会保留)。
|
||||
*/
|
||||
function cloneRow(row) {
|
||||
if (row === null || typeof row !== 'object')
|
||||
@@ -4856,9 +4865,18 @@ const SSTABLE_MAGIC_V1 = 0x53535442;
|
||||
const SSTABLE_MAGIC_V2 = 0x53535443;
|
||||
const SSTABLE_FOOTER_SIZE = 32;
|
||||
class SSTableBuilder {
|
||||
constructor(blockSizeLimit = 4096) {
|
||||
/**
|
||||
* @param blockSizeLimit 块大小上限(字节)
|
||||
* @param bloomBitsPerKey Bloom Filter 每 key 位数(v0.8.0 审查修复:此前
|
||||
* 无论 `bloomFilterBitsPerKey` 配成多少,这里都写死用默认值 ——
|
||||
* 配置项被接受却完全不起作用)
|
||||
*/
|
||||
constructor(blockSizeLimit = 4096, bloomBitsPerKey = DEFAULT_BLOOM_BITS_PER_KEY) {
|
||||
this.entries = [];
|
||||
this.blockSizeLimit = blockSizeLimit;
|
||||
this.bloomBitsPerKey = Number.isFinite(bloomBitsPerKey) && bloomBitsPerKey > 0
|
||||
? Math.floor(bloomBitsPerKey)
|
||||
: DEFAULT_BLOOM_BITS_PER_KEY;
|
||||
}
|
||||
/** 添加一个 key-value 条目(必须按键排序添加) */
|
||||
add(key, value) {
|
||||
@@ -4878,7 +4896,7 @@ class SSTableBuilder {
|
||||
valueBytes: encoder.encode(JSON.stringify(value)),
|
||||
}));
|
||||
const blocks = this.splitIntoBlocks(encoded);
|
||||
const bloomFilter = new BloomFilter(this.entries.length);
|
||||
const bloomFilter = new BloomFilter(this.entries.length, this.bloomBitsPerKey);
|
||||
// 预计算总大小(字节)
|
||||
let totalSize = 0;
|
||||
const blockOffsets = [];
|
||||
@@ -5498,6 +5516,8 @@ const BACKPRESSURE_FILES = 8;
|
||||
* 超过则退回到"等后台链静默"的保守读取(见 collectReaders)。
|
||||
*/
|
||||
const MAX_READ_STRUCTURE_RETRIES = 16;
|
||||
/** 保留的后台故障诊断条数上限(v0.8.0 review 修复:防止无界增长) */
|
||||
const MAX_BACKGROUND_WARNINGS = 64;
|
||||
// ---------------------------------------------------------------------------
|
||||
// LSM
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -5565,6 +5585,9 @@ class LSM {
|
||||
this.namespace = config.namespace ?? 'main';
|
||||
this.walLsnProvider = config.walLsnProvider ?? (() => 0);
|
||||
this.requireDurableCoverage = config.requireDurableCoverage ?? false;
|
||||
this.bloomBitsPerKey = Number.isFinite(config.bloomBitsPerKey) && config.bloomBitsPerKey > 0
|
||||
? Math.floor(config.bloomBitsPerKey)
|
||||
: DEFAULT_BLOOM_BITS_PER_KEY;
|
||||
this.recoveryReport = {
|
||||
namespace: this.namespace,
|
||||
droppedSSTables: [],
|
||||
@@ -5767,7 +5790,7 @@ class LSM {
|
||||
return;
|
||||
}
|
||||
const id = await this.sstableStore.allocateId();
|
||||
const builder = new SSTableBuilder(this.blockSize);
|
||||
const builder = new SSTableBuilder(this.blockSize, this.bloomBitsPerKey);
|
||||
for (const [key, value] of entries) {
|
||||
builder.add(key, value);
|
||||
}
|
||||
@@ -5863,6 +5886,45 @@ class LSM {
|
||||
}
|
||||
});
|
||||
}
|
||||
/**
|
||||
* v0.8.0(review 修复):显式维护(VACUUM)的逐层压缩 —— **与后台 compaction 串行**。
|
||||
*
|
||||
* 为什么必须串行:compaction 产物一律 `unshift` 到目标层队首,层内数组顺序即
|
||||
* "新旧顺序"。若 VACUUM 直接 `await compactLevel()`(绕过维护链),它可能与
|
||||
* 后台 compaction 同时向**同一层**写产物 —— 旧数据的产物被插到新数据之前,
|
||||
* 读路径取层内第一个命中 → 读到旧值;底部层还会丢墓碑 → 已删除的行复活。
|
||||
*
|
||||
* 实现:每个层的压缩都作为维护链上的一个任务执行并等待其完成(复用
|
||||
* `compacting` 集合防重入),因此永远不会与后台 compaction 交错。
|
||||
*
|
||||
* @returns 真实发生合并的层数
|
||||
*/
|
||||
async vacuumLevels() {
|
||||
let compacted = 0;
|
||||
for (let level = 0; level < MAX_LSM_LEVELS; level++) {
|
||||
// 底部层即使只有 1 个文件也要合并:那正是"墓碑/历史版本回收"的唯一时机
|
||||
const minFiles = level === MAX_LSM_LEVELS - 1 ? 1 : 2;
|
||||
// 该层已有 compaction 在跑 → 先等维护链静默(不打断、不并入)
|
||||
if (this.compacting.has(level))
|
||||
await this.drainMaintenance();
|
||||
if (this.levels[level].length < minFiles)
|
||||
continue;
|
||||
this.compacting.add(level);
|
||||
await new Promise((resolve) => {
|
||||
this.enqueueMaintenance(async () => {
|
||||
try {
|
||||
if (await this.compactLevelAsync(level, minFiles))
|
||||
compacted++;
|
||||
}
|
||||
finally {
|
||||
this.compacting.delete(level);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
return compacted;
|
||||
}
|
||||
/** 背压场景下排队 compaction(写入路径调用) */
|
||||
enqueueCompact(level) {
|
||||
if (level > MAX_LSM_LEVELS - 1 || this.compacting.has(level))
|
||||
@@ -5986,6 +6048,45 @@ class LSM {
|
||||
}
|
||||
})();
|
||||
}
|
||||
/**
|
||||
* v0.8.0(review 修复):当前是否有在途读者。
|
||||
*
|
||||
* 用途:维护路径(repair 的强制回收 / 孤儿清理)必须在没有读者时才能删文件 ——
|
||||
* 读者的快照可能正持有已被 compaction 取代的"退休"文件。
|
||||
*/
|
||||
hasActiveReaders() {
|
||||
return this.activeReaders.size > 0;
|
||||
}
|
||||
/** v0.8.0:退休但尚未物理删除的 SSTable 所占用的页面 id(孤儿页回收必须把它们算作"在用") */
|
||||
getRetiredPageIds() {
|
||||
const out = [];
|
||||
for (const group of this.retired) {
|
||||
for (const meta of group.metas) {
|
||||
if (meta.pageIds)
|
||||
out.push(...meta.pageIds);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
/** v0.8.0:退休但尚未物理删除的 SSTable id(孤儿整 value 文件回收必须排除它们) */
|
||||
getRetiredSstableIds() {
|
||||
const out = [];
|
||||
for (const group of this.retired) {
|
||||
for (const meta of group.metas)
|
||||
out.push(meta.id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
/**
|
||||
* v0.8.0:等待两条链静默(诊断与测试用)。
|
||||
*
|
||||
* 生产语义上等价于"当前没有在跑的后台 flush/compaction";测试用它替代
|
||||
* `setTimeout(N)` 这类墙钟等待(sleep 不够会误报成产品缺陷)。
|
||||
*/
|
||||
async whenIdle() {
|
||||
await this.drainChain();
|
||||
await this.drainMaintenance();
|
||||
}
|
||||
/** v0.8.0(测试/诊断):退休但尚未物理删除的 SSTable 数量 */
|
||||
getRetiredCount() {
|
||||
return this.retired.reduce((sum, g) => sum + g.metas.length, 0);
|
||||
@@ -5999,6 +6100,13 @@ class LSM {
|
||||
reclaimRetiredNow() {
|
||||
if (this.retired.length === 0)
|
||||
return;
|
||||
// v0.8.0(review 修复):有在途读者时**不得**强制删除 —— 读者的快照可能正持有
|
||||
// 这些文件,删掉会让它读不到数据(而且会被误判为"文件损坏")。此时退化为
|
||||
// 延迟回收(等读者退出后由 reclaimRetired 处理)。
|
||||
if (this.activeReaders.size > 0) {
|
||||
this.reclaimRetired();
|
||||
return;
|
||||
}
|
||||
const all = this.retired.flatMap((g) => g.metas);
|
||||
this.retired = [];
|
||||
void (async () => {
|
||||
@@ -6214,7 +6322,7 @@ class LSM {
|
||||
return true;
|
||||
}
|
||||
const id = await this.sstableStore.allocateId();
|
||||
const builder = new SSTableBuilder(this.blockSize);
|
||||
const builder = new SSTableBuilder(this.blockSize, this.bloomBitsPerKey);
|
||||
for (const [key, value] of merged) {
|
||||
builder.add(key, value);
|
||||
}
|
||||
@@ -6384,6 +6492,11 @@ class LSM {
|
||||
// `getBackgroundWarnings()` 与告警日志提供,错误语义只保留
|
||||
// "数据没落盘才算失败"。
|
||||
this.backgroundWarnings.push(effective);
|
||||
// v0.8.0(review 修复):诊断数组必须有界 —— 长时间运行 + 反复瞬时故障下
|
||||
// 无界增长等于内存泄漏(只保留最近若干条,足够定位问题)
|
||||
if (this.backgroundWarnings.length > MAX_BACKGROUND_WARNINGS) {
|
||||
this.backgroundWarnings.splice(0, this.backgroundWarnings.length - MAX_BACKGROUND_WARNINGS);
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[AriaEngine LSM] background failure recovered by retry:', effective);
|
||||
}
|
||||
@@ -6544,6 +6657,13 @@ class LSM {
|
||||
/** 清理无效 SSTable 的 meta 与文件(打开自愈路径)+ 记录恢复诊断 */
|
||||
async dropInvalidSSTable(meta, reason) {
|
||||
this.recoveryReport.droppedSSTables.push({ id: meta.id, level: meta.level, reason });
|
||||
// v0.8.0(review 修复):meta 被丢弃后必须同时从内存层数组移除。
|
||||
// 否则 `levels` 会保留一个"manifest 里已不存在、文件也已删除"的幽灵条目 ——
|
||||
// stats 虚高、每次读都要扫它一遍,且掩盖"内存与 manifest 应当一致"这一不变量。
|
||||
if (this.levels[meta.level]?.some((m) => m.id === meta.id)) {
|
||||
this.levels[meta.level] = this.levels[meta.level].filter((m) => m.id !== meta.id);
|
||||
this.structureVersion++;
|
||||
}
|
||||
if (this.requireDurableCoverage) {
|
||||
// manifest 已推进 WAL 水位(startLsn > 0)→ 被丢弃的 SSTable 没有 WAL 兜底
|
||||
this.recoveryReport.dataLossSuspected = true;
|
||||
@@ -6819,10 +6939,12 @@ class WAL {
|
||||
const fromLsn = opts.fromLsn ?? 0;
|
||||
let data;
|
||||
let gaps = [];
|
||||
let missingPrefix = [];
|
||||
if (typeof this.store.readAllFrom === 'function') {
|
||||
const result = await this.store.readAllFrom(fromSegment);
|
||||
const result = await this.store.readAllFrom(fromSegment, fromLsn);
|
||||
data = result.data;
|
||||
gaps = result.gaps;
|
||||
missingPrefix = result.missingPrefix ?? [];
|
||||
}
|
||||
else {
|
||||
const exists = await this.store.exists();
|
||||
@@ -6834,7 +6956,8 @@ class WAL {
|
||||
throw new DatabaseError(`WAL segment gap detected in live range (missing segment(s): ${gaps.join(', ')}) — ` +
|
||||
'records after the gap cannot be verified; refusing to continue silently', 'ARIA_WAL_GAP');
|
||||
}
|
||||
const records = data.byteLength === 0 ? [] : this.decodeAllRecords(data);
|
||||
const decoded = data.byteLength === 0 ? { records: [], corrupt: 0 } : this.decodeAllRecords(data);
|
||||
const records = decoded.records;
|
||||
let applied = 0;
|
||||
let skipped = 0;
|
||||
let maxLsn = 0;
|
||||
@@ -6853,7 +6976,11 @@ class WAL {
|
||||
// 磁盘上的全部记录已经读出来了,缓冲区里不再有待落盘记录
|
||||
this.buffer = [];
|
||||
this.bufferedBytes = 0;
|
||||
this.lastRecoveryInfo = { applied, skipped, maxLsn, gaps, fromSegment, fromLsn };
|
||||
this.lastRecoveryInfo = {
|
||||
applied, skipped, maxLsn, gaps, missingPrefix,
|
||||
corruptRecords: decoded.corrupt,
|
||||
fromSegment, fromLsn,
|
||||
};
|
||||
return applied;
|
||||
}
|
||||
/** v0.8.0: 最近一次恢复诊断(applied/skipped/gaps) */
|
||||
@@ -6989,6 +7116,7 @@ class WAL {
|
||||
}
|
||||
decodeAllRecords(data) {
|
||||
const records = [];
|
||||
let corrupt = 0;
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||
let offset = 0;
|
||||
while (offset + 15 <= data.byteLength) {
|
||||
@@ -7033,7 +7161,10 @@ class WAL {
|
||||
const computedLegacy = this.legacyChecksum(recordBytes);
|
||||
if ((computedNew >>> 0) !== storedCrc && (computedLegacy >>> 0) !== storedCrc) {
|
||||
// CRC 不匹配,跳过此损坏记录(长度字段链完整时后续好记录仍可恢复,
|
||||
// 行为由 aria-wal-crc 测试锁定)
|
||||
// 行为由 aria-wal-crc 测试锁定)。
|
||||
// v0.8.0(review 修复):跳过必须**被计数**并上报给恢复方 ——
|
||||
// 否则"少了一条已提交写入"在引擎层完全不可观测(静默丢数据)。
|
||||
corrupt++;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[AriaEngine WAL] CRC mismatch at record LSN=${lsn}, skipping`);
|
||||
continue;
|
||||
@@ -7052,7 +7183,7 @@ class WAL {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return records;
|
||||
return { records, corrupt };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7151,25 +7282,36 @@ class SegmentedWALStore {
|
||||
* 同一个连续历史。修复前 `readAll` 遇到空洞直接丢弃空洞之后的全部记录,
|
||||
* 调用方(引擎恢复)完全无法感知"少了一批已提交事务"。
|
||||
*/
|
||||
async readAllFrom(fromSegment = 0) {
|
||||
async readAllFrom(fromSegment = 0, fromLsn = 0) {
|
||||
const keys = await this.backend.listKeys();
|
||||
const allSegments = await this.listSegments();
|
||||
const segments = allSegments.filter((s) => s.seq >= fromSegment);
|
||||
// 空洞检测:活跃区间内分片序号必须连续。
|
||||
// v0.8.0:**前缀缺失**同样算空洞 —— 调用方(manifest 的 `wal.startSegment`)
|
||||
// 保证"小于 startSegment 的分片已整体落盘、可以不存在",因此活跃区间内
|
||||
// 第一片就缺席(segments[0].seq > fromSegment)意味着那段记录不知所踪。
|
||||
const gaps = [];
|
||||
// ---- 前缀缺失(fromSegment .. segments[0].seq - 1)----
|
||||
// v0.8.0(review 修复):以前这里把前缀缺失也算作空洞,于是"回退到上一代
|
||||
// manifest"时(上一代的 startSegment 比现存最小分片更小)会命中
|
||||
// `kept = segments.filter(s => s.seq < firstGap)` = 空 —— **整段活 WAL 被丢掉**。
|
||||
// 正确语义:前缀缺失只影响"本就被水位跳过"的那一段,后缀分片必须照常读取。
|
||||
const missingPrefix = [];
|
||||
if (segments.length > 0) {
|
||||
for (let missing = fromSegment; missing < segments[0].seq; missing++)
|
||||
gaps.push(missing);
|
||||
missingPrefix.push(missing);
|
||||
}
|
||||
// ---- 内部空洞(segments[i-1] 与 segments[i] 之间)----
|
||||
const gaps = [];
|
||||
for (let i = 1; i < segments.length; i++) {
|
||||
for (let missing = segments[i - 1].seq + 1; missing < segments[i].seq; missing++) {
|
||||
gaps.push(missing);
|
||||
}
|
||||
}
|
||||
// 空洞之后的分片整体丢弃(长度字段链无法跨空洞验证),并计入 diagnostics
|
||||
// 从未推进过水位(fromLsn === 0)时,分片本应从 fromSegment 连续存在 →
|
||||
// 此时前缀缺失同样是异常(介质丢了一段记录),并入 gaps。
|
||||
if (fromLsn === 0 && missingPrefix.length > 0) {
|
||||
gaps.unshift(...missingPrefix);
|
||||
missingPrefix.length = 0;
|
||||
}
|
||||
gaps.sort((a, b) => a - b);
|
||||
// 内部空洞之后的分片整体丢弃(长度字段链无法跨空洞验证);
|
||||
// 前缀缺失**不**丢弃任何后缀分片。
|
||||
let kept = segments;
|
||||
if (gaps.length > 0) {
|
||||
const firstGap = gaps[0];
|
||||
@@ -7235,6 +7377,7 @@ class SegmentedWALStore {
|
||||
fromSegment,
|
||||
segments: readSegments,
|
||||
gaps,
|
||||
missingPrefix,
|
||||
missingTail: 0,
|
||||
};
|
||||
}
|
||||
@@ -7247,8 +7390,14 @@ class SegmentedWALStore {
|
||||
if (walKeys.length > 0) {
|
||||
await this.backend.deleteMany(walKeys);
|
||||
}
|
||||
// v0.8.0:分片号只增不减(同 truncateBefore 的说明)—— 复用序号会让
|
||||
// "同一序号对应两代记录",恢复按序号排序时旧记录可能排在新记录之后被重放。
|
||||
// 分片号**只降不增**是禁止的(同 truncateBefore 的说明)。这里的准确语义是:
|
||||
// 序号绝不回退,且绝不小于 manifest 记录的水位下限(`open(fromSegment)` 会把它
|
||||
// 抬到 fromSegment);整体清空后允许复用**最后用过的那个号** —— 这是安全的,
|
||||
// 因为记录自带 LSN,`lsn <= startLsn` 的旧世代记录在恢复时一律跳过。
|
||||
//
|
||||
// 反例(修复前的真实缺陷):整段清空后把号重置为 0,而 manifest 的
|
||||
// startSegment 已经是 K>0 → 新记录写进分片 0,恢复时被 `seq >= K` 过滤掉 →
|
||||
// 已确认写入静默消失。因此这里绝不允许回退到 0。
|
||||
const maxSeq = (await this.listSegments()).reduce((max, s) => Math.max(max, s.seq), -1);
|
||||
this.currentSegment = Math.max(this.currentSegment, maxSeq + 1);
|
||||
this.currentSize = 0;
|
||||
@@ -7289,8 +7438,11 @@ class SegmentedWALStore {
|
||||
*/
|
||||
async planKeepFrom(durableLsn, latestLsn) {
|
||||
const segments = await this.listSegments();
|
||||
// v0.8.0(review 修复):介质上没有分片时**返回当前分片号**(= 下一条记录将写入
|
||||
// 的号),而不是 0。返回 0 会让 manifest 的 `startSegment` 回退,与
|
||||
// "分片号只增不减" 的声明冲突(一旦将来前缀缺失被当作异常,就会误判成丢数据)。
|
||||
if (segments.length === 0)
|
||||
return 0;
|
||||
return this.currentSegment;
|
||||
// 判定每个分片是否"整段已被水位覆盖":
|
||||
// - 非末分片:下一分片的首条记录 LSN <= durableLsn ⇒ 本分片全部记录都 <= 水位
|
||||
// - 末分片:已知当前 LSN 高水位 <= durableLsn ⇒ 本分片全部记录都 <= 水位
|
||||
@@ -7343,9 +7495,9 @@ class SegmentedWALStore {
|
||||
}
|
||||
const segments = await this.listSegments();
|
||||
if (segments.length === 0) {
|
||||
this.currentSegment = 0;
|
||||
// 无分片可删:分片号保持不变(只增不减),返回下一条记录将写入的号
|
||||
this.currentSize = 0;
|
||||
return 0;
|
||||
return this.currentSegment;
|
||||
}
|
||||
const keepFrom = await this.planKeepFrom(durableLsn, latestLsn);
|
||||
const obsolete = segments.filter((s) => s.seq < keepFrom);
|
||||
@@ -8942,8 +9094,11 @@ class ManifestStore {
|
||||
* AriaEngine MVCC — 多版本并发控制
|
||||
* @module engine/aria/transaction/mvcc
|
||||
*
|
||||
* 实现快照隔离 (Snapshot Isolation)。
|
||||
* 每个事务看到数据库在事务开始时的快照。
|
||||
* v0.8.0(review 修正文档):本模块提供**行版本链**,用途是"事务内的 undo",
|
||||
* 提交即清理。它**不提供快照隔离** —— 引擎的隔离语义是"事务串行"(同一实例
|
||||
* 同时只允许一个事务,并发 `beginTransaction` 抛 `TX_ACTIVE`),读取走
|
||||
* `txnSnapshot` 未提交快照。旧注释声称 "实现快照隔离 (Snapshot Isolation)"
|
||||
* 与实现不符(全量审查发现)。
|
||||
*/
|
||||
// ---------------------------------------------------------------------------
|
||||
// MVCCManager
|
||||
@@ -9485,6 +9640,7 @@ class AriaEngine {
|
||||
droppedSSTables: [],
|
||||
dataLossSuspected: false,
|
||||
walGaps: [],
|
||||
droppedWALRecords: 0,
|
||||
legacyImported: false,
|
||||
manifestFallback: false,
|
||||
};
|
||||
@@ -9664,6 +9820,15 @@ class AriaEngine {
|
||||
allowGaps: true,
|
||||
});
|
||||
const walInfo = this.wal.getLastRecoveryInfo();
|
||||
if (walInfo && walInfo.corruptRecords > 0) {
|
||||
// v0.8.0(review 修复):记录级 CRC 损坏此前只 console.warn —— 恢复完全
|
||||
// 不感知"少了几条记录",与"静默丢数据必须显式化"的目标不符。
|
||||
this.recoveryReport.droppedWALRecords = walInfo.corruptRecords;
|
||||
this.recoveryReport.dataLossSuspected = true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[AriaEngine] WAL: ${walInfo.corruptRecords} record(s) failed CRC and were skipped — ` +
|
||||
'the writes they carried are missing (recovery report records this)');
|
||||
}
|
||||
if (walInfo && walInfo.gaps.length > 0) {
|
||||
this.recoveryReport.walGaps = [...walInfo.gaps];
|
||||
this.recoveryReport.dataLossSuspected = true;
|
||||
@@ -9920,27 +10085,34 @@ class AriaEngine {
|
||||
* 且 manifest 完整可信时才允许回收空间。
|
||||
*/
|
||||
async cleanupOrphanPages() {
|
||||
if (this.recoveryReport.dataLossSuspected || this.recoveryReport.manifestFallback) {
|
||||
const damage = this.describeRecoveryDamage();
|
||||
if (damage.length > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[AriaEngine] repair: skipping orphan-page reclamation — recovery report shows ' +
|
||||
'damage or manifest fallback (unreferenced pages are kept, never deleted blindly)');
|
||||
console.warn(`[AriaEngine] repair: skipping orphan reclamation — recovery shows damage (${damage.join('; ')}); ` +
|
||||
'unreferenced data is kept, never deleted blindly');
|
||||
return;
|
||||
}
|
||||
// v0.8.0(review 修复):**有在途读者时一律不回收**。
|
||||
// 读者的快照可能持有已被 compaction 取代("退休")的文件;那些文件的页面
|
||||
// 既不在 manifest、也不在 levels 里 —— 按"没人引用"删掉它们会让正在进行中的
|
||||
// 扫描静默少数据。回收只能在没有读者时做。
|
||||
if (this.allLsms().some((lsm) => lsm.hasActiveReaders())) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[AriaEngine] repair: skipping orphan reclamation — readers are active');
|
||||
return;
|
||||
}
|
||||
const keys = await this.backend.listKeys();
|
||||
const pgKeys = keys.filter((k) => /^pg_\d+$/.test(k));
|
||||
if (pgKeys.length === 0)
|
||||
return;
|
||||
// ---- 被引用的页面 id:manifest + 各层内存视图 + 退休表 ----
|
||||
const used = new Set();
|
||||
for (const state of Object.values(this.manifest.namespaces)) {
|
||||
for (const m of state.sstables) {
|
||||
if (m.pageIds) {
|
||||
if (m.pageIds)
|
||||
for (const pid of m.pageIds)
|
||||
used.add(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 未落盘的页面(正在写入的 SSTable)也不能删
|
||||
for (const lsm of this.allLsms()) {
|
||||
// 未落盘的页面(正在写入的 SSTable)不能删
|
||||
for (const level of this.getLsmLevels(lsm)) {
|
||||
for (const meta of level) {
|
||||
if (meta.pageIds)
|
||||
@@ -9948,7 +10120,11 @@ class AriaEngine {
|
||||
used.add(pid);
|
||||
}
|
||||
}
|
||||
// 退休但尚未物理删除的 SSTable 的页面同样不能删
|
||||
for (const pid of lsm.getRetiredPageIds())
|
||||
used.add(pid);
|
||||
}
|
||||
const pgKeys = keys.filter((k) => /^pg_\d+$/.test(k));
|
||||
const orphanIds = pgKeys
|
||||
.map((k) => Number(k.slice('pg_'.length)))
|
||||
.filter((pid) => !used.has(pid));
|
||||
@@ -9957,6 +10133,48 @@ class AriaEngine {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[AriaEngine] repair: reclaimed ${orphanIds.length} orphan page file(s)`);
|
||||
}
|
||||
// ---- 孤儿 SSTable 文件(整 value 路径:sst_<ns>_<id> / sst_<id>)----
|
||||
// 退休 SSTable 的物理删除是"尽力而为":删除失败/崩溃会留下既不被 manifest
|
||||
// 引用、也不在任何层里的文件。页面化路径由上面的 pg_ 回收覆盖;
|
||||
// 整 value 路径(pageStorage:false / 旧库 / 迁移数据)此前**没有任何回收路径**。
|
||||
const usedSstIds = new Set();
|
||||
for (const state of Object.values(this.manifest.namespaces)) {
|
||||
for (const m of state.sstables)
|
||||
usedSstIds.add(m.id);
|
||||
}
|
||||
for (const lsm of this.allLsms()) {
|
||||
for (const level of this.getLsmLevels(lsm)) {
|
||||
for (const meta of level)
|
||||
usedSstIds.add(meta.id);
|
||||
}
|
||||
for (const id of lsm.getRetiredSstableIds())
|
||||
usedSstIds.add(id);
|
||||
}
|
||||
const orphanSstKeys = [];
|
||||
for (const [ns, prefix] of this.sstableKeyPrefixes()) {
|
||||
const re = new RegExp(`^${prefix}(\\d+)$`);
|
||||
for (const key of keys) {
|
||||
const m = re.exec(key);
|
||||
if (!m)
|
||||
continue;
|
||||
if (!usedSstIds.has(Number(m[1])))
|
||||
orphanSstKeys.push(key);
|
||||
}
|
||||
}
|
||||
if (orphanSstKeys.length > 0) {
|
||||
await this.backend.deleteMany(orphanSstKeys);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[AriaEngine] repair: reclaimed ${orphanSstKeys.length} orphan SSTable file(s)`);
|
||||
}
|
||||
}
|
||||
/** v0.8.0:命名空间 → SSTable 文件 key 前缀(与 createSSTableStore 保持一致) */
|
||||
sstableKeyPrefixes() {
|
||||
const out = [['main', 'sst_']];
|
||||
for (const ns of Object.keys(this.manifest.namespaces)) {
|
||||
if (ns !== 'main')
|
||||
out.push([ns, `sst_${ns}_`]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
/** v0.8.0: 读取某个 LSM 当前引用的层结构(诊断/孤儿回收用) */
|
||||
getLsmLevels(lsm) {
|
||||
@@ -11281,6 +11499,47 @@ class AriaEngine {
|
||||
async persistSchemas() {
|
||||
await this.commitManifest();
|
||||
}
|
||||
/**
|
||||
* v0.8.0(review 修复):表结构记录的**唯一**解析实现。
|
||||
*
|
||||
* 为什么必须集中且严格:结构记录有两种坏法 —— JSON 本身就坏了,或 JSON 合法但
|
||||
* 形状不对(数组 / null / 表名映射到非对象 / 列定义不是对象)。修复前只有
|
||||
* "JSON 坏"这一种会抛错,形状不对则被**静默忽略** → 打开后看不到任何表,
|
||||
* 表现为"库是空的"(与审计里"静默空库"同一类缺陷)。
|
||||
*
|
||||
* 判定原则:只要记录存在却不可用,就抛 ARIA_LEGACY_META_CORRUPT —— 宁可让
|
||||
* 调用方看到明确的损坏错误,也不假装这是一个没有表的空库。
|
||||
*/
|
||||
parseSchemaRecord(raw, source) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(new TextDecoder().decode(raw));
|
||||
}
|
||||
catch (error) {
|
||||
throw new DatabaseError(`${source} is corrupt and cannot be loaded: ${error.message}`, 'ARIA_LEGACY_META_CORRUPT', error);
|
||||
}
|
||||
return this.validateSchemaShape(parsed, source);
|
||||
}
|
||||
/** 形状校验(与 parseSchemaRecord 分离:便于直接喂各种形状做单测) */
|
||||
validateSchemaShape(parsed, source) {
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new DatabaseError(`${source} is not a table-name → columns object (got ${Array.isArray(parsed) ? 'array' : typeof parsed})`, 'ARIA_LEGACY_META_CORRUPT');
|
||||
}
|
||||
const out = {};
|
||||
for (const [tableName, columns] of Object.entries(parsed)) {
|
||||
if (!columns || typeof columns !== 'object' || Array.isArray(columns)) {
|
||||
throw new DatabaseError(`${source} entry "${tableName}" is not a column map (got ${Array.isArray(columns) ? 'array' : typeof columns})`, 'ARIA_LEGACY_META_CORRUPT');
|
||||
}
|
||||
for (const [colName, def] of Object.entries(columns)) {
|
||||
if (!def || typeof def !== 'object' || Array.isArray(def)) {
|
||||
throw new DatabaseError(`${source} entry "${tableName}.${colName}" is not a column definition ` +
|
||||
`(got ${Array.isArray(def) ? 'array' : typeof def})`, 'ARIA_LEGACY_META_CORRUPT');
|
||||
}
|
||||
}
|
||||
out[tableName] = columns;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
async loadSchemas() {
|
||||
// 权威来源:manifest(旧格式已在 importLegacyState 阶段导入)
|
||||
let data = this.manifest.schemas ?? {};
|
||||
@@ -11289,17 +11548,9 @@ class AriaEngine {
|
||||
if (Object.keys(data).length === 0) {
|
||||
const raw = await this.backend.read('__aria_schemas');
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(new TextDecoder().decode(raw));
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
data = parsed;
|
||||
this.manifest.schemas = parsed;
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
// 修复前这里 `catch {}` 静默忽略 → 坏 schema = 看不到任何表(静默空库)
|
||||
throw new DatabaseError(`Schema record is corrupt and cannot be loaded: ${error.message}`, 'ARIA_LEGACY_META_CORRUPT', error);
|
||||
}
|
||||
// 修复前这里 `catch {}` 静默忽略 → 坏 schema = 看不到任何表(静默空库)
|
||||
data = this.parseSchemaRecord(raw, 'Schema record "__aria_schemas"');
|
||||
this.manifest.schemas = data;
|
||||
}
|
||||
}
|
||||
for (const [tableName, columns] of Object.entries(data)) {
|
||||
@@ -11311,6 +11562,40 @@ class AriaEngine {
|
||||
// =======================================================================
|
||||
// v0.8.0(B-6):单一提交点与命名空间工厂
|
||||
// =======================================================================
|
||||
/**
|
||||
* v0.8.0(review 修复): 本次打开/修复过程中出现过的**一切损坏迹象**。
|
||||
*
|
||||
* 为什么需要它而不是只看 `this.recoveryReport.dataLossSuspected`:
|
||||
* SSTable 被丢弃这一事实记录在**各 LSM** 的报告里,引擎层的 `dataLossSuspected`
|
||||
* 只在"WAL 水位已推进、被丢的数据没有 WAL 兜底"时才置位。于是"manifest 已被
|
||||
* 推进 + 某个 SSTable 因文件损坏被丢"这类**真损坏**会在引擎层看不到 —— 孤儿页
|
||||
* 回收就会照常执行,把在途/退休文件按"没人引用"删掉。
|
||||
*
|
||||
* 判定原则:只要有任何"曾经自愈/丢失/回退"的迹象,就一律不回收任何未被引用
|
||||
* 的文件(宁可留空间,也不可逆地删数据)。
|
||||
*/
|
||||
describeRecoveryDamage() {
|
||||
const reasons = [];
|
||||
if (this.recoveryReport.manifestFallback)
|
||||
reasons.push('manifest fallback to previous generation');
|
||||
if (this.recoveryReport.dataLossSuspected)
|
||||
reasons.push('engine-level data loss suspected');
|
||||
if (this.recoveryReport.walGaps.length > 0) {
|
||||
reasons.push(`WAL segment gap(s) ${this.recoveryReport.walGaps.join(',')}`);
|
||||
}
|
||||
if (this.recoveryReport.droppedWALRecords > 0) {
|
||||
reasons.push(`${this.recoveryReport.droppedWALRecords} corrupt WAL record(s)`);
|
||||
}
|
||||
for (const lsm of this.allLsms()) {
|
||||
const r = lsm.getRecoveryReport();
|
||||
if (r.droppedSSTables.length > 0) {
|
||||
reasons.push(`${r.namespace}: ${r.droppedSSTables.length} dropped SSTable(s)`);
|
||||
}
|
||||
if (r.dataLossSuspected)
|
||||
reasons.push(`${r.namespace}: LSM data loss suspected`);
|
||||
}
|
||||
return reasons;
|
||||
}
|
||||
/**
|
||||
* v0.8.0: 恢复诊断(打开时被丢弃的 SSTable、WAL 空洞、是否怀疑数据丢失)。
|
||||
*
|
||||
@@ -11333,6 +11618,7 @@ class AriaEngine {
|
||||
droppedSSTables: dropped,
|
||||
dataLossSuspected: dataLoss,
|
||||
walGaps: [...this.recoveryReport.walGaps],
|
||||
droppedWALRecords: this.recoveryReport.droppedWALRecords,
|
||||
legacyImported: this.recoveryReport.legacyImported,
|
||||
manifestFallback: this.recoveryReport.manifestFallback,
|
||||
};
|
||||
@@ -11436,6 +11722,18 @@ class AriaEngine {
|
||||
* - 全部落盘 → 推进到当前 LSN(这些记录已存在于已提交的 SSTable 中)。
|
||||
*/
|
||||
computeDurableLsn(intents) {
|
||||
// v0.8.0(review 修复 P0):**事务进行中一律不得推进水位**。
|
||||
//
|
||||
// 事务内的写入只落在 `txnSnapshot`(内存)+ WAL 里,**不进 LSM memtable**
|
||||
// —— 因此 `hasPendingFlushData()`(只看 memtable/frozen)会说"没有未落盘数据",
|
||||
// 水位就被推到当前 LSN 并按该水位删掉旧分片。而此时事务记录既不在 SSTable
|
||||
// 也不在 memtable:随后 `COMMIT`(返回成功)→ 崩溃 → 重开时那些 INSERT 记录
|
||||
// 因 `lsn <= startLsn` 被跳过、只剩 COMMIT 记录 → **已确认提交的事务整批消失**,
|
||||
// 且恢复报告是"干净"的(实测复现)。
|
||||
//
|
||||
// 水位是"这些 LSN 已存在于已提交 SSTable 中"的断言,而活跃事务的数据不满足它。
|
||||
if (this.currentTxnId !== null)
|
||||
return this.durableLsn;
|
||||
if (intents.length > 0) {
|
||||
return Math.min(this.durableLsn, Math.min(...intents.map((i) => i.lsnAtFreeze)));
|
||||
}
|
||||
@@ -11451,6 +11749,15 @@ class AriaEngine {
|
||||
* 否则又会出现"同一语义多处实现、只改一处"的老问题。
|
||||
*/
|
||||
async advanceWalCheckpoint() {
|
||||
// v0.8.0(review 修复 P0):事务活跃时整条水位推进 + 分片回收都不做。
|
||||
// 与 `CheckpointManager` 的两个回调同一守卫;这里放在入口处,
|
||||
// 使 repair()/close()/周期 checkpoint 三条路径全部覆盖(它们此前只有后两条有守卫)。
|
||||
if (this.currentTxnId !== null) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[AriaEngine] WAL checkpoint deferred: an active transaction may hold data ' +
|
||||
'that exists only in memory + WAL (advancing the durable watermark would drop it)');
|
||||
return;
|
||||
}
|
||||
await this.wal.flush();
|
||||
// 1. 先算:以"如果没有未落盘数据,水位会到哪里"为基准
|
||||
const target = this.hasPendingFlushData()
|
||||
@@ -11520,16 +11827,9 @@ class AriaEngine {
|
||||
// 2. 表结构(__aria_schemas)
|
||||
const schemaRaw = await this.backend.read('__aria_schemas');
|
||||
if (schemaRaw) {
|
||||
try {
|
||||
const parsed = JSON.parse(new TextDecoder().decode(schemaRaw));
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
this.manifest.schemas = parsed;
|
||||
imported = true;
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
throw new DatabaseError(`Legacy schema record "__aria_schemas" is corrupt and cannot be migrated: ${error.message}`, 'ARIA_LEGACY_META_CORRUPT', error);
|
||||
}
|
||||
// 与 loadSchemas 走**同一个**校验实现(形状不对同样抛错,绝不静默空库)
|
||||
this.manifest.schemas = this.parseSchemaRecord(schemaRaw, 'Legacy schema record "__aria_schemas"');
|
||||
imported = true;
|
||||
}
|
||||
// 3. 页面水位(__aria_meta,仅作为单调下限)
|
||||
const pageMeta = await this.backend.read('__aria_meta');
|
||||
@@ -11563,9 +11863,6 @@ class AriaEngine {
|
||||
return; // WAL 覆盖到了这些数据(重放会重建)
|
||||
if (!this.config.walEnabled)
|
||||
return; // 未启用 WAL:本来就没有日志兜底(配置语义)
|
||||
// 全部 LSM 已落盘(意图来自上一次会话的残留)→ 数据其实已经安全
|
||||
if (!this.hasPendingFlushData() && this.manifest.frozen.length === 0)
|
||||
return;
|
||||
const summary = intents.map((i) => `${i.ns}#${i.id}(${i.entryCount} 项)`).join(', ');
|
||||
throw new DatabaseError(`AriaEngine manifest declares ${intents.length} un-flushed frozen table(s) [${summary}] ` +
|
||||
'but no WAL record was replayed — confirmed writes are missing ' +
|
||||
@@ -12139,18 +12436,11 @@ class AriaEngine {
|
||||
// → 墓碑与历史版本在最底层永久累积),且无论是否真的合并过都返回
|
||||
// `compactedLevels: 6`("报告的数字与事实无关",审计 item 52)。
|
||||
// 现在逐层尝试(含底部层的原地合并 —— 它会回收墓碑),只统计真正合并了的层。
|
||||
let compactedLevels = 0;
|
||||
const isBottom = (level) => level === MAX_LSM_LEVELS - 1;
|
||||
for (let level = 0; level < MAX_LSM_LEVELS; level++) {
|
||||
const files = this.lsm.getStats().levelCounts[level] ?? 0;
|
||||
// 底部层即使只有 1 个文件也要合并:那正是"墓碑/历史版本回收"的唯一时机
|
||||
//(删除密集场景下底部层通常就是一个大文件)
|
||||
const minFiles = isBottom(level) ? 1 : 2;
|
||||
if (files < minFiles)
|
||||
continue;
|
||||
if (await this.lsm.compactLevel(level, minFiles))
|
||||
compactedLevels++;
|
||||
}
|
||||
// 逐层压缩交给 LSM:它会把这些任务挂到**维护链**上串行执行 ——
|
||||
// 直接 `await compactLevel()` 会与后台 compaction 并发写同一层的产物,
|
||||
// 而产物一律 unshift 到队首(层内顺序 = 新旧顺序)→ 旧数据可能排到新数据
|
||||
// 之前(读到旧值),底部层还会因丢墓碑让已删除的行复活。
|
||||
const compactedLevels = await this.lsm.vacuumLevels();
|
||||
// GC MVCC 版本(保留最新 10 个)
|
||||
const beforeGC = this.mvcc.getGlobalLSN();
|
||||
this.mvcc.gc(10);
|
||||
|
||||
Reference in New Issue
Block a user