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:
thzxx
2026-09-15 16:33:40 +08:00
parent c3757f486c
commit 0b44620721
34 changed files with 4026 additions and 641 deletions
+367 -77
View File
@@ -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`逐层复制
* 普通对象/数组并额外保留 DateUint8ArrayArrayBuffer
*
* 前提存储层写入的行已经过 `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.0review 修复显式维护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.0review 修复当前是否有在途读者
*
* 用途维护路径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.0review 修复):有在途读者时**不得**强制删除 —— 读者的快照可能正持有
// 这些文件,删掉会让它读不到数据(而且会被误判为"文件损坏")。此时退化为
// 延迟回收(等读者退出后由 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.0review 修复):诊断数组必须有界 —— 长时间运行 + 反复瞬时故障下
// 无界增长等于内存泄漏(只保留最近若干条,足够定位问题)
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.0review 修复):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.0review 修复):跳过必须**被计数**并上报给恢复方 ——
// 否则"少了一条已提交写入"在引擎层完全不可观测(静默丢数据)。
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.0review 修复):介质上没有分片时**返回当前分片号**(= 下一条记录将写入
// 的号),而不是 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.0review 修正文档本模块提供**行版本链**用途是"事务内的 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.0review 修复):记录级 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.0review 修复):**有在途读者时一律不回收**。
// 读者的快照可能持有已被 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;
// ---- 被引用的页面 idmanifest + 各层内存视图 + 退休表 ----
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.0review 修复表结构记录的**唯一**解析实现
*
* 为什么必须集中且严格结构记录有两种坏法 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.0review 修复: 本次打开/修复过程中出现过的**一切损坏迹象**
*
* 为什么需要它而不是只看 `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: 恢复诊断打开时被丢弃的 SSTableWAL 空洞是否怀疑数据丢失
*
@@ -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.0review 修复 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.0review 修复 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);