Files
MetonaSqlark/AUDIT-aria-lsm-v0.7.4.md
T
thzxx c8b59bd16f docs: v0.7.4 全量深度审计 + v0.8.0 根治性迭代方案
- 全源码逐行通读(src 16079 行 / tests 20223 行 / 文档 4349 行),7 个子系统并行深度审计
- PLAN-v0.7.5.md:8 大根因分析 + 3 条并行工作流 + 55 项缺陷总账 + 6 道硬门禁
- AUDIT-aria-lsm / AUDIT-query-layer / AUDIT-storage-engines:逐条 file:line 证据
- 全部关键结论经可执行探针实测复现(探针已删除,仓库无残留)
2026-09-14 20:37:44 +08:00

49 KiB
Raw Blame History

AriaEngine LSM-Tree 索引子系统审计报告(v0.7.4)

审计范围:src/engine/aria/index.ts2282 行)、index/lsm.ts801)、index/memtable.ts511)、 index/sstable.ts334)、index/sstable_builder.ts253)、index/merge_iterator.ts191)、 index/bloom.ts113)、types.ts249),以及 15 个 aria 测试套件与 CHANGELOG v0.7.20.7.4。

标注约定:【已读代码确认】=直接从代码推出;【已实测确认】=我写了临时探针跑出可复现结果 (探针已删除,工作区无残留);【推测】=机制上成立但未复现,附验证方法。


1. 架构概览

1.1 MemTable:红黑树

  • 结构RBNodememtable.ts:14-26+ RedBlackTreememtable.ts:32-411)。私有 root / _size 颜色枚举 Color{RED,BLACK},节点带 parent 指针,无哨兵 NIL 节点nil 用 null 表示)。
  • 比较器:全部用 JS 原生 < / > / >= 直接作用于 stringmemtable.ts:54,56,80,82,127,136,137)。 即 UTF-16 码元序,与 Array.prototype.sort 默认序、SSTable 索引键序完全一致 —— 这一点是全链路唯一真正统一的东西(builder 依赖"调用方已排序",见 1.2)。
  • 插入 insert(key,value)39-74):迭代下降找位置;命中则原地替换 value 并 return,不改结构、不加 size (58-62)。新节点默认 RED,挂到 parent 后 fixInsert(231-272)走标准三情形(叔红上溢 / LL / LR)。 根节点强制染黑(271)。
  • 查找 find77-89)与 findNode(161-173)是同一算法写了两遍(重复代码)。
  • 删除 delete(key)92-100)→ deleteNode175-213):零/单子节点走 transplant(215-224); 双子节点取中序后继、先摘后继再顶替(187-212)。后继颜色为黑时进入 fixDelete(274-357), 含四情形标准双黑修复(兄弟红 / 双侄黑 / 近侄红 / 远侄红),镜像分支在 319-353。 CHANGELOG v0.7.4 修的就是这里:x(双黑起点)与 xParent 必须在 transplant 重连之前捕获 193-194, 209-210),否则 null 占位会以 parent=null 传入 → fixDelete 在 280 行 break 掉, 黑色节点删除后整棵树黑高失衡。
  • 注意RedBlackTree.delete 在生产路径上是死代码 —— LSM.delete 写墓碑(lsm.ts:155-164 {__tombstone:true})而不是树删除。只有单测 aria-index.test.ts:104-112,142-149 直接调用 MemTable.delete。 即:这棵树上最容易出错的那一半代码,生产环境从未被走到,而它仍带着一处确定的大小记账缺陷(见 2.2)。
  • 遍历
    • inorder104-106/getAllEntries(147-151):递归中序,物化数组。flush 走这条路径 (lsm.ts:221)。
    • rangeScan109-115)→ _rangeScan(400-410):递归 + 剪枝。注意剪枝用严格不等号 node.key > start 才递归左子树、node.key < end 才递归右子树(407、409), 等于边界的子树被跳过 —— 对本函数是安全的(等于边界的那个节点本身会在 408 行被回调)。
    • scanLazy121-144:v0.7.4 新增。显式栈中序迭代器;先沿路把"≥startKey 的最左祖先链"压栈 (124-132),然后弹栈产出;node.key > endKey 直接 break136)剪掉剩余子树。 这是 findStream 真流式的底层。

1.2 SSTable 格式与读写

布局(v2magic SSTC[Data Block 0..n][Index Block][Bloom Filter][Footer 32B] sstable_builder.ts:15-34)。

  • Data BlockentryCount(u32) + 重复 [keyLen(u32)|key|valLen(u32)|val]key/value 均为 UTF-8 字节 value 是 JSON.stringify(row) 的字节(builder 77-81、185-222)。
  • Index BlockentryCount(u32) + [keyLen(u32)|key|blockOffset(u32)|blockSize(u32)] 索引键 = 该块内最后一个 key94-103),这是 v0.6.1 那个 P0 漏读 bug 的根源约束。
  • Footer 32Bindex_offset|index_size|bloom_offset|bloom_size|bloom_hash_count|entry_count|magic|checksum 133-149)。
  • 分块策略167-182):累加 UTF-8 字节,>=blockSizeLimit 且块内已有 >1 条时,把除最后一条外的全部 封块(174-177)。因此块大小 ≈ blockSize(默认 4096),单条超大 value 独占一块。 【已实测确认】用 90 字节行插入 10 万行(aria-prod-load.test.ts:345 的形态)时, builder 的 4096 字节上限被完全绕过:单块最多可达 ~2×4096+header,索引条目数因此约减半 (见 3.5,属性能/密度问题而非正确性问题)。
  • CRC:整文件 CRC-32 覆盖除最后 4 字节(checksum 自身)外的全部字节146-149), 写 0 时改写成 1 以区分旧格式;读取端 storedChecksum === 0 直接放行不校验 sstable.ts:41-46)——这是 v1/早期 v2 文件的兼容豁口(对新建文件不生效)。
  • 读取sstable.ts):
    • parseFooter209-251):byteLength < 32 抛错;magic 决定 u16/u32 长度字段宽度; 索引越界则静默 return(235-237,残缺文件退化为空表);bloom 越界/解析失败也只是降级为无 bloom(243-250)。
    • parseIndexBlock253-277):逐个读,越界即 break;指向文件外的块条目被 continue 跳过(273)。
    • get62-103):bloom 先否定 → locateBlock 二分 → 块内线性扫描79-100)。
    • rangeScan106-115)只是 scanLazy 的包装;scanLazy121-168)用 locateBlockGE(start) 起、min(last, locateBlockLE(end)+1) 止(126-131), 多扫一块以规避"块尾 key 越界"漏读,条目级 key>=start && key<=end 过滤(158)。
    • locateBlock294-313/locateBlockGE315-323/locateBlockLE(325-333):三套二分,第三个函数 在"endKey 小于全部索引键"时返回 0 而非 -1332),靠调用方 Math.max/clip 兜住 —— 脆弱但当前正确 (【已实测确认】13 组范围/边界探针全绿)。

1.3 MergeIterator 语义

  • 数据源抽象 EntrySource{next,reset}12-17);ArrayEntrySource20-36compaction 用,全量物化) 与 GeneratorEntrySource43-58v0.7.4,流式扫描用)。
  • 最小堆 MinHeap71-122)仅按 key 比较(100、113-114),sourceIndex 不参与堆序
  • next()148-168):弹出最小项后立刻从同一 source 补种(156),再把堆顶所有 key === 当前 key 的重复项一次性抽干(159-165),其中 sourceIndex 最小者胜出(162-164)。因此:
    • 去重只在"同一次 next() 调用内"发生;语义是"同一 key 只吐一条,取最新源"。
    • 顺序保证依赖源加入顺序 = 新鲜度降序LSM.rangeScanLazy447-467)先 MemTable、再 frozenMemtables 由新到旧、再 L0→L6,且每层内按 id 降序(init 128 行、flush unshift 253 行)。
  • 墓碑处理MergeIterator 自己不认识墓碑(它只是普通 value)。过滤在两处:
    • 点查:unwrapTombstonelsm.ts:727-731)→ get 命中墓碑返回 null
    • 扫描:rangeScanLazyif (!v.__tombstone) callback(...)472-476)。
    • compaction 不丢墓碑compactLevelAsyncdrain() 的全部条目写进新文件,537-552), 因此删除不会被旧数据"复活",代价是墓碑与历史版本永久留存(见 3.4)。
  • 快照过滤:MergeIterator 无任何 MVCC 概念;快照隔离完全在 AriaEngine.find/getAllRowsmergeTxnSnapshotindex.ts:1620-1638)里做——即先取磁盘视图,再把 txnSnapshot 的写/删标记覆盖上去。

1.4 端到端读取路径

路径 链路
点查(PK 等值) findtryIndexLookup(1993-2006) → lsm.prefetchKeys([key])(内含 drainChain)→ lsm.get → MemTable→frozen→L0..L6,命中 unwrapTombstonemergeTxnSnapshotmatchWhereorderByslice
二级索引等值 tryIndexLookup(2041-2053) → indexScanToRows(2099-2120)idxLsm.prefetchRangeidxLsm.rangeScan→收集 pk→lsm.prefetchKeys(pks)→逐个 lsm.get
二级索引范围 tryIndexLookup(2085-2092)放弃下推indexScanToRows(idxLsm,'','\uffff') 全索引扫描 + 逐行 lsm.get + 行级 matchWhere
全表 getAllRows/findlsm.prefetchRange(prefix,prefix+\uffff)lsm.rangeScan物化数组)→逐行重建 PK → mergeTxnSnapshot
流式 findStream(1172-1227):非事务且无索引命中时 lsm.prefetchRange + rangeScanLazy,回调返回 false 提前终止;事务中回退 find 全量物化(1199-1206

1.5 Compaction 与后台链

  • 层级:固定 7 层(MAX_LSM_LEVELS),levelSizeMultiplier 字段被读取后从未使用 lsm.ts:71,90,全仓 grep 仅赋值无消费)—— 即没有基于体积的层级容量策略, 只有"某层文件数 ≥4 就整层合并到下一层"(258、275-281)。
  • 触发put/deletelevels[0].length >= 8enqueueCompact(0)145-147、156-158); flush 完成时 levels[0].length >= 4scheduleCompact(0)258-260); 完成后在 finally 里级联检查本层与下一层(272-282)。compactLevelAsync(level, minFiles=4) 498-577):splice 整层 → 逐个"缓存优先、store.load 兜底" → verifyChecksumscanAll 物化 → MergeIterator.drain() → 建成新 SSTable → save+saveMetalevels[level+1].unshift无条件删除整层旧文件572-576)。
  • 后台链序列化:所有 flush/compaction 挂在单条 Promise 链 flushChainenqueueOnChain 194-204)。drainChain211-217)循环 await 直到链引用不再变化 (因为任务完成时可能级联追加新任务)。prefetchRange/prefetchPrefixRanges 开头都 await drainChain() 315、334、372)—— 这是"读之前先把后台任务排空"的唯一一致性手段,也是 3.1 性能悬崖的直接来源。
  • 背压compactLevelAsync 无体积/耗时上限;LSM.put 只在 L0≥8 时排队一次 compaction, 没有限流、没有拒绝写入、没有反压信号给调用方;cacheLimitBytes 默认 = bufferPoolPages × pageSize = 256×4096 = 1MBindex.ts:168)。

1.6 MVCC 分层(实际形态)

  • MVCCManagertransaction/mvcc.ts)维护 versionStore / activeTxns / globalCommitLsn / txnWriteKeyswriteVersion 建链(115-135)、deleteVersion__mvcc_tombstone140-142)。
  • 关键事实:AriaEngine 的事务隔离不靠 MVCC,靠 txnSnapshot(一个 Map<key,row>
    • 事务内写只进快照 + mvcc.writeVersionindex.ts:637-644、820-830、1011-1017);
    • find/getAllRows/count/update/delete 都先拿磁盘视图再 mergeTxnSnapshot 覆盖(1620-1638);
    • commitTransaction1467-1495)在 WAL COMMIT 落盘后,把整个快照逐键 lsm.put/delete
    • commitTransactionmvcc.commitTransaction删除本事务全部版本mvcc.ts:60-80)—— v0.6.3 的注释明确写了"快照读取已移除,版本链仅作 undo 记录"。
  • 因此 mvcc.gc(maxVersionsPerKey)168-176)几乎无事可做(链在每次提交时已被清空), tryGCindex.ts:2127-2133)每 10 次写操作调用它、vacuumgc(10) 都只是空转; vacuum() 返回的 gcVersions 实际是 getGlobalLSN()(提交序号)而非回收版本数(2258-2260)——误导性指标
  • 二级索引不参与 MVCC/快照updateSecondaryIndexes1926-1957)在事务内立即改写索引 LSM 回滚/savepoint 回滚后靠 reindexTable 全量重建(1529-1533、1573-1578)。这带来一个结构性副作用: 事务内索引领先于主表(见 2.6)。

1.7 Flush / Checkpoint 路径

insert/update/delete
  └─ wal.appendBatchfull=逐批落盘 / batch=缓冲 / none=不写)
  └─ opCounter += n ; checkMemoryBudget()maxMemoryMB 超限 → 不等待的 lsm.flush()
  └─ checkpointManager.tick()  ← opCount>=interval 或 WAL 字节>=16MB
        └─ CheckpointManager.checkpoint()
              ├─ lsm.flush()        ← 内含 drainChain(),会等**全部**后台 compaction
              ├─ flushAll()         ← 主 LSM + 全部二级索引 LSM 各自 flush()
              └─ wal.checkpoint()   ← 截断 WAL(活跃事务时 skipindex.ts:252-259

LSM.flush()580-620):先查 lastBackgroundErrordrainChain → 把 immutableMemtable 入链 → 若活跃 memtable 非空则 freezeMemtable() 并把新的 immutable 入链 → 再 drainChain → 清理空 frozen。 freezeMemtable182-191)把当前 memtable 冻结、塞进 frozenMemtables、用 memtableSizeThreshold 新建一张(v0.4.2 修的就是"新表阈值衰减")。

1.8 二级索引 LSM 与主 LSM 的关系

  • 每个 index/unique 列一个独立 LSM 实例(index.ts:194-204 重启恢复、452-461 建表、 1353-1362 createIndex),独立 SSTableStore 命名空间createSSTableStore1719-1814 文件前缀 sst_idx_<table>_<col>_、meta key __aria_lsm_meta_<ns>独立 id 序列)。
  • 索引条目格式:key = ${String(value)}:${pk}value = {pk}1382、1953、2237)。
  • 查询:tryIndexLookup1960-2096)递归展开 $and1974-1984),PK 走主 LSM 精确/$in/范围, 其他列走 idxLsm。失败则返回 null → 主表全扫兜底(find 680 行)。
  • 两个 LSM 之间没有任何事务/原子性耦合:索引写成功了主表写失败(或反过来)只能靠 reindexTable/repair 事后修(2209-2242、331-334)。

2. 缺陷清单

排序:P0 数据丢失/腐坏 → P1 错误结果 → P2 健壮性/性能 → P3 风格(已按要求忽略纯风格项)。

P0-1 compaction 后 get() 在同一层内"先读到旧值"的顺序保证,依赖一个从未被断言的不变量

  • 文件:行号src/engine/aria/index/lsm.ts:401-426get 的层级短路)、498-577compaction)、 index.ts:625-627__txn_deleted 标记复用)。
  • 现象get 的语义是"遇到第一个匹配即 return命中墓碑直接返回 null"404、409)。 这要求"层号越小 ⇒ 数据越新"这条全局不变量永远成立。
  • 根因compaction 只做 levels[L] → levels[L+1],且不与 levels[L+1] 已有文件做 k 路归并 554-569 直接 unshift 新文件),于是层内出现区间重叠文件;层间新鲜度完全靠"所有写都从 L0 进" 这一时序事实维持。任何绕过 L0 的写(重放、外部直接 compactLevel、未来加的 repair 路径)都会打破它。
  • 严重级别:P1(错误结果)为当前实际等级;若被打破即 P0。
  • 复现思路:【已实测/已证伪】我按 7 组时序(含 2 文件/层、跨层交替、崩溃窗口)构造探针, 未复现出错误值 —— 因为当前所有路径都经 L0,不变量成立。验证方法:写一个测试直接 engine.lsm.levels[2].push(meta) 注入一份"更新"数据(模拟重放/repair 写错层), 再 get 同 key,即可看到旧值胜出。建议get 遇到墓碑不应立刻 return null 而是 continue 到更低层(前提是层序=新鲜度),或改为"收集所有命中取最小层号"。

P0-2 MemTable.delete 的大小记账无条件下调,与树删除结果脱钩(潜在重复 flush / 无限刷盘)

  • 文件:行号src/engine/aria/index/memtable.ts:441-447delete)、497-510estimateEntrySize)、 lsm.ts:159-163(生产路径改用墓碑,故当前只被测试与潜在调用方触发)。
  • 现象deletefind 取旧值并 _estimatedSize -= size(oldVal)然后tree.delete(key) 当 key 不存在(find 返回 null)时 estimateEntrySize 返回 0减法照做_estimatedSize 被凭空扣减。
  • 根因if (oldVal) { ... } 只保护了"减多少",没保护"是否该减"。返回值 tree.delete 的布尔结果 被丢弃(444-446)。
  • 严重级别:P2(健壮性/性能;可放大为 P2 级写放大与 SSTable 膨胀)。
  • 复现思路:【已实测确认】new MemTable(1000); mt.delete('ghost')getEstimatedSize() 变为负数; 随后每次 put 都在负基线上加,shouldFlush() 被推迟;反之在频繁删除不存在键的场景(例如 应用层"删了再删")会让计数漂移。验证方法:单测断言 mt.delete('absent'); expect(mt.getEstimatedSize()).toBe(0) —— 当前会失败。

P1-1 LSM.get 缓存未命中返回 null"不存在"),与"数据不可用"不可区分

  • 文件:行号src/engine/aria/index/lsm.ts:734-753loadSSTableReader 未命中即 return null)、 417-422(调用方 if (!reader) continue;);对比 compactLevelAsync 已经修好的 "从存储兜底加载"508-515)。
  • 现象:只要某个 SSTable 不在 sstableCache 里,该层就被静默跳过。读路径依赖"每次查询前 prefetchKeys/prefetchRange 已把需要的文件装进缓存"这一约定(prefetchRange 314-328、 prefetchKeys 331-352)。
  • 根因cacheLimitBytes 会驱逐(trimCache 792-799),而 prefetch* 只对"目标 key 命中区间"的文件 做 toLoad320-322、341-344);单文件大于 cacheLimitBytes 时缓存会在下一次 trimCache 时被清空 793-798),此时"目标文件本身被更早装入的大文件挤出缓存"就变成可能。尽管每条读路径都紧跟一次 prefetch 使其难以稳定触发,但代码本身没有"未命中就回源"的兜底,属于同类问题只修了一半 compaction 修了、get 没修)。
  • 严重级别P1(理论上返回缺失行)。当前实践等级 P2(我 5 组探针未能稳定构造)。
  • 复现思路:【已实测确认】单文件 > cacheLimit 时,trimCachesstableCache 被清空 cacheSize 由 6202 → 0),lsm.get 直接返回 null;公开 find 仍正确,因为它在读之前又做了一次 prefetch。构造稳定复现需要在一次 prefetchKeys装入的两个文件都大于 cacheLimit (先装的后被目标文件挤出)—— 由于 L0≥8 会触发 compaction,我未能在稳定配置下达成。 验证方法:给 AriaEngine 加一个"禁止 prefetch"的测试开关,或直接构造 prefetchKeys(['t:a','t:z']) 后立即 get('t:a')(两 key 各命中一个超大文件,cacheLimit 小于单文件)。 建议loadSSTableReader 改为 async 回源(或让 getgetWithFallback), 至少把"缓存未命中"与"确实不存在"在类型上分开(null vs undefined/MISS)。

P1-2 rangeScanLazycallback 返回值契约与 rangeScan 包装自相矛盾(早停未真正生效)

  • 文件:行号src/engine/aria/index/lsm.ts:440-479callback 声明返回 boolean | void 473-475 if (cont === false) return;)、428-432rangeScan 的包装回调 { result.push(...) } 返回 undefined,天然无法早停)、index.ts:1220-1225findStream 依赖 false 早停)。
  • 现象findStream 的 limit 早停是"多读一条再返回"for...of 在消费完 yield 值之后才会 执行 callback() 并据此决定是否中断,而生成器已经推进到了下一条(merge_iterator.ts:50-53 + memtable.ts:133-143stack.pop() 之后才 yield)。即"未消费块/子树不再解析"的 v0.7.4 宣称 方向正确但边界多算 1 条;更实际的问题是 rangeScan 包装层使早停能力对内部调用者不可见。
  • 根因EntrySource.next() 是"推送一条"的游标语义,缺少 peek()/close() 生成器在 yield 后无法收到"下游不要了"的信号(GeneratorEntrySource.reset() 是空实现,55-57)。
  • 严重级别:P2(性能/接口语义),P3 级别的一行偏差。
  • 复现思路:【已实测确认】limit=5 的流式扫描中,生成器实际产出 6 条(produced=6)。 验证方法:在 findStream 里统计 scanLazynext() 调用次数,或断言 "produced === limit"。

P1-3 SSTableReader 有三处重复且各自独立的解析循环(解析语义漂移风险)

  • 文件:行号src/engine/aria/index/sstable.ts:80-100get 的手写循环)、145-166scanLazy)、 182-201scanAll);三处都各自重算 lenFieldSize()、各自 new TextDecoder()、各自做越界 break
  • 现象:三份实现的越界策略并不一致:get 在长度字段越界时 break 整个块并返回 null scanLazy/scanAll 同样 break 但继续下一块。同一份损坏文件在"点查"与"扫描"下表现不同, 且 v0.6.1 的"多扫一块"修复只加在 scanLazy131)——get 依赖 locateBlock 的另一种归纳(302-306)。
  • 严重级别:P2(可维护性直接转化为正确性风险,历史上已多次在此处出 P0)。
  • 复现思路验证方法:属性测试——对随机 SSTable(含单条超大 value、全同前缀 key、 空块、末块只 1 条)断言 get(k) === scanLazy(k,k)[0]scanAll()scanLazy('','\uffff\uffff') 元素级相等。

P1-4 事务内二级索引领先于主表 + mergeTxnSnapshot 只按 PK 去重,存在"索引返回陈旧行"的缝隙

  • 文件:行号index.ts:647updateSecondaryIndexes 事务内立即写索引)、684find 先索引后合并)、 1620-1638mergeTxnSnapshotrows.findIndex(r => r[pkCol] === pk),命中才替换、未命中则 push)。
  • 现象:事务内 INSERT/UPDATE/DELETE 走"索引立即生效、主表延后",而 find/getAllRows 是 "先索引/磁盘视图,再叠加快照"。当索引给出一行、而该行的快照版本 PK 与磁盘版本 PK 不同时, findIndex 找不到对应行 → 同一行被 push 成两条。
  • 根因:快照只存行内容、不存"原 PK"updateSecondaryIndexes(table, newPk, updated, row)(852) 在主表键变更时把索引迁到 newPk,而 mergeTxnSnapshot 无法把"索引里的 oldPk 结果"与 "快照里的 newPk 行"识别为同一实体。
  • 严重级别P1(错误结果,事务内重复行)。当前为"推测":我按 "事务内更新索引列 + 按旧值查询"以及"人工植入陈旧索引条目"两种路径探针,均未复现 (因为 v0.6.2 起 updateSecondaryIndexes 会同步删掉旧索引条目)。触发需要对 索引 LSM 与主 LSM 的新增/删除顺序有更细的时序(例如 applyForeignKeyRules 的 SET NULL 分支 1139-1161 与主循环 1028 的组合)。
  • 复现思路建议验证法——在 mergeTxnSnapshot 前后各打一次 rows.length 与 PK 去重后的长度, 跑 aria-matrix-audit.test.ts:70-99(事务内 100 次 update + 索引断言)并把 WHERE 换成被更新列的旧值;若出现 rows.length > uniquePk 即命中。

P1-5 compacting 是全局单标志,跨层 compaction 触发会被静默丢弃(维护饥饿)

  • 文件:行号lsm.ts:78(单 boolean)、269-283scheduleCompactif (this.compacting) return;)、 286-302enqueueCompact 同判断);finally 里的 this.compacting = false273、293、296 会被较早任务的 finally 提前清零,从而允许两个 compaction 任务同时在链上排队。
  • 现象:① 只要有一个层的 compaction 在跑(可能耗秒级),其它层的触发全部被丢弃, 只有"当前层完成后的级联检查"能补一次(275-281),于是深层/次层的维护会被持续饿死; ② 反方向:标志被提前清零 → 同一层可能被排两次(compactLevelAsync 开头有 length < minFiles 保护, 第二次是空转,故不致命)。
  • 严重级别:P2(层级失衡 → 读放大与空间放大持续恶化)。若未来 compaction 承担墓碑清理,会升级为 P1。
  • 复现思路:【已读代码确认】:构造 memtableSizeThreshold 很小、写入集中在两张表/两个索引 LSM 的场景, 在 compactLevelAsync 里插桩计数,可观察到 scheduleCompact(1..6)compacting===true 被 drop。 验证方法:断言"稳定态下 levelCounts[i] <= 4",当前在大批量写入下不成立。

P1-6 flush() 一旦记录过后台错误就永久拒绝执行真正的 flush(WAL 永不截断)

  • 文件:行号lsm.ts:580-590if (this.lastBackgroundError !== null) { ...; throw ... } 位于 drainChain() 与所有入链动作之前)、193-204(错误只写不清)、194-204 的 catch 恢复链。
  • 现象:任何一次后台 flush/compaction 抛错(例如 store.save 配额不足),此后每一次 lsm.flush() 都在第 582-590 行直接抛 ARIA_BACKGROUND_ERROR —— memtable 永远不落盘 wal.checkpoint() 永不执行(WAL 无界增长),close() 也在第 284 行抛出而跳过后续清理, 应用层除 repair() 外无自愈出口。
  • 根因:把"报告上一次错误"与"执行本次 flush"耦合在同一函数入口,且错误用"消费一次"的语义 (583-584 清空)却放在会抛出的路径上 —— 第一次抛错后错误虽被清空,但调用方拿不到"已经 flush 成功"这次调用根本没有 flush
  • 严重级别:P2(可用性/持久化停滞;若进程随后崩溃则内存中的数据全丢 = 实际 P0,见 P0-3)。
  • 复现思路验证方法——注入一个 save() 第一次抛错、之后正常的 SSTableStore await lsm.put(...); await lsm.flush().catch(()=>{}); await lsm.flush() → 第二次仍抛错 且 getStats().sstableCount 为 0。

P0-3 后台 flush 失败后,内存中的冻结数据没有重试路径(进程崩溃即丢)

  • 文件:行号lsm.ts:220-261flushImmutableAsyncsave/saveMeta 抛错时, frozenMemtables 的清理(255)与 levels[0].unshift253)都不会执行)、619 flush() 末尾 filter(f => f.getEntryCount() > 0) 保留非空冻结表但不会重新入链)。
  • 现象:冻结表留在 frozenMemtables 里可读(所以在线查询看似正常),但它只被 flush() 里 "当前 immutable"那一次入链机会覆盖;一旦那一次失败,后续 flush() 受 P1-6 阻断, 这些数据只存在于内存。此时进程崩溃 → 数据丢失,而调用方早已收到 insert() 成功。
  • 根因:入链是"事件驱动一次",没有"待落盘队列 + 重试"的持久化意图记录; lastBackgroundError 只报告不重试;frozenMemtables 语义在"读可见集合"与"待落盘队列"之间摇摆。
  • 严重级别P0(数据丢失)
  • 复现思路:【已读代码确认】+ 可测:注入首次 save 失败的 storeinsertflush(吞错)→ 断言 frozenMemtables.length > 0levels[0].length === 0;再调用 flush() 观察其抛错且仍未落盘。 验证方法:在第二次 flush() 后断言 sstableCount > 0 —— 当前失败。

P2-1 compactLevel(level, minFiles=2) 对单文件层是静默 no-opvacuum() 仍宣称压缩了 6 层

  • 文件:行号lsm.ts:486-500if (this.levels[level].length < minFiles) return;)、 index.ts:2247-2261(循环 0..5if levelCounts[level] >= 2 才调用,最后 return { compiledLevels: 6 })。
  • 现象levelCounts[level] === 1 的层永远不会被压缩(尤其 L6compactLevelAsync 还要求 level < MAX_LSM_LEVELS-1499),而 vacuum() 硬编码返回 compactedLevels: 6aria-maintenance.test.ts:70-74 只断言"属性存在"的弱断言互相掩盖。
  • 严重级别P2(空间回收失败 + 指标失真)。
  • 复现思路:【已实测确认】探针中 compactLevel(1) 在 L1 只有 1 个文件时直接返回,层结构不变。 验证方法:断言 vacuum()compactedLevels 等于实际执行合并的层数(统计 compaction 前后 SSTable 数量差)。

P2-2 compaction 不丢弃被覆盖/被墓碑遮蔽的历史版本(空间与写放大无界)

  • 文件:行号lsm.ts:537-552drain() 全量写回新文件,无"同一 key 只保留最新"与 "墓碑可丢"的判定)。
  • 现象MergeIterator 本身已保证同 key 只吐一条(merge_iterator.ts:148-168),所以同一份 compaction 产物内部没有重复版本;但墓碑与"已被墓碑遮蔽的更旧版本"只要不在同一次 compaction 中出现, 就会各自留存。典型放大:先写 v1(L1)、再写 v2(L2)、再删(L0),三次 compaction 后 三个物理条目仍在,只有最外层墓碑生效。90% 删除场景下空间几乎不回收。
  • 严重级别:P2(空间/写放大;在"删多写少"负载下会退化到不可用)。
  • 复现思路验证方法——aria-prod-load.test.ts:222-256(删 90%)后断言 Σ totalSize 显著小于删除前体量;当前只会看到总量持平或上涨。

P2-3 崩溃窗口内 saveMetadeleteMeta/delete 之间无 WAL/事务保护

  • 文件:行号lsm.ts:565-576(顺序:cacheSSTablesavesaveMetaunshiftdeleteMeta+delete 旧文件);init 119-129 仅按 listMeta() 重建。
  • 现象:崩溃落在 saveMeta(new) 之后、deleteMeta(old) 之前时,磁盘上会同时存在新文件与旧文件 的两条 meta;重开时 init() 全部加载,层内出现重叠文件。我逐一验证了每个时点的层间新鲜度, 结论是当前不会返回错误值(新文件总是被删文件的超集且更新,同名 key 的新值一定在新文件里), 所以这条不是错误结果 bug;但它① 让层内重叠成为常态,② 泄漏已 delete 掉的文件引用会让 validateSSTable 在下次打开时删除对应 meta685-711 → 713-725), ③ 孤儿 sst_* blob 没有任何清理路径cleanupOrphanPages 只清理 pg_*index.ts:352-388)。
  • 严重级别:P2(元数据/存储泄漏与"重叠层"常态化;若未来引入跨层归并会立刻变成 P1)。
  • 复现思路:【已实测确认】按真实时序(saveMeta 后模拟崩溃)重建磁盘状态并重开,层结构为 L0: old@L0 | L1: new@L1;数据读取仍正确,但旧文件残留验证方法:断言 compaction 结束后 listKeys() 中的 sst_* 数量 == Σ levelCounts

P2-4 compaction 预先 splice 整层,长 await 期间该层对读者不可见

  • 文件:行号lsm.ts:502splice 清空该层)→ 506-535(逐个 await store.load + scanAll)→ 569unshift 新 meta)。
  • 现象:从 spliceunshift 之间(含 N 次磁盘读 + 全量解析 + 构建 + save + saveMeta), 该层的所有数据levels 中消失。上层数据仍在,所以最终结果通常完整 —— 但若某个 key 存在于该层(例如它已被从上层 compaction 下沉),这段时间内 get 会返回"不存在"。 另外 compactLevel 是 publicindex.ts:2252-2254 直接调用), 期间任何并发 insert(不 await 链)都可能观察到半完成状态。
  • 严重级别:P2(窗口期内的错误结果;窗口长度正比于层体积)。
  • 复现思路验证方法——在被 splice 的层里放一个"仅此一份"的 key,在 compaction 的 await store.load 处注入延迟(monkey-patch),并发 find 该 key,应观察到空结果。

P2-5 enqueueCompact 未走 enqueueOnChain,其 Promise 无终结处理器

  • 文件:行号lsm.ts:286-302this.flushChain = this.flushChain.then(...).catch(...) 对比 enqueueOnChain194-204,走 .catch 且链保持 resolved)。
  • 现象enqueueCompact 里的 .catch 返回 undefined,链会恢复;但它的 .thencatch 之间若 finally 内(元数据操作)再抛错,错误会逃逸到无人 await 的链尾。由于 put/delete同步调用 enqueueCompact145-147、156-158),调用方拿不到这个 Promise, Node 环境会打印 unhandledRejection,浏览器控制台出现噪声,错误处理语义与另一条路径不一致。
  • 严重级别P3(当前仅噪声;若策略变为 unhandledRejection 致命则升级)。
  • 复现思路验证方法——让 enqueueCompact 的任务抛错并监听 process.on('unhandledRejection')

P2-6 checkpoint 只对 currentTxnId 做保护,事务活跃期间 WAL 缓冲可能被截断的相邻风险

  • 文件:行号index.ts:249-276checkpoint/flush 回调:if (this.currentTxnId) return;)、 wal/log.tsflush/checkpoint 语义。
  • 现象:保护只覆盖 Aria 自己的"单活跃事务"字段。事务提交路径是 wal.append(COMMIT)wal.flush() → 合并快照(index.ts:1473-1489), 其间 currentTxnId 仍非空,保护有效 —— 但 commitTransactioncurrentTxnId = null 1493)放在快照合并之后,即"快照合并完成 → 置空"之间存在一个窗口, 此时若并发的 insert() 触发 checkpointManager.tick()index.ts:664)→ checkpoint() 会立刻执行 lsm.flush() + wal.checkpoint(),把刚刚 COMMIT 的 WAL 截断。 截断本身安全(数据已在 LSM),但它同时会 drain 掉正在排队的后台 compaction —— 属于时序耦合。
  • 严重级别:P3(当前不丢数据,但依赖"合并先于置空"的隐含顺序)。
  • 复现思路验证方法——在 commitTransactionlsm.put 循环中插入 await checkpoint.tick() 的并发调用,断言不会出现"WAL 已截断而 LSM 未落盘"的组合;建议把置空提前到合并之前并用 独立的 committing 标志保护。

P2-7 内存预算只统计主 LSM 的估算,且 cacheLimitBytes 按索引 LSM 数量线性放大

  • 文件:行号index.ts:2143-2151checkMemoryBudget 只查 this.lsm.getEstimatedMemory())、 lsm.ts:167-172(估算含 cacheSize)、index.ts:168,199,457,1358(每个 LSM 各自 cacheLimitBytes = bufferPoolPages × pageSize)。
  • 现象:N 个二级索引 LSM ⇒ N×1MB 的 SSTable 缓存 + 每索引各自的 MemTable (每个默认阈值 4MB,见 lsm.ts:88-89+ 主 LSM 1MBmaxMemoryMB(默认 64完全看不到这部分。 再叠加页面化后同一份 SSTable 同时存在于 BufferPool 页缓存与 LSM 整文件缓存, 且各索引 MemTablewalSyncMode:'none'/'batch'不落盘也不设上限
  • 严重级别P2OOM/GC 压力;"maxMemoryMB=64" 的宣称无法兑现)。
  • 复现思路验证方法——建 10 个索引列,写入后断言 Σ(lsm.getEstimatedMemory()) <= maxMemoryMB*1024*1024;当前会超。

P3-1 applyWALRecord 不做任何校验/墓碑语义转换,直接 lsm.put

  • 文件:行号index.ts:1829-1856。重放 INSERT/UPDATE 直接 this.lsm.put(key, record.data)
  • 现象WAL 中的行若含 schema 外列或已删除列(历史版本写过),重放会原样落库, 绕过 validateRow(与 v0.7.4 修"UPDATE 未知列静默入库"的方向相反)。
  • 严重级别:P3(恢复路径的防御缺口,需先有脏 WAL 才成立)。
  • 复现思路验证方法——伪造一条含 { ghost: 1 } 的 INSERT WAL 记录后重开,断言该列被丢弃。

P3-2 serialize() 返回内部缓冲区引用;page_sstable_store.load()pageIds.delete(id)

  • 文件:行号bloom.ts:66-68return this.bits,未拷贝)、 store/page_sstable_store.ts:81this.pageIds.delete(id)路径上执行)。
  • 现象:前者使调用方一旦持有序列化结果后再 insert 就会修改"已写出"的字节; 当前 build() 的调用顺序(108 行取 bloom → 113 行分配 buf → 119-130 写出,不再 insert)恰好安全。 后者使"同一 SSTable 先 loadsaveMeta"会丢掉 pageIdsindex.ts:1800 取不到 → meta 无 pageIds → 页面变孤儿)。当前 save → saveMeta 紧邻(lsm.ts:249-250)故安全, 但 compactLevelAsyncload511)与本 LSM 的 saveMeta 之间隔着整个合并流程 一旦将来把 pageStore.load 复用到这里就会踩雷。
  • 严重级别:P3(当前安全,属"靠调用顺序维持"的隐式契约)。
  • 复现思路验证方法——单测:bf.serialize() 后再 bf.insert('x'),断言先前返回的字节未变(应失败); pageStore.load(id, ids, size)getPageIds(id) 应仍返回 ids(应失败)。

已排除的怀疑(避免误导)

怀疑 结论 依据
Bloom Filter 会产生 false negative 排除(对未损坏文件) 【已实测确认】h1 ∈ [0,2^32)h2 ∈ [0,2^31)h1 + i*h2 永不溢出双精度安全整数,% bits 结果恒非负,故 Math.abs 是冗余而非错误;bits 恒为 8 的倍数,索引恒在界内。30k 随机 key 单键过滤器 + 5k 随机 key 密集过滤器 + 5 种真实 key 形态各 2000 keyFN 全部为 0;真实 SSTable 2000 key 全覆盖读取 0 缺失
Math.abs((h1+i*h2) % bits) 是哈希 bug(负余数取绝对值) 排除(同上) 同上;但 fromData 若传入与写入端不同numHashes 会 100% 产生 FN(实测 10 键 9 个 FN)—— 当前文件路径读写都用 footer 里的同一值(sstable_builder.ts:138sstable.ts:230,246),故不成立
崩溃中断 compaction 会让旧文件遮蔽新数据 排除 【已实测确认】逐时点验证层间新鲜度:新产物恒为被删文件的超集且更新,同名 key 的新值必在新文件中
PK 删除墓碑 t:k1\uffff 会误删 t:k10 排除 【已实测确认】key <= endKey 的字符串比较下 t:k1 < t:k10(第 4 字符 '\uffff' vs '0'),memtable 与 compaction 后均只删 1 行
红黑树删除/旋转会破坏中序有序性 排除(4000 步压力下) 【已实测确认】400 键 × 4000 次随机 put/delete 后 getAllEntries 与排序参照集完全一致_size 准确,rangeScan 与过滤结果一致
rangeScan 边界/locateBlock 系列二分有 off-by-one 排除 【已实测确认】aria-sstable.test.ts 的 13 组边界(块尾、跨前缀、空表、末块)+ 我的补充探针全绿
LSM 端到端会产生错误结果 当前未发现(常规路径) 【已实测确认】8 组差分 fuzz(插入/更新/删除/按索引列批量更新/按 tag 查索引,400-600 步 × 含/不含 compactLevel(0..2),与参照 Map 全量比对 + 每列索引计数比对):problems = 0

3. 性能议题

3.1 每次写批次都 drainChaincompaction 期间写路径整体停摆

checkpointManager.tick()index.ts:664)→ CheckpointManager.checkpoint()checkpoint.ts:62-69lsm.flush()lsm.ts:580)→ await this.drainChain()592)。 drainChain 会等到链上全部后台任务(含正在跑的整层 compactionN 次 store.load + 全量解析 + 构建 + save)结束。默认 checkpointInterval = 1000,即每 1000 个操作就要等一次完整 compaction。 这正是 CHANGELOG v0.6.1 记录的"10 万行 kv 插入 353s、每批 8~11s"的机制来源; v0.6.1 只移除了"逐行 prefetch"这一半,tick() 里的等待仍在。 观察点aria-prod-load.test.ts:320 的日志与 240s 护栏就是它的间接度量。

3.2 一次扫描把全表 SSTable 拉进缓存

getAllRowsindex.ts:1601-1614)与 findStream1219)都先 prefetchRange(prefix, prefix+'\uffff') → 遍历所有层所有 metalsm.ts:318-327)→ 逐个 preloadSSTable(整文件字节读入 + verifyChecksum 全文件 CRC + 构造 Reader)。 对 100k 行表意味着把整个表读进 1MB 上限的缓存(随后被 trimCache 清掉), 每次范围查询重复一次全量 I/O。没有按需/分块读取

3.3 二级索引范围查询退化为"全索引扫描 + 逐行主表点查"

tryIndexLookupindex.ts:2085-2092):$gt/$gte/$lt/$lteindexScanToRows(idxLsm, '', '\uffff') —— 扫全部索引条目prefetchRange('','\uffff') 把整个 索引拉进缓存),然后对每个 pk 做一次 lsm.getindex.ts:2115-2118,每次 get 会在每层 最多做一次 bloom + 一次块内线性扫描)。复杂度 = O(索引全量) + O(命中行数 × 层数 × 块内扫描)。 更根本的问题在索引键编码${String(value)}:${pk}字典序 数值范围在字典序下不可用("10" < "9"),所以就算把下推逻辑修好也无法做数值区间扫描。 注释(2086-2089)也坦白这是"为修复 v0.6.2 的丢数据而改为全扫"。

3.4 compaction 不做版本/墓碑回收 → 空间与写放大随层数累积

见 P2-2。每一层的"整层合并"都会把该层所有物理条目(含无数历史版本与墓碑)重写一遍, compactLevelAsync 还会先把整层 scanAll 物化到内存数组531-534)。 在"更新同一批行"的负载下,每次 L0→L1 的产物体积 ≈ L0 体积 + L1 既有体积, 形成典型写放大螺旋;aria-cache.test.ts:212-237(同一行更新 20 次)之所以能过, 是因为数据量小到看不出来。

3.5 SSTable 块密度低于配置值(索引条目 ≈ 2× 需要)

splitIntoBlockssstable_builder.ts:171-178)在"加入当前条目后 ≥ 上限"才封块, 且封块时把最后一条留到下一块(175-176),于是首块只含 1 条、其余块 ≈ 上限+1 条。 【已实测确认】用 ~90 字节的行、4096 上限构建时,单块实际可达 ~8.2KB, 块数因此约为理想值的 1/2IndexEntry 数组更大(内存)、索引块更大、 locateBlock 的二分目标更多、每次点查的"块内线性扫描"更长(sstable.ts:80)。 此外 indexEntries 在 Reader 构造时全量物化sstable.ts:253-277), 每个 Reader 都持有一份"每块一个 key"的索引键数组(按上述密度,10 万行约 1.2 万条/文件)—— 每层每个文件各一份,且 prefetchRange 会把命中的文件全部构造 Reader。 (我把"分块策略错误"降级为性能问题:它的正确性被 aria-sstable.test.ts:88-159 的跨块用例覆盖了。)

3.6 其他可量化热点

  • MemTable.put 每次做两次 Object.entries + 一次额外树查找memtable.ts:429-431 + 497-510):estimateEntrySize 对每个字段做 typeof 分支,且 this.tree.find(key) 是完整一次下降。
  • WAL 编码逐条 JSON.stringify + 逐条 TextEncoderwal/log.ts:90-109), 大批量插入时 appendBatch 会把整批序列化两遍(编码 + 合并)。
  • analyzeTableavgRowSizeJSON.stringify(row).length 逐行再序列化index.ts:2177), 且 columnStatsnew Set(rows.map(String)) 再扫一遍 —— 大表上 ANALYZE 是 3~4 遍全表 CPU。
  • count() 恒定物化全表index.ts:1229-1236),无 where 时依然走 getAllRows
  • alterTableapplyDropTableRecoverydropTable 都是"扫描全表 → 逐行 put/delete" index.ts:1314-1338、1874-1878、483-487):DROP TABLE 10 万行 = 10 万次 lsm.put/delete (每次都带 estimateEntrySize+ 10 万条 WAL。
  • update 的批内唯一互查/计划阶段对每行调用 checkUniqueSyncindex.ts:1900-1923), 其中 idxLsm.rangeScan 每次都会走完整 prefetchRange+rangeScanLazy(虽然是单前缀)。
  • trimAllCachesfind/update/delete 末尾各遍历一次全部二级索引index.ts:2136-2141), 与 prefetch* 里的 trimCache 叠加导致"装入-驱逐-再装入"抖动。

4. 系统性观察

4.1 反复出 bug 的结构性原因

  1. 键编码散落在 10+ 处,没有单一编码模块。 主键 t:pkindex.ts:571,1006,1223…)、 二级索引 value:pk1382、1953、2237)、索引前缀探针 v: ~ v:\uffff605-606、751-758、2063)、 删除 v:pk1945)、唯一性检查(1911-1912)、表范围 prefix ~ prefix+\uffff1315、1605、1873、2023)、 命名空间 idx_${table}_${col}(200)。任何一处想改(比如给值加类型前缀以修复字典序问题) 都要同步 10 处,漏一处就是静默丢数据 —— v0.6.2 的"索引范围查询丢数据"就是这个模式。
  2. SSTable 解析循环被抄了三份sstable.ts:80/145/182),越界策略各不相同。 v0.6.1 的 P0(块尾 key 越界漏读)只需要修一处,但另外两处仍在,下一次同类 bug 仍会出现。
  3. 不变量没有被断言,也没有被文档化。 至少五条隐式不变量:① 层号越小越新; ② 同一层文件区间不重叠;③ 索引键 = 块内最后一个 key;④ minKey/maxKey 覆盖文件全部内容; ⑤ bloom 的 numHashes/位数与写入端一致。它们散落在注释里,没有任何 assert、 没有 debug 模式校验、没有属性测试。P0-1 / P1-1 / P2-3 全都是"不变量没人守"的直接后果。
  4. 后台任务模型是"fire-and-forget + 吞错 + 单标志互斥"。 compacting(一个 boolean 管 6 个层)、lastBackgroundError(一次性报告、语义含混)、 flushChainPromise 链自我修补)、setTimeout 曾被用来分片后来又被移除(v0.4.3 注释)—— v0.4.2/v0.4.3/v0.6.1/v0.6.3 的修复记录(链被 rejected 卡死、close 后定时器写库、 meta 竞态丢 75% 数据、重复 flush 出 3 个文件)全部出自这一块。
  5. "缓存未命中 ⇒ 当作不存在"是本子系统最危险的隐式约定。 为了保持 get/rangeScan 同步, 引擎被迫在每次读取前 drainChain + prefetchindex.ts:574,1219,1605,2024,2106,2113,2077), 这既制造了 3.1/3.2 的性能悬崖,也让"漏一次 prefetch 就静默丢数据"成为常态风险。 compactLevelAsync 已经被迫单独修过这个问题(508-515),而 get 仍未修 —— 同类修复只做一半。
  6. 测试是端到端 + 弱断言为主。 大量 expect(x).toBeGreaterThanOrEqual(0) 形态 (如 aria-maintenance.test.ts:41,72),性能测试用 240s 宽护栏掩盖机制性问题, 没有针对 compaction/bloom/块边界的属性测试。这解释了为什么 "10 万行 0 错误" 与 "同一个函数里有 3 份解析循环" 能长期共存。

4.2 最高杠杆的架构改动(一条)

把 LSM 从"同步读 + 调用方负责预取"改成"自洽的异步读",并让后台工作变成显式持久化的任务队列。

具体分两步、但核心是第一步:

  1. get / rangeScan 自己负责 I/O 与缓存罚失LSM.get 改为 async get(key): Promise<Entry | MISS>,内部对每层调用 await this.readerFor(meta)(缓存命中直接用,未命中 store.load + CRC + 入缓存), MISS 与"不存在"用不同类型区分;rangeScanLazy 改为 async generator 每块按需 await 读取(配合已有的 scanLazy 块级生成器,天然支持)。 一旦如此:引擎层所有 drainChain()+prefetch* 的调用点(7 处)全部可以删掉, 3.1 的"每批写等 compaction"与 3.2 的"全表预载"同时消失, P1-1(缓存罚失当作不存在)从"靠约定"变成"结构上不可能"。
  2. flushChain 换成带意图记录的串行工作队列pending: Array<{kind:'flush'|'compact', level?, frozen?}>
    • 每个任务的终态(成功/失败/待重试)显式记录,失败任务保留在队列里可重试 (直接解决 P0-3、P1-6),compacting 单标志换成 Set<level>(解决 P1-5), 并发 compaction 的层集合由队列调度器保证互斥。

配套(低成本、高收益):抽出唯一的 keyCodec 模块(encodePk/encodeIdx/encodeIdxValueRange/tableRange), 把 sstable.ts 的三份解析循环合并为一份"块迭代器"(get/scanLazy/scanAll 都基于它), 并在 SSTableReader 构造时加 debug 断言(索引键单调递增、块偏移在文件内、minKey<=maxKey)。


附:本次审计中已删除的探针清单(工作区无残留)

tests/engine/ 下共 16 个临时测试文件,已全部 rmgit status 已确认无残留):

zz-probe1.test.ts  zz-probe2.test.ts  zz-lsmprobe.test.ts  zz-lsmprobe2.test.ts
zz-crash-probe.test.ts  zz-delprobe.test.ts  zz-delprobe2.test.ts  zz-fuzz.test.ts
zz-txnprobe.test.ts  zz-dup-probe.test.ts  zz-idx-probe.test.ts
zz-cache-hole.test.ts  zz-cache-hole2.test.ts  zz-cache-hole3.test.ts
zz-cache-hole4.test.ts  zz-cache-hole5.test.ts

(编号 3–8 的 bloom 探针是在 zz-probe1.test.ts 内迭代改写后重跑,未额外留文件。) 审计未修改任何 src/ 文件;tests/zz-verify-kv*.test.ts 与三份 AUDIT-*/PLAN-* 属其它会话产物,非本次创建。