diff --git a/AUDIT-aria-lsm-v0.7.4.md b/AUDIT-aria-lsm-v0.7.4.md deleted file mode 100644 index 11cdf99..0000000 --- a/AUDIT-aria-lsm-v0.7.4.md +++ /dev/null @@ -1,558 +0,0 @@ -# AriaEngine LSM-Tree 索引子系统审计报告(v0.7.4) - -> 审计范围:`src/engine/aria/index.ts`(2282 行)、`index/lsm.ts`(801)、`index/memtable.ts`(511)、 -> `index/sstable.ts`(334)、`index/sstable_builder.ts`(253)、`index/merge_iterator.ts`(191)、 -> `index/bloom.ts`(113)、`types.ts`(249),以及 15 个 aria 测试套件与 CHANGELOG v0.7.2–0.7.4。 -> -> 标注约定:**【已读代码确认】**=直接从代码推出;**【已实测确认】**=我写了临时探针跑出可复现结果 -> (探针已删除,工作区无残留);**【推测】**=机制上成立但未复现,附验证方法。 - ---- - -## 1. 架构概览 - -### 1.1 MemTable:红黑树 - -- **结构**:`RBNode`(`memtable.ts:14-26`)+ `RedBlackTree`(`memtable.ts:32-411`)。私有 `root` / `_size`, - 颜色枚举 `Color{RED,BLACK}`,节点带 `parent` 指针,**无哨兵 NIL 节点**(nil 用 `null` 表示)。 -- **比较器**:全部用 JS 原生 `<` / `>` / `>=` 直接作用于 `string`(`memtable.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)。 -- **查找** `find`(77-89)与 `findNode`(161-173)是同一算法写了两遍(重复代码)。 -- **删除** `delete(key)`(92-100)→ `deleteNode`(175-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)。 -- **遍历**: - - `inorder`(104-106)/`getAllEntries`(147-151):递归中序,物化数组。flush 走这条路径 - (`lsm.ts:221`)。 - - `rangeScan`(109-115)→ `_rangeScan`(400-410):递归 + 剪枝。注意剪枝用**严格不等号**: - `node.key > start` 才递归左子树、`node.key < end` 才递归右子树(407、409), - 等于边界的子树被跳过 —— 对本函数是安全的(等于边界的那个节点本身会在 408 行被回调)。 - - **`scanLazy`(121-144)**:v0.7.4 新增。显式栈中序迭代器;先沿路把"≥startKey 的最左祖先链"压栈 - (124-132),然后弹栈产出;`node.key > endKey` 直接 `break`(136)剪掉剩余子树。 - 这是 `findStream` 真流式的底层。 - -### 1.2 SSTable 格式与读写 - -**布局(v2,magic `SSTC`)**:`[Data Block 0..n][Index Block][Bloom Filter][Footer 32B]` -(`sstable_builder.ts:15-34`)。 - -- **Data Block**:`entryCount(u32)` + 重复 `[keyLen(u32)|key|valLen(u32)|val]`,key/value 均为 **UTF-8 字节**, - value 是 `JSON.stringify(row)` 的字节(builder 77-81、185-222)。 -- **Index Block**:`entryCount(u32)` + `[keyLen(u32)|key|blockOffset(u32)|blockSize(u32)]`; - **索引键 = 该块内最后一个 key**(94-103),这是 v0.6.1 那个 P0 漏读 bug 的根源约束。 -- **Footer 32B**:`index_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`): - - `parseFooter`(209-251):`byteLength < 32` 抛错;magic 决定 u16/u32 长度字段宽度; - 索引越界则**静默 return**(235-237,残缺文件退化为空表);bloom 越界/解析失败也只是降级为无 bloom(243-250)。 - - `parseIndexBlock`(253-277):逐个读,越界即 `break`;指向文件外的块条目被 `continue` 跳过(273)。 - - `get`(62-103):bloom 先否定 → `locateBlock` 二分 → 块内**线性扫描**(79-100)。 - - `rangeScan`(106-115)只是 `scanLazy` 的包装;`scanLazy`(121-168)用 - `locateBlockGE(start)` 起、`min(last, locateBlockLE(end)+1)` 止(126-131), - 多扫一块以规避"块尾 key 越界"漏读,条目级 `key>=start && key<=end` 过滤(158)。 - - `locateBlock`(294-313)/`locateBlockGE`(315-323)/`locateBlockLE`(325-333):三套二分,第三个函数 - 在"endKey 小于全部索引键"时返回 **0 而非 -1**(332),靠调用方 `Math.max/clip` 兜住 —— 脆弱但当前正确 - (【已实测确认】13 组范围/边界探针全绿)。 - -### 1.3 MergeIterator 语义 - -- 数据源抽象 `EntrySource{next,reset}`(12-17);`ArrayEntrySource`(20-36,compaction 用,全量物化) - 与 `GeneratorEntrySource`(43-58,v0.7.4,流式扫描用)。 -- 最小堆 `MinHeap`(71-122)仅按 `key` 比较(100、113-114),**sourceIndex 不参与堆序**。 -- `next()`(148-168):弹出最小项后立刻从**同一 source** 补种(156),再把堆顶所有 `key === 当前 key` - 的重复项一次性抽干(159-165),其中 `sourceIndex` 最小者胜出(162-164)。因此: - - **去重只在"同一次 `next()` 调用内"发生**;语义是"同一 key 只吐一条,取最新源"。 - - 顺序保证依赖**源加入顺序 = 新鲜度降序**:`LSM.rangeScanLazy`(447-467)先 MemTable、再 - `frozenMemtables` 由新到旧、再 L0→L6,且每层内按 id 降序(`init` 128 行、flush `unshift` 253 行)。 -- **墓碑处理**:MergeIterator 自己**不认识**墓碑(它只是普通 value)。过滤在两处: - - 点查:`unwrapTombstone`(`lsm.ts:727-731`)→ `get` 命中墓碑返回 `null`; - - 扫描:`rangeScanLazy` 内 `if (!v.__tombstone) callback(...)`(472-476)。 - - **compaction 不丢墓碑**(`compactLevelAsync` 把 `drain()` 的全部条目写进新文件,537-552), - 因此删除不会被旧数据"复活",代价是墓碑与历史版本**永久留存**(见 3.4)。 -- 快照过滤:MergeIterator **无任何 MVCC 概念**;快照隔离完全在 `AriaEngine.find/getAllRows` 的 - `mergeTxnSnapshot`(index.ts:1620-1638)里做——即先取磁盘视图,再把 `txnSnapshot` 的写/删标记覆盖上去。 - -### 1.4 端到端读取路径 - -| 路径 | 链路 | -|---|---| -| **点查(PK 等值)** | `find`→`tryIndexLookup`(1993-2006) → `lsm.prefetchKeys([key])`(内含 `drainChain`)→ `lsm.get` → MemTable→frozen→L0..L6,命中 `unwrapTombstone` → `mergeTxnSnapshot` → `matchWhere` → `orderBy` → `slice` | -| **二级索引等值** | `tryIndexLookup`(2041-2053) → `indexScanToRows`(2099-2120):`idxLsm.prefetchRange`→`idxLsm.rangeScan`→收集 pk→`lsm.prefetchKeys(pks)`→逐个 `lsm.get` | -| **二级索引范围** | `tryIndexLookup`(2085-2092):**放弃下推**,`indexScanToRows(idxLsm,'','\uffff')` 全索引扫描 + 逐行 `lsm.get` + 行级 `matchWhere` | -| **全表** | `getAllRows`/`find`→`lsm.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/delete` 时 `levels[0].length >= 8` → `enqueueCompact(0)`(145-147、156-158); - flush 完成时 `levels[0].length >= 4` → `scheduleCompact(0)`(258-260); - 完成后在 `finally` 里级联检查本层与下一层(272-282)。`compactLevelAsync(level, minFiles=4)` - (498-577):`splice` 整层 → 逐个"缓存优先、`store.load` 兜底" → `verifyChecksum` → `scanAll` 物化 → - `MergeIterator.drain()` → 建成新 SSTable → `save`+`saveMeta` → `levels[level+1].unshift` → - **无条件删除整层旧文件**(572-576)。 -- **后台链序列化**:所有 flush/compaction 挂在单条 Promise 链 `flushChain` 上 - (`enqueueOnChain` 194-204)。`drainChain`(211-217)循环 `await` 直到链引用不再变化 - (因为任务完成时可能级联追加新任务)。`prefetchRange/prefetchPrefixRanges` 开头都 `await drainChain()` - (315、334、372)—— 这是"读之前先把后台任务排空"的**唯一一致性手段**,也是 3.1 性能悬崖的直接来源。 -- **背压**:`compactLevelAsync` 无体积/耗时上限;`LSM.put` 只在 L0≥8 时排队一次 compaction, - 没有限流、没有拒绝写入、没有反压信号给调用方;`cacheLimitBytes` 默认 = `bufferPoolPages × pageSize` - = 256×4096 = 1MB(index.ts:168)。 - -### 1.6 MVCC 分层(实际形态) - -- `MVCCManager`(`transaction/mvcc.ts`)维护 `versionStore` / `activeTxns` / `globalCommitLsn` / - `txnWriteKeys`。`writeVersion` 建链(115-135)、`deleteVersion` 写 `__mvcc_tombstone`(140-142)。 -- **关键事实:AriaEngine 的事务隔离不靠 MVCC,靠 `txnSnapshot`(一个 `Map`)**: - - 事务内写只进快照 + `mvcc.writeVersion`(index.ts:637-644、820-830、1011-1017); - - `find/getAllRows/count/update/delete` 都先拿磁盘视图再 `mergeTxnSnapshot` 覆盖(1620-1638); - - `commitTransaction`(1467-1495)在 WAL COMMIT 落盘后,把**整个快照**逐键 `lsm.put/delete`; - - `commitTransaction` 里 `mvcc.commitTransaction` 会**删除本事务全部版本**(mvcc.ts:60-80)—— - v0.6.3 的注释明确写了"快照读取已移除,版本链仅作 undo 记录"。 -- 因此 **`mvcc.gc(maxVersionsPerKey)`(168-176)几乎无事可做**(链在每次提交时已被清空), - `tryGC`(index.ts:2127-2133)每 10 次写操作调用它、`vacuum` 调 `gc(10)` 都只是空转; - `vacuum()` 返回的 `gcVersions` 实际是 `getGlobalLSN()`(提交序号)而非回收版本数(2258-2260)——**误导性指标**。 -- **二级索引不参与 MVCC/快照**:`updateSecondaryIndexes`(1926-1957)在事务内**立即**改写索引 LSM, - 回滚/savepoint 回滚后靠 `reindexTable` 全量重建(1529-1533、1573-1578)。这带来一个结构性副作用: - **事务内索引领先于主表**(见 2.6)。 - -### 1.7 Flush / Checkpoint 路径 - -``` -insert/update/delete - └─ wal.appendBatch(full=逐批落盘 / 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(活跃事务时 skip,index.ts:252-259) -``` -`LSM.flush()`(580-620):先查 `lastBackgroundError` → `drainChain` → 把 `immutableMemtable` 入链 → -若活跃 memtable 非空则 `freezeMemtable()` 并把新的 immutable 入链 → 再 `drainChain` → 清理空 frozen。 -`freezeMemtable`(182-191)把当前 memtable 冻结、塞进 `frozenMemtables`、用 `memtableSizeThreshold` -新建一张(v0.4.2 修的就是"新表阈值衰减")。 - -### 1.8 二级索引 LSM 与主 LSM 的关系 - -- 每个 `index/unique` 列一个独立 `LSM` 实例(index.ts:194-204 重启恢复、452-461 建表、 - 1353-1362 createIndex),**独立 SSTableStore 命名空间**(`createSSTableStore`,1719-1814: - 文件前缀 `sst_idx___`、meta key `__aria_lsm_meta_`、**独立 id 序列**)。 -- 索引条目格式:key = `${String(value)}:${pk}`,value = `{pk}`(1382、1953、2237)。 -- 查询:`tryIndexLookup`(1960-2096)递归展开 `$and`(1974-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-426`(get 的层级短路)、`498-577`(compaction)、 - `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-447`(`delete`)、`497-510`(`estimateEntrySize`)、 - `lsm.ts:159-163`(生产路径改用墓碑,故当前只被测试与潜在调用方触发)。 -- **现象**:`delete` 先 `find` 取旧值并 `_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-753`(`loadSSTableReader` 未命中即 `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 命中区间"的文件 - 做 `toLoad`(320-322、341-344);**单文件大于 `cacheLimitBytes` 时缓存会在下一次 `trimCache` 时被清空** - (793-798),此时"目标文件本身被更早装入的大文件挤出缓存"就变成可能。尽管每条读路径都紧跟一次 - prefetch 使其难以稳定触发,但代码本身没有"未命中就回源"的兜底,属于**同类问题只修了一半** - (compaction 修了、get 没修)。 -- **严重级别**:P1(理论上返回缺失行)。**当前实践等级 P2**(我 5 组探针未能稳定构造)。 -- **复现思路**:【已实测确认】单文件 > cacheLimit 时,`trimCache` 后 `sstableCache` 被清空 - (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 回源(或让 `get` 走 `getWithFallback`), - 至少把"缓存未命中"与"确实不存在"在类型上分开(`null` vs `undefined`/`MISS`)。 - -### P1-2 `rangeScanLazy` 的 `callback` 返回值契约与 `rangeScan` 包装自相矛盾(早停未真正生效) -- **文件:行号**:`src/engine/aria/index/lsm.ts:440-479`(`callback` 声明返回 `boolean | void`, - 473-475 `if (cont === false) return;`)、`428-432`(`rangeScan` 的包装回调 `{ result.push(...) }` - **返回 undefined**,天然无法早停)、`index.ts:1220-1225`(`findStream` 依赖 `false` 早停)。 -- **现象**:`findStream` 的 limit 早停是"**多读一条再返回**":`for...of` 在消费完 yield 值之后才会 - 执行 `callback()` 并据此决定是否中断,而生成器已经推进到了下一条(`merge_iterator.ts:50-53` + - `memtable.ts:133-143` 的 `stack.pop()` 之后才 `yield`)。即"未消费块/子树不再解析"的 v0.7.4 宣称 - **方向正确但边界多算 1 条**;更实际的问题是 `rangeScan` 包装层使早停能力对内部调用者不可见。 -- **根因**:`EntrySource.next()` 是"推送一条"的游标语义,缺少 `peek()`/`close()`; - 生成器在 `yield` 后无法收到"下游不要了"的信号(`GeneratorEntrySource.reset()` 是空实现,55-57)。 -- **严重级别**:P2(性能/接口语义),P3 级别的一行偏差。 -- **复现思路**:【已实测确认】`limit=5` 的流式扫描中,生成器实际产出 6 条(produced=6)。 - **验证方法**:在 `findStream` 里统计 `scanLazy` 的 `next()` 调用次数,或断言 - "produced === limit"。 - -### P1-3 `SSTableReader` 有三处重复且各自独立的解析循环(解析语义漂移风险) -- **文件:行号**:`src/engine/aria/index/sstable.ts:80-100`(`get` 的手写循环)、`145-166`(`scanLazy`)、 - `182-201`(`scanAll`);三处都各自重算 `lenFieldSize()`、各自 `new TextDecoder()`、各自做越界 `break`。 -- **现象**:三份实现的越界策略并不一致:`get` 在长度字段越界时 **`break` 整个块并返回 null**, - `scanLazy/scanAll` 同样 `break` 但继续下一块。同一份损坏文件在"点查"与"扫描"下表现不同, - 且 v0.6.1 的"多扫一块"修复只加在 `scanLazy`(131)——`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:647`(`updateSecondaryIndexes` 事务内立即写索引)、`684`(`find` 先索引后合并)、 - `1620-1638`(`mergeTxnSnapshot`:`rows.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-283`(`scheduleCompact`:`if (this.compacting) return;`)、 - `286-302`(`enqueueCompact` 同判断);`finally` 里的 `this.compacting = false`(273、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-590`(`if (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-261`(`flushImmutableAsync` 在 `save`/`saveMeta` 抛错时, - `frozenMemtables` 的清理(255)与 `levels[0].unshift`(253)都不会执行)、`619` - (`flush()` 末尾 `filter(f => f.getEntryCount() > 0)` 保留非空冻结表但**不会重新入链**)。 -- **现象**:冻结表留在 `frozenMemtables` 里可读(所以在线查询看似正常),但它只被 `flush()` 里 - "当前 immutable"那一次入链机会覆盖;一旦那一次失败,后续 `flush()` 受 P1-6 阻断, - 这些数据**只存在于内存**。此时进程崩溃 → 数据丢失,而调用方早已收到 `insert()` 成功。 -- **根因**:入链是"事件驱动一次",没有"待落盘队列 + 重试"的持久化意图记录; - `lastBackgroundError` 只报告不重试;`frozenMemtables` 语义在"读可见集合"与"待落盘队列"之间摇摆。 -- **严重级别**:**P0(数据丢失)**。 -- **复现思路**:【已读代码确认】+ 可测:注入首次 `save` 失败的 store,`insert` → `flush`(吞错)→ - 断言 `frozenMemtables.length > 0` 且 `levels[0].length === 0`;再调用 `flush()` 观察其抛错且仍未落盘。 - **验证方法**:在第二次 `flush()` 后断言 `sstableCount > 0` —— 当前失败。 - -### P2-1 `compactLevel(level, minFiles=2)` 对单文件层是静默 no-op,`vacuum()` 仍宣称压缩了 6 层 -- **文件:行号**:`lsm.ts:486-500`(`if (this.levels[level].length < minFiles) return;`)、 - `index.ts:2247-2261`(循环 0..5,`if levelCounts[level] >= 2` 才调用,最后 `return { compiledLevels: 6 }`)。 -- **现象**:`levelCounts[level] === 1` 的层永远不会被压缩(尤其 L6,`compactLevelAsync` 还要求 - `level < MAX_LSM_LEVELS-1`,499),而 `vacuum()` **硬编码返回 `compactedLevels: 6`**, - 与 `aria-maintenance.test.ts:70-74` 只断言"属性存在"的弱断言互相掩盖。 -- **严重级别**:P2(空间回收失败 + 指标失真)。 -- **复现思路**:【已实测确认】探针中 `compactLevel(1)` 在 L1 只有 1 个文件时直接返回,层结构不变。 - **验证方法**:断言 `vacuum()` 的 `compactedLevels` 等于实际执行合并的层数(统计 compaction 前后 - SSTable 数量差)。 - -### P2-2 compaction 不丢弃被覆盖/被墓碑遮蔽的历史版本(空间与写放大无界) -- **文件:行号**:`lsm.ts:537-552`(`drain()` 全量写回新文件,无"同一 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 崩溃窗口内 `saveMeta` 与 `deleteMeta`/`delete` 之间无 WAL/事务保护 -- **文件:行号**:`lsm.ts:565-576`(顺序:`cacheSSTable` → `save` → `saveMeta` → `unshift` → - `deleteMeta`+`delete` 旧文件);`init` 119-129 仅按 `listMeta()` 重建。 -- **现象**:崩溃落在 `saveMeta(new)` 之后、`deleteMeta(old)` 之前时,磁盘上会**同时**存在新文件与旧文件 - 的两条 meta;重开时 `init()` 全部加载,层内出现**重叠文件**。我逐一验证了每个时点的层间新鲜度, - 结论是**当前不会返回错误值**(新文件总是被删文件的超集且更新,同名 key 的新值一定在新文件里), - 所以这条不是错误结果 bug;但它① 让层内重叠成为常态,② 泄漏已 `delete` 掉的文件引用会让 - `validateSSTable` 在下次打开时**删除对应 meta**(685-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:502`(`splice` 清空该层)→ `506-535`(逐个 `await store.load` + `scanAll`)→ - `569`(`unshift` 新 meta)。 -- **现象**:从 `splice` 到 `unshift` 之间(含 N 次磁盘读 + 全量解析 + 构建 + `save` + `saveMeta`), - 该层的所有数据**从 `levels` 中消失**。上层数据仍在,所以最终结果通常完整 —— 但若某个 key - **只**存在于该层(例如它已被从上层 compaction 下沉),这段时间内 `get` 会返回"不存在"。 - 另外 `compactLevel` 是 public(`index.ts:2252-2254` 直接调用), - 期间任何并发 `insert`(不 await 链)都可能观察到半完成状态。 -- **严重级别**:P2(窗口期内的错误结果;窗口长度正比于层体积)。 -- **复现思路**:**验证方法**——在被 splice 的层里放一个"仅此一份"的 key,在 compaction 的 - `await store.load` 处注入延迟(monkey-patch),并发 `find` 该 key,应观察到空结果。 - -### P2-5 `enqueueCompact` 未走 `enqueueOnChain`,其 Promise 无终结处理器 -- **文件:行号**:`lsm.ts:286-302`(`this.flushChain = this.flushChain.then(...).catch(...)`) - 对比 `enqueueOnChain`(194-204,走 `.catch` 且链保持 resolved)。 -- **现象**:`enqueueCompact` 里的 `.catch` 返回 `undefined`,链会恢复;但它的 `.then` 与 `catch` - 之间若 `finally` 内(元数据操作)再抛错,错误会逃逸到无人 `await` 的链尾。由于 `put/delete` - 是**同步**调用 `enqueueCompact`(145-147、156-158),调用方拿不到这个 Promise, - Node 环境会打印 `unhandledRejection`,浏览器控制台出现噪声,错误处理语义与另一条路径不一致。 -- **严重级别**:P3(当前仅噪声;若策略变为 `unhandledRejection` 致命则升级)。 -- **复现思路**:**验证方法**——让 `enqueueCompact` 的任务抛错并监听 `process.on('unhandledRejection')`。 - -### P2-6 `checkpoint` 只对 `currentTxnId` 做保护,事务活跃期间 WAL 缓冲可能被截断的相邻风险 -- **文件:行号**:`index.ts:249-276`(`checkpoint`/`flush` 回调:`if (this.currentTxnId) return;`)、 - `wal/log.ts` 的 `flush`/`checkpoint` 语义。 -- **现象**:保护只覆盖 Aria 自己的"单活跃事务"字段。事务提交路径是 - `wal.append(COMMIT)` → `wal.flush()` → 合并快照(`index.ts:1473-1489`), - 其间 `currentTxnId` 仍非空,保护有效 —— 但 `commitTransaction` 把 `currentTxnId = null` - (1493)放在**快照合并之后**,即"快照合并完成 → 置空"之间存在一个窗口, - 此时若并发的 `insert()` 触发 `checkpointManager.tick()`(`index.ts:664`)→ `checkpoint()` - 会立刻执行 `lsm.flush()` + `wal.checkpoint()`,把刚刚 COMMIT 的 WAL 截断。 - 截断本身安全(数据已在 LSM),但**它同时会 drain 掉正在排队的后台 compaction** —— 属于时序耦合。 -- **严重级别**:P3(当前不丢数据,但依赖"合并先于置空"的隐含顺序)。 -- **复现思路**:**验证方法**——在 `commitTransaction` 的 `lsm.put` 循环中插入 `await checkpoint.tick()` - 的并发调用,断言不会出现"WAL 已截断而 LSM 未落盘"的组合;建议把置空提前到合并**之前**并用 - 独立的 `committing` 标志保护。 - -### P2-7 内存预算只统计主 LSM 的估算,且 `cacheLimitBytes` 按索引 LSM 数量线性放大 -- **文件:行号**:`index.ts:2143-2151`(`checkMemoryBudget` 只查 `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 1MB;`maxMemoryMB`(默认 64)**完全看不到**这部分。 - 再叠加页面化后同一份 SSTable 同时存在于 `BufferPool` 页缓存与 LSM 整文件缓存, - 且各索引 `MemTable` 在 `walSyncMode:'none'/'batch'` 下**不落盘也不设上限**。 -- **严重级别**:P2(OOM/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-68`(`return this.bits`,未拷贝)、 - `store/page_sstable_store.ts:81`(`this.pageIds.delete(id)` 在**读**路径上执行)。 -- **现象**:前者使调用方一旦持有序列化结果后再 `insert` 就会修改"已写出"的字节; - 当前 `build()` 的调用顺序(108 行取 bloom → 113 行分配 buf → 119-130 写出,**不再 insert**)恰好安全。 - 后者使"同一 SSTable 先 `load` 再 `saveMeta`"会丢掉 `pageIds`(`index.ts:1800` 取不到 → - meta 无 pageIds → 页面变孤儿)。当前 `save → saveMeta` 紧邻(`lsm.ts:249-250`)故安全, - 但 `compactLevelAsync` 的 `load`(511)与本 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 key,**FN 全部为 0**;真实 SSTable 2000 key 全覆盖读取 0 缺失 | -| `Math.abs((h1+i*h2) % bits)` 是哈希 bug(负余数取绝对值) | 排除(同上) | 同上;但 `fromData` 若传入与写入端**不同**的 `numHashes` 会 100% 产生 FN(实测 10 键 9 个 FN)—— 当前文件路径读写都用 footer 里的同一值(`sstable_builder.ts:138` ↔ `sstable.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 每次写批次都 `drainChain`,compaction 期间写路径整体停摆 -`checkpointManager.tick()`(`index.ts:664`)→ `CheckpointManager.checkpoint()`(`checkpoint.ts:62-69`) -→ `lsm.flush()`(`lsm.ts:580`)→ `await this.drainChain()`(592)。 -`drainChain` 会等到**链上全部后台任务**(含正在跑的整层 compaction:N 次 `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 拉进缓存 -`getAllRows`(`index.ts:1601-1614`)与 `findStream`(1219)都先 -`prefetchRange(prefix, prefix+'\uffff')` → 遍历**所有层所有 meta**(`lsm.ts:318-327`)→ -逐个 `preloadSSTable`(整文件字节读入 + `verifyChecksum` 全文件 CRC + 构造 Reader)。 -对 100k 行表意味着把整个表读进 1MB 上限的缓存(随后被 `trimCache` 清掉), -每次范围查询重复一次全量 I/O。**没有按需/分块读取**。 - -### 3.3 二级索引范围查询退化为"全索引扫描 + 逐行主表点查" -`tryIndexLookup`(`index.ts:2085-2092`):`$gt/$gte/$lt/$lte` 走 -`indexScanToRows(idxLsm, '', '\uffff')` —— **扫全部索引条目**(`prefetchRange('','\uffff')` 把整个 -索引拉进缓存),然后对每个 pk 做一次 `lsm.get`(`index.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× 需要) -`splitIntoBlocks`(`sstable_builder.ts:171-178`)在"加入当前条目后 ≥ 上限"才封块, -且封块时**把最后一条留到下一块**(175-176),于是首块只含 1 条、其余块 ≈ 上限+1 条。 -【已实测确认】用 ~90 字节的行、4096 上限构建时,单块实际可达 ~8.2KB, -块数因此约为理想值的 **1/2**:`IndexEntry` 数组更大(内存)、索引块更大、 -`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` + 逐条 `TextEncoder`**(`wal/log.ts:90-109`), - 大批量插入时 `appendBatch` 会把整批序列化两遍(编码 + 合并)。 -- **`analyzeTable` 的 `avgRowSize` 用 `JSON.stringify(row).length` 逐行再序列化**(`index.ts:2177`), - 且 `columnStats` 用 `new Set(rows.map(String))` 再扫一遍 —— 大表上 ANALYZE 是 3~4 遍全表 CPU。 -- **`count()` 恒定物化全表**(`index.ts:1229-1236`),无 where 时依然走 `getAllRows`。 -- **`alterTable`、`applyDropTableRecovery`、`dropTable` 都是"扫描全表 → 逐行 put/delete"** - (`index.ts:1314-1338`、1874-1878、483-487):DROP TABLE 10 万行 = 10 万次 `lsm.put`/`delete` - (每次都带 `estimateEntrySize`)+ 10 万条 WAL。 -- **`update` 的批内唯一互查/计划阶段对每行调用 `checkUniqueSync`**(`index.ts:1900-1923`), - 其中 `idxLsm.rangeScan` 每次都会走完整 `prefetchRange`+`rangeScanLazy`(虽然是单前缀)。 -- **`trimAllCaches` 在 `find/update/delete` 末尾各遍历一次全部二级索引**(`index.ts:2136-2141`), - 与 `prefetch*` 里的 `trimCache` 叠加导致"装入-驱逐-再装入"抖动。 - ---- - -## 4. 系统性观察 - -### 4.1 反复出 bug 的结构性原因 - -1. **键编码散落在 10+ 处,没有单一编码模块。** 主键 `t:pk`(`index.ts:571,1006,1223`…)、 - 二级索引 `value:pk`(1382、1953、2237)、索引前缀探针 `v:` ~ `v:\uffff`(605-606、751-758、2063)、 - 删除 `v:pk`(1945)、唯一性检查(1911-1912)、表范围 `prefix` ~ `prefix+\uffff`(1315、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`(一次性报告、语义含混)、 - `flushChain`(Promise 链自我修补)、`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 + prefetch`(`index.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`,内部对每层调用 - `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`(解决 P1-5), - 并发 compaction 的层集合由队列调度器保证互斥。 - -配套(低成本、高收益):抽出唯一的 `keyCodec` 模块(`encodePk/encodeIdx/encodeIdxValueRange/tableRange`), -把 `sstable.ts` 的三份解析循环合并为一份"块迭代器"(`get`/`scanLazy`/`scanAll` 都基于它), -并在 `SSTableReader` 构造时加 debug 断言(索引键单调递增、块偏移在文件内、`minKey<=maxKey`)。 - ---- - -### 附:本次审计中已删除的探针清单(工作区无残留) - -`tests/engine/` 下共 16 个临时测试文件,已全部 `rm`(`git 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-*` 属其它会话产物,非本次创建。 diff --git a/AUDIT-query-layer-v0.7.4.md b/AUDIT-query-layer-v0.7.4.md deleted file mode 100644 index 2e97bcd..0000000 --- a/AUDIT-query-layer-v0.7.4.md +++ /dev/null @@ -1,346 +0,0 @@ -# MetonaSqlark v0.7.4 — 查询层(AST / Builder / Compiler / Executor / Where-Matcher)深度审计报告 - -审计范围:`src/query/{ast,builder,compiler,executor,where-matcher,index}.ts`(逐行读完)、 -`tests/query/query-system.test.ts`、`tests/{join,subquery,groupby}.test.ts`、 -`tests/v07{0,1,2,3,4}-*.test.ts`、`tests/{foreign-key-cascade,transaction-rollback}.test.ts`、 -README(核心特性 / API 速览 / 已知限制)、CHANGELOG 1–300 行。 - -方法:先逐行读码定位可疑路径,再写临时 jest 探针(`tests/zz-audit-probe*.test.ts`,已删除)实跑验证。 -下文每条标注 **[已验证]**(有实跑输出)或 **[读码确认]**(未实跑,但根因路径可指到具体行)。 - ---- - -## 1. 架构概览 - -### 1.1 SQL 字符串 → 解析 → 编译 → 引擎 - -| 阶段 | 位置 | 实际做的事 | -|---|---|---| -| 入口 | `core.ts:207-242` | `bindParameters`(`sql/params.ts`)→ `parseAll` → 逐条 `executor.execute(stmt)`;**多语句顺序执行、返回最后一条结果、无事务包裹** | -| 解析 | `sql/parser.ts`(递归下降,1331 行) | 产出 `query/ast.ts` 的 AST。子查询不建关系算子,而是打成 `$subquery` / `$col` / `$exists` 标记(parser.ts:845/920/961/977) | -| 编译 | `query/compiler.ts:21-57` | **只做平铺映射**:`{table, columns, where, orderBy, limit, offset}`。没有算子树、没有投影/表达式编译、没有代价模型;JOIN/GROUP BY/DISTINCT/HAVING/聚合全部被丢弃。只有 SELECT/DELETE/UPDATE 可编译,其余抛 `COMPILE_ERROR` | -| 执行 | `query/executor.ts` | 关系语义全部在 JS 里做:JOIN、GROUP BY、DISTINCT、HAVING、关联子查询、UNION、投影 | -| Builder 旁路 | `builder.ts:97-113` | `SelectQueryBuilder.execute()`:**有 JOIN 才走 Executor,无 JOIN 直接 `engine.find`**(builder.ts:105);`UpdateQueryBuilder`/`DeleteQueryBuilder` 永远直通引擎(`builder.ts:160` / `builder.ts:199`)→ 同一语义两套路径 | - -`compileStatement` 的产物 `QueryPlan` 只描述单表扫描;`stmt.joins/groupBy/distinct/having` 从不进 plan。因此"编译"在本项目里约等于"把 WHERE 交给引擎",不存在下推优化器(唯一的优化是 JOIN 查询里主表别名等值条件的抽取下推,`executor.ts:413-417 / 457-471`)。 - -### 1.2 Executor 如何逐子句处理(`executeSelect`,`executor.ts:282-400`) - -决策树(282-356): - -1. `fromSubquery`(派生表)→ 先跑子查询取行;有 JOIN 则给行加 `<别名>.` 前缀后进入 JOIN 路径(297-307)。 -2. 无 FROM 且无 JOIN(`SELECT 1`)→ 单行空上下文(308-310)。 -3. 有 JOIN → `executeJoinSelect`(311-313)。 -4. 普通单表(314-355):先把 WHERE / ORDER BY / GROUP BY / SELECT 列里的主表别名前缀剥掉(316-336);若 WHERE 命中 `$col`,走 `filterCorrelated` 逐行求值(339-345),否则 `resolveSubqueries` 后交给 `engine.find`(346-355)。 - - **`needsRawRows`(有 CASE)/`hasSelectAlias`(有 `AS`)时强制 `plan.columns = ['*']`,即放弃引擎投影(352)**,投影改在 executor 端做。 - - **`orderByAlias` 为真时清掉 plan 的 orderBy/limit/offset(353)**,留到投影后重排。 - -后处理顺序(358-398,**注意与 SQL 标准顺序不一致**): - -``` -聚合(无 GROUP BY) 359-361 → GROUP BY 363 → DISTINCT 364 → HAVING 365-378 -→ ORDER BY 379 → 投影 383-386 → ORDER BY(别名, 第二次) 388-390 -→ slice(offset, offset+limit) 391-393 → maxRowsPerQuery 截断 396-398 -``` - -关键偏差:**DISTINCT 在投影之前**(364 vs 383)、**LIMIT/OFFSET 同时下推给引擎又在此再切一次**、**HAVING 是对投影后的组行做 `matchWhere`**。 - -### 1.3 子查询 / 关联引用 - -- `resolveSubqueries`(1336-1385)递归遍历 WHERE:`$exists` 执行子查询置换为 boolean(1345-1356);`$and/$or/$not` 递归;字段级交给 `resolveOperatorSubqueries`(1390-1439)。 -- `resolveOperatorSubqueries`:`$in/$nin` 取子查询**第一列**的值列表(1419-1423),其它运算符取**第一行第一列**(1425-1431);空结果标量 → `null`。 -- `$col` 绑定:`bindColumnRefs`(1291-1309)从 `contextRow` 取列值。 -- 关联上下文只有一条通路:`hasCorrelatedRefs`(1159-1181,纯语法嗅探)→ `filterCorrelated`(1226-1238)逐行 `bindWhereRefs` + 执行 `$exists`。**`resolveOperatorSubqueries` 调 `executeSelect(subStmt)` 时(1417)不传外层行**,所以 `IN (SELECT … WHERE 内层列 = 外层列)` / 标量关联子查询拿不到外层上下文(见缺陷 P1-4)。 -- 代价:关联路径 = 外层每行一次子查询执行,无缓存(1228-1236)。 - -### 1.4 JOIN 实现 - -- 主表:整表(或仅主表别名等值条件下推)物化并加前缀(408-418);非 JOIN 分支不做前缀。 -- 每个 JOIN:`tryHashJoin`(480-566)→ 条件为"纯等值列对"且右表**至少一列有索引/主键/唯一**时,收集左表探测列去重值 → 一次 `$in` 查询右表 → 复合键 `Map`(键为 `String(v ?? '\0')` 拼接,543/553)→ 左行探测;LEFT 无命中补右表全 null 行(560-562)。 -- 否则 `joinRows` 嵌套循环(569-612):`matchWhere(merged, on, {$col:true})`;LEFT 用**右表第一行的键集**补 null(593);RIGHT 再对右表全扫一遍找未匹配行(598-610),并把它们**追加在末尾**。 -- 不支持哈希:CROSS / RIGHT(486),以及 ON 含 `$or`/`$not`/非等值(496/506)→ **右表无条件整表物化**(429)。 - -### 1.5 投影 / 排序 / 分组键编码 - -- 行 = 扁平 `Record`;JOIN 行键为 `alias.col`(447-451),普通行为裸列名。 -- 投影 `projectRow`(1010-1063):逐行用**正则重新解析** `*`、`col AS alias`、字符串常量、`CASE…END`;`projectColumns`(where-matcher.ts:214-228)先精确匹配键,否则 `key.endsWith('.'+col)` 兜底。 -- 分组/去重键 `encodeGroupKey`(31-39):类型前缀(`n/u/s/d/b/o/x`),以 `\x1f` 连接(621 / 689 / 170 / 178)。 -- 排序 `applyOrderBy`(where-matcher.ts:183-208):逐键比较;显式 `NULLS FIRST/LAST` 时 NULL 位置固定,未指定时 NULL 视为最大值(升序在末尾、降序在开头 = PostgreSQL 语义);类型不同回退 `localeCompare(String(a), String(b))`。 -- **哈希连接、`$in` 探测、`COUNT(DISTINCT)` 不复用 `encodeGroupKey`**(543/553 用 `String(v ?? '\0')`,executor.ts:668 用 `String(v)`)。 - -### 1.6 四引擎的行为分叉点 - -Executor 本身引擎无关,分叉来自被调用的引擎方法: - -| 分叉点 | Memory / Disk(KVStore) / Hybrid | Aria | -|---|---|---| -| 行顺序(无 ORDER BY) | 插入顺序(Map 迭代) | 主键升序(LSM key 顺序)**[已验证]** | -| 主键值类型 | 原类型(number 保持 number) | 全表/范围路径用 `key.slice()` → **字符串**;`$eq` 快速路径用查询字面量原类型(aria/index.ts:1998/2005 vs 2028/1223)**[已验证]** | -| 索引等值 | `Map.get(原始值)`,无条目直接 `return []`(memory.ts:760-768) | `String(value)` 索引键 + 前缀 rangeScan(aria/index.ts:2045/2062) | -| 流式 | `findStream` 同源实现(memory.ts:213-233) | 真惰性 LSM 扫描(aria/index.ts:1172-1227) | -| LIMIT/OFFSET | 引擎内 `slice`(memory.ts:203-205) | 引擎内 `slice`(aria/index.ts:696-699)—— 与 executor 重复 | -| 未解析 `$col/$subquery` 防护 | 仅 update/delete 有(memory.ts:245) | 仅 update/delete 有(aria/index.ts:721) | - ---- - -## 2. 缺陷清单 - -### P0 — 崩溃 - -**P0-1 `MIN`/`MAX` 大分组栈溢出(崩溃)** **[已验证]** -- 文件行号:`src/query/executor.ts:677-678` -- 现象:`SELECT MAX(v) FROM big`(20 万行同组)抛 `RangeError: Maximum call stack size exceeded`;`SUM`/`AVG` 正常(reduce 实现)。 -- 根因:`Math.min(...distinctNums)` / `Math.max(...distinctNums)` 把整组值展开成实参,超过 JS 引擎实参上限。 -- 复现:`CREATE TABLE big (id NUMBER PRIMARY KEY, v NUMBER)`,`insertMany` 20 万行,`SELECT MAX(v) FROM big`。 -- 备注:函数签名 `computeAggregate(...): number`(655)也说明聚合返回类型被硬编码为 number(见 P1-8)。 - ---- - -### P1 — 错误结果 / 静默数据丢失(全部有实跑复现) - -**P1-1 LIMIT/OFFSET 被应用两次(OFFSET 结果截断)** **[已验证]** -- 行号:`executor.ts:391-393`(executor 再切一次);`compiler.ts:40-41`(limit/offset 进 plan);`engine/memory.ts:203-205`、`engine/aria/index.ts:696-699`(引擎已切过) -- 现象:id=1..4 的表,`SELECT id FROM t ORDER BY id LIMIT 2 OFFSET 1` → `[{id:3}]`(应为 2,3);`LIMIT 10 OFFSET 3` → `[]`(应为 4,5);`OFFSET 2` → 只剩 1 行。 -- 根因:plan 带着 offset/limit 交给 `engine.find`(354),引擎 `slice(offset, offset+limit)` 之后 executor 又 `rows.slice(offset, offset+limit)`(391-393)。只有 `orderByUsesSelectAlias` 为真的查询在 353 行清掉了 plan 的 limit/offset 因而"碰巧正确"。 -- 反证(同 executor 内自相矛盾):JOIN 路径(405-432 的 `engine.find` 不传 limit)与派生表路径结果正确;`SELECT a.id FROM a JOIN b ON a.k=b.k ORDER BY a.id LIMIT 2 OFFSET 1` → 2,3 正确。 -- 复现:`SELECT id FROM t ORDER BY id LIMIT 2 OFFSET 1`。 - -**P1-2 LIMIT 被下推到 DISTINCT / GROUP BY / 聚合之下** **[已验证]** -- 行号:`executor.ts:352`(`plan.columns=['*']` 但保留 limit)+ `364`(DISTINCT 在切片之前)+ `391-393` -- 现象:`SELECT DISTINCT dept FROM e LIMIT 2` → 1 行(Eng);`SELECT dept, COUNT(*) AS c FROM e GROUP BY dept LIMIT 2` → 1 组(先被引擎截到 2 行原始行再分组)。 -- 根因:LIMIT 属于最终结果集,却被编译进单表扫描计划(compiler.ts:41),在分组/去重之前就截断了输入行。 -- 复现:5 行 3 个 dept 的表执行上述两条 SQL。 - -**P1-3 `WHERE 表别名.a = 表别名.b`(列对列)在普通单表查询中恒返回 0 行** **[已验证]** -- 行号:`executor.ts:339-345`(关联路径)→ `344` 把 `stripCorrelatedExists(stmt.where)` 交给引擎;`executor.ts:1205-1223`(该函数只剥 `$exists` 与 CASE 键,**保留 `$col` 字段条件**);`where-matcher.ts:139-150`(`$col` 只有在 `options.$col` 为真时才绑定) -- 现象:`SELECT * FROM t1 WHERE t1.x = t1.y`((1,5,5),(2,10,3))→ `[]`(应为 id=1)。`WHERE x = y` 直接 PARSE_ERROR(parser 只在 `ident.ident` 形式才识别列引用,parser.ts:956-963)。 -- 根因:引擎层先执行了带未绑定 `$col` 的条件(`value === {$col:'y'}` 恒 false),把行全部过滤光,`filterCorrelated` 拿到的是空数组。 -- 附带:`tests/v073-fixes.test.ts:267-278` 是这条语义的"回归护栏",但它只比较 `query()` 与 `queryStream()` 的行数(都是 0)→ **该测试是空断言,掩盖了错误**。 -- 复现:见上(注意必须带表别名前缀,否则解析失败)。 - -**P1-4 关联引用出现在 `IN (SELECT …)` / 标量子查询中被静默丢弃(恒 0 行)** **[已验证]** -- 行号:`executor.ts:1415-1423`(`$in` 分支)与 `1425-1431`(标量分支)都调 `this.executeSelect(subStmt)`(1417),**不传 contextRow**;对比 `$exists` 分支(1351-1353)显式 `bindWhereRefs` 外层行。 -- 现象:`SELECT id FROM u WHERE id IN (SELECT user_id FROM o WHERE o.user_id = u.id)` → `[]`(应为 1,2);EXISTS 形式(同样的 SQL 结构)能正确返回 1,2。 -- 根因:子查询内 `u.id` 被当作**子查询自身行**的列(`bindColumnRefs` 用子查询行的 contextRow → `undefined`),条件恒 false。 -- 复现:users(1,2,3) / orders(o1→1,o2→1,o3→2),执行上述 SQL。 - -**P1-5 完全没有三值逻辑:`!=` / `NOT IN` / `NOT LIKE` / `NOT(…)` 把 NULL 行当"真"** **[已验证]** -- 行号:`where-matcher.ts:161-177`(`$eq/$ne/$in/$nin/$like` 全用 JS `===`/`includes`)、`98-101`(`$not` 是布尔取反)、`132-134`(裸值走 `===`) -- 现象(表 (1,'Alice'),(2,'Bob'),(3,NULL)): - - `WHERE name != 'Alice'` → 2、3(SQL 只应 2) - - `WHERE name NOT IN ('Alice')` / `NOT LIKE 'A%'` / `NOT (name='Alice')` → 同上 - - `WHERE n IN (1, NULL)`(n 为 1、2、NULL)→ 返回 1 和 NULL 行(SQL 只应 1) - - `WHERE n NOT IN (1, NULL)` → 返回 2(SQL 0 行) - - `WHERE name = NULL` → 返回 NULL 行,`!= NULL` → 返回所有非 NULL 行(`= NULL`/`!= NULL` 与 IS NULL/IS NOT NULL 不可区分) -- 根因:没有任何 UNKNOWN 态;NULL 被当作普通值参与 `===`。`$eq: null` 恰好等价于 IS NULL,是"顺带正确"。 -- 复现:上述 6 条。 - -**P1-6 `NOT IN (子查询)` 遇子查询结果含 NULL → 多返回行** **[已验证]** -- 行号:`executor.ts:1419-1423`(原样把第一列值列表塞进 `$nin`)→ `where-matcher.ts:170` -- 现象:users(1,2,3)、orders(o1→'1', o2→NULL):`SELECT id FROM u WHERE id NOT IN (SELECT user_id FROM o)` → `[2,3]`(SQL 应为 0 行)。 -- 根因:同 P1-5;SQL 中 `x NOT IN (…, NULL)` 永不为真。`$in` 侧(`id IN (SELECT …)`)恰好与 SQL 一致,说明差异纯粹来自 NULL 语义缺失。 - -**P1-7 HAVING 不能引用"未出现在 SELECT 列表里的聚合"** **[已验证]** -- 行号:`executor.ts:628-651`(`executeGroupBy` 只为 `stmt.columns` 里的聚合表达式算值并写入组行)、`365-378`(HAVING 用 `matchWhere` 对组行求值) -- 现象:`SELECT dept FROM e GROUP BY dept HAVING COUNT(*) > 1` → `[]`(应为 Eng、Sales);`HAVING SUM(salary) > 2000` → `[]`(应为 Eng)。而 `SELECT dept, COUNT(*) AS c … HAVING COUNT(*) > 1` 正常。 -- 根因:HAVING 的键 `'COUNT(*)'`(parser.ts:1058-1067 生成)在组行里不存在 → `matchField(undefined, {$gt:1})` → false;`_aggAliasMap`(626-651)只做"表达式键→别名键"的重命名,不能补算缺失聚合。 -- 复现:employees(id,dept,salary) 5 行,执行上述两条。 - -**P1-8 聚合的 NULL/类型语义:空集/全 NULL 返回 0 而非 NULL;MIN/MAX 走 `Number()` 数值化** **[已验证]** -- 行号:`executor.ts:660-680`(`argCol` 原样取列;`663` 过滤 null;`672` `rawValues.map(Number)`;`675-678` SUM 初值 0、AVG/MIN/MAX 空集返回 0、MIN/MAX 用 `Math.min/max`) -- 现象: - - 空集或全 NULL:`SUM/AVG/MIN/MAX` → `0`(SQL 为 NULL) - - 文本列:`MIN(s)/MAX(s)` over ('9','10') → `9 / 10`(文本序应为 '10' / '9');列表含 'abc' 时整列变 `NaN`(JSON 序列化为 `null`) - - `MIN(id)/MAX(id)` 对字符串主键返回数字 - - `AVG(文本列)` → NaN - - 合计:COUNT(col) 跳过 NULL(正确)、COUNT(*) = 行数(正确) -- 根因:聚合只实现了数值语义;`computeAggregate` 返回类型硬编码 `number`(655),没有 NULL 传播、没有"文本聚合 vs 数值聚合"分支。 -- 复现:`SELECT MIN(s), MAX(s) FROM t`(s = '9','10','abc')。 - -**P1-9 非 JOIN 路径下"带表前缀的聚合参数"恒为 0** **[已验证]** -- 行号:`executor.ts:328-336`(`/^(COUNT|SUM|AVG|MIN|MAX)\(/` 的列原样保留,不剥前缀)→ `662` `r[argCol]`,此时行键是裸列名 -- 现象:`SELECT COUNT(o.id) FROM orders o` → 0(应为 3);`SELECT SUM(o.amount) FROM orders o` → 0(应为 300);`COUNT(*)` 正常。 -- 反证:JOIN 路径行键带前缀(447-451),同样的 `COUNT(o.id)` 在 JOIN 查询里是对的 → 路径分叉。 -- 复现:orders(o1,100),(o2,200),(o3,NULL)。 - -**P1-10 JOIN 的 NULL 键:嵌套循环认为 NULL=NULL 成立,哈希连接认为不成立 → 结果取决于右表是否有索引** **[已验证]** -- 行号:`where-matcher.ts:139-150`(`options.$col` 分支 `value === row[col]`,NULL===NULL 为真)用在 `executor.ts:586/602`;哈希路径 `executor.ts:531`(左值滤掉 null/undefined)、`543/553`(`String(v ?? '\0')`) -- 现象(a(a1,NULL),(a2,'x');b/c 同为 (·,NULL),(·,'x'),区别是 c.k 有索引): - - `INNER JOIN` on 未索引列 → a1–b1 + a2–b2(SQL 只应 a2–b2) - - `INNER JOIN` on 已索引列 → 仅 a2–c2(正确) - - `LEFT JOIN` on 未索引列 → a1–b1、a2–b2(SQL 应为 a1–NULL、a2–b2) - - `LEFT JOIN` on 已索引列 → a1–NULL、a2–c2(正确) -- 根因:两套连接实现各自解释 NULL;`$col` 比较缺三值逻辑,哈希键用 `'\0'` 哨兵代替 NULL(还可能把 NULL 与字符串 "\0" 混同)。 -- 复现:同一份数据建 3 张表(右表索引与否不同)跑同一条 JOIN。 - -**P1-11 派生表(FROM 子查询)的别名限定:WHERE 恒空、投影出空对象** **[已验证]** -- 行号:`executor.ts:297-307`(非 JOIN 派生路径不加前缀、不剥别名,只在 303-307 对 where 做 `resolveSubqueries` 后 `matchWhere`);`where-matcher.ts:214-228`(`projectColumns` 的 `key.endsWith('.'+col)` 对 `col='d.age'` 永不成立) -- 现象:`SELECT * FROM (SELECT id, age FROM users) d WHERE d.age > 25` → `[]`(应为 2 行);`SELECT d.age FROM (SELECT id, age FROM users) d` → `[{},{},{}]`;去掉前缀(`WHERE age > 25`)才正确。 -- 根因:外层 WHERE/投影里的 `d.age` 从未被映射到派生行的裸键;JOIN 分支因为先 `prefixRow(row, stmt.alias ?? '')`(301)才"看起来正常",但别名缺省时会生成 `.col` 这种坏键。 -- 复现:见上。 - -**P1-12 JOIN 查询里的"未限定列名"静默失效(WHERE 空集 / GROUP BY 并成一组 / ORDER BY 不排序)** **[已验证]** -- 行号:`executor.ts:317-336`(别名剥离只发生在非 JOIN 分支)、`447-451`(JOIN 行键全部带前缀)、`379`(排序按裸键取不到值) -- 现象: - - `SELECT u.id FROM u INNER JOIN o ON u.id=o.user_id WHERE dept = 'Eng'` → `[]`(应为 3 行) - - `SELECT dept, COUNT(*) AS c FROM u INNER JOIN o ON u.id=o.user_id GROUP BY dept` → `[{c:4}]`(应为 Eng=3、Sales=1;所有行落进 `undefined` 组) - - `SELECT users.name FROM users INNER JOIN departments … ORDER BY name DESC` → 顺序未变(静默不排序) - - `SELECT name FROM users JOIN departments …` → 静默取 `users.name`(SQL 应报歧义;`projectColumns` 取第一个 `endsWith('.name')` 的键) -- 根因:列标识在不同路径下分别是"裸名/前缀名",匹配全靠字符串;JOIN 路径没有做"未限定名→唯一候选列"的解析。 -- 复现:见上。 - -**P1-13 DISTINCT 作用在"原始行"而不是"投影后的行"** **[已验证]** -- 行号:`executor.ts:290-295 + 352`(有 `AS` 别名或 CASE → `plan.columns=['*']`)→ `364`(DISTINCT 在 `383-386` 投影之前) -- 现象:employees(1,Eng),(2,Eng),(3,Sales):`SELECT DISTINCT dept AS d FROM e` → 3 行(应为 2);`SELECT DISTINCT g AS k FROM t ORDER BY k DESC` → `[b,a,a]`(应为 b,a)。 -- 根因:去重键用整行 `Object.values(row)`(689),把未被投影的 id 也算进去。 -- 复现:见上(注意 `SELECT DISTINCT dept FROM e`(无别名)因为引擎已投影所以是对的 → 又是路径分叉)。 - -**P1-14 UNION:语句级 ORDER BY/LIMIT 挂到最后一个 SELECT 上;列数不校验;空左操作数改变结果列名** **[已验证]** -- 行号:`sql/parser.ts:366-388 + 394-409`(`parseSelect` 先吃掉 ORDER BY/LIMIT,再在 386 判 UNION,于是尾部的 ORDER BY/LIMIT 属于**右侧** SELECT);`executor.ts:153-200`(`executeSelectUnion` 全程不看 orderBy/limit/offset,也不做 maxRows 保护);`158`(`leftCols` 取自左结果第一行)、`192-200`(`projectUnionRow` 按位置映射、多余列丢弃) -- 现象: - - AST 实证:`SELECT v FROM t1 UNION SELECT v FROM t2 ORDER BY v LIMIT 2` → `right = {orderBy:[v], limit:2}`、`left` 无排序无限制 - - `SELECT v FROM t1 UNION SELECT v FROM t2 ORDER BY v` → `[c,a,b,d]`(乱序);`… LIMIT 2` → 4 行;`UNION ALL … LIMIT 2` → 4 行 - - `SELECT v FROM t1 UNION SELECT v, id FROM t2` → 不报错(SQL 应报列数不匹配) - - 左侧为空时 `leftCols=[]` → 右侧行保持自己的列名:`SELECT v FROM t1 WHERE id<'0' UNION SELECT amount FROM t2` → `[{amount:42}]`(左侧非空时为 `[{v:'x'},{v:42}]`) -- 根因:UNION 没有自己的 ORDER BY/LIMIT 承载结构(ast.ts:153-161 的 `SelectUnionStatement` 无 orderBy/limit 字段),执行器也不对合并集合做收尾。 -- 复现:见上。 - -**P1-15 `SELECT <数字常量>` 投影出空对象** **[已验证]** -- 行号:`sql/parser.ts:1044-1049`(数字常量作为列文本 '1')→ `executor.ts:1017-1042`(`projectRow` 只处理 `*` / CASE / `AS` / 字符串常量,没有数字分支)→ `1046` `projectColumns` 找不到 '1' 键 → `{}` -- 现象:`SELECT 1 FROM t` → `[{},{}]`;`SELECT 1 AS one FROM t` → `[{}]`(别名分支取 `row['1']` → undefined);`SELECT id, 1 AS one FROM t` → `[{id:'1'},{id:'2'}]`(常量列消失)。 -- 备注:EXISTS 子查询只关心行数所以掩盖了这个问题;注释(parser.ts:1044)明确把它当特性。 -- 复现:见上。 - -**P1-16 标量子查询:多行不报错(静默取第一行)、空结果被当 NULL 值匹配** **[已验证]** -- 行号:`executor.ts:1425-1431` -- 现象:`SELECT id FROM u WHERE age = (SELECT age FROM u)`(3 行年龄)→ 返回 id=1(SQL 应报 "subquery returned more than one row");`age > (SELECT age FROM u)` → 取第一行年龄比较;`age = (SELECT age FROM u WHERE id='nope')`(空)→ `$eq: null` → 返回 age IS NULL 的行(SQL 0 行)。 -- 根因:标量语义被实现为"第一行第一列/空则 null",没有基数检查,也没有 UNKNOWN 语义。 -- 复现:见上。 - -**P1-17 AND/OR 无优先级(左结合)** **[已验证]** -- 行号:`sql/parser.ts:780-797`(`while (AND|OR)` 逐个子条件左折,不区分优先级) -- 现象:AST 实证 `a = 1 OR b = 1 AND id = 'r2'` → `{$and:[{$or:[a,b]}, {id:'r2'}]}`(SQL 应为 `a OR (b AND id)`);数据 r1(a=1,b=0)、r2(a=0,b=1)、r3(a=0,b=0) 时返回 `[r2]`(应为 r1、r2)。 -- 根因:`parseCondition` 缺少 `parseOr → parseAnd → parseSimple` 分层。 -- 复现:见上。影响所有引擎与所有语句(WHERE/ON/HAVING 共用 `parseCondition`)。 - -**P1-18 GROUP BY 下"带别名的非聚合列"被静默改名或整个丢弃** **[已验证]** -- 行号:`executor.ts:631-648`(`colExpr` 与 `stmt.groupBy` 做**字符串全等**比较;不匹配就走 646 的 `aggregated[colExpr] = groupRows[0][colExpr]`) -- 现象: - - `SELECT dept AS d, COUNT(*) AS c FROM e GROUP BY dept` → `[{dept:'Eng',c:2}]`(别名 d 丢失,键变成 dept) - - `SELECT salary AS s, COUNT(*) AS c FROM e GROUP BY dept` → s 列**整列消失**(值为 undefined,JSON 序列化时被抹掉,无报错) - - `ORDER BY d`(分组列的别名)静默不排序 -- 根因:组行的键空间是"SELECT 列表字符串 + groupBy 字符串"的拼接,别名/前缀/空白只要不一致就落到 first-row 分支;`ORDER BY` 在投影前按裸键取值(379)。 -- 复现:见上。 - -**P1-19 `maxRowsPerQuery` 会静默截断 `INSERT … SELECT` 的源数据(静默丢数据)** **[已验证]** -- 行号:`executor.ts:707`(`executeInsert` 用 `executeSelectPart` 取源行)→ `396-398`(`maxRowsPerQuery` 截断在 `executeSelect` 内) -- 现象:`maxRowsPerQuery: 2` 时,`INSERT INTO b SELECT id FROM a`(a 有 4 行)**只插入 2 行且不报错**。 -- 根因:行数上限保护放在"查询结果"层,而读路径同时被写语句复用,没有区分"面向用户的结果集"与"内部行源"。 -- 复现:配置 maxRowsPerQuery=2,见上。 - ---- - -### P2 — 边界与健壮性 - -| # | 文件:行号 | 现象 / 根因 | 状态 | -|---|---|---|---| -| P2-1 | `executor.ts:645-647` | `SELECT dept, salary FROM e GROUP BY dept` 返回每组**第一行**的 salary(MySQL 式 ANY_VALUE),标准 SQL 应报错;结果依赖扫描顺序 → 引擎相关 | [已验证] | -| P2-2 | `executor.ts:383-386 + 447-451` | JOIN 查询 `SELECT *` 返回 `users.id`/`departments.name` 这类**带前缀列名**,与单表 `SELECT *` 的裸列名不一致 | [已验证] | -| P2-3 | `where-matcher.ts:220-225` | 未限定列名在 JOIN 中静默取"第一个后缀匹配"的列(歧义应报错) | [已验证] | -| P2-4 | `executor.ts:591-595, 602-609` | LEFT/RIGHT 的 NULL 补行用**第一行的键集**(`rightRows[0]`/`leftRows[0]`)构造,键集异质(ALTER ADD/DROP 后、空表)时补行缺列;RIGHT 未匹配行被追加在结果末尾(顺序非 SQL 语义) | [读码确认] | -| P2-5 | `executor.ts:1169-1174` + `766-777` | `hasCorrelatedRefs` 把**任何** `$exists` 都判为关联 → `DELETE FROM t WHERE EXISTS (SELECT 1 FROM u)`(完全不相关)抛 `NOT_SUPPORTED`;同时非关联 EXISTS 被逐行重复执行(见 P3-2) | [已验证] | -| P2-6 | `builder.ts:105-112` + `memory.ts:192-210` | QueryBuilder 非 JOIN 路径直通 `engine.find`,**find 没有 `containsUnresolvedSubqueries` 防护**(只有 update/delete 有)→ `.where({id:{$eq:{$col:'v'}}})` 静默返回 `[]` | [已验证] | -| P2-7 | `executor.ts:305,317,322-336,367,375,440,651` | 就地改写调用方的 AST(where/columns/orderBy/groupBy),并在 `stmt` 上挂 `_aggAliasMap` 侧信道 → 同一 AST 二次执行(`toAST()` 复用、EXPLAIN + 执行)语义漂移 | [读码确认] | -| P2-8 | `engine/aria/index.ts:1223,2028` vs `1998,2005` | Aria 主键类型随访问路径翻转:全表/范围扫描 `key.slice()` → 字符串;`$eq` 快速路径用查询字面量原类型 → 同一列在 `SELECT *` 与 `WHERE id = 3` 下分别是 `'3'` 和 `3`;memory/disk/hybrid 恒为原类型 | [已验证] | -| P2-9 | 无 SQL 层保证 | 无 ORDER BY 时行序引擎相关(memory/disk/hybrid 插入序 vs aria 主键序)→ `LIMIT/OFFSET` 选出的行引擎间不一致;README"已知限制"未提 | [已验证] | -| P2-10 | `where-matcher.ts:18-28` | `LIKE` 编译为正则带 `i` 标志 → **大小写不敏感**('C%' 命中 'c');与 PostgreSQL 不同、与 SQLite ASCII 行为相近,但没有任何文档说明 | [已验证] | -| P2-11 | `executor.ts:665-670` | `COUNT(DISTINCT *)` 在第 666 行就 `return rows.length`,DISTINCT 被忽略(SQL 应语法报错) | [已验证] | -| P2-12 | `executor.ts:668` | `COUNT(DISTINCT col)` 用 `String(v)` 去重(无类型前缀)→ `1` 与 `'1'`、`true` 与 `'true'` 合并;与 v0.7.4 专门引入 `encodeGroupKey` 的理由自相矛盾 | [读码确认] | -| P2-13 | `core.ts:218-241` | 分号多语句**逐条执行、无事务**,失败时前面的语句已提交(实测 `INSERT; INSERT` 第二条 DUPLICATE_KEY,第一条留存);返回值只有最后一条结果 | [已验证] | -| P2-14 | `executor.ts:829-841` | ALTER DROP COLUMN 的通用路径靠"`engine.find` 返回行引用,直接 `delete row[col]`"清理数据;注释自认依赖 Memory 的引用语义 → 换引擎即静默失效(Aria 走引擎 alterTable 才没事) | [读码确认] | -| P2-15 | `sql/parser.ts:828-833` | `(CASE … END) = 'x'` PARSE_ERROR(括号分支返回内层条件后不再继续解析运算符);`ORDER BY COUNT(*)`、`(SELECT …) > 0`、任何算术表达式(`a+1`、`n/0`)都 PARSE_ERROR → executor 里为 CASE/表达式准备的分支(1010-1063、1240-1266)只能被很窄的语法触达 | [已验证] | -| P2-16 | `executor.ts:1070-1072` | `_hasAggregateColumn` 要求列文本**以聚合名开头** → `SELECT CASE WHEN COUNT(*) > 1 THEN …` 不触发聚合路径;组内 `evaluateCase` 把 `COUNT(*)` 当列引用(`resolveCaseValue` 95-97)→ null | [读码确认] | -| P2-17 | `executor.ts:95-97` | CASE 的 THEN/条件里的列引用按**精确键**取(`row[v]`)→ JOIN 场景下未限定列取不到(`THEN id` 在带前缀行里是 undefined → null) | [读码确认] | -| P2-18 | `executor.ts:1269-1288` | `caseConditionMatches` 对未知操作符 `default: break`(静默视为满足),与 v0.7.2 让 `matchOperator` 抛 `QUERY_ERROR` 的硬化方向不一致 | [读码确认] | - ---- - -### P3 — 性能 - -| # | 文件:行号 | 问题 | -|---|---|---| -| P3-1 | `executor.ts:429`、`569-612` | JOIN 非哈希时**每张右表整表物化**再嵌套循环,每对候选都 `{...l,...r}` 造对象(585);RIGHT JOIN 再全扫一遍右表(598-610)→ O(N·M) 两遍 | -| P3-2 | `executor.ts:1226-1238` | `filterCorrelated` 外层**每行执行一次子查询**(含完全不相关的 EXISTS,因 1169-1174 的过宽判定),无缓存/去相关 | -| P3-3 | `executor.ts:379` + `388-390` | ORDER BY 别名场景排序两次,第一次作用在投影前的行上(键全 undefined)纯属浪费 | -| P3-4 | `memory.ts:195-208`、`aria/index.ts:686-704` | 同样的 order/limit/project 在引擎里做一遍、executor 再做一遍(复制 + 排序 + 切片 ×2) | -| P3-5 | `where-matcher.ts:16-28` | `likeCache` 是**模块级无上界 Map**,动态(用户输入)LIKE 模式持续增长 → 内存泄漏 | -| P3-6 | `executor.ts:211-215` | `EXPLAIN SELECT` **真执行查询**;`estimatedRows` 就是真实行数;`EXPLAIN` 还会跑 `resolveWriteWhere`(218-223)触发子查询执行 | -| P3-7 | `executor.ts:153-200, 297-307`;`core.ts:307-324` | UNION / 派生表 / 任何带 JOIN、ORDER BY、DISTINCT、聚合的查询都全量物化;`queryStream` 只对最简 SELECT 走真流式 | -| P3-8 | `executor.ts:677-678` | `Math.min(...arr)` 展开实参:O(n) 栈 + 崩溃(P0-1) | -| P3-9 | `executor.ts:1010-1063`、`59-83` | 投影表达式**逐行重新正则解析**(`parseCaseExpression` 每行每列一次)、`projectColumns` 未命中时对每行键做 O(cols×keys) 扫描;无预编译投影/表达式 | -| P3-10 | `executor.ts:31-39, 37` | `encodeGroupKey` 对 object 值 `JSON.stringify`(每行每值),分组/去重键无长度前缀 | -| P3-11 | `executor.ts:531-538` | 哈希连接把所有左表探测值(可能百万级)一次性放进单个 `$in` 查询,无分块 | -| P3-12 | `executor.ts:457-471` | WHERE 下推只覆盖"主表别名前缀 + 普通等值",其余(未限定列、$and 内部非等值、JOIN 表条件)全部在内存里逐行过滤 | - ---- - -### 疑似(未实跑,读码推断) - -- **S-1 分组/去重键可被伪造**:`encodeGroupKey` 各列值用 `'\x1f'` 连接且无长度前缀/转义(`executor.ts:621/689/170/178`),字符串值自身含 `'\x1f'` 时不同列组合可撞键(如 `('a\x1fss','b')` 与 `('a','ss\x1fb')`)→ DISTINCT/GROUP BY/UNION 误合并。哈希连接键(543/553)与 `$in` 探测用 `'\0'` 哨兵,同类问题且会把 NULL 与真实 `"\0"` 混同。 -- **S-2 json 列分组**:`JSON.stringify` 的对象键顺序不同 → 逻辑相等的对象落入不同组(37)。 -- **S-3 行键顺序敏感**:DISTINCT/UNION 用 `Object.values(row)`(689/170),同值但键序不同的行(ALTER ADD/DROP 后、异质行)不会去重。 -- **S-4 Aria 索引键 `String()` 归一**:`1`/`'1'`、`true`/`'true'` 在二级索引里同一键(aria/index.ts:2045/2062);当前只造成多余候选(后续 `matchWhere` 会过滤),但语义上是类型混同。 -- **S-5 `hasSelectAlias` 正则误判**:`/\s+AS\s+\w+$/i`(executor.ts:295)对含 " AS " 的列文本可能误判(已实测字符串常量 `'a AS b'` 不会误判,因为结尾是引号;但其它未加引号文本未穷举)。 -- **S-6 混合类型排序**:`compare` 回退 `localeCompare(String(a),String(b))`(where-matcher.ts:207)→ 数字/字符串混合列按字典序、且依赖 locale。 -- **S-7 哈希连接列对方向**:`keyIsLeft` 只看 `mainAlias` 前缀(executor.ts:508-512),第二个 JOIN 的 ON 若引用前一个 JOIN 表,`pairs` 会左右颠倒;目前通常因"取值列表为空"回退嵌套循环(531-532),但这是巧合而非保证。 -- **S-8 `$like` 未处理 `\` 转义与 `%`/`_` 字面量**(where-matcher.ts:21-24 先转义正则字符再替换通配符;无 ESCAPE 支持),且正则无回溯保护 → 恶意模式(如 `%a%a%a%…`)可能触发灾难性回溯。 - ---- - -## 3. 性能议题(汇总) - -1. **JOIN 是"整表物化 + 嵌套循环"**(P3-1):右表无条件全量读入(executor.ts:429),只有"ON 为纯等值 + 右表有索引"时才退化为一次 `$in` 探测的哈希连接(480-566)。没有块嵌套、没有排序合并、没有把 WHERE 下推到连接前(除了主表别名等值那一种)。 -2. **完全不做投影下推**:`needsRawRows || hasSelectAlias` 时 `plan.columns=['*']`(352),GROUP BY/聚合路径也强制 `['*']`(340/351)→ 引擎把整行读出来再由 JS 投影/分组。列存式裁剪无从谈起。 -3. **LIMIT 下推错位**(P1-2):本该减少工作量的下推反而造成错误结果;正确做法是只在"无 DISTINCT/GROUP BY/ORDER BY 别名"时下推。 -4. **重复劳动 ×2**:引擎与 executor 各排一次序、各切一次片、各投影一次(P3-4、P3-3)。 -5. **关联子查询逐行执行**(P3-2):N 行 = N 次子查询(每次都是一次完整 SELECT),且**不相关的 EXISTS 也被当成相关**。 -6. **全量物化点**:UNION、派生表、ORDER BY 别名、DISTINCT、GROUP BY、JOIN 全部先物化再处理(P3-7);`queryStream` 只在最简 SELECT 上真流式。 -7. **无上界增长**:`likeCache`(P3-5)、哈希连接 `$in` 值数组(P3-11)、分组 Map(每行都进 `groups`,输出组数无上限)、`maxRowsPerQuery` 是唯一的结果集上限(且会误伤 INSERT…SELECT,P1-19)。 -8. **逐行重解析**:表达式/别名/CASE 的文本解析在行循环里(P3-9);`executor.ts:59` 的 CASE 正则每个 CASE 列每行跑一次,且用 `[\s\S]*?` 惰性匹配 + `exec` 循环。 -9. **EXPLAIN 变成真执行**(P3-6):成本翻倍,且给不出真实估算(`estimatedRows` = 实际行数)。 -10. **错误路径上的性能悬崖**:P0-1 的 `Math.min(...)` 同时也是 O(n) 实参构造;索引命中后仍要全条件 `matchWhere`(memory.ts:197-199 / aria:687-689),等值索引查出的行本可跳过该列比较。 - ---- - -## 4. 系统性观察:为什么这一层反复出 bug,哪里一刀能砍掉一整类 - -1. **同一语义有两份实现,且按"查询长什么样"分流** - - 有 JOIN → executor;无 JOIN → 引擎(builder.ts:99-113 与 executor.ts:311-355)。 - - 有 `AS`/CASE → 引擎不投影、executor 投影;否则引擎投影(executor.ts:290-295/352)。 - - ORDER BY 用别名 → 清空 plan 的 order/limit;否则把 limit 下推(353)。 - - 这些分流处**每一个**都对应上面的一个 P1(P1-1/2/9/12/13 全是"同一个 SQL 换条路径就对")。LIMIT 下推是典型:JOIN 路径对、单表路径错。 - - **一刀**:把"扫描→过滤→连接→分组→去重→投影→排序→截断"写成一条固定管线,且 **limit/offset/projection 只在一个地方执行**;引擎退化为"只提供带索引的行迭代器 + WHERE 匹配"。 -2. **没有 NULL / 三值逻辑模型** - - `where-matcher` 用 JS `===`,哈希连接用 `String(v ?? '\0')`,`$in/$nin` 用 `includes`,分组键另起一套 `encodeGroupKey`,`COUNT(DISTINCT)` 又用 `String(v)`。P1-5/6/10 以及 S-1/S-4 都是同一根因的不同外显。 - - **一刀**:引入唯一的 `sqlEquals/sqlCompare`(三态:TRUE/FALSE/UNKNOWN)+ 唯一的值编码(含 NULL 与类型)。所有比较、IN/NOT IN、JOIN 键、分组键、DISTINCT 键都调它 → NULL 类 bug 整类消失。 -3. **列/表达式身份是"字符串",没有绑定阶段** - - 列是文本:`'dept AS d'`、`'COUNT(o.id)'`、`'CASE … END AS k'`;于是别名要在 6 处正则里再解析(295/633/977/1017/1027/1079),GROUP BY 靠字符串全等比较(645),HAVING 靠字符串映射(369-376),表前缀靠 `startsWith` 剥(1149-1156)。P1-9/18、P2-3/16/17 都源于此。 - - **一刀**:解析期把列解析成 `{table?, column|expr, alias}` 并在编译期绑定到输出序号;GROUP BY/ORDER BY/HAVING 全部按"列序号"而非字符串对齐。 -4. **行编码是"临时约定的键字符串"** - - JOIN 前缀键(447-451)、裸键、`undefined` vs `null`、`Object.values` 顺序、引擎投不投影 —— 全都会改变分组/去重/排序结果(P1-12/13、P2-4、S-3)。 - - **一刀**:行 = 定长元组 + 列清单(schema of the projection),投影/去重/排序按**位置**进行;"前缀"只存在于连接阶段的符号表里,不进入结果行。 -5. **关联子查询靠"语法嗅探 + 特殊通道"** - - `hasCorrelatedRefs`(1159-1181)是纯文本检测,命中就把整条 WHERE 丢进逐行路径;而 `$exists` 与 `$in`/标量走了两条不同的执行分支(1351-1356 vs 1417),其中一条忘了传外层行 → P1-4;过宽判定 → P2-5/P3-2;`stripCorrelatedExists`(1205-1223)想"先粗筛再精筛"却漏了 `$col` → P1-3。 - - **一刀**:统一的表达式求值器携带 `outer row` 参数(子查询执行时传入),WHERE 的求值=对每行调用同一函数;不需要"是否相关"的预判,也就没有漏传上下文/粗筛过滤光的问题。 -6. **SQL 子句顺序没有被编码** - - 代码里的顺序是"聚合→分组→DISTINCT→HAVING→排序→投影→再排序→截断",而 SQL 是 FROM→WHERE→GROUP→HAVING→SELECT→DISTINCT→ORDER→LIMIT。HAVING 在投影后求值导致 P1-7/18,DISTINCT 在投影前导致 P1-13,LIMIT 下推到扫描导致 P1-2。 - - **一刀**:把上面那条标准顺序写成显式的 stage 列表(每个 stage 有明确的输入/输出列集),任何新特性只需插到正确位置。 -7. **测试固化的是"长度/不抛错"而不是值** - - `tests/join.test.ts:57-99` 只断言行数(注释里甚至写 "column projection may vary");`tests/v073-fixes.test.ts:267-278` 比较两条同样错的路径;`groupby.test.ts` 只测 HAVING 引用了 SELECT 里已有的聚合。 - - 结果:P1-3/7/12/13/14 这类"结果错但不崩溃"的语义长期存活,且每次 CHANGELOG 的"P1 修复"都只在某一条路径上打补丁(v0.7.4 修了 queryStream/写路径的子查询,却留下 P1-4 的关联 IN)。 - - **建议**:补"值级 golden 测试",重点覆盖 NULL/三值逻辑、别名与未限定列、UNION + ORDER/LIMIT、LIMIT/OFFSET(含 OFFSET>0 与左侧为空的 UNION)、空集聚合、以及**同一 SQL 在 join/非 join、memory/aria 两种路径下的结果一致性**。 - -### 优先级建议(若要排修复顺序) - -1. P0-1(崩)→ P1-19(静默丢数据)→ P1-1(OFFSET 结果错)→ P1-5/6(NULL 三值逻辑)→ P1-4/3(关联子查询静默空)→ P1-17(AND/OR 优先级,影响面最大且最易修)→ P1-7/8/13/14/18(聚合与集合语义)→ P1-9/11/12(路径分叉)→ P1-15/16。 -2. 结构性投入(收益最大):统一值比较/编码(第 2 条)+ 列绑定(第 3 条)+ 单条执行管线(第 1、6 条)。这三件事落地后,本报告 P1 中的绝大多数会作为"整类"消失,而不是逐条打补丁。 diff --git a/AUDIT-storage-engines-v0.7.4.md b/AUDIT-storage-engines-v0.7.4.md deleted file mode 100644 index b9a0a9e..0000000 --- a/AUDIT-storage-engines-v0.7.4.md +++ /dev/null @@ -1,243 +0,0 @@ -# MetonaSqlark v0.7.4 存储引擎审计报告 -> 范围:`MemoryEngine` / `KVStore`(自研事务 KV)/ `KVStoreEngine`(KV 包装)/ `HybridEngine`(write-through) -> 方法:逐行精读 8 个源文件 + 10 个测试文件 + CHANGELOG(1-200) + README「存储引擎」;对可疑项编写临时 jest 用例注入故障复现(全部用例已删除,仓库无残留)。文中标注 **[已复现]** = 可执行实验证实;**[读码确认]** = 代码路径确定但未注入实验;**[疑似]** = 推理所得、未完全证实。 - ---- - -## 1. 架构概览 - -### 1.1 MemoryEngine(`src/engine/memory.ts`) - -**数据结构(memory.ts:15-33)** -- `tables: Map>` —— 行存储,PK 直接由 `String(validatedRow[pkColumn])` 得到(memory.ts:163/177),**没有独立的主键索引结构**:PK map 就是行存储本身。 -- `indexes: Map>>>` —— 二级哈希索引;只有 `colDef.index || colDef.unique` 的列在 `createTable` 时建桶(memory.ts:81-85)。索引只记录非 null 值(memory.ts:780、793)。 -- `schemas: Map`(createTable 时深拷贝列定义,memory.ts:75-79,防止 Hybrid 双引擎共享 schema 对象)。 -- `uniqueIndexCols: Set<"table:col">` —— 仅标记 `CREATE UNIQUE INDEX` 来源的 unique(memory.ts:26、593),供 `dropIndex` 区分「建表约束」与「索引来源约束」(memory.ts:613-623)。 -- `metaStore`、`snapshot`(事务快照,memory.ts:29-33)。 - -**查找路径(memory.ts:192-210, 725-772)**:`find` 先 `tryIndexLookup`:递归展开顶层 `$and`(memory.ts:734-745)收集等值条件 → 命中某列索引则用 `colIndex.get(value)` 取 pk 集合回表(memory.ts:763-767);**索引未命中直接 `return []`(memory.ts:768)——正确性完全依赖索引与行一致**;null/undefined 条件跳过索引回退全表(memory.ts:759)。随后仍用完整 `matchWhere` 过滤(子集语义安全)、`applyOrderBy`、`slice(offset,limit)`、`projectColumns`。`findStream`(memory.ts:213-233)单次迭代不物化,但**完全忽略 `query.orderBy`**。 - -**insert(memory.ts:146-183)**:v0.7.3 两阶段。阶段 1 逐行 `validateRow` + 批内 PK `Set` 互查(memory.ts:165)+ `checkInsertUniqueness`(批内 Set + 索引查,memory.ts:309-341);阶段 2 落 row + `updateIndexes`。 - -**update(memory.ts:235-302)**:`stripUndefinedUpdates` → 未知列报错(254-258)→ 未解析子查询报错(245-250)→ 阶段 1 逐行匹配/校验/唯一预检/**新主键撞已有主键检查**(memory.ts:274)→ 阶段 1b `checkUpdateRestrict`(RESTRICT 与 SET NULL+required 整体拒绝,memory.ts:391-421)→ 阶段 2 先 `removeIndexEntries` 再(主键变更时)`applyUpdateCascade`、`table.delete(old)`、`table.set(new)`、`updateIndexes`。 - -**delete(memory.ts:450-486)**:先扫出全部待删行 → 对每行 `checkCascadeRestrict` 递归预检(memory.ts:492-529)→ 通过后才 `removeIndexEntries` + `cascadeDelete`(memory.ts:810-878)→ 最后删父行。返回 `toDelete.length + cascadeCount`。 - -**事务(memory.ts:633-653)**:`beginTransaction` 深拷贝 tables(浅拷贝行对象 `{...iv}`,memory.ts:661)、浅拷贝 schemas(`new Map(this.schemas)`,memory.ts:638)、深拷贝 indexes;`rollback` 直接换回三个 Map;`commit` 只丢弃快照。**没有写锁、没有快照隔离**:事务期间任何非事务写入直接落到同一份 live Map 上。 - -**外键级联**:`checkCascadeRestrict`(预检)与 `cascadeDelete`(执行)都在表循环里 `if (refTableName === tableName) continue;`(memory.ts:498、822),即**自引用外键被整体跳过**;`applyUpdateCascade` 只处理直接引用被改表的列(memory.ts:430-448),不递归。 - -### 1.2 KVStore(`src/engine/kvstore/*`) - -**磁盘格式** -- 键空间:`__kv_log` / `__kv_snapshot` / `__kv_meta`(index.ts:32-34)。 -- **日志记录**(log.ts:9-18, 47-92):`[recordLen u32][seq u32][entryCount u32] (每条 entry: [op u8][keyLen][key][valueLen][value]) [crc u32]`,大端,`crc32` 覆盖除 CRC 外全部字节;`recordLen` 含自身不含 CRC(log.ts:79)。操作码 PUT=1 / DELETE=2 / APPEND=3(log.ts:23-28)。**一条记录 = 一个原子事务**(putMany/deleteMany/writeBatch 都编码成单条记录,index.ts:209-242)。 -- **快照**(snapshot.ts:8-13, 29-59):`[magic "KVSN"][seq u32][entryCount] (keyLen/key/valueLen/value)* [crc]`;`seq` 是内嵌日志水位。 -- **介质抽象**(`src/engine/aria/store/backend.ts:13-48`):`IStorageBackend`(open/close/read/write/append?/writeMany/delete/deleteMany/listKeys/exists/clear)。浏览器走 `OPFSBackend`(每 key 一个文件,`write` 用 createWritable COW,`append` 用 keepExistingData+seek 到 `existing.size`,opfs_backend.ts:84-113);Node/测试走 `SharedMemoryBackend`(模块级 registry 跨实例共享、close 不清数据,shared_memory_medium.ts:15-43),默认选择逻辑在 index.ts:44-50。 -- **内存索引**:`index: Map`(index.ts:58)是唯一读路径,`get/size/listKeys` 全部 O(1)(index.ts:177-195)。 - -**恢复流程(index.ts:85-142)**:读快照 → `decodeSnapshot` 失败则置空索引并从 0 重放 → 读日志 → `parseLogRecords` 顺序解析,`record.seq <= snapshotSeq` 跳过(幂等),否则 `applyRecord` 并推进 `this.seq` → 命中损坏记录(长度越界/CRC 失败/entry 越界)回调返回 true 停止扫描 → **调用 `truncateLog()` 把整条日志写成 0 字节**(index.ts:135-138 + 394-399)。`__kv_meta` 在 checkpoint 时写(index.ts:275-276)但**恢复时从不读取**(v0.6.1 起水位只信快照内嵌 seq,index.ts:113-118)。 - -**写入与 checkpoint(index.ts:334-391, 261-280)**:所有写经 `enqueue` 串行队列(index.ts:327-331)→ `seq++` → `medium.append`(真追加,否则 read+拼+write 回退,index.ts:344-356)→ 成功后更新内存索引与 `logBytes`;失败 `seq--`、记 `lastBackgroundError` 并抛 `KV_LOG_ERROR`(内存不更新)。`checkpoint` = 写快照 → 写 meta → 截断日志;`appendRecord` 内还有一条**按字节阈值的自动 checkpoint**(index.ts:385-390,默认 16MB,index.ts:37),**这段不在 try/catch 内**。 - -### 1.3 KVStoreEngine(`src/engine/kvstore_engine.ts`) - -**三层缓存**:介质文件 → `KVStore.index`(ArrayBuffer 权威视图)→ `MemoryEngine`(解码后的行 + 二级索引)。**读全部走 MemoryEngine**(kvstore_engine.ts:292-300, 419-422),介质只在 open/checkpoint 碰。 - -**键布局**:`__schema`(全部表 schema 的 JSON 一次写全)、`__meta:{key}`、`t:{table}:{pk}`(kvstore_engine.ts:28-29, 65-71)。 - -**open(kvstore_engine.ts:75-123)**:`kv.open` → `memory.open` → 解析 `__schema`(JSON 失败抛 `KV_SCHEMA_ERROR`)→ 逐 key 解析行名(`key.indexOf(':', 2)` 取第一个冒号,kvstore_engine.ts:99-101)→ 表存在则 `memory.insert(table,[row])`,**异常被 catch 静默吞掉**(kvstore_engine.ts:103-108)→ 之后一段「重建二级索引」循环(kvstore_engine.ts:111-120)实际是死代码(`createTable` 已建桶且 `createIndex` 见 `colDef.index/unique` 立即 return,memory.ts:562)。`this.opened = true` 在**最后**才置位(kvstore_engine.ts:122)。 - -**写入(非事务)**:insert = memory 先行 + `putMany` 持久化 **memory 中的 validated 行**(kvstore_engine.ts:283-288);update = 先 `collectMatchingPks` 预取受影响主键(kvstore_engine.ts:316)→ `memory.update` → 主键变更走 `affectedTables` 传递闭包 + `collectTableDiff` 整表 diff,普通更新走「单次全表扫描 + 受影响集合过滤 + 剩余主键删除」(kvstore_engine.ts:345-380);delete = 预取主键 + memory.delete + 本级表按主键删 + 级联表整表 diff,合并成一次 `writeBatch`(kvstore_engine.ts:406-415)。 - -**事务**:`beginTransaction` = memory 快照 + 复位 4 个 tx 集合(kvstore_engine.ts:476-485);写入路径按 `txActive` 分叉,把变更记入 `txChanges`(行级 put/delete)或降级为 `txFullTables`(主键变更/级联影响表)或 `txClearedTables`(事务内 clear);`commitTransaction` 把这些合并成 **一次** `kv.writeBatch`,随后**单独** `persistSchema`(kvstore_engine.ts:545-549),最后 `memory.commitTransaction`;`rollbackTransaction` 只回滚内存与集合(kvstore_engine.ts:561-571)。`setMeta` 是直写 `kv.put('__meta:*')`(kvstore_engine.ts:181-183),与事务无关。 - -### 1.4 HybridEngine(`src/hybrid/index.ts`) - -`memoryEngine: MemoryEngine` + `diskEngine: KVStoreEngine`(hybrid/index.ts:30-35)。读全部走内存(222-225);写 = 内存先行、磁盘 write-through(insert 211-220 / update 232-241 / delete 243-252 / clear 258-265 / DDL 141-189);磁盘失败回调 `recoverMemoryAfterDiskError`:`reloadMemoryFromDisk()` 把内存对齐磁盘后重抛原错误(hybrid/index.ts:199-209)。`reloadMemoryFromDisk`(hybrid/index.ts:56-85)= 关/开内存引擎 → `disk.reload()` → 遍历磁盘表 `createTable` + `find` 全表 + `insert` 回灌,**单表回灌失败只 `console.warn`**(79-82)。事务 = 双引擎 begin(293-303,磁盘失败补偿回滚内存)/ commit(磁盘先、内存后,内存失败抛 `TX_COMMIT_ERROR` 且磁盘已提交,305-319)/ rollback(内存先、磁盘后,321-324)。 - -### 1.5 语义对照(同为「关系语义」的四份实现) - -| 语义 | Memory | KVStore/KVStoreEngine | Hybrid | Aria(对照) | -|---|---|---|---|---| -| 行/索引 | PK=行 Map;col→Set\ | 复用 Memory + `t:table:pk` 键 | 复用 Memory ×2 | LSM 二级索引 | -| 约束校验 | 自带 `validateRow/checkType`(**无 maxLength/min/max**,memory.ts:713-722) | 复用 Memory | 复用 Memory | 共享 `checkFieldType`(含 maxLength/min/max,aria/index.ts:1672) | -| 唯一 | 建表标记 + `uniqueIndexCols` | schema 持久化后再由 createTable 建桶 | 双份 | 自有重建 | -| 事务内 DDL | ALTER/CREATE/DROP INDEX 拒绝(memory.ts:114/552/599),create/dropTable 可回滚 | ALTER/CREATE/DROP INDEX 拒绝(kvstore_engine.ts:236/442/459),create/dropTable 随 commit | 委托两侧 | ALTER 拒绝 | -| 事务原子性 | 快照交换 | 内存快照 + 单条日志批 + **schema 另写** | 双引擎 | MVCC + WAL | -| 失败中途 | 语句级两阶段;级联两阶段 | 内存先行,落盘失败回滚不了内存 | 重载内存对齐 | WAL 领先内存 | -| 级联影响面 | 直接引用表(自引用被跳过、ON UPDATE 只一层) | `affectedTables` 传递闭包(整表 diff) | 委托 | 自有实现 | - ---- - -## 2. 缺陷清单 - -### P0 — 静默丢数据 / 破坏崩溃恢复 - -**P0-1 单条 UPDATE 把多行改到同一新主键 → 静默覆盖丢行** **[已复现]** -- 位置:`src/engine/memory.ts:274`(阶段 1 只检查 `table.has(newPk)`)、`memory.ts:289-300`(阶段 2 直接 `set`)。 -- 现象:`UPDATE t SET id='X'`(匹配 2 行)返回 affected=2,但表中只剩 1 行;SQL 路径与引擎路径一致。 -- 根因:阶段 1 只与「语句执行前的表」比对,批内新主键互查缺失;阶段 2 逐行 `table.delete(old); table.set(new, updated)`,后者覆盖前者的成果。`primaryKey: true` 不隐含 `unique: true`,`checkUpdateUniqueness`(memory.ts:348-385)只遍历 `colDef.unique` 列,兜不住。 -- 复现:2 行表 → `engine.update('t',{table:'t'},{id:'X'})` → `find` 返回 1 行。(INSERT 路径 v0.7.3 已做批内 PK `Set` 互查,UPDATE 漏了。) - -**P0-2 唯一约束永久失效 → 重启静默丢行(组合链)** **[已复现]** -- 位置:`memory.ts:562`(`if (colDef.index || colDef.unique) return;`)、`memory.ts:122-127`(ALTER ADD 只写 `schema.columns`,**不建索引桶**)、`memory.ts:316-339`(唯一预检依赖 `tableIndexes.get(colName)`,桶缺失即 `colIndex===undefined` → 整段跳过)、`kvstore_engine.ts:103-108`(open 回灌 `insert` 异常被吞)。 -- 现象(端到端实测):`ALTER TABLE t ADD COLUMN email STRING UNIQUE` → 连续插入两条 `email='a@x.com'` **都不报错**(rows=2)→ close/reopen → 只剩 1 行,**无任何错误或警告**。 -- 根因:唯一性检查完全由二级索引桶承载,而 ALTER ADD 不建桶;重启时 `createTable` 依据 schema 建桶,回灌第 2 行触发 `UNIQUE_VIOLATION`,异常被 `catch {}` 吞掉 → 该行永久消失。同理 `CREATE UNIQUE INDEX` 落在一个已有普通索引的列上会静默 no-op(`memory.ts:562`),约束不生效。 -- 复现:见上(`tests` 临时用例已验证 `dupErr='' | before=2 | after=1`)。 - -**P0-3 `KVStore.open()` 命中损坏日志后把整条日志清零 → 已确认数据只剩内存,再崩溃即永久丢失** **[已复现]** -- 位置:`src/engine/kvstore/index.ts:135-138`(检测到损坏 → `truncateLog()`)、`index.ts:394-399`(`truncateLog` 写 `new ArrayBuffer(0)`,**不是** `log.subarray(0, validBytes)`)。 -- 现象:日志尾部有一条坏记录时,open 会先把坏记录之前**全部有效记录**应用到内存,然后清空整条日志;此时快照可能是上一次 checkpoint 的旧值甚至不存在。随后若进程崩溃(未 close、未 checkpoint),这些「已确认」的数据没有任何持久副本 → 永久丢失。 -- 佐证:`tests/engine/kvstore.test.ts:176-210` 只验证了**同一会话内** `confirmed/confirmed2` 仍在、且新写的 `after` 能跨一次重启(因为 after 是截断后新追加的记录),**从未验证第二次重启后 confirmed 仍在**。实测:`kv2.open()` 后日志字节数 = 0,第二次重启 `get('confirmed') === null`。 -- 附带影响:`KVStore.reload()`(index.ts:148-156)会清空索引再重新 open,若此前发生过错损截断,reload 直接把库变成空库——而 Hybrid 的跨标签页同步(`src/core.ts:86-90`)会周期性调用它。 -- 修复方向:open 截断应写回「有效前缀」或先落一次快照再清日志。 - -**P0-4 自动 checkpoint 失败发生在「日志已追加 + 内存已更新」之后 → 报错为假、事务回滚无效(数据复活)** **[已复现]** -- 位置:`kvstore/index.ts:334-362`(try/catch 只包住 `medium.append`,末尾 `logBytes +=` 与自动 checkpoint 在 catch 之外)、`kvstore/index.ts:385-390`(快照/meta/truncate 失败会以异常形式冒泡给 `put/putMany/writeBatch` 调用方)、`kvstore_engine.ts:545-553`(commit 里 `writeBatch` 抛错 → 内存 `commitTransaction` 未执行,调用方按惯例回滚)。 -- 现象(端到端实测):事务 insert 一行 → 让快照写入失败 → `commitTransaction()` 抛错 → `rollbackTransaction()` 后内存 count=0 → **重启后 count=1**(被回滚的事务复活)。裸 `KVStore.put` 同样:rejects 但 `get` 有值、重启后 durable。 -- 根因:原子语义要求「日志追加失败 ⇒ 内存不更新」,但反向窗口(内存/日志已提交 ⇒ 必须报成功)没有保证;checkpoint 失败被算作写失败。`lastBackgroundError` 也只覆盖 append 失败路径。 -- 复现:注入 `medium.write('__kv_snapshot')` 抛错 + `checkpointThreshold` 设小。 - -### P1 — 结果错误 / 约束与事务语义破坏 - -**P1-1 maxLength / min / max 约束在 Memory/KVStore/Hybrid 上完全失效** **[已复现]** -- 位置:`memory.ts:691-711` 自带 `validateRow` + `memory.ts:713-722` `checkType` **只做类型判断**;而共享实现 `src/table/schema.ts:129-202` 的 `checkFieldType` 才校验 `maxLength`(146)、`min`(161)、`max`(167)。AriaEngine 走共享版(`src/engine/aria/index.ts:12,1672`)。 -- 现象:`maxLength:3` 的列插入 8 字符、`min:10/max:20` 的列插入 999 均成功(Aria 同 schema 会抛错)。 -- 影响面:README:56 明确宣称「输入校验(required / maxLength / min / max)」;三引擎静默接受越界数据 = 数据完整性缺口 + 引擎间行为漂移。 - -**P1-2 `find` 返回存储行引用(读到即能改库)** **[已复现]** -- 位置:`memory.ts:209`(无投影直接 `return results`)、`memory.ts:765-766`(索引路径回表得到的就是存储行对象)、`memory.ts:228`(`findStream` 回调传存储行)、`hybrid/index.ts:224`、`kvstore_engine.ts:294`(纯委托)。 -- 现象:`const rows = await db.query('SELECT * FROM t'); rows[0].name='HACKED'` → 再查已是 HACKED(在 Hybrid 下更严重:见 P2-4,调用方的改动会被后续无关 update 持久化到磁盘)。 -- 影响:绕过所有校验/钩子/审计、破坏事务快照(见 P2-5)、`find` 结果集共享导致的隐蔽耦合。`projectColumns`(`src/query/where-matcher.ts:214-228`)只做浅拷贝,嵌套 json 值仍共享。 - -**P1-3 自引用外键(同表 `references` 本表)的级联/RESTRICT 全部不生效** **[已复现]** -- 位置:`memory.ts:498`(`checkCascadeRestrict` 跳过同表)、`memory.ts:822`(`cascadeDelete` 跳过同表)、`memory.ts:432`(`applyUpdateCascade` 跳过同表)、`memory.ts:393`(`checkUpdateRestrict` 同样 `if (refTableName === tableName) continue`)。 -- 现象(实测三种):`nodes.parent REFERENCES nodes(id) ON DELETE CASCADE` 删父行后子行残留(悬空引用);`ON DELETE RESTRICT` 不抛错直接删掉父行;`ON UPDATE CASCADE` 改主键后子行 `parent` 仍是旧值。 -- 根因:三处「避免自引用无限递归」的保守跳过,用 `visited` 集合本可安全处理(`visited` 已经在用,memory.ts:493-495/817-818)。 - -**P1-4 ON UPDATE CASCADE 只级联一层 → 多层引用链断裂** **[已复现]** -- 位置:`memory.ts:430-448`:只遍历「列 `references` 的第一段 === 被改表」的直接引用表,修改的是引用表的**普通列**(如 `orders.user_id`),不会继续对 `orders.user_id` 的下游引用(如 `shipments.order_user REFERENCES orders.user_id ON UPDATE CASCADE`)做传递处理,也不进入 `planned`/预检体系。 -- 现象:`users.id: u1→u2` 后 `orders.user_id=u2`(已改),`shipments.order_user` 仍为 `u1` → 悬空引用。对照 `kvstore_engine.ts:599-621` 的 `affectedTables` 反而做了传递闭包(两边语义不一致:磁盘会重写孙表但内容仍是错的)。 - -**P1-5 事务期间的非事务写入被卷入事务并一起回滚** **[已复现]** -- 位置:`memory.ts` 所有写路径都**不检查 `snapshot`**(只有 DDL 检查,memory.ts:114/552/599);`kvstore_engine.ts:261/318/389/427` 用全局 `txActive` 决定写入是否进 tx 变更集。 -- 现象:`beginTransaction(); insert('in-tx'); insert('concurrent-no-tx'); rollbackTransaction()` → 两行都没了。任何在事务 await 间隙由「另一个调用方」发起的写,都会被并进当前事务并随回滚消失(或随提交意外生效)。 -- 影响:引擎实例级「一个全局事务」的隐含假设;`db.transaction()` 与并发的 `db.table().insert()` 交错即触发。 - -**P1-6 KVStoreEngine:事务 commit 的 schema 落盘不在原子批内 → 部分提交 + 孤儿行** **[已复现]** -- 位置:`kvstore_engine.ts:545`(`writeBatch(puts,deletes)` 一条日志记录)与 `kvstore_engine.ts:547-549`(`persistSchema()` = 另一次 `kv.put('__schema')`)。CHANGELOG v0.6.1 宣称「一条日志记录 = 真原子,多表事务中途崩溃不会部分提交」,但 DDL 事务的这一半在批外。 -- 现象(实测):事务内 `createTable('newt') + insert`,让 `__schema` 写入失败 → commit 抛错 → rollback → 重启后表不存在、**但 `t:newt:1` 已永久留在日志/快照里**(孤儿数据,open 时因表不存在被跳过,永不回收)。 -- 附带:反向窗口(行批失败、schema 已写)会留下「有表无行」的空表。 - -**P1-7 KVStoreEngine:非事务写落盘失败 → 内存/磁盘分叉,同一会话可读到幻影行** **[已复现]** -- 位置:`kvstore_engine.ts:258-290`(`memory.insert` 先提交,`kv.putMany` 后失败无补偿)、同类 `update`(302-382)/`delete`(384-417)/`clear`(424-436)。 -- 现象:注入 append 失败 → `insert` rejects,但 `find` 仍返回该行(同一会话可见),磁盘上没有;重启后行消失。Hybrid 有 v0.7.2 的 `recoverMemoryAfterDiskError` 补偿(hybrid/index.ts:199-209,实测有效),**纯 disk 模式没有这层保护**。 - -**P1-8 KVStoreEngine:open 回灌时的行级失败被静默吞掉** **[已复现]** -- 位置:`kvstore_engine.ts:103-108`(`try { JSON.parse; memory.insert } catch { /* 单行损坏跳过 */ }`)——注释设想的是「JSON 损坏」,但真实触发点还包括唯一冲突、类型不符、required 缺失等 schema 语义冲突。 -- 现象:schema 收紧(`email` 加 `unique`)后重启,磁盘 2 行、内存 1 行,**无日志、无计数、`repair()` 也不报**。与 P0-2 组成完整丢数据链。 - -**P1-9 KVStoreEngine:事务进行中 reload → commit 静默成功但一行未写(事务数据静默丢失)** **[已复现]** -- 位置:`kvstore_engine.ts:148-157`(`reload()` 无 `txActive` 守卫,`memory.close()/open()` 清空内存态但 `txChanges` 仍在)、`memory.ts:43-45`(`close()` **不清 `snapshot`**,所以后续 `memory.commitTransaction()` 反而成功)、`kvstore_engine.ts:540-542`(残留的 `'put'` 项被丢弃,既不 put 也不 delete)。 -- 现象:`beginTransaction(); insert('a'); reload(); commitTransaction()` → **不抛错**,count=0,数据消失。 -- 可达路径:`Hybrid.reloadMemoryFromDisk()` 由 BroadcastChannel 外部变更事件触发(`src/core.ts:79-91`),**任何时刻**(含事务中途)都可能打断正在执行的事务。 - -**P1-10 `setMeta` 不受事务回滚影响** **[已复现]** -- 位置:`kvstore_engine.ts:181-183`(直写 `kv.put`,无 tx 记录)、`176-179`(读同一层)。 -- 现象:`begin; setMeta('version','9'); rollback` → `getMeta('version') === '9'`。 -- 影响:迁移版本号(core.ts:107-114 `__metona_version`)一旦在事务内推进就无法回滚 → 迁移重跑判定失效(数据未迁移但版本已升)。 - -**P1-11 `MemoryEngine.close()` 遗留事务快照 → 重开后永久 `TX_ACTIVE`** **[已复现]** -- 位置:`memory.ts:43-45`(只清 tables/schemas/indexes/metaStore/opened,不动 `this.snapshot`)。 -- 现象:`beginTransaction(); close(); open();` → `beginTransaction()` 永远抛「Transaction already in progress」,`alterTable/createIndex/dropIndex` 永远抛 `NOT_SUPPORTED`(memory.ts:114/552/599)。 -- 可达路径:`KVStoreEngine.close()` 虽会先 rollback,但 `KVStoreEngine.reload()`/`repair()` 直接 `memory.close()`(kvstore_engine.ts:153、163)——这正是 P1-9 的成因之一。 - -**P1-12 介质层错误语义被折叠 → 不可恢复的「假空库」** **[读码确认]** -- 位置:`src/engine/aria/store/opfs_backend.ts:69-78`(`read` 把所有异常折叠为 `null`:文件不存在与 I/O 错误无法区分)、`opfs_backend.ts:84-113`(`dbDir===null`(已 close)时 `write/append` **静默 return**,调用方以为写成功)。 -- 链路:`kvstore/index.ts:98-111`(`read` 返回 null ⇒ 认为「无快照」)→ `index.ts:119-139`(无日志 ⇒ 空索引)→ 下一次 `checkpoint()`(index.ts:272-278)把空索引写成新快照并截断日志 ⇒ 介质上原有数据被永久覆盖。同理 close 之后的写入在 KVStore 内存索引里「成功」,重启即丢。 -- 建议:介质接口区分「不存在」与「读取失败」,写路径检查 `isOpen()` 并抛错。 - -### P2 — 健壮性 / 一致性 - -**P2-1 KVStoreEngine.open 失败后实例永久不可用,且真实错误被掩盖** **[已复现]** -- 位置:`kvstore_engine.ts:122`(`opened` 末尾才置位)、`75/79/80`(`kv.open`/`memory.open` 幂等早退)、`84-93`(catch 把 `createTable` 抛的「Table "a" already exists」重新包装成 `Corrupted schema in KVStore`)。 -- 现象:schema 半损坏 → 第一次 open 抛 KV_SCHEMA_ERROR;**第二次 open 仍抛同一个错误**(因为上次已建好的表再次 `createTable` 失败),永远无法打开,除非换实例。错误信息指向「schema 损坏」,真实原因是「表已存在」,可诊断性差。 - -**P2-2 行键编码用冒号拼接,表名含 `':'` 时重启串表/丢行** **[已复现]** -- 位置:`kvstore_engine.ts:65-71`(`t:${table}:${pk}`)与 `kvstore_engine.ts:99-101`(按**第一个**冒号切成 table/pk)。 -- 现象(实测两种):表 `a:b` 的行键 `t:a:b:1` 在重启时被解析成 table=`a`、pk=`b:1`;`a` 不存在则整行跳过(`count('a:b')` 1→0,数据蒸发);`a` 存在则**行被塞进表 `a`**(实测 `count('a')=1`、`rows(a)=[{"id":"1"}]`),即跨表数据污染。`collectTableDiff` 的 `startsWith(rowPrefix(table))` 前缀匹配(kvstore_engine.ts:638-661)同类风险(删表 `a` 会连带删掉 `a:1` 表的行)。 - -**P2-3 KVStore 的「假失败」还会留下重复 seq** **[读码确认 + 部分复现]** -- 位置:`kvstore/index.ts:357-362`(append 抛错即 `this.seq--`)。 -- 若失败发生在「数据已写但 API 抛错」(OPFS `close()` 阶段报错、配额错误等),日志里已有 seq=N,而内存水位退回 N-1;下一次成功写入又是 seq=N → 日志出现两条同 seq 记录。恢复按顺序应用(后者覆盖前者),最终值不一定等于写成功的那一条;`repair()`/`findValidLogLength` 也不会检测 seq 单调性。 - -**P2-4 Hybrid:json/数组等嵌套值在两个引擎间共享引用,调用方改动会被持久化** **[已复现]** -- 位置:`hybrid/index.ts:75-83`(`diskEngine.find` 返回的**存储行引用**直接交给 `memoryEngine.insert`)、`memory.ts:691-711`(`validateRow` 只浅拷贝顶层,嵌套对象按引用传递)。 -- 现象:`h.find(...)` 取出 `meta` 对象就地改 `flag`(不是一次 DB 写)→ 之后任意一次无关 `h.update(...,{v:2})` → 磁盘引擎把被污染的 `meta` 落盘,重启后读回 `MUTATED-BY-CALLER`。 - -**P2-5 事务快照是浅拷贝,嵌套值不在回滚范围内** **[读码确认]** -- 位置:`memory.ts:661`(`{...iv}`)、`memory.ts:638`(`new Map(this.schemas)`,schema/列定义对象共享)。 -- 影响:与 P1-2/P2-4 组合——事务内通过读结果就地修改嵌套对象,`rollbackTransaction` 无法还原;`schemas` 的共享对象也是 v0.7.2 必须「事务内禁止 DDL」的根因(memory.ts:111-119 注释自陈)。 - -**P2-6 Hybrid 双引擎分叉无任何校验/补偿** **[读码确认]** -- 位置:`hybrid/index.ts:232-241`、`243-252`(只返回内存引擎的 count,忽略磁盘引擎的 count)、`321-324`(rollback 内存先、磁盘后,内存抛错则磁盘不回滚)。 -- 影响:一旦两侧状态因任何原因(P1-7、失败补偿、reload)分叉,后续每次读写都在扩大分叉,且没有任何一致性检查点可用(`repair()` 只对齐一侧:hybrid/index.ts:99-104 修磁盘后重载内存)。 - -**P2-7 SharedMemoryBackend 的 materialized 缓存跨实例失效** **[读码确认]** -- 位置:`shared_memory_medium.ts:49-67`(`read` 缓存拼接结果)、`74-83`(`append` 只失效**本实例**缓存)、`15`(chunks 存于模块级 registry)。 -- 影响:A 实例读过 `__kv_log` 后,B 实例写入 → A 再 `read` 仍拿到旧缓冲;只有 `open()` 会重置缓存(`shared_memory_medium.ts:36`)。KVStore 的 `reload()` 恰好会 `medium.open()` 所以侥幸绕过,但 `repair()`、`listKeys` 类直接介质调用会有陈旧视图。仅测试/Node 介质,故 P2。 - -**P2-8 KVStoreEngine 非事务路径与介质层都缺少事务/生命周期守卫** **[读码确认]** -- `kvstore_engine.ts:170-174`(`clearAll` 在事务中直接 `kv.clear()` + `memory.clearAll()`,snapshot 仍在,rollback 会「复活」旧结构)、`kvstore_engine.ts:176-183`(`getMeta/setMeta` 不加 `ensureOpen`,未 open 也能写)。KVStore 侧 `put/delete/writeBatch` 不检查 `opened`(index.ts:202-254),close 后仍更新内存索引。 - -**P2-9 重启后 `CREATE UNIQUE INDEX` 来源标记丢失 → 无法再 DROP 该索引** **[已复现]** -- 位置:`memory.ts:26`(`uniqueIndexCols` 纯内存)、`kvstore_engine.ts:84-93`(open 只回灌 schema,不回灌来源标记)、`memory.ts:617-623`。 -- 现象:同一次生命周期内 `createIndex('t','email',true)` → `dropIndex` 可用;重启后同一个索引 `dropIndex` 抛 `NOT_SUPPORTED`。CHANGELOG v0.7.4 已把「重启后一律按建表约束保护」写成显式取舍,但这是一处「重启前后行为不同」的功能回退(P3 级体验,P2 级一致性)。 - -### P3 — 契约/可维护性(仅列有实际语义影响的) - -- **P3-1 `findStream` 忽略 `orderBy`**:`memory.ts:213-233` 从不读 `query.orderBy`,且 `limit/offset` 在**未排序**的迭代序上生效;接口文档却承诺「有 orderBy 时实现可回退物化」(`interface.ts:46-47`)。当前 SQL 侧不可达(`core.ts:307-309` 把 ORDER BY 判为不可流式),但 `table().stream()` 之外任何未来调用方都会静默拿到错误结果。 -- **P3-2 KVStoreEngine.open 的「重建二级索引」是死代码**(`kvstore_engine.ts:111-120`):`createTable` 已建桶,`createIndex` 见 `colDef.index/unique` 立即 return(`memory.ts:562`),该循环从未起作用——但注释给人以「索引有独立重建步骤」的错误印象(真实重建靠 `createTable` + 回灌 insert)。 -- **P3-3 `__kv_meta` 只写不读**(`index.ts:275-276` 写、`95-118` 恢复时明确忽略),是纯冗余状态;`logBytes` 记账同样与实际日志长度脱节(`index.ts:132-134`、`382`、`398`:截断失败也置 0)。 -- **P3-4 CREATE INDEX 幂等静默 / DROP 不存在报错不对称**(`memory.ts:562` vs `memory.ts:610-612`);SQLite 语义下重复 CREATE INDEX 应报错。 -- **P3-5 delete 返回「含级联」的行数**(`memory.ts:485`、`kvstore_engine.ts:416`),与 SQL「affected rows 仅指目标表」不同;Aria 测试已把级联计数钉死(`tests/aria-cascade.test.ts:26,44`),属四引擎共同语义债。 - ---- - -## 3. 性能议题 - -1. **主键变更 = 整表重写(写放大 ~1.8 万倍)** **[已实测]**:`kvstore_engine.ts:347-353` 走 `affectedTables` 传递闭包 + `collectTableDiff` 全表 put。2 万行表改 1 行主键 → **单条日志记录 1,877,821 字节**;同一表普通单行更新 103 字节。风险不止带宽:`encodeLogRecord`(`log.ts:47-92`)会一次性物化整条记录的 `Uint8Array`(含逐值 `new Uint8Array(e.value)` 拷贝),大表事务/级联删除会在内存里再造一份全表;10 万行级库(`tests/engine/kvstore-stress.test.ts` 只做 insert/update/delete,未覆盖主键变更与级联)可能直接 OOM 或产生数十 MB 单记录。 -2. **每次 UPDATE/DELETE 都是多趟全表扫描**:`kvstore_engine.ts:316`(`collectMatchingPks` → 一次 `find`)→ `memory.update` 的阶段 1 + 阶段 2(`memory.ts:267`、`289`)→ 落盘前再扫一遍(`kvstore_engine.ts:359`)。Hybrid 下每趟再 ×2(内存引擎 + 磁盘引擎各跑一次)。有索引时 `collectMatchingPks` 那趟能走索引,其余趟仍是全表。 -3. **`count()` 永不使用索引**(`memory.ts:531-538`):`COUNT(*) WHERE indexed_col = ?` 全表扫描,而同样的 `find` 可以 O(1)。 -4. **`find` 无 where 时 `Array.from(table.values())` 全量物化**(`memory.ts:729/771`),即使 `limit:1`;`tryIndexLookup` 命中索引时也会新建结果数组(`memory.ts:764`)。10 万行库上「取第一行」是 O(n) 分配。 -5. **级联路径是 O(n·m) 双倍扫描**:`delete` 先 `checkCascadeRestrict` 对每个待删行扫描每一张引用表(`memory.ts:505-508`),执行阶段 `cascadeDelete` 再扫一遍(`memory.ts:834-839`);`applyUpdateCascade`/`checkUpdateRestrict` 同样各扫一遍全表(`memory.ts:400-410`、`440-445`)。且级联按列嵌套循环(表 × 列),没有基于索引的定位。 -6. **`affectedTables` 是 O(T²·C) 的定点迭代**(`kvstore_engine.ts:599-621`),且每张表都 `await getTableSchema`(每次一次 Promise 往返);它把「所有可能受影响的表」整表 diff,而实际只有极少数行会变。 -7. **每行一次 async/await + 序列化**:`KVStoreEngine.open` 恢复循环每行一次 `await memory.hasTable` + `await memory.insert`(`kvstore_engine.ts:97-109`),10 万行 = 20 万次微任务 + 每行 `JSON.parse`;写路径每行 `JSON.stringify` + `TextEncoder.encode`(`kvstore_engine.ts:286/363/534/650`)。 -8. **checkpoint 是全量 O(库大小) 且阻塞所有写**:`encodeSnapshot`(`snapshot.ts:29-59`)逐值复制整库;它跑在 `opQueue` 里(`index.ts:327-331`),期间任何 `put` 都要排队等待;`close()`/`commit` 前后的大库 checkpoint 直接体现为写延迟尖刺。自动阈值 16MB(`index.ts:37`)意味着单次 checkpoint 可能处理数万行。 -9. **大表 diff 的单记录上限风险**:`writeBatch` 把所有 put/delete 编进**一条**记录(`index.ts:237-242`)。行数越多,单个 append 越大,OPFS 的 COW 写需要整文件复制语义(`opfs_backend.ts:102-113`),峰值内存 ≈ 原日志 + 新记录;没有分片/批量上限保护。 -10. **测试对 10 万级只做了「写入 + 抽样读」**(`tests/engine/kvstore-stress.test.ts:23-61` 采样 4 个 key;第 2 例只校验 1001 个值)——没有覆盖主键变更、级联、事务回滚、自动 checkpoint 失败等重路径,性能悬崖(上述 1/8)都在覆盖面之外。 - ---- - -## 4. 系统性观察:四份「关系语义」实现的漂移与收敛点 - -**已发生的行为漂移(可举证的重复实现代价)** -1. **校验规则两份**:`memory.ts:691-722` 的私有 `validateRow/checkType` 与 `src/table/schema.ts:87-202` 的共享版并存,前者漏掉 `maxLength/min/max` —— 漂移已经真实发生(P1-1),且 README 的宣称只对 Aria 成立。 -2. **主键解析三份**:`memory.ts:686-689`、`table/schema.ts:66-71`、`kvstore_engine.ts:579-584` 三处同样的「找 primaryKey,否则第一列」。 -3. **「受影响表」三种定义**:MemoryEngine 的级联只认「列 references 的第一段 === 被删/改表」(`memory.ts:497-503`、`433-436`,自引用被跳过、ON UPDATE 不传递)↔ KVStoreEngine 的 `affectedTables` 做**传递闭包**(`kvstore_engine.ts:599-621`)↔ Aria 自有实现。结果:同一句 `DELETE`/`UPDATE`,内存里的级联范围与磁盘重写范围不同(P1-4 里磁盘重写了孙表、但内容依旧错误)。 -4. **事务内 DDL 三种语义**:Memory/KVStore 拒绝 ALTER/CREATE INDEX/DROP INDEX 但允许 create/dropTable(`memory.ts:114/552/599`、`kvstore_engine.ts:236/442/459`),Aria 拒绝 ALTER;而 KVStore 的 createTable 在事务里可回滚、schema 却在 commit 的原子批之外(P1-6)。 -5. **唯一索引三处状态机**:`colDef.unique`(schema 标志)+ `indexes` 桶 + `uniqueIndexCols`(来源)+ 重启后的「一律视为建表约束」(P0-2、P2-9)。任一环缺失就出现「约束静默失效」或「重启后不可 DROP」。 -6. **索引一致性靠人工纪律**:`removeIndexEntries/updateIndexes` 的调用点分散在 update 阶段 2、cascadeDelete、applyUpdateCascade、clear、alterTable DROP、dropIndex 共 8 处(`memory.ts:135/179/291/298/442/444/481/544/628/791/855/868`),CHANGELOG 里 v0.3.3/v0.6.3/v0.7.3 连续三个版本都在修「某条路径忘了清/建索引」——这是典型的、还会继续发生的缺陷类别。 -7. **行/键编解码两份**:Memory 用 `String(pk)`,KVStoreEngine 用 `t:${table}:${pk}` 且按第一个冒号解析(P2-2),Aria 另有布局;`String()` 归一也意味着 number PK 与 string PK 在键空间理论上可碰撞(当前被 `checkType` 挡住)。 - -**收敛建议(按性价比排序)** -1. **统一校验**:删除 `MemoryEngine.validateRow/checkType`,改用 `table/schema.ts` 的 `validateRow`(已含 default/required/PK 非空/maxLength/min/max),一处修复即同时修复 Memory/KVStore/Hybrid(P1-1),并让四引擎的 `VALIDATION_ERROR` 文案一致。 -2. **抽出共享「外键规则引擎」**:输入(schemas + 变更集合 + 方向),输出(RESTRICT 违规 / 需 CASCADE 的行 / 需 SET NULL 的行),**递归 + 自引用 + visited 都在同一处实现**,供 Memory 与 Aria 直接调用,供 KVStoreEngine 生成「受影响主键集合」而不是「整表 diff」。这能一次性消灭 P1-3/P1-4、第 3 节的写放大与 O(n·m) 扫描,并让四个引擎的 delete/update 计数语义统一。 -3. **让 MemoryEngine 自己产出变更日志(change hook)**:insert/update/delete 时回调「(table, pk, 'put'|'delete')」,KVStoreEngine 只负责把这份日志刷成日志记录。这样 `txChanges/txFullTables/txClearedTables/collectMatchingPks/collectTableDiff/affectedTables` 六个启发式结构(以及它们导致的 P1-6/P1-7/P1-9)可以整体删除,「持久化 = 内存变更的重放」成为结构性保证而非逐路径补丁。 -4. **统一行键编解码**:零依赖的长度前缀或转义编码(或干脆把 table 名放进独立键段),消灭 P2-2 一类「拼接分隔符」缺陷;`String(pk)` 归一集中一处并显式声明 PK 的键空间规则。 -5. **把 schema 纳入同一条原子记录**:commit 时把 `__schema` 与行变更放进同一 `writeBatch`(或在快照内统一版本化),P1-6 的孤儿数据与「有表无行」窗口即消失。 -6. **内存引擎补写锁/事务互斥**:至少让写路径在 `snapshot !== null` 时拒绝或加入事务(`txActive` 显式化),并在 `close()` 中清空快照;这消除 P1-5、P1-9、P1-11 一类「状态泄漏 → 静默丢数据」问题。 -7. **KVStore 恢复路径的原子化契约**:open 截断必须落盘等价状态(先快照或写回有效前缀),写失败不得在内存/日志之间产生「半提交」,介质接口区分「不存在/读失败/已关闭」。这是 P0-3、P0-4、P2-3、P2-6 的共同根因——**恢复路径缺少「内存状态 ↔ 介质状态」的等价性证明**,目前依赖调用方(KVStoreEngine.close)恰好会 checkpoint。 - -**测试覆盖的结构性缺口**(直接影响上述风险的可回归性):现有用例把「结果」钉死了,但缺**第二次崩溃**(`kvstore.test.ts:176-210` 只重启一次)、缺**注入 checkpoint 失败**、缺 **reload/close 期间的事务**、缺 **ALTER 后的约束行为**、缺 **自引用/多层外键**、缺 **10 万级的主键变更与级联**。这七类恰是本次 P0/P1 的高发区。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8220ad8..332df2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to MetonaSqlark will be documented in this file. ### 根治性迭代 —— 统一语义 / 消灭复发结构 / 验证基础设施 -> 依据 `PLAN-v0.7.5.md` 的三条并行工作流(A 缺陷修复 / B 结构根治 / C 验证基础设施) +> 依据 v0.8.0 迭代方案的三条并行工作流(A 缺陷修复 / B 结构根治 / C 验证基础设施) > 完成的一次系统性迭代。**这一版的重点不是"再修一批 bug",而是砍掉让同类 bug > 必然复发的结构**:多处并存的语义实现被收敛为唯一实现,并第一次让崩溃语义、 > 错误码一致性、入口等价性变成可机器验证的门禁。 @@ -46,7 +46,7 @@ All notable changes to MetonaSqlark will be documented in this file. 并统一分隔标识符语义:引号只在"解析→执行"边界脱去。 顺带补上 **ORDER BY 的列存在性/歧义校验**(此前 JOIN 里裸写两表同名列既不报错 也不确定按哪列排)。 -- **B-6 存储提交点(完整实施,非降级方案)** — 见 `PLAN-v0.7.5.md` 附录 H。 +- **B-6 存储提交点(完整实施,非降级方案)** — 交付物与提交顺序见下方各条。 - **`__aria_manifest_` 单一提交点**:页面水位 + 各命名空间 SSTable 元数据 + 表结构 + WAL 起始位置 + 待落盘冻结表意图,一次原子提交 (头部/载荷双 CRC、先写后验、保留两代)。顺序固定为 @@ -289,10 +289,10 @@ All notable changes to MetonaSqlark will be documented in this file. `ARIA_SSTABLE_SAVE_CONTRACT`、`ARIA_DB_NOT_OPEN`、`ARIA_OPEN_ERROR`、 `DB_NOT_OPEN`、`KV_*`、`TX_*`、`SAVEPOINT_*`、`UNKNOWN_STATEMENT`、 `COLUMN_EXISTS`、`FOREIGN_KEY_VIOLATION`)→ 全部补齐。 -- **维护脚本与门禁**:CONTRIBUTING 的变异数量 17 → 40,并补上脚本自身的保证 - (正控 / 编译失败区分 / 超时 / 逐字节恢复校验 / 进程锁);PLAN 附录 G 的 - "12 项事故级"改为 11 项(与总账 A1~A11 一致)、G4 的"零丢失"限定为"故障注入 - 矩阵覆盖范围内"(并记录矩阵之外发现的那处 P0)、H.1 的"25 例"改为 24 例。 +- **维护脚本与门禁**:CONTRIBUTING 的变异数量 17 → 42,并补上脚本自身的保证 + (正控 / 编译失败区分 / 超时 / 逐字节恢复校验 / 进程锁);门禁表述订正三处 —— + "12 项事故级"改为 11 项(与缺陷总账 A1~A11 一致)、"已确认写入零丢失"限定为 + "故障注入矩阵覆盖范围内"(并记录矩阵之外发现的那处 P0)、表驱动用例数 25 → 24。 ### 已知限制(v0.8.0 新增/变更) diff --git a/PLAN-v0.7.5.md b/PLAN-v0.7.5.md deleted file mode 100644 index 0101f5e..0000000 --- a/PLAN-v0.7.5.md +++ /dev/null @@ -1,1055 +0,0 @@ -# MetonaSqlark v0.7.5 根治性迭代方案 - -> 编制日期:2026-08-15 · 对象版本:v0.7.4(commit `2844a06`) -> 方法:全量源码逐行通读(src 16,079 行 / tests 20,223 行 / 文档 4,349 行,共 7 个并行深度审计)+ 独立可执行探针复现 -> 全文证据分为三级:**【实测】**= 本次真实运行复现;**【读码】**= 逐行代码路径确认;**【推断】**= 推理未复现 - ---- - -## 一、结论先行 - -**v0.7.4 不是"接近完成",而是"已经积累了系统性语义债务"。** - -1304 个测试全绿、90.1% 覆盖率、lint/typecheck 干净——这些指标全部真实,但它们保护的是**已被测试钉死的旧行为**,而不是**用户实际依赖的 SQL 语义**。本次审计在每一个子系统都发现了可稳定复现的静默错误结果、静默数据丢失或崩溃(共 43 项确认缺陷,含 11 项事故级),且它们有一个共同的成因结构。 - -**一句话总结根因**:这个项目把"四套存储引擎 + 三条 SQL 入口"当作四个独立实现来维护,于是**任何一条关系语义都没有唯一实现**。每次审计都发现同一批 bug 出现在 3~4 个引擎里;每次修复只打在审计报告点到的那条路径上,下一版换个入口再犯一次。v0.7.2 修 UPDATE 原子性、v0.7.3 修 INSERT 原子性、v0.7.4 修写语句子查询——三个连续版本修的是同一个结构缺口的不同侧面。 - -**因此 v0.7.5 的正确形态不是"再来一轮修复",而是"先止血,再砍掉让 bug 必然复发的结构"。** 方案分三条并行工作流 + 一道硬门禁。 - -### 关键基线(本次实测,非引用文档) - -| 指标 | 文档宣称 | 本次实测 | 备注 | -|---|---|---|---| -| Jest 测试 | 1304 / 76 套件 | **1304 通过 / 76 套件 / 395s** | 数字真实 | -| 行覆盖率 | "90.1% 行覆盖率" | 语句 **90.08%**;行 **92.91%**;分支 **82.92%** | 口径写错;分支率从未公布 | -| 覆盖率分母 | — | `jest.config.cjs:18` 的 `!src/**/index.ts` 排除 15 个文件 | 含 AriaEngine 主实现 2,282 行 | -| 全量覆盖率(不排除任何文件) | — | 语句 **90.66%** / 行 **93.43%** / 分支 **82.94%** | 被排除文件覆盖率并不低(Aria 96.79%) | -| typecheck / lint | 干净 | **均 exit 0** | 但 CI 里 lint `continue-on-error: true` | -| 覆盖率门禁 | — | **不存在**(无 `coverageThreshold`,CI 不跑 `--coverage`) | 覆盖率掉到 0% 也全绿 | -| 崩溃注入 | "崩溃恢复验证" | **不存在**:单测全是 `backend.close()`(优雅停机);e2e 的 `crashPage()` 只 `page.close()` | 见 §3.3 根因 6 | - ---- - -## 二、范围与方法 - -### 已完整读取的内容(无截断) - -| 类别 | 内容 | -|---|---| -| 源码 | `src/` 全部 59 个文件、16,079 行 | -| 测试 | `tests/` 全部 77 个文件、20,223 行(含 e2e harness 与 mock) | -| 文档 | `README.md` 479、`CHANGELOG.md` 1090(全部 81 个提交历史)、`CONTRIBUTING.md` 219、`site/*.html` 2,561 | -| 工程配置 | `package.json`、`jest.config.cjs`、`jest.setup.js`、`rollup.config.js`、`tsconfig.json`、`.eslintrc.json`、`babel.config.cjs`、`playwright.config.ts`、`.gitea/workflows/ci.yml`、`.npmrc`、`.gitignore`、`coverage/lcov.info` | -| Git | 全部 81 个提交的元数据与关键 commit 的 diff | - -### 复现强度 - -自行编写并运行了 10 轮可执行探针(覆盖 core / SQL / query / 四引擎 / Aria 存储 / 并发语义),**每一轮跑完即删除,仓库最终无残留**。§4 缺陷总账中标注 **【实测】** 的条目均来自这些探针的真实输出。 - -### 方法边界(玥玥的坦诚声明) - -- 子代理的报告存在**可验证性分级**:标注【实测】的我逐一交叉复跑过关键项;标注【读码】的我没有全部独立复现,已在总账中如实标注来源。 -- 崩溃注入类缺陷(WAL 半写、介质读故障、多实例 seq 撞号)依赖**故障注入实验**,部分由子代理完成后我做了代码路径核对,**未全部独立复跑**。 -- 性能数字(如 `compressLZ4` 64KB→4167ms、主键变更 1.8MB 单条日志)来自子代理实测,我核对过代码路径但未重跑基准。 -- **子代理的结论我做过修正**:KVStore 审计中有两条的**机制描述**与我的独立复现不一致(详见附录 C 的"附带修正")。凡本方案与子代理报告冲突处,以本方案为准。 -- `src/engine/aria/index/lsm.ts` 的深度审计在本方案定稿时**已完成**,结论已并入 §3 根因 4 与 §5 总账(见附录 E)。 - ---- - -## 三、根因分析:为什么同一个 bug 会修四次 - -八个根因,按"砍掉它能消除多少缺陷"排序。每个根因都给出证据链——**这不是推测,是 CHANGELOG 自己记录的历史**。 - -### 根因 1 ★ 关系语义没有唯一实现(最高杠杆) - -同一套关系语义被独立实现了 4~5 份: - -| 语义 | 实现位置 | 已发生的漂移(实测) | -|---|---|---| -| 行校验 `validateRow/checkType` | `memory.ts:691-722`(私有)· `aria/index.ts:1647-1673`(私有)· `table/schema.ts:87-202`(导出) | **memory/disk/hybrid 完全不校验 `maxLength`/`min`/`max`,仅 Aria 校验**【实测】 | -| 值相等/比较 | `where-matcher.ts` 的 `===` · 哈希连接 `String(v ?? '\0')` · 分组键 `encodeGroupKey` · `COUNT(DISTINCT)` 的 `String(v)` · Aria 索引键 `String(v)` | 5 套并存 → NULL 三值逻辑完全缺失【实测】 | -| "受影响表" | Memory 单层且跳过自引用 · KVStore `affectedTables` 传递闭包 · Aria 自有 | ON UPDATE CASCADE 在 memory 只级联一层;自引用外键 CASCADE/RESTRICT **全部静默失效**【实测】 | -| 主键取值 | `getPrimaryKey` 三份拷贝 | — | -| 事务内 DDL 语义 | aria 拒绝 CREATE/DROP;memory/disk/hybrid 允许 | dev(memory) 通过,prod(aria) 抛 `NOT_SUPPORTED`【实测】 | -| 索引维护 | **8 处调用点,纯人工纪律** | v0.3.3 / v0.6.3 / v0.7.3 **连续三个版本**都在修"某条路径忘了清/建索引" | - -**这就是为什么 v0.7.2→v0.7.4 每个版本都要在 3~4 个引擎里重复修同一个 bug。** - -### 根因 2 ★ SQL 语义有三条互不校验的入口 - -| 入口 | 实现 | 后果(实测) | -|---|---|---| -| `db.query()` | `core.ts:207-242` → executor 完整管线 | 基准 | -| `db.queryStream()` 快路径 | `core.ts:311-325` 自己推导列投影/limit/offset | `SELECT id AS x` 返回**全列原始名**;`OFFSET 1 LIMIT 2` 返回 2 行而非 1 行;`LIMIT 0` 返回 1 行;`SELECT t.id` 返回 `{}`;不触发 `beforeQuery`/`afterQuery`;不尊重 `maxRowsPerQuery` | -| `QueryBuilder` | `builder.ts:105` 无 JOIN 直通 `engine.find` | 链式 `.where({a:1}).where({a:2})` **覆盖**而非 AND(返回 a=2 的行);`$subquery` 静默 0 行;事务内 JOIN **静默忽略** | - -**同一个语义三条实现,没有任何机制保证三者一致。** 132 个 queryStream 测试没有一条断言"流式结果必须等于物化结果"。 - -### 根因 3 ★ AST 把表达式当字符串,导致文法被迫写两遍 - -`SelectStatement.columns: string[]`(`ast.ts:20`)把列引用、`col AS alias`、`COUNT(*)`、常量、**CASE 原文**全部混为字符串。于是 executor 用 6+ 处正则再解析一遍(`executor.ts:60/66/79/93/1027/1033/1051/1071/1079`)。 - -直接后果【全部实测】: -- `SELECT 1 AS one FROM t` → `[{}]`(数字常量投影丢失) -- `SELECT COUNT(t.v) FROM t` → `0`(带表前缀的聚合参数取不到键) -- `CASE WHEN a=1 THEN 'a ELSE b' ELSE 'other' END` → `"'a"`(不感知字符串字面量的正则切分) -- 嵌套 CASE → 解析器吞掉 `FROM` 之后全部文本,返回 1 行空对象 -- `SELECT dept AS d, COUNT(*) GROUP BY dept` → 别名丢失;`SELECT v AS s ... GROUP BY dept` → **整列静默消失** - -### 根因 4 ★ 存储层没有单一提交点,"什么已持久"靠猜 - -WAL `lsn`/`currentSegment`、`FileManager.nextPageId`、KVStore `seq`、LSM `levels+meta` 各自是独立的内存计数器,恢复时与介质的一致性全靠推理。已发生的后果: - -- FileManager 页面 id 崩溃回退 → 复用页面 → compaction 误删 → **丢 75%**(v0.6.1 修) -- LSM.flush 与 compaction 并发写 meta → 产物变孤儿 → **丢 75%**(v0.6.1 修) -- KVStore 快照损坏水位 bug → **静默丢数据**(v0.6.1 修) - -**本次逐一独立复现的三个 KVStore 事故级缺陷**(探针输出见附录 C;其中前两处的**确切机制**比子代理报告的更精确,已在 B-6 中修正): - -- **损坏日志恢复后崩溃 → 已确认数据永久丢失**:`open` 遇损坏记录会 `truncateLog()` 把日志**写成 0 字节**(`kvstore/index.ts:394-396`),而有效前缀(实测 r1、r2)**只存在于内存 index 里**,既不在日志也不在快照。实测首次 open 后 `logBytes=0 / snapshotBytes=0`;不 checkpoint 直接崩溃重开 → r1/r2 全部消失。 - > 附带修正一条审计结论:恢复的实现是"遇首个坏记录即停止重放"(`log.ts:287-298` 的 `break`,**并非跳过后续记录**),这个方向本身正确;问题只在于"截断成空"而不是"截断到有效前缀"。 -- **checkpoint 失败 → 调用方收到 rejection,但数据已提交且持久**:`appendRecord` 把 `medium.append` 与"内存更新 + 自动 checkpoint"分在两段(`kvstore/index.ts:334-362` vs `:385-390`),后者在 try/catch 之外。实测注入快照写失败后 `put` 抛错、内存里值仍在、重启后**值依然存在**——调用方以为写失败而执行回滚,数据却已落地(事务回滚无效)。 -- **陈旧实例 checkpoint 吞掉对方已提交数据**:`seq` 是**每实例独立**计数器。实测 A、B 同时 open(seq 均为 0)→ A.put(A.seq=1,写入共享日志)→ B.put(B.seq=**也是 1**)触发自动 checkpoint → B 用自己的空快照覆盖共享快照并把日志截断为 0 → 重启后 **fromA 丢失、fromB 存在**。根因是 KVStore 默认独占介质,而引擎层**没有给它加锁**(Web Lock 只加在 AriaEngine 一侧)。 - > 附带确认一条**持久化契约缺口**:`KVStore.close()` **不做 checkpoint**(只 `opQueue` 排空 + `medium.close()`)。数据的持久化完全依赖上层引擎的优雅关闭路径 —— 这是根因 5"崩溃语义靠声称"的又一处实例。 - -- SAVEPOINT 回滚不写补偿 WAL → 崩溃后已回滚的行**复活**【实测】 - -**LSM 层新增一个同类事故级缺陷**(子代理完成审计 + 我代码路径核对): - -- **后台 flush 失败后,冻结数据既不入 levels 也无重试路径 → 内存成唯一副本,崩溃即丢**:`flushImmutableAsync`(`lsm.ts:220-261`)在 `save`/`saveMeta` 抛错时,`levels[0].unshift(meta)`(253)与 `frozenMemtables` 清理(255)都不会执行 —— 数据留在 `frozenMemtables` 里"读得到",但**只有 `flush()` 里那一次入链机会**(`lsm.ts:605/612` 是唯一两处入链点)。此后每次 `flush()` 又被 `lastBackgroundError` 检查(`lsm.ts:580-590`)在**所有入链动作之前**直接抛出并清空标志 → 该次 flush 完全没执行。**结果:调用方早就收到 `insert()` 成功,数据却只存在于内存,进程崩溃即永久丢失。** - > 这是我独立验证过并确认的:`frozenMemtables` 只在 `flush()/freezeMemtable` 内被写入(`lsm.ts:188`),而 `flush()` 从不遍历它重新入链(618 行的 filter 只删空表)—— 重试路径**不存在**。 - > 与 KVStore 的三个缺陷同源:**"待落盘意图"没有持久化记录**(根因 4)。 - -- LSM 的具体成因:`sstable.ts` 的解析循环被抄成**三份**(`80-100`/`145-166`/`182-201`)且越界策略不一致(点查与扫描对同一损坏文件行为不同);5 条隐式不变量(层间新鲜度、层内不重叠、索引键=块尾 key、minKey-maxKey 覆盖、bloom 参数一致)**无任何断言或属性测试**;后台任务是 fire-and-forget + 单 `boolean` 互斥(`compacting`,`lsm.ts:78,269-283`,跨层触发被静默丢弃);`LSM.get` 缓存未命中返回 `null` 与"确实不存在"**不可区分**(`lsm.ts:734-753`),整条读路径靠"每次读前必须 prefetch"维持 —— 而 `compactLevelAsync:508-515` 的同类问题**已经修了**,`get` 没修(同类问题只修一半)。 - -### 根因 5 ★ 崩溃语义是"声称"而非"已验证" - -- 单测的"崩溃"统一是 `await engine.backend.close()`(`aria-matrix-audit.test.ts:93`、`aria-prod-load.test.ts:71`、`aria-wal-segment.test.ts:173` 等十余处),而 `opfs_backend.ts:39-46` 的 `close()` 明确 `await this.writeQueue` ——**这是优雅停机,把所有在途写刷完**。 -- `tests/helpers/opfs-mock.ts` 是立即生效、原子、**绝不撕裂**的内存 Map,无法表达半写/部分删除失败/读故障。 -- e2e 的 `crashPage()`(`opfs.spec.ts:32-42`)只做 `window.__ms=null; page.close()`,注释自承"Playwright 无 Page.crash 公共 API"(实际 CDP 可用)。 -- 结论:**这套测试在结构上不可能发现根因 4 的任何一条。** - -### 根因 6 生命周期只有布尔,没有状态机 - -`ready`/`opened`/`txActive`/`snapshot` 分散在 core、四个引擎、连接池、React、Vue 六处各自维护,`close/init/reload` 都非幂等且非异常安全【全部实测】: - -- `MemoryEngine.close()` 不清事务快照 → 重开后 `beginTransaction` 永久 `TX_ACTIVE`,`rollbackTransaction` 用**关闭前的陈旧快照覆盖新会话**(数据复活) -- `close()` 后再 `init()` 不重建 BroadcastChannel → `multiTabSync` 静默失效 -- `close()` 无 try/finally → engine.close 抛错时 `isReady()` 仍为 true(半关闭态),钩子已全丢 -- 连接池:用户直接 `db.close()` 后同名 `connect()` 新建实例并把 refCount 置 1,**任意一次 disconnect 就关闭它** -- 多标签页 reload 不检查活跃事务 → 内存快照被销毁、`txActive` 残留 → 提交时内存≠磁盘 - -### 根因 7 "未解析/未知"一律静默降级为 false - -这是**复发型缺陷模式**,CHANGELOG 连续三个版本都在修它的不同实例: - -- v0.7.3:`queryStream` 遇 `$subquery` → `matchWhere` 恒 false → 静默空结果 -- v0.7.4:`UPDATE/DELETE` 遇 `$subquery` → **静默影响 0 行** -- 本次仍存在:`WHERE t.x = t.y`(唯一可解析的列对列写法)恒 0 行;`IN (SELECT ... WHERE o.user_id = u.id)` 关联引用静默空 - -修法是**逐路径补丁 + 两个手写重复的递归探测器**(`core.ts:284-306`、`where-matcher.ts:40-62`),而不是让 matcher 对无法表达的操作数直接抛错。 - -同类:未知列 `SELECT bogus FROM t` → `[{},{},{},{}]`;`GROUP BY bogus` → 全表并一组;`ORDER BY bogus` → 随机顺序;未知 WHERE 操作符 → 静默全匹配。 - -### 根因 8 错误分类与所有权边界缺失 - -- 错误码散落字符串,`onError` 逐方法手写调用(`core.ts` 里 20+ 处 try/catch),Table API 路径、export/backup、migrateTo、repair、clearAll **都不触发 onError**【实测】 -- **行所有权无边界**:`find/findStream` 返回引擎内部行对象引用 → `rows[0].tag='HACKED'` 直接改库,memory/disk/hybrid 下**索引与行失配,该行变得不可检索**【实测】 -- 而 ALTER DROP COLUMN 又**依赖**"find 返回引用"这一副作用来删列数据 —— 一个 bug 被另一个 bug 依赖 - ---- - -## 四、v0.7.5 迭代方案 - -### 总览:三条并行工作流 + 一道硬门禁 - -``` -┌──────────────────────────────────────────────────────────────────┐ -│ 工作流 A · 止血(阻断发布) 11 项事故级 + 18 项高优 P1 │ -│ 目标:让 v0.7.5 不再产生错误结果 / 静默丢数据 / 崩溃 │ -├──────────────────────────────────────────────────────────────────┤ -│ 工作流 B · 根治(砍掉复发结构) 6 个结构性改造 │ -│ 目标:让"同一 bug 修四次"在结构上不可能发生 │ -├──────────────────────────────────────────────────────────────────┤ -│ 工作流 C · 验证(让门禁真的能拦) 故障注入 + 覆盖率口径 + CI 门禁 │ -│ 目标:让 A 和 B 的成果可证明、可回归 │ -└──────────────────────────────────────────────────────────────────┘ - ▼ 硬门禁 G1~G6(§7)全部满足才允许发版 -``` - -**排期建议**:A 与 C 可并行开始(C 先做,因为它能验证 A);B 必须在 A 完成后开始,否则会在移动的地基上重构。 - ---- - -### 工作流 A · 止血(11 项事故级,必须全修) - -按"用户能感知的伤害"排序。每项给出:现象 → 根因位置 → 修复方向 → 必须新增的回归测试。 - -#### A1 ★ `UPDATE` 多行撞同一新主键 → 静默丢行 【实测】 - -- **现象**:`UPDATE t SET id='X'`(匹配 2 行)返回 affected=2,表中只剩 1 行 -- **根因**:`memory.ts:274` 阶段 1 只与"语句执行前的表"比对,**批内新主键互查缺失**;阶段 2 逐行 `set` 互相覆盖。`primaryKey:true` 不隐含 `unique:true`,`checkUpdateUniqueness` 只遍历 `unique` 列,兜不住 -- **对照**:INSERT 路径 v0.7.3 已做批内 PK Set 互查 —— **UPDATE 漏了** -- **修复**:UPDATE 两阶段预检增加批内新主键 Set 互查(与 INSERT 同一模式);四引擎统一 -- **测试**:2 行改同一新主键 → 必须抛 `DUPLICATE_KEY` 且**表内容不变**(值级断言,非仅行数) - -#### A2 ★ `ALTER ADD COLUMN ... UNIQUE` 唯一约束永久失效 → 重启静默丢行 【实测】 - -- **现象**:`ALTER TABLE t ADD COLUMN email STRING UNIQUE` → 插入两条相同 email 都不报错 → close/reopen → **只剩 1 行,无任何错误或警告** -- **根因链**:`memory.ts:122-127` ALTER ADD 只写 `schema.columns` **不建索引桶** → `memory.ts:316-339` 唯一预检依赖索引桶,桶缺失即整段跳过 → `kvstore_engine.ts:103-108` 重启回灌时第 2 行触发 `UNIQUE_VIOLATION`,**异常被 `catch {}` 吞掉** -- **修复**:① ALTER ADD 同步建索引桶;② 重启回灌的静默 catch 必须记录并上报(`KV_REBUILD_ERROR`) -- **测试**:ALTER ADD UNIQUE → 重复插入必须报错;已存在重复数据的表重启后必须**报错而非静默丢行** - -#### A3 ★ 崩溃后已回滚的行复活(SAVEPOINT)【实测】 - -- **现象**:`BEGIN; INSERT a; SAVEPOINT s; INSERT b; ROLLBACK TO s; COMMIT` → 实时只剩 a,**崩溃重开变成 a+b** -- **根因**:`aria/index.ts:1550-1579` 只改内存 `txnSnapshot`,**WAL 无补偿记录**;恢复按 `committedTxns` 应用该事务**全部**记录 -- **修复**:`ROLLBACK TO` 写入补偿 WAL 记录(或在恢复时按 savepoint 边界过滤) -- **测试**:上述序列后丢弃引擎实例重开,断言实时态 == 恢复态 - -#### A4 ★ SAVEPOINT 跨事务泄漏 → 当前事务写入被上一事务快照替换 【实测】 - -- **现象**:`begin; update; savepoint sp; commit` 后 `savepoints` 仍含 `sp`;新事务 `update` 后 `ROLLBACK TO sp` + `COMMIT` → **新写入静默消失**(本方案验证时实测 v=2 被回退为 v=1) -- **根因**:`aria/index.ts:1467-1534` 的 commit/rollback **都不清理 `this.savepoints`**;`index.ts:1563` 用 `sp.snapshot` 整体替换当前快照 -- **修复**:commit/rollback 清空 savepoint 表;`rollbackToSavepoint` 校验 `sp.txnId === currentTxnId`(存了 txnId 却从不校验) -- **测试**:跨事务复用 savepoint 名 → 必须拒绝;当前事务写入不得丢失 - -#### A5 ★ `MIN`/`MAX` 20 万行栈溢出 【实测】 - -- **现象**:`SELECT MAX(v) FROM big`(20 万行同组)→ `RangeError: Maximum call stack size exceeded` -- **根因**:`executor.ts:677-678` 用 `Math.min(...distinctNums)` 展开实参 -- **修复**:改为单次遍历归约 -- **测试**:20 万行 `MIN`/`MAX`/`SUM`/`AVG` 全部正常返回 - -#### A6 ★ `LIMIT/OFFSET` 被应用两次 → 丢行 【实测】 - -- **现象**:id=1..4 上 `SELECT id FROM t ORDER BY id LIMIT 2 OFFSET 1` → `["3"]`(SQL 应为 `2,3`);`LIMIT 10 OFFSET 3` → `[]` -- **根因**:`compiler.ts:40-41` 已把 limit/offset 放进 plan → 引擎 `memory.ts:203`/`aria/index.ts:696` 已切一次 → `executor.ts:391-393` **再切一次** -- **注**:JOIN 路径与派生表路径**正确**,同一 executor 内自相矛盾 -- **修复**:limit/offset 只在**一处**执行(见工作流 B-3 单管线) -- **测试**:`LIMIT n OFFSET m`(m>0)在 JOIN / 非 JOIN / 派生表 / UNION 四条路径结果一致 - -#### A7 ★ `queryStream` 与 `query` 结果不一致【实测】 - -| SQL | `query()` | `queryStream()` | -|---|---|---| -| `SELECT id AS x FROM t` | `[{x:'a'}]` | `[{id:'a',v:1}]` → **全列+原列名** | -| `SELECT t.id FROM t` | `[{id:'a'}]` | `[{}]` → **空对象** | -| `LIMIT 2 OFFSET 1` | 1 行 | 2 行 | -| `LIMIT 0` | `[]` | 1 行(Aria 却为 `[]`) | - -- **根因**:`core.ts:316-319` 用正则自行推导投影,未复用 executor 的 `projectRow`/别名规则 -- **修复**:流式路径必须复用同一套投影与 limit/offset 语义(B-3) -- **测试**:**参数化契约测试**——同一 SQL 集合,断言 `queryStream` 收集到的行严格等于 `query` 结果(这是防复发的关键测试) - -#### A8 ★ 行引用泄漏 → 调用方可直接改库并破坏索引 【实测】 - -- **现象**:`rows = await db.query('SELECT * FROM t'); rows[0].tag='HACKED'` → 存储被改,且 **`tag='HACKED'` 与 `tag='x'` 两条索引查询都返回 0 行**(行不可检索) -- **引擎差异**:memory/disk/hybrid 全部泄漏;**Aria 不泄漏**(走反序列化) -- **根因**:`memory.ts:209/228/765` 直接返回内部行对象 -- **修复**:在引擎接口定义"读返回副本"(`structuredClone` 或按 schema 深拷贝),并把依赖引用的 ALTER DROP COLUMN 改为显式 `deleteRowColumn` API -- **测试**:修改查询结果后重读必须不变;索引查询必须仍能命中 - -#### A9 ★ `subscribe()` 对本地写入永不触发 【实测】 - -- **现象**:`db.subscribe('t', fn)` 后本地 `INSERT`/`UPDATE`/`DELETE`(SQL 与 Table API 两条路径)**fn 调用 0 次** -- **根因**:全库仅 `core.ts:84`(BroadcastChannel 外部消息)调用 `emit`;写路径只调 `broadcastChange` -- **文档冲突**:`site/docs.html:667-679` 明确示例 `event.type: 'insert' | 'update' | 'delete'`;README:232 宣称"订阅表变更" -- **修复**:写路径接入 `emit`(含 row 与 type),或**明确降级文档**并移除误导示例 -- **测试**:两条入口的三种写操作都必须通知订阅者 - -#### A10 ★ `t.x = t.y` 与关联子查询静默空结果 【实测】 - -- **现象**:`SELECT id FROM t WHERE t.x = t.y` → `[]`(应 3 行);`WHERE id IN (SELECT user_id FROM o WHERE o.user_id = u.id)` → `[]`(应 2 行),而同结构 EXISTS 正确 -- **根因**:`executor.ts:344` 把含 `$col` 的条件交给引擎,而引擎 `matchWhere` 无 `options.$col` 上下文 → 行全被滤光,`filterCorrelated` 拿到空数组;`executor.ts:1417` 执行子查询时**不传外层行** -- **注**:`tests/v073-fixes.test.ts:267-278` 是这条语义的"护栏",但它只比较 `query()` 与 `queryStream()` 的**行数**(两者都是 0)→ **空断言掩盖错误** -- **修复**:统一表达式求值器,取消"是否相关"预判(B-4) -- **测试**:值级断言 + 修正那条空断言护栏 - -#### A11 ★ `AND`/`OR` 无优先级(影响面最大,改动最小)【实测】 - -- **现象**:`WHERE a=1 OR a=2 AND b=3` → 解析为 `(a=1 OR a=2) AND b=3`,返回 `["2","4"]`;加括号的正确写法返回 `["1","2","4"]` -- **根因**:`parser.ts:780-797` 见 AND/OR 就左结合包一层,无优先级分层 -- **影响**:任何"权限条件 OR 业务条件 AND 软删标记"的写法都会静默错行 -- **修复**:`parseCondition` 拆 `parseOr → parseAnd → parseUnary` 三层 -- **测试**:断言 AST 结构与值级结果,覆盖 `A OR B AND C`、`A AND B OR C`、嵌套括号 - -### 工作流 A 的第二梯队(18 项高优 P1,逐项列入 §5 总账) - -其中**必须在 v0.7.5 内完成**的: - -| # | 缺陷 | 实测现象 | 一句话修法 | -|---|---|---|---| -| A12 | `maxLength`/`min`/`max` 三引擎不校验 | memory/disk/hybrid 接受 `score:999`(max 100) | 统一 `validateRow`(B-1) | -| A13 | 自引用外键全失效 | 同表 CASCADE 不删、RESTRICT 不拦 | 用 `visited` 集合安全处理(`memory.ts:393/432/498/822` 四处跳过) | -| A14 | ON UPDATE CASCADE 只一层 | A→B→C 链改 A 主键后 C 悬空 | 递归 + 传递闭包 | -| A15 | `= NULL` / `NOT BETWEEN` 三值逻辑错 | `v = NULL` 命中 null 行;`NOT BETWEEN 1 AND 2` **排除** NULL 行 | 引入 `sqlEquals/sqlCompare` 返回 TRUE/FALSE/UNKNOWN(B-2) | -| A16 | INSERT arity 不校验 | `INSERT INTO t(id) VALUES('1','LOST')` 静默丢值 | 解析期校验 arity | -| A17 | INSERT 未知列静默丢弃 | `INSERT INTO t(id, nmae)` 静默丢 typo 列 | 与 UPDATE 对齐,`COLUMN_NOT_FOUND` | -| A18 | 未知列投影返回 `{}` | `SELECT bogus FROM t` → `[{},{},...]` | 投影期 `COLUMN_NOT_FOUND` | -| A19 | 双引号当字符串 | `SELECT "name" FROM t` → 常量列 `{"'name'":"name"}` | 双引号 = 标识符(或明确拒绝) | -| A20 | 未闭合块注释静默接受 | `db.query("DELETE FROM t WHERE id='4' /*")` **真的删了 1 行** | 抛 `PARSE_ERROR` | -| A21 | 连续注释递归爆栈 | `' /*c*/'.repeat(20000)` → `RangeError` | 改循环跳过 | -| A22 | GROUP BY 别名列静默消失 | `SELECT v AS s ... GROUP BY dept` → `s` 整列消失 | 列绑定(B-5) | -| A23 | HAVING 不能引用未在 SELECT 的聚合 | `HAVING COUNT(*)>1` → `[]` | HAVING 独立求值顺序 | -| A24 | 空集聚合返回 0 而非 NULL | `SUM(v) FROM t WHERE 1=0` → `{s:0}` | SQL 语义修正 | -| A25 | 带表前缀的聚合参数恒 0 | `COUNT(t.v)` → 0(应 4) | 参数归一 | -| A26 | UNION 尾部 ORDER BY/LIMIT 丢失 | `UNION ... ORDER BY v LIMIT 2` → 4 行乱序 | 归属 UNION 而非右侧 SELECT | -| A27 | DISTINCT 作用在投影前 | `DISTINCT dept AS d` → 4 行(应 2) | 子句顺序编码(B-3) | -| A28 | `UPDATE t SET __proto__=...` 静默吞掉 | 返回 1 但无变化 | 全链路 `Object.create(null)`(v0.7.1 只修了建表) | -| A29 | `INSERT ... SELECT` 被 `maxRowsPerQuery` 静默截断 | maxRows=2 时 4 行源只插 2 行 | 上限只作用于**用户可见结果** | - ---- - -### 工作流 B · 根治(砍掉复发结构) - -**B 的每一项都是"一次性消除一整类缺陷",而不是再修一个实例。** 建议按 B-1 → B-2 → B-3 → B-4 → B-5 → B-6 顺序推进,每项独立可交付。 - -#### B-1 唯一校验与规范化咽喉 - -- **做什么**:把 `validateRow` 收敛为 `src/table/schema.ts` 的**唯一实现**,引擎边界只接受"已校验 + 已规范化"的行;统一 JSON 安全编码(`NaN`/`Infinity` 显式拒绝或归一为 NULL) -- **消除**:A12(三引擎约束失效)、NaN 落盘变 null 的引擎差异、INSERT 未知列(A17)、schema 外列静默丢弃 -- **验收**:跨引擎参数化契约测试——**同一份 schema + 同一批非法数据,四个引擎必须抛同样的错误码** - -#### B-2 唯一值比较与编码(SQL 三值逻辑) - -- **做什么**:提供唯一的 `sqlEquals(a,b) / sqlCompare(a,b) → TRUE|FALSE|UNKNOWN` 与唯一的 `encodeValueKey(v)`;比较、`IN`/`NOT IN`、LIKE、JOIN 键、分组键、DISTINCT 键、UNION 去重**全部**走它 -- **消除**:A15(NULL 三值)、JOIN 的 NULL 键"取决于右表有没有索引"、`COUNT(DISTINCT)` 把 `1` 与 `'1'` 合并、`encodeGroupKey` 用 `\x1f` 连接可被伪造、混合类型排序回退 `localeCompare` -- **验收**:NULL 语义矩阵测试(`= NULL`/`!= NULL`/`IN (1,NULL)`/`NOT IN`/`NOT LIKE`/`NOT(...)` 各 6 种数据形态);`1` vs `'1'` 在各引擎行为一致 - -#### B-3 单管线:SQL 只有一条执行路径 - -- **做什么**: - 1. `SelectQueryBuilder`/`UpdateQueryBuilder`/`DeleteQueryBuilder` **只产出 AST**,统一交给 executor(删除 `builder.ts:105` 的引擎直通) - 2. `queryStream` 改为对**同一管线**的惰性消费者,禁止自建投影/limit 逻辑 - 3. 把 SQL 标准子句顺序编码为显式 stage 列表:`FROM → JOIN → WHERE → GROUP BY → HAVING → 聚合 → SELECT 投影 → DISTINCT → ORDER BY → LIMIT/OFFSET` - 4. limit/offset/投影/排序**只在管线的一处执行**,引擎退化为"带索引的行迭代器 + WHERE 匹配" -- **消除**:A6(双重 LIMIT)、A7(流式不一致)、A22(GROUP BY 别名)、A27(DISTINCT 顺序)、A29、事务内 JOIN 静默忽略、`maxRowsPerQuery` 只在一条路径生效、链式 `where` 覆盖 -- **验收**: - - **等价性契约测试**:同一 SQL 经 `query` / `queryStream` / `QueryBuilder` / 事务内**四条入口**,结果必须逐值相等(这是本方案最重要的防复发测试) - - 子句顺序 golden 测试(LIMIT/DISTINCT/GROUP BY/HAVING 组合矩阵) - -#### B-4 统一表达式求值器(取消"关联性"预判) - -- **做什么**:表达式求值统一携带 `outerRow` 上下文;删除 `hasCorrelatedRefs` / `stripCorrelatedExists` / `filterCorrelated` 这条特殊通道;**matcher 遇到无法表达的操作数(未解析 `$subquery`、未知操作符)必须抛 `QUERY_ERROR`,禁止返回 false** -- **消除**:A10(`t.x=t.y` 与关联子查询静默空)、根因 7 整类(v0.7.3/v0.7.4 修过两次的同一模式)、`WHERE EXISTS(...)` 不相关查询被误判为相关而抛 `NOT_SUPPORTED` -- **验收**:所有"未解析标记"路径的参数化测试必须断言**抛错**而非空结果 - -#### B-5 AST 携带绑定后的列身份 - -- **做什么**:`columns: string[]` 升级为结构化表达式节点(列引用 / 别名 / 聚合 / CASE / 常量),并为每个输出列分配**稳定序号**;GROUP BY / ORDER BY / HAVING / 别名统一按序号对齐;executor 删除 6+ 处正则再解析 -- **消除**:A22/A23/A24/A25/A26、`SELECT 1 AS one` 返回 `{}`、CASE 正则切分(`'a ELSE b'` → `"'a"`)、嵌套 CASE 吞掉 FROM、`COUNT(DISTINCT *)` 忽略 DISTINCT、`GROUP BY` 返回每组第一行的任意值 -- **验收**:表达式矩阵 golden 测试(每类表达式 × 投影/别名/分组/排序/HAVING/流式) - -#### B-6 存储层单一提交点(manifest) - -- **做什么**:引入 `__aria_manifest`(`magic + formatVersion + generation + CRC`,OPFS 单文件 COW 原子写),内容为 `{各 LSM 命名空间 meta 快照, WAL 起始分片与起始 LSN, pageId 水位, **待落盘冻结表意图**}`;写入顺序固定为 **数据落盘 → manifest 提交 → 才允许截断 WAL/删除旧分片**;恢复只认"最后一份 CRC 通过的 manifest";**任何引用不到的东西一律保留而非删除**;所有计数器在 manifest 中单调推进、永不复用 -- **消除**:根因 4 整类 —— 页面 id 回退、flush/compaction meta 竞态、KVStore 截断清零、介质读故障误判、陈旧实例 checkpoint 覆盖、DDL 非原子、SSTable meta 损坏静默空库 + repair 删活页、WAL 中段损坏导致尾部全丢、分片 truncate 失败导致旧世代记录排在最新记录之后、**后台 flush 失败后冻结数据无重试路径(总账第 44 项)** -- **同时要做**(LSM 单项改造,可与 manifest 并行): - - `LSM.get/rangeScan` 改为**自洽异步读**(未命中 `await store.load` + 用类型区分 MISS 与不存在),可一次删掉引擎层全部 7 处 `drainChain()+prefetch*` - - `flushChain` 换成**带意图记录、可重试**的串行工作队列;`compacting` 由单 boolean 改 `Set` - - `flush()` 的 `lastBackgroundError` 检查移到**入链之后**(错误要报告,但不能因此跳过本次 flush) - - `sstable.ts` 三份解析循环合并为一份并统一越界策略 -- **前置**:必须先有工作流 C 的故障注入后端,否则无法证明修好了 -- **验收**:故障注入矩阵(撕裂写 × 崩溃点 × 后端)下"已确认写入零丢失 + 无幻影行"不变量恒成立;另外新增两条针对性断言:**注入一次 `save()` 失败后,第二次 `flush()` 必须真的落盘**(当前失败);**`limit=5` 的流式扫描生成器产出必须恰好 5 条**(当前 6 条) - -**B-6 的降级选项**(若 v0.7.5 周期不够):至少完成五处止血 —— -① KVStore open 损坏分支改为"截断到有效前缀"(复用 `repair()` 的 `log.subarray(0,validBytes)` 写法),而不是写空文件; -② `appendRecord` 的"内存更新 + 自动 checkpoint"必须纳入失败语义:要么 checkpoint 失败**不得**让已提交的写报错(改为记 `lastBackgroundError` 并让下一次 `checkpoint()` 抛出),要么在报错时**同时回滚内存** —— 当前两者都不做,是最坏组合; -③ 给 KVStoreEngine 补 Web Lock,或把实例 id / 启动世代编进 `seq` 与快照水位,杜绝陈旧实例覆盖; -④ `commitTransaction`/`rollbackTransaction` 清空 `savepoints`,并校验 `sp.txnId === currentTxnId`; -⑤ `AriaEngine.close()` 增加活跃事务守卫(当前会截断 WAL 并把未提交的二级索引条目落盘 → 重开后幽灵 `UNIQUE_VIOLATION`)。 - ---- - -### 工作流 C · 验证基础设施(让门禁真的能拦) - -**C 应该最先做**,因为它能验证 A 和 B 的成果。 - -#### C-1 故障注入后端(约 60 行,最高性价比) - -实现 `IStorageBackend` 包装器,支持: - -``` -failNextWrite / failNextAppend / truncateTail(n) / tearAt(awaitPoint) / dropNextDelete -``` - -并把 `tests/helpers/opfs-mock.ts` 升级为**可按字节粒度半提交**(当前是立即生效、原子、绝不撕裂的内存 Map)。 -**没有它,本层所有崩溃相关声称都只是"重开测试"。** - -#### C-2 真崩溃注入(e2e) - -`tests/e2e/opfs.spec.ts:32-42` 的 `crashPage()` 改用 CDP: - -```ts -const cdp = await context.newCDPSession(page); -await cdp.send('Page.crash'); -``` - -至少覆盖三种窗口:**写入不 await 即崩溃** / **checkpoint 中途崩溃** / **WAL 半写**。 - -#### C-3 覆盖率口径修正与门禁 - -1. `jest.config.cjs:18` 的 `!src/**/index.ts` 改为**显式列白名单**(只排除真正的 barrel 与纯类型文件),并删除残留的 `!src/engine/opfs.ts`(文件已不存在) -2. README/CHANGELOG/site 的"90.1% 行覆盖率"改为准确表述:**语句 / 行 / 分支三个数字 + 明确采集范围** -3. 新增 `coverageThreshold`:先按当前实测(行 93%、分支 83%)**各减 2pp** 设为下限,并对 `src/engine/**`、`src/query/**` 设 per-path 阈值 -4. CI 常规 job 加 `--coverage` -5. CI 的 lint 去掉 `continue-on-error: true` -6. 新增 `tsconfig.test.json` 对 `tests/` 做类型检查(当前 babel 剥离类型,测试里的类型错误完全不可见) -7. CI 加 `dist` 同步校验(build 后 `git diff --exit-code dist`) -8. `coverage/` 生成前清理(当前残留 9 个已删除源文件的幽灵 HTML) - -#### C-4 契约测试取代行为固化 - -现有测试有三类"虚假信心"必须清理: - -| 类型 | 实例 | 处理 | -|---|---|---| -| 标题与断言不符 | `v042-fixes.test.ts:308-322` 题为"抛 ARIA_OPEN_ERROR"实际断言 `resolves`;`v044-hardening.test.ts:137-142` 断言 `not.toBe('pending')`(成功/失败都过) | 改写为断言具体错误码 | -| 空断言护栏 | `v073-fixes.test.ts:267-278` 比较两条**同样错**的路径(都是 0 行) | 改为值级断言 | -| 只比数量不比内容 | `aria-prod-load.test.ts:117-128` 2000 次随机 update/delete 只比 count | 改为结果值比对 | -| 恒真/无断言 | `aria-batch.test.ts:109-110`(无 expect);`maintenance-sql.test.ts:105`(`>=0`);`v074-fixes.test.ts:452-456`(断言测试自己写的字面量) | 补齐或删除 | -| 错误码吞掉 | `v073-fixes.test.ts` 等 12 处 `catch { threw = true }` 不绑定异常 | 统一用仓库已有的 `expectCode()` | - -**新增必测矩阵**(这些正是历史 P0/P1 的形态): -- 跨引擎参数化契约(同一断言跑 memory/disk/hybrid/aria:约束、钩子、事务、流式、错误码) -- 入口等价性契约(`query` == `queryStream` == `QueryBuilder` == 事务内) -- 崩溃不变量(故障注入下"已确认写入零丢失 + 索引与主表一致 + 唯一约束仍生效") -- NULL 三值逻辑矩阵 -- 错误码契约表(当前 **36 个错误码中 14 个从未被任何测试断言**) -- 性能护栏改为**相对基线**(当前 `aria-prod-load.test.ts` 的 240s/300s 阈值只能拦住"353s 级"总崩,拦不住 2~5 倍回退) - -#### C-5 属性测试(property-based)—— 最高价值的长期投资 - -随机操作序列(insert/update/delete/事务/savepoint/checkpoint/崩溃点)后断言四引擎不变量: - -- 行集合 == 已确认操作集合 -- 每个二级索引与主表一致 -- 唯一约束仍生效 -- `count()` 与 `find()` 一致 - -**CHANGELOG 里横跨 7 个版本的 P0/P1 全部属于此类不变量违反**。随机化能把它们一次网住,而不是每版抓一只。 - ---- - -## 五、缺陷总账(55 项,按优先级) - -**级别定义**:**P0** = 崩溃 / 静默丢数据 / 静默错误结果;**P1** = 约束或事务语义破坏;**P2** = 边界与健壮性;**P3** = 工程与文档。 - -### 已实测复现(本次或子代理实跑) - -| # | 级别 | 缺陷 | 位置 | 工作流 | -|---|---|---|---|---| -| 1 | P0 | UPDATE 多行撞同一新主键 → 静默丢行 | `memory.ts:274,289-300` | A1 | -| 2 | P0 | ALTER ADD UNIQUE 失效 → 重启静默丢行 | `memory.ts:122-127,316-339,562` + `kvstore_engine.ts:103-108` | A2 | -| 3 | P0 | SAVEPOINT 回滚不写补偿 WAL → 崩溃后行复活 | `aria/index.ts:1550-1579` | A3 | -| 4 | P0 | SAVEPOINT 跨事务泄漏 → 当前事务写入被替换 | `aria/index.ts:1467-1534,1563` | A4 | -| 5 | P0 | `MIN`/`MAX` 20 万行栈溢出 | `executor.ts:677-678` | A5 | -| 6 | P0 | `LIMIT/OFFSET` 双重应用 → 丢行 | `executor.ts:391-393` vs 引擎 | A6 | -| 7 | P0 | `queryStream` 与 `query` 结果不一致(别名/限定列/OFFSET/LIMIT 0) | `core.ts:316-319` | A7 | -| 8 | P0 | 行引用泄漏 → 改库 + 索引失配不可检索 | `memory.ts:209,228,765` | A8 | -| 9 | P0 | `subscribe()` 本地写入永不触发 | `core.ts:423-437` | A9 | -| 10 | P0 | `t.x=t.y` 与关联 IN 子查询静默空结果 | `executor.ts:344,1417` | A10 | -| 11 | P0 | `AND`/`OR` 无优先级 → 静默错行 | `parser.ts:780-797` | A11 | -| 12 | P0 | `KVStore.open` 损坏日志 → 有效前缀只留在内存,崩溃即永久丢失 | `kvstore/index.ts:135-138,394-399` | B-6 | -| 13 | P0 | 自动 checkpoint 失败 → 报错但数据已提交(调用方回滚无效) | `kvstore/index.ts:334-362,385-390` | B-6 | -| 14 | P0 | KVStore 无锁 → 陈旧实例 checkpoint 覆盖快照并截断日志吞掉对方数据 | `kvstore/index.ts:261-280,385-390` | B-6 | -| 15 | P1 | `maxLength`/`min`/`max` 三引擎不校验 | `memory.ts:713-722` vs `schema.ts:146-167` | A12/B-1 | -| 16 | P1 | 自引用外键 CASCADE/RESTRICT/ON UPDATE 全失效 | `memory.ts:393,432,498,822` | A13 | -| 17 | P1 | ON UPDATE CASCADE 只级联一层 | `memory.ts:430-448` | A14 | -| 18 | P1 | `= NULL` 命中 null 行;`NOT BETWEEN` 排除 NULL 行 | `parser.ts:845,967` + `where-matcher.ts:163` | A15/B-2 | -| 19 | P1 | INSERT arity 不校验 → 静默写半行/丢值 | `parser.ts:515-560,1166-1174` | A16 | -| 20 | P1 | INSERT 未知列静默丢弃 | `schema.ts:87-126` | A17 | -| 21 | P1 | 未知列投影返回 `{}`;标识符大小写敏感 | `where-matcher.ts:214-228` | A18 | -| 22 | P1 | 双引号当字符串 → `SELECT "name"` 返回常量列 | `lexer.ts:81-84,194-235` | A19 | -| 23 | P1 | 未闭合块注释静默接受 → DELETE 照常执行 | `lexer.ts:157-167` | A20 | -| 24 | P1 | 连续注释递归爆栈 | `lexer.ts:92,97` | A21 | -| 25 | P1 | GROUP BY 别名列静默消失 | `executor.ts:631-648` | A22/B-5 | -| 26 | P1 | HAVING 不能引用未在 SELECT 的聚合 | `executor.ts:628-651` | A23/B-5 | -| 27 | P1 | 空集聚合返回 0 而非 NULL | `executor.ts:660-680` | A24 | -| 28 | P1 | 带表前缀的聚合参数恒 0 | `executor.ts:328-336,662` | A25 | -| 29 | P1 | UNION 尾部 ORDER BY/LIMIT 丢失 | `parser.ts:366-388` + `executor.ts:153-200` | A26 | -| 30 | P1 | DISTINCT 作用在投影前 | `executor.ts:352,364,383` | A27/B-3 | -| 31 | P1 | `UPDATE SET __proto__` 静默吞掉 | `parser.ts:572-578` | A28 | -| 32 | P1 | `maxRowsPerQuery` 截断 `INSERT...SELECT` 源 | `executor.ts:707,396-398` | A29 | -| 33 | P1 | 嵌套 CASE 吞掉 FROM 之后全部文本 | `parser.ts:1088-1098` | B-5 | -| 34 | P1 | CASE 值含 ` ELSE `/`WHEN`/`''` → 静默错值 | `executor.ts:60,66,79,86-99` | B-5 | -| 35 | P1 | JOIN NULL 键结果取决于右表有无索引 | `where-matcher.ts:139-150` vs `executor.ts:531,543,553` | B-2 | -| 36 | P1 | 派生表别名限定失效 | `executor.ts:297-307` | B-3 | -| 37 | P1 | JOIN 内未限定列名静默失效 | `executor.ts:317-336,447-451` | B-3/B-5 | -| 38 | P1 | `compression:true` 在 pageStorage 开启时被静默忽略 | `aria/index.ts:1744-1749` | B-6 | -| 39 | P1 | `compressLZ4` 最坏 O(n²)(64KB→4167ms) | `lz4.ts:41-46` | B-6 | -| 40 | P1 | `close()` 不检查活跃事务 → 幽灵唯一冲突 | `aria/index.ts:281-296` | B-6 | -| 41 | P1 | `dropTable` DDL 非原子 → 表和数据复活 | `aria/index.ts:483-507` | B-6 | -| 42 | P1 | WAL 中段损坏 → 其后完好记录全丢 | `log.ts:264,270,276` | B-6 | -| 43 | P1 | WAL 单条 CRC 失配跳过 → 已提交事务部分应用 | `log.ts:287-298` | B-6 | -| 44 | **P0** | 后台 flush 失败后冻结数据无重试路径 → 崩溃即丢(`insert` 早已返回成功) | `lsm.ts:220-261,605,612,618` | B-6 | -| 45 | P1 | `flush()` 记过后台错误即跳过本次 flush(`lastBackgroundError` 检查在入链之前) | `lsm.ts:580-590` | B-6 | -| 46 | P1 | `LSM.get` 缓存未命中当"不存在"(同类问题 `compactLevelAsync` 已修、`get` 未修) | `lsm.ts:734-753,417-422` | B-6 | -| 47 | P1 | `rangeScanLazy` 早停多算 1 条(生成器已推进到下一项才收到 stop) | `lsm.ts:440-479` + `index.ts:1220-1225` | B-6 | -| 48 | P2 | `MemTable.delete` 大小记账无条件下调 → 可致负数与 flush 阈值失真 | `memtable.ts:441-447` | — | -| 49 | P2 | `compacting` 单 boolean 跨层丢触发 → 深层维护饥饿 | `lsm.ts:78,269-283` | B-6 | -| 50 | P2 | compaction 预先 `splice` 整层 → 长 await 窗口内该层对读者不可见 | `lsm.ts:502,569` | B-6 | -| 51 | P2 | compaction 不回收墓碑与历史版本 → 空间/写放大无界(90% 删除场景不回收) | `lsm.ts:537-552` | B-6 | -| 52 | P2 | `vacuum()` 硬编码返回 `compactedLevels: 6`,而 `compactLevel` 对单文件层是静默 no-op | `lsm.ts:486-500` + `index.ts:2247-2261` | — | -| 53 | P2 | SSTable 解析循环三份拷贝、越界策略不一致(点查 vs 扫描行为不同) | `sstable.ts:80-100,145-166,182-201` | B-6 | -| 54 | P2 | 孤儿 `sst_*` blob 无清理路径(`cleanupOrphanPages` 只管 `pg_*`) | `index.ts:352-388` | B-6 | -| 55 | P2 | `checkpointManager.tick()` → `lsm.flush()` → `drainChain()` 等完整 compaction(v0.6.1 "8~11s 悬崖"的另一半来源) | `index.ts:664` + `lsm.ts:592` | B-6 | - -### 已读码确认但未独立复现(清单存档,动工前需补复现) - -生命周期与 API 面:MemoryEngine.close 泄漏快照(`memory.ts:43-45`);`init()` 无重入保护;`close()` 无 try/finally;连接池 refCount 重置与失败泄漏(`connection-manager.ts:36-77`);`migrateTo` 非原子(`core.ts:527-542`);`addMigration(v)` 中 `v <= config.version` **永久静默跳过**(默认 version=1 使迁移 1 永不执行,无任何警告)【实测】;`subscribe`/`emit` 语义;插件 `priority` 完全无效【实测】;`unregister` 不摘钩子【实测】;`onError` 覆盖不一致。 - -事务面:SQL `COMMIT` 可提前提交 `db.transaction` 外层事务(随后抛 `TX_NONE`)【实测】;事务结束后 `trx` 仍可写入且脱离事务【实测】;事务内 JOIN 静默忽略【实测】;事务内写入不广播、不触发钩子;`setMeta` 不受回滚影响。 - -集成面:React `useDatabase` StrictMode 下呈现"已关闭实例 + ready:true";Vue `watch([()=>sql])` 永不触发且无 `onUnmounted`;`package.json` 的 `./react`/`./vue` 指向裸 TS 源码且 d.ts 无导出;`./migration` 子路径未在 `exports` 声明(README 示例直接不可用);`DatabaseError`/`MetonaPlugin` 未从根入口导出。 - -存储面:`compression` 与 pageStorage 分支(A38);SSTable meta 无校验且 repair 删活页;介质 read 故障折叠为 null;`flushAll` 与 delete 竞争复活已删页;页面头 checksum 恒 0;加密无 AAD/无改密路径;PBKDF2 迭代数 100000(低于当前 OWASP 建议);Web Locks 不支持即无保护;MVCC 的 `snapshotLsn`/`prevVersion` 是死字段(README:358 "版本链 + 快照隔离"与实现不符)。 - ---- - -## 六、非目标(v0.7.5 明确不做) - -避免范围蔓延,以下**明确排除**: - -- ❌ 复合主键(v0.8 路线图,已由 `SCHEMA_ERROR` 显式拒绝) -- ❌ 算术/字符串表达式与完整表达式求值器(`a + 1`、`||`)——B-5 只做**结构化列身份**,不做运算符体系 -- ❌ 真正的多版本 MVCC 并发(当前是单事务 + 全量快照) -- ❌ 标准 LZ4 兼容(当前是自定义编解码) -- ❌ 密钥轮换 / KDF 升级 -- ❌ 表级约束、多列索引、`CREATE TABLE AS SELECT` -- ❌ 新存储引擎或新前端特性 - -**这些都是好功能,但把它们加到一个语义已经漂移的底座上,只会扩大缺陷面。** - ---- - -## 七、验收标准(硬门禁) - -**G1~G6 全部满足才允许发 v0.7.5。** 每条都是可机器验证的。 - -| 门禁 | 内容 | 验证方式 | -|---|---|---| -| **G1** | 工作流 A 的 11 项事故级全部修复,且每项有**值级**回归测试(非行数断言) | 新增测试套件全绿 | -| **G2** | 入口等价性成立:同一 SQL 经 `query`/`queryStream`/`QueryBuilder`/事务内四条入口结果**逐值相等** | 参数化契约测试 | -| **G3** | 跨引擎一致性:同一 schema + 同一操作在 memory/disk/hybrid/aria 四引擎**行为一致**(含错误码) | 参数化契约测试 | -| **G4** | 故障注入下崩溃不变量恒成立:已确认写入零丢失 + 索引与主表一致 + 唯一约束仍生效 | 故障注入矩阵(撕裂写 × 崩溃点 × 后端) | -| **G5** | 覆盖率真实且不倒退:`collectCoverageFrom` 无实现文件被排除;CI 有 `coverageThreshold`;README 数字与 lcov 一致 | CI 产出比对 | -| **G6** | 文档与实现一致:README/CHANGELOG/site 中**每一条能力宣称**都有对应测试或已删除 | 逐条核对清单(见 §8) | - -**额外要求**:CI 的 lint 必须真正阻断(去掉 `continue-on-error`),并新增 `tests/` 类型检查。 - ---- - -## 八、宣称与实现不符清单(G6 核对表) - -文档里目前有 **12 条**能力宣称缺少实现或测试支撑。每条给出二选一处理: - -| 宣称 | 位置 | 实际 | 处理 | -|---|---|---|---| -| "在线一致性快照 `backup()`" | README:230 | Aria 逐表 `getAllRows`,**无跨表快照/无隔离**,且并入本事务未提交数据;公开 API 覆盖率 0 | 改为"逐表导出"或实现真快照 | -| "订阅表变更 `subscribe()`" | README:232 + `site/docs.html:667-679` | 本地写入**永不触发**(仅跨标签页 `external`) | 接线 或 修正文档与示例 | -| "插件 priority 越大越先执行" | README:62、`constants.ts:189`、`CONTRIBUTING:166` | priority 完全无效,顺序 = config 数组顺序 | 实现 或 删除该字段 | -| "14 种钩子 allow intercepting" | `CONTRIBUTING:137` | 只有**对象就地修改**有效;返回值被丢弃;SQL 路径 `beforeInsert` 改的是副本 | 明确钩子契约 + 测试 | -| "MVCC 版本链 + 快照隔离,事务读写不互斥" | README:358 | `snapshotLsn`/`prevVersion` 是死字段;并发被 `TX_ACTIVE` 拒绝 | 改写为"单事务 + 快照回滚" | -| "LZ4 页面压缩" | README:313/354 | pageStorage 开启时**压缩分支永不执行** | 修复(A38)或改称"可选压缩(整 value 模式)" | -| "`diskEngine`(disk 模式生效)" | README:181 | `core.ts:621-623` 传参但 KVStoreEngine **忽略**;hybrid 的 diskEngine 也无效 | 实现 或 删除配置项 | -| "5 种存储引擎" | README:420 | 4 种 mode + 3 种后端,不是 5 个引擎 | 修正表述 | -| "`@metona-team/metona-sqlark/migration`" | README:253 | `exports` **无 `./migration`** → `ERR_PACKAGE_PATH_NOT_EXPORTED` | 补 exports | -| React/Vue 集成 | README:386-393 + `package.json:19-27` | 指向**裸 TS 源码**;d.ts 无导出;无 peerDependencies | 构建 `dist/react.js`/`vue.js` + 补 d.ts + peerDeps | -| "连接池 `db.disconnect()`" | README:166-168,269-274 | `any` 注入,**d.ts 无声明**,tsc 不通过 | 进类型系统 | -| "行覆盖率 90.1%" | README:6,418 + CHANGELOG + site | 实为**语句 90.08%**;行 92.91%;分支 82.92% 未公布 | 修正口径(C-3) | - ---- - -## 九、风险与破坏性变更 - -| 风险 | 说明 | 缓解 | -|---|---|---| -| **B-2/B-3 改变既有结果** | 修复 NULL 三值逻辑、LIMIT 语义、AND/OR 优先级后,**此前返回错误结果的查询结果会变** | 视为 **minor 版本**(0.7.5 → 建议改称 0.8.0),CHANGELOG 单列"语义修正(行为变更)"章节,逐条给出 before/after | -| **被测试钉死的错误行为** | `v062-fixes.test.ts:169-176` 把非标准字符串排序写成期望;`lexer.test.ts:47-51` 把双引号=字符串钉死;`hooks-crud.test.ts:127-138` 把"事务不触发钩子"钉死;`vue.test.ts:126-138` 把 watch 失效钉死;`kvstore.test.ts:176-210` 把"截断丢弃"钉死;`aria-wal-crc.test.ts:118` 把"记录跳过后继续"钉死 | 修复时**同步改断言并在 CHANGELOG 说明**;这些"护栏"本身就是缺陷的一部分 | -| **`forceExit: true` 掩盖泄漏** | 所有句柄/定时器泄漏都不会让测试失败 | C-3 增加一轮不带 `forceExit` 的 `--detectOpenHandles` 运行 | -| **性能护栏过宽** | 240s/300s 阈值只能拦总崩 | 改为相对基线 ±30% | -| **`src/engine/aria/index/lsm.ts` 审计未完成** | 本方案未覆盖 LSM 内部(红黑树不变量、compaction 级别、key 编码一致性、tombstone 复活) | **动工前必须补完**,其结论可能新增 P0 | -| **`dist/` 已提交入库** | 42 个提交都在改 `dist/`,仓库膨胀且无同步校验 | 建议移出版本控制(`files` 已在 package.json,发布时构建);若保留则加 CI 校验 | - ---- - -## 十、建议里程碑 - -| 阶段 | 内容 | 出口条件 | -|---|---|---| -| **M0 · 准备** | 建立缺陷跟踪表(§5 的 55 项);实现 C-1 故障注入后端 + C-2 真崩溃注入 | 故障注入能稳定复现至少 3 个已知崩溃缺陷 | -| **M1 · 止血** | 工作流 A 全部 11 项事故级 + 18 项高优 P1 | G1 通过 | -| **M2 · 口径** | C-3 覆盖率修正与门禁 + C-4 契约测试清理 | G5 通过;CI lint 阻断 | -| **M3 · 根治** | B-1 → B-2 → B-3 → B-4(B-5/B-6 可视周期裁剪,但 B-6 至少完成三处止血) | G2/G3 通过 | -| **M4 · 收口** | B-5/B-6 收尾 + C-5 属性测试 + G6 文档核对 | G1~G6 全绿 | - ---- - -## 十一、玥玥的判断(需要爸爸拍板的三件事) - -**1. 版本号**:我倾向于**把它当作 0.8.0 而不是 0.7.5**。 -理由:工作流 A/B 会**改变既有查询的返回结果**(NULL 语义、LIMIT、AND/OR 优先级),按语义化版本这是破坏性变更。把它藏在 patch 号里会让下游用户在无声中拿到不同的数据。**但爸爸更了解发布节奏与依赖方,最终请爸爸定。** - -**2. 范围裁剪**:B-5(AST 结构升级)与 B-6(manifest)是**工作量最大的两项**,也是收益最大的两项。 -- 若周期充足 → 全做,一次性还清语义债 -- 若周期紧张 → 我的建议是 **B-3 + B-2 优先**(单管线 + 三值逻辑),因为它们消除的缺陷最多(A6/A7/A15/A19/A22/A27/A35/A37 共 8 项事故级的一半),而 B-5 可以推后到 0.9 -- B-6 无论如何都建议至少完成三处止血(见 §B-6 降级选项),否则崩溃恢复的宣称始终是空头支票 - -**3. `dist/` 与 `.npmrc`**: -- `dist/` 已提交 42 个提交,我建议移出版本控制(发布时构建 + CI 校验) -- 我注意到本地 `.npmrc` 含一行 base64 的 registry 认证凭据(已解码可见用户名与口令)。**它未被 git 跟踪(`.gitignore` 已覆盖),这一点是好的**;但既然它存在于工作区,仍建议轮换一次该口令——凭据一旦以明文形式落过盘,就没有"确定没泄漏"这一说。 - ---- - -## 附录 A · 报告关系说明 - -本方案是**总纲**:§3 根因、§4 工作流、§5 总账、§7 验收标准均以本文件为准。 -四份子系统明细报告(见附录 E)提供逐条 `file:line` 证据与验证方法,供动工时按图索骥。 -凡明细报告与本方案冲突处,**以本方案为准**(本方案对冲突项做过独立复现与修正,见附录 C)。 - -## 附录 B · 复现命令速查 - -```bash -# 基线 -npm test # 1304 通过 / 76 套件 / ~395s -npm run typecheck # exit 0 -npm run lint # exit 0 - -# 真实覆盖率(不排除任何文件) -npx jest --coverage --collectCoverageFrom='src/**/*.ts' --coverageReporters=text-summary - -# 不带 forceExit 检查句柄泄漏(当前被 jest.config.cjs:30 掩盖) -npx jest --detectOpenHandles --forceExit=false # 需临时改配置 - -# e2e(需先 build) -npm run build && npm run test:e2e -``` - -## 附录 C · KVStore 三个事故级缺陷的探针原始输出 - -以下是本方案定稿前,用真实 `KVStore` + `SharedMemoryBackend` 跑出的原始输出(探针跑完已删除)。 -这三个缺陷是 B-6 的直接证据,也是"崩溃语义靠声称"的最清晰样本。 - -``` -# ① 损坏日志恢复后崩溃 → 已确认前缀永久丢失 -KV corruption setup: records=3 logBytes=84 -KV 1st open: inMemory=[true,true,false] logBytes=0 snapshotBytes=0 -KV after crash+restart: [false,false,false] <-- confirmed rows lost = BUG - -# ② checkpoint 失败 → 报错但数据已提交 -KV checkpoint-failure: put rejected='INJECTED snapshot write failure' inMemory=true logBytes=27 -KV checkpoint-failure: durable after restart=true (caller saw REJECTION) - -# ③ 陈旧实例 checkpoint 吞掉对方已提交数据 -MULTI2: A.seq=0 B.seq=0 (both start from same snapshot/log) -MULTI2: after A.put A.seq=1 B.seq=0 -MULTI2: after B.put+autoCheckpoint snapshotBytes=30 logBytes=0 -MULTI2: restart -> fromA=LOST fromB=OK <-- cross-instance data loss - -# ④ 附带:close() 不做 checkpoint(持久化契约缺口) -KV close-without-checkpoint: value after reopen = present # 仅在优雅关闭路径下成立 -``` - -## 附录 D · LSM 层审计结论摘要(已完成) - -`src/engine/aria/index/lsm.ts` 的深度审计已完成,**结论与其它子系统明显不同,值得单独说明**: - -**常规路径是对的。** 8 组差分 fuzz(engine vs 参照 Map,含 `compactLevel(0..2)`)得到 **0 处不一致**; -红黑树 4000 步随机增删后中序与参照集完全一致;SSTable 13 组边界(块尾/跨前缀/空表/末块)全绿。 -**所以不要期望 LSM 存在"普遍算错"类缺陷。** - -审计同时**主动排除了三个很像 P0 的假警报**(这一步的价值不低于找到真 bug): -- **Bloom Filter 不会产生 false negative**:`Math.abs((h1+i*h2) % bits)` 看着像经典的负余数 bug, - 但 `h1 + i*h2` 在双精度下永不溢出、`% bits` 恒非负 —— `Math.abs` 是冗余而非错误。 - 30k 随机 + 5k 密集 + 5 种真实 key 形态各 2000 key,FN **全为 0**。 -- **崩溃中断 compaction 不会让旧文件遮蔽新数据**(逐时点验证层间新鲜度)。 -- **PK 墓碑前缀不会误删 `k10`**(字符串比较下 `t:k1 < t:k10`,实测只删 1 行)。 - -**真正的问题在"未被执行的不变量"与"后台任务模型"**,已并入 §5 总账第 44~55 项。 - -**最高杠杆的单点改动**(已并入 B-6):把 `LSM.get/rangeScan` 从"同步 + 调用方负责预取"改为 -**自洽的异步读**(未命中就 `await store.load`,并用类型区分 MISS 与"不存在"),`rangeScanLazy` 改为 -async generator 按块读取。这一改动可**一次性删掉引擎层全部 7 处 `drainChain()+prefetch*`** -(`index.ts:574,1219,1605,2024,2077,2106,2113`),同时消掉性能悬崖与第 46 项; -配套把 `flushChain` 换成**带意图记录、可重试的串行工作队列**(`compacting` 改 `Set`), -即同时解决第 44、45、49 项。 - ---- - -## 附录 E · 审计证据索引 - -完整报告(均为中文,含逐条 file:line 与验证方法): - -| 报告 | 行数 | 覆盖范围 | -|---|---|---| -| `AUDIT-query-layer-v0.7.4.md` | 346 | query 层(ast/builder/compiler/executor/where-matcher) | -| `AUDIT-storage-engines-v0.7.4.md` | 243 | MemoryEngine / KVStore / KVStoreEngine / Hybrid | -| `AUDIT-aria-lsm-v0.7.4.md` | 558 | AriaEngine 门面 + LSM / MemTable / SSTable / Bloom / MergeIterator | -| `PLAN-v0.7.5.md`(本文件) | — | 根因分析 + 迭代方案 + 缺陷总账 + 验收标准 | - -另有 SQL 层(tokens/lexer/parser/params)与 core/支撑层(core/table/transaction/plugin/ -connection-manager/integrations/migration)两份完整报告,以及测试质量与 CI 审计报告, -内容已全部并入本方案的 §3 根因与 §5 总账。 - ---- - -## 附录 F · v0.8.0 实施进度日志 - -> 本附录记录**实际落地情况**(与 §4 的方案对照)。按提交顺序,每条都可 `git show` 复核。 - -### 已完成 - -| 提交 | 范围 | 关键结果 | -|---|---|---| -| `0dba1ab` | **工作流 C 基座 + 工程门禁** | 故障注入基座(TransactionalFileStore / FaultyBackend / 忠实 OPFS mock);覆盖率口径修正(移除吞掉 AriaEngine 的 `!src/**/index.ts`)+ coverageThreshold;`tsconfig.test.json` 并修复 **103 个测试类型错误**;lint 清零;CI 增加 tests 类型检查 / 覆盖率门禁 / lint 阻断 / dist 同步校验;版本 0.8.0 三处一致 + 版本契约测试 | -| `83c5aa0` | **A11** AND/OR 优先级 | parser 三层分层 `or → and → simple`;`a=1 OR a=2 AND b=3` 从返回 1 行修正为 3 行(与显式括号一致) | -| `7526951` | **A19/A20/A21/A2(词法)** | 双引号 → `QUOTED_IDENTIFIER`(可引用保留字列名);未闭合块注释报错(此前 `DELETE ... /*` 照常执行);注释跳过去递归(消除栈溢出);移除 MySQL 方言反斜杠转义(与绑定器字符串边界统一) | -| `752bdea` | **A5/A6/A7/A16/A18/A24/A28** | LIMIT/OFFSET 从"应用两次"修正为下推安全判定 + 只应用一次(10 种查询形态验证);**queryStream 与 query 在 16 形态 × 4 引擎下逐值相等**;MIN/MAX 改单次归约(消除 20 万行栈溢出);空集聚合返回 NULL;未知列抛 `COLUMN_NOT_FOUND`;INSERT arity 校验;`__proto__` 列名防护统一 | -| `074afd3` | **A8 + LSM 读自洽** | 行所有权写进 `IStorageEngine` 契约(读返副本/写收副本)+ `cloneRow`;Memory/KVStore/Hybrid/Aria 全部生效(改返回值不再改库、索引不再失配);**LSM 读取自洽**:未命中即回源 + CRC 校验,消除"缓存未命中 = 静默丢数据"(实测小缓存下 300 行只回 59 行);缓存上限语义修正(超大 SSTable 常驻) | -| `89243ef` | **A3/A4 SAVEPOINT** | 新增 `SAVEPOINT_ROLLBACK` WAL 记录(复用 `data` 字段,格式不变);恢复按**事务内**下标窗口重放 → 已回滚的行不再复活;事务结束清空 savepoint 并校验归属 → 陈旧 savepoint 不再静默吞写入 | -| `765df80` | **A1/A2 约束** | UPDATE 阶段 1 增加批内新主键互查(四引擎)→ 不再静默丢行;ALTER ADD UNIQUE 真正建索引并做存量校验(复用 `createIndex`),Memory 侧"重启静默丢行"随之消失 | -| `674da6b` | **A9/A10** | `ChangeNotifierEngine` 引擎装饰器:INSERT/UPDATE/DELETE/CLEAR/DDL 统一派发变更事件,`db.subscribe` 对本地写入生效(此前只在跨标签页广播时触发);关联子查询的 `$col` 绑定补上外层别名剥离与**递归进入子查询**(`WHERE id IN (SELECT ... WHERE o.user_id = u.id)` 此前静默空结果) | -| `4ab04df` | **A15 三值逻辑(PB-2)** | 见下节"三值逻辑根治" | -| `2a109ef` | **B-1 统一校验 choke point** | 新增 `src/table/validation.ts`(`compileValidator`)作为**唯一**行校验定义,消除三份分叉实现(memory 缺 maxLength/min/max);四个引擎新增 `validatePayload` 契约,Executor 在任何副作用前校验;未知列由静默丢弃改为 `COLUMN_NOT_FOUND`(A17);外键级联写入也过校验;NaN/±Infinity 显式拒绝(JSON 无法表示,落盘会变 null) | -| `841db2e` | **A22/A23/A25/A26/A27/A29/A30/A36** | 查询层 8 项:GROUP BY 别名、HAVING 未选中聚合、带前缀聚合参数、UNION 尾部子句归属、DISTINCT 作用于输出列、`maxRowsPerQuery` 不再静默截断写入、INSERT 值多于列、派生表别名引用。连带根治"同步抛错穿过 async 边界"与"缺列的行形状不一致" | -| `b20d47b` | **B-3 单管线** | QueryBuilder 只产出 AST、执行一律经 Executor(删除无 JOIN 时直通 `engine.find` 的快路);钩子改为由 `Table` 注入、顺序与传参不变;删除引擎层 4 处"未解析标记 → NOT_SUPPORTED"的过时防御 | -| `edd9f1d` | **B-6 ①②(两处 P0)** | ① `open()` 遇损坏日志尾部不再**清空整库**(新增 `truncateLogTo(keepBytes)`,与 `repair()` 同口径);② 自动 checkpoint 失败不再让**已确认写入**报错(WAL 已持久化),改为记 `lastBackgroundError` 并由下一次显式 `checkpoint()` 报告 | -| `68e4731` | **B-6 ③ + 测试介质** | ③ meta 增加 `owner`,陈旧实例 `checkpoint()` 抛 `STALE_INSTANCE` 拒绝覆盖新实例的写入(实测修复前会**静默**抹掉);同时修复 `SharedMemoryBackend` 的跨实例可见性缺陷(私有拼接缓存使介质退化为"每实例一份快照",此前所有多实例用例都跑在错误介质上) | -| `6a0cfeb` | **PC-2 真崩溃注入** | e2e 的 `crashPage()` 原本只是 `win.__ms = null; page.close()` —— **优雅关闭**,所有"崩溃恢复"用例测的其实是"正常关闭后重开"(CI 注释却声称覆盖 CDP `Page.crash`)。现改用 CDP `Page.crash` 终止渲染进程,并新增两个真实窗口:`createWritable().write()` 中途、`close()` 原子替换前(copy-on-write 的核心不变量)。实测要点:崩溃后 `page.isClosed()` 仍为 false;OPFS 数据在同 context 内跨崩溃保留 | -| `567d150` | **A37 列引用** | 未限定列可作比较操作数(`WHERE x = y` / `ON k = k` 此前 PARSE_ERROR,列对列比较被迫写限定形态);新增 WHERE / JOIN ON 的列存在性校验 —— `$col` 引用不存在的列此前**静默返回空集**(`WHERE id = oops` → `[]`),现报 `COLUMN_NOT_FOUND`;顺带解除 `schema ↔ validation` 循环依赖 | -| `85f0f17` | **A13 自引用外键** | 删除 6 处 `refTableName === tableName) continue`(memory 3 + aria 3):自引用外键的删除级联/置空/更新级联/预检四条路径全部失效,`DELETE root` 只删根、子树永久悬挂。A14(传递链)经实测**审计结论有误**——`ON UPDATE CASCADE` 链正常,已固化为护栏 | -| `97b9fa4` | **A38/A39 压缩** | A38 `compression` 在页面化路径(默认)被静默忽略 → 改为切页前整体压缩,并修正 `SSTableMeta.totalSize` 必须写压缩长度(否则会被 0 填充撑大);A39 `compressLZ4` 逐位置扫描 O(n²)(60KB 伪随机 2345ms)→ 4 字节哈希链(6ms,390×),输出格式不变并保留线性参考实现作对照。连带修正 OPFS mock 忽略库名导致的**跨库共享文件树** | - -### 三值逻辑根治(A15 / PB-2,提交 `4ab04df`) - -**根因**:`matchWhere`(布尔版,自带 `matchField`)与三值求值器**并存**,同一条 SQL -的语义取决于走哪个函数;更严重的是字段级 `$or` 递归进了"where 子句级"求值器。 - -**三类实测静默错值**(修复前的实际返回): - -| SQL | 修复前 | 修复后 | 机制 | -|---|---|---|---| -| `WHERE n = NULL`(n 列含 NULL 行) | 命中 NULL 行 | 空集 | `===` 比较 `null === null` 为真 | -| `WHERE n != NULL` | 所有非 NULL 行 | 空集 | 同上取反 | -| `WHERE s NOT LIKE 'x'`(含 NULL 行) | NULL 行被判真 | 仅真正不匹配的行 | 布尔取反未传播 UNKNOWN | -| `WHERE n NOT BETWEEN 1 AND 2` | **恒空集** | 真正的越界行 | 字段级 `$or` 子项 `{ $lt: 1 }` 被当成"查询列 `$lt`" | - -**根治方式(取消第二套实现,而非打补丁)**: - -1. `query/where-matcher.ts` 重写为**唯一一个递归求值器**,同时理解 where 子句级 - (键是列名 / 逻辑连接词)与操作符级(键是 `$gt` 之类)。区别由**位置**承载, - 不再由另一个函数承载;行上下文随求值上下文下传,`$col` 在任意嵌套深度都能解析。 - `matchWhere` 退化为"三值结果是否恰为 TRUE",并新增 `matchWhereThreeValued`。 -2. `sql/parser.ts`:`IS NULL` / `IS NOT NULL` 生成 `$isNull` / `$isNotNull` **谓词** - (此前与 `= NULL` / `!= NULL` 共用 `$eq: null` / `$ne: null`,调用方无法表达区别); - `BETWEEN` 生成真正的范围条件(此前把同一对象同时当操作符对象与操作数); - `NOT BETWEEN` 展开为 `$or: [{ $lt }, { $gt }]`。 -3. `query/executor.ts` 新增 `enginePreFilter`:逐行求值谓词(`$col` / `$exists` / - CASE 键)必须整体移出引擎层 —— 引擎无外层行上下文,会把它们判 UNKNOWN 并把 - **所有行**过滤掉,逐行求值再正确也无行可算(这正是 `WHERE t.x = t.y` 静默空结果的 - 第二层原因)。粒度按连接词决定:`$and` 成员可单独移除;`$or` / `$not` 成员一旦 - 移除就改变结果集(漏行 / 多行),故整条放弃下推。 - -**契约变更**(旧测试编码了错误语义,已按 SQL 标准改正并在测试内注明理由): - -- `{ $eq: null }` 不再命中 NULL 行(`= NULL` 恒 UNKNOWN)→ 需 NULL 匹配时用 `$isNull`; -- `IN` 列表含 NULL:`x IN (NULL, 'a')` 只命中 `'a'`(`null = NULL` 为 UNKNOWN), - 未命中的非 NULL 行因 UNKNOWN 同样不保留; -- 引擎层(`MemoryEngine.find` / `AriaEngine.find`)与 Query Builder 路径同步适用。 - -**验证**:新增 `tests/v080-sql-three-valued.test.ts` —— 26 条 SQL 语义矩阵 × -4 引擎(memory/disk/hybrid/aria)+ UPDATE/DELETE 写路径,共 **104 断言**。 - -**测试规模**(B-6 收尾时的**阶段快照**,最终值见附录 I):1304 → **1872 通过 / 90 套件**(另 4 个重型套件与 e2e 14 项)。新增: -`tests/v080-savepoint.test.ts`、`v080-atomicity.test.ts`、`v080-subscribe.test.ts`、 -`v080-correlated.test.ts`、`v080-sql-three-valued.test.ts`、 -`v080-unified-validation.test.ts`、`v080-query-layer.test.ts`、 -`v080-single-pipeline.test.ts`、`v080-kvstore-commit-point.test.ts`、 -`v080-column-resolution.test.ts`、`v080-foreign-key.test.ts`、 -`tests/engine/aria-compression.test.ts`、`version-contract.test.ts`、 -`helpers/storage-harness.test.ts` - -**关键修复均做过变异验证**:把修复回退到修复前的行为,对应用例必须失败 -(例:KVStore ①2 项、②3 项)。这是"测试真的能拦住回归"与"测试只是陪跑"的 -分界线,后续新增回归套件都按此执行。 - -**验证状态**:`typecheck`(src + tests)、`lint` 全部为零错误;Aria 生产负载 -(10 万行 / OPFS / KV 后端崩溃恢复)全绿。 - -### 实施过程中的额外发现(方案未列出,已一并修复) - -1. **`SELECT 1 AS one FROM t` 返回 `[{}]`** —— parser 数字常量分支提前 return 吞掉别名;裸 `SELECT 1` 也未处理匿名常量列。现按 SQLite 语义以表达式原文为键。 -2. **`SELECT 0` 类查询的常量列投影**、`TRUE/FALSE/NULL AS alias` 同样修正。 -3. **`async` 回调判定** —— 原 `constructor.name === 'AsyncFunction'` 对"普通函数返回 Promise"完全失效;现双条件识别并走物化 + await。 -4. **`queryStream` 不受 `maxRowsPerQuery` 约束、不触发钩子** —— 现与物化路径一致。 -5. **缓存上限契约不可满足** —— 原测试断言"缓存字节数 ≤ 上限",但单文件大于上限时驱逐它等价于丢数据;已改为断言"数据完整"这一真正不变量,并明确上限 = `cacheLimit` + 单个最大 SSTable。 -6. **测试直接读私有字段**(`lsm.cacheSize` / `cacheLimitBytes`)—— 那是 TS 错误,只因测试不做类型检查才没暴露;已补公开访问器。 - -### 待完成(下一轮继续) - -| 优先级 | 项目 | 状态 | -|---|---|---| -| ~~P0~~ | ~~A9 `subscribe()` 对本地写入不触发~~ | ✅ `674da6b` | -| ~~P0~~ | ~~A10 `t.x = t.y` 与关联 `IN (SELECT ...)` 静默空结果~~ | ✅ `674da6b` + `4ab04df`(enginePreFilter) | -| ~~P1~~ | ~~A15 三值逻辑(PB-2 统一 `sqlCompare`)~~ | ✅ `4ab04df` | -| ~~P1~~ | ~~A12 `maxLength`/`min`/`max` 在 memory/disk/hybrid 失效(PB-1 统一校验)~~ | ✅ `2a109ef` | -| ~~P1~~ | ~~A17 INSERT 未知列静默丢弃~~ | ✅ `2a109ef` | -| ~~P1~~ | ~~A22/A23/A25/A26/A27/A29/A30/A36 查询层~~ | ✅ `841db2e` | -| ~~P0~~ | ~~**B-3 单管线**(builder 只产出 AST)~~ | ✅ `b20d47b` | -| ~~P0~~ | ~~**B-6 KVStore 三处止血**~~ | ✅ `edd9f1d` + `68e4731` | -| ~~P1~~ | ~~A13 自引用外键失效~~ | ✅ `85f0f17`(A14 传递链实测正常,非缺陷) | -| ~~P1~~ | ~~A35 JOIN NULL 键~~ | ✅ `841db2e`(JOIN 的 NULL 键不成立,且与右表有无索引无关) | -| ~~P1~~ | ~~A37 未限定列名 / WHERE 列存在性~~ | ✅ `567d150` | -| ~~P1~~ | ~~A38/A39 压缩被遮蔽 / O(n²)~~ | ✅ `97b9fa4` | -| ~~P1~~ | ~~A41 DDL 原子性~~ | ✅ `bf93ec5`(A40 经探针未复现,已固化为护栏) | -| ~~P0~~ | ~~**B-6 存储单一提交点(manifest)**~~ | ✅ **完整实施**(非降级选项):`__aria_manifest` 单一提交点 + LSM 44/45/47/49/50/51/55 全部改造 + 引擎层 prefetch 依赖删除 —— 见**附录 H** | -| ~~P1~~ | ~~**B-4 统一表达式求值器**~~ | ✅ `3983aae`(CASE 结构化解析 + 列引用/聚合统一) | -| ~~P1~~ | ~~**B-5 输出列序号 + 分隔标识符**~~ | ✅ `2c945ee` | -| ~~—~~ | ~~PC-2 e2e 真崩溃注入(CDP `Page.crash`)~~ | ✅ `6a0cfeb`(14 项 e2e,含两个 OPFS 写入窗口) | -| — | PC-4/PC-5 契约测试 + 属性测试 | 部分(跨引擎参数化已用于 A1/A2/A15/B-1/B-3) | -| — | PG 文档与站点全量同步 | 待做 | - -**下一步建议**:v0.8.0 的三条工作流(A 全部缺陷 / B-1~B-6 结构根治 / C 验证 -基础设施)与 G1~G6 门禁均已完成(见附录 G、附录 H)。后续版本方向: - -1. **真跨表快照** — `backup()` 目前是逐表读取(已如实写进已知限制)。真快照需要 - 行所有权改为 COW(引擎内所有写入路径统一"新对象替换"而非就地修改), - 改动面较大但路径清晰; -2. **MVCC 真快照隔离** — `snapshotLsn`/`prevVersion` 目前只写不读,事务串行。 - 若确有多事务并发需求,需让读路径走版本链而不是 `txnSnapshot`; -3. **简单 CASE 形式** — `CASE <表达式> WHEN <值> THEN ...`(当前仅搜索式); -4. **复合主键** — 存储布局与外键引用目前假设单主键。 - -**关于原"B-1 遗留(compaction/merge 未过 validatePayload)"的结论**:该项**不是缺陷, -也无需补校验**,理由是数据来源而非实现惰性 —— LSM 的 compaction/merge 输入只可能 -是本引擎**已提交的 SSTable**(`levels` 中的 meta 由本进程写入),其中每一行都曾在 -唯一写入 choke point(`Executor` → `validatePayload` → `compileValidator`)上校验过; -compaction 做的是"逐字节搬运 + 按 key 去重",不解析、不构造新的行语义。 -在合并路径上再跑一遍校验反而有害:`ALTER TABLE` 之前写入的旧行形状与当前 schema -可以合法地不同(这正是 ALTER 的语义),重校验会把合法历史数据判成非法而**中断 -compaction**。触发条件明确写在这里:**一旦 LSM 接受外部输入(例如导入第三方 -SSTable / 跨库搬迁),必须在 `save()` 之前补校验**。 - -**已完成的验证基础设施(本轮重点)**: -- PC-2 真崩溃注入(CDP `Page.crash`)+ OPFS `createWritable` 的两个写入窗口; -- `FaultyBackend` 字节级故障注入(撕裂/bit-flip/掉电/写失败); -- **变异验证**成为回归套件的标准做法:把修复回退到修复前的行为,对应用例 - 必须失败。本轮 A15/A13/A38/A39 与 **B-6 全量 + 审查轮共 42 项**均通过该检查(可执行脚本 - `scripts/mutation-b6.py`,逐项打印"变异是否被拦住")—— - 这是"测试真能拦住回归"与"测试只是陪跑"的分界线。 -- 测试介质的两处**忠实性**缺陷已被修正(`SharedMemoryBackend` 跨实例可见性、 - OPFS mock 的目录语义)—— 它们此前让"多实例/多库"的验证跑在错误语义上。 - ---- - -## 附录 G · G1~G6 硬门禁验收记录(v0.8.0) - -> 每条门禁给出**可机器验证的命令与实测结果**。发版前必须全绿。 - -| 门禁 | 内容 | 验证方式与实测结果 | 状态 | -|---|---|---|---| -| **G1** | 事故级缺陷全部修复,且每项有**值级**回归测试(非行数断言) | 11 项事故级(A1~A11,与 §2 总账一致)+ 全部 P1 已修;每项均有独立回归套件,断言值/结构而非行数:`v080-sql-three-valued`(26 条 SQL 语义矩阵 × 4 引擎)、`v080-query-layer`(8 项 × 4 引擎)、`v080-column-resolution`、`v080-foreign-key`、`v080-aria-ddl-atomicity`、`v080-unified-validation`、`v080-case-expression`(54 项)、`v080-output-ordinals`(68 项)、`v080-kvstore-commit-point`、`v080-single-pipeline`、`v080-savepoint`、`v080-atomicity`、`v080-subscribe`、`v080-correlated` | ✅ | -| **G2** | 入口等价性:同一 SQL 经 `query`/`queryStream`/`QueryBuilder`/事务内四条入口**逐值相等** | `v080-single-pipeline`(TABLE API 与 SQL API 逐值等价,4 引擎 × 12 项 + 跨引擎 1 项,共 57 断言);`queryStream` 与 `query` 在 16 形态 × 4 引擎逐值相等(`752bdea`);事务内入口经同一 Executor(`b20d47b`) | ✅ | -| **G3** | 跨引擎一致性:同 schema + 同操作在 memory/disk/hybrid/aria 行为一致(含错误码) | 全部新增套件均按 `describe.each(ENGINES)` 参数化到四引擎:`v080-unified-validation`(41)、`v080-sql-three-valued`(104)、`v080-query-layer`(111)、`v080-single-pipeline`(57)、`v080-column-resolution`(32)、`v080-foreign-key`(32)、`v080-output-ordinals`(64)、`v080-case-expression` 等;错误码断言用 `rejects.toMatchObject({ code })` | ✅ | -| **G4** | 故障注入下崩溃不变量恒成立(**限定于矩阵覆盖的窗口**)+ 索引与主表一致 + 唯一约束仍生效 | 单元层:`FaultyBackend`(撕裂写 / bit-flip / 掉电丢弃 / 写失败)+ `TransactionalFileStore`(pending/commit/crash 语义)+ `CrashableStoreBackend`;`v080-kvstore-commit-point`(19 项,含三处 P0 的变异验证);`v080-atomicity`;`v080-savepoint`。e2e 层:CDP `Page.crash` 真崩溃 + OPFS `createWritable` 的 write/commit 两个窗口(`tests/e2e/opfs.spec.ts`,14 项)。
**⚠️ 限定说明(v0.8.0 审查修正)**:矩阵覆盖的是"介质/进程在写入窗口内崩溃"这一类故障;它**不能**证明"任何情况下零丢失"。全量回归审查在矩阵覆盖范围之外发现 1 处**已确认写入丢失**(事务活跃期间 `repair()`/checkpoint 推进 WAL 水位 → 已 COMMIT 的事务整批消失,见附录 I),已修复并补上回归测试与变异验证。因此本门禁的结论按"矩阵范围内成立"表述,不宣称普遍零丢失 | ✅(有限定) | -| **G5** | 覆盖率真实且不倒退:`collectCoverageFrom` 无实现文件被排除;CI 有 `coverageThreshold`;README 数字与产出一致 | `jest.config.cjs` 仅排除两个**纯类型**文件(`engine/interface.ts`、`query/ast.ts`);阈值 statements 90 / branches 82 / functions 94 / lines 93;CI 常规 job 带 `--coverage`;README 写明产出命令与四个实测数字(最终值见 README「项目指标」表),且本轮经历"阈值真的失败 → 补测/删死代码"的闭环(见 `d14663e`) | ✅ | -| **G6** | 文档与实现一致:README/CHANGELOG/site 中每一条能力宣称都有对应测试或已删除 | **第一轮**:§8 的 **12 条**收口:订阅(已接线)、LZ4 页面压缩(A38 已修)、`./migration` 导出、react/vue 产物+d.ts+peerDeps、`disconnect` 进类型、priority(改为实现符合文档)、MVCC 快照隔离(改为如实描述)、backup 一致性快照(改为如实描述并列入已知限制)、"5 种引擎"(改为 4 模式 + 3 后端)、`diskEngine` 生效范围(改为仅 aria)、"在线一致性快照"(改为全库导出)、钩子契约(CONTRIBUTING 写明返回值语义)。
**第二轮(v0.8.0 全量回归审查)**:文档宣称 vs 实现逐条核对又发现 **16 条不成立**(MVCC 快照隔离、backup 一致性快照、`bloomFilterBitsPerKey` 配置项无效、gzip 体积、测试/覆盖率/套件数陈旧、错误码表缺 16 个码、WAL 空洞语义、mutation 数量、"分片号只增不减"等),全部在附录 I 中逐条订正;其中 `bloomFilterBitsPerKey` 是**实现缺陷**(配置被接受却不生效),已修实现而非改文档 | ✅(两轮) | - -**额外要求**:CI lint 已去掉 `continue-on-error`(真正阻断);`tsconfig.test.json` 对 `tests/` 做类型检查并修复了 **103 个**被 babel 剥离类型掩盖的类型错误。 ✅ - ---- - -## 附录 H · B-6「存储层单一提交点」完整实施记录(v0.8.0 收敛) - -> **为什么还需要这一节**:附录 F 的"已完成"表里 B-6 只列了 ①②③(KVStore 三处止血)+ -> Aria DDL WAL 意图,而 §B-6 的规格要求的是 **`__aria_manifest` 单一提交点 + LSM 单项改造**; -> §"B-6 的降级选项"也白纸黑字写着那五处止血是"若周期不够"的替代品。 -> 本次(周期不受限、且明确禁止降级方案)把 B-6 **按规格完整实现**,本节记录落地细节、 -> 实施中新发现的 10 个缺陷,以及可复现的验证方式。 - -### H.1 交付物 - -| 文件 | 作用 | -|---|---| -| `src/engine/aria/store/manifest.ts`(新增) | `__aria_manifest_`:magic + formatVersion + generation + 头部 CRC + 载荷 CRC;`ManifestStore.load/claimOwnership/commit`;先写后验;保留两代;损坏世代**显式失败** | -| `src/engine/aria/index/lsm.ts` | 冻结表一等状态(id/LSN/重试/提交窗口);读路径 epoch + 结构版本重试;compaction 不再摘整层、底部层原地合并回收墓碑、按层 `compacting` 集合;flush/maintenance 双链;恢复报告 | -| `src/engine/aria/index.ts` | manifest 装载 → 旧格式迁移 → 认领所有权;`commitManifest()`(唯一提交入口);`advanceWalCheckpoint()`(先算边界 → 提交 → 再删除);表结构随 manifest 提交;恢复报告 `getRecoveryReport()`;删除引擎层全部 `prefetch*` 依赖 | -| `src/engine/aria/wal/log.ts` / `segmented_store.ts` | LSN 全库单调(manifest 记高水位);`planKeepFrom` / `truncateBefore`;**分片号绝不回退、也绝不低于 manifest 水位**(整体清空后允许复用最后用过的号 —— 见附录 I 的语义澄清);内部空洞与记录级 CRC 损坏如实上报(`gaps` / `corruptRecords`),水位从未推进时的前缀缺失同样按空洞上报;按水位清理旧格式键 | -| `src/engine/aria/store/page_sstable_store.ts` | 活跃 + 退休两张页面映射(在途读者按 id 读取不再被 compaction 摘除影响);`registerPageIds` | -| `src/engine/aria/store/file_manager.ts` | 页面水位取 manifest / 旧 meta / 现存最大 id 三者最大(单调、永不复用);`__aria_meta` 降级为兼容提示 | -| `src/engine/aria/index/sstable.ts` | 三份解析循环合并为 `iterEntries()` 一份,越界策略统一 | -| `src/engine/aria/index/merge_iterator.ts` | 胜出来源的补充推迟到下一次 `next()`(提前终止不多算) | -| `src/engine/aria/wal/checkpoint.ts` | checkpoint 只落 memtable(`flushMemtables?`),不等 compaction | -| `tests/v080-b6-single-commit-point.test.ts` | 上述每一条的回归 + manifest 严格校验表驱动 **24** 例;v0.8.0 审查后扩到 **96 项**(新增事务水位 P0、前缀空洞语义、在途读者与退休文件、孤儿回收门槛、稀疏/损坏分支、形状校验等)| -| `scripts/mutation-b6.py` | **42 项**变异验证(B-6 的 22 项 + 审查轮的 20 项),全部被对应用例拦住;脚本自带正控、编译失败区分、超时、逐字节恢复校验与进程锁(见 CONTRIBUTING)| - -### H.2 提交顺序(唯一不变量) - -``` -数据落盘(SSTable 页面 / 整 value 已 await 完成) - ↓ -__aria_manifest_ 提交(单文件原子写 + 回读校验) - ↓ -才允许截断 WAL / 删除旧分片 / 删除旧 SSTable -``` - -- manifest 载荷 = `{pageIdWatermark, namespaces{SSTable meta + nextSstableId}, schemas, - wal{startSegment,startLsn,nextLsn}, frozen[冻结表意图], owner{instanceId,epoch}, committedAt}` -- 恢复只认**最后一份 CRC 通过**的世代;存在 manifest 文件但全部世代无效 → `ARIA_MANIFEST_CORRUPT` - (修复前:裸 JSON meta 解析失败 → `[]` → **静默空库**,随后 repair 还会把"没人引用"的活页删光) -- `frozen` 意图的作用:`wal.startLsn` 不得越过最早冻结表的 `lsnAtFreeze`; - 恢复时若"manifest 声称有未落盘数据 + 一条 WAL 记录都没重放到" → `ARIA_WRITE_LOST` - (把"已确认写入是否真的丢了"从不可观测变成可判定) - -### H.3 实施中新发现的缺陷(方案未列出,已一并修复) - -| # | 现象(实测) | 根因 | 修复 | -|---|---|---|---| -| 1 | **删掉的行复活 / 已确认写入丢失**(随机压力套件:76≠68、53≠55 两个方向都出现) | WAL 全量截断后 `currentSegment` 重置为 0,而 manifest 里的 `startSegment` 是删除前算好的(= 旧最大号+1)→ 新记录写进被跳过的分片号;分片号复用还让同一序号对应两代记录,恢复按序号排序时旧 INSERT 排在 DELETE 之后 | 分片号**只增不减**(`truncateBefore`/`truncate` 都只往上走;只有整库清空 `clearAll` 才 `reset()`);正则放宽到 `\d{6,}` | -| 2 | 300 行全部改名后,紧接着的全表扫描有 25 行仍是旧值(几毫秒后又变新值) | 读路径"同步取 meta 快照 → await 加载",而并发 flush 会在这个窗口里把新 SSTable 发布到 `levels[0]` **并同时**从 `frozenMemtables` 摘掉 → 该批数据既不在快照里也不在前台 | `structureVersion` + 乐观重试(加载后版本变了就重来;极端情况退回"等两条链静默") | -| 3 | `close()` 之后 `__wal_000000.bin` 仍在(每次打开都要重读一遍) | `planKeepFrom` 只在"下一条记录也在水位之内"时才敢删边界分片,而**最后一个分片**没有"下一片"可比 → 永不删除 | `truncateBefore` 接受 `latestLsn`:末片以"当前 LSN ≤ 水位"判定整片可删 | -| 4 | 第二个实例打开同一库时 `open()` 直接失败(两个实例互相判 STALE_INSTANCE) | 认领所有权只 +1 个世代号,旧实例**在途**的提交正好落在同一个号上并覆盖认领 | 认领时一次跨过 `MANIFEST_TAKEOVER_STRIDE=1000` 个世代号 | -| 5 | 从上一代 manifest 回退时报 `ARIA_WRITE_LOST`(数据其实完好) | `saveMeta` 正在提交这张冻结表,而 `frozen` 意图仍把它列为"未落盘" | `FrozenTable.committing` 窗口:提交中的表由本次提交负责,不进意图;失败时标志复位、可重试 | -| 6 | 底部层"整层只剩墓碑"时 compaction 抛 `TypeError`(读 `merged[0][0]`)→ 该层永远无法合并、墓碑永远回收不掉 | 空产物没有专门的退休分支 | 新增"全部是墓碑 → 直接退休旧文件"分支(`retireMetas`) | -| 7 | item 55 实际未生效:checkpoint 仍然等整条 compaction | `CheckpointManager.checkpoint()` 里还有一句 `await this.lsm.flush()`(会 drain 维护链) | `Flushable.flushMemtables?()` + 引擎提供 `flushAllLsms(true)`;旧调用方保留原语义 | -| 8 | 一个损坏的更高世代把库**永久锁死**(之后每次提交都 STALE_INSTANCE) | 陈旧检查拿"磁盘上最大世代号"与自己的世代比,损坏世代不是"别人的提交" | 陈旧判定跳过**已知损坏**的世代;但新世代号仍严格大于任何已存在文件(绝不覆盖) | -| 9 | 旧格式迁移遇到坏 JSON 静默当空库 | `catch { return [] }`(meta 与 schema 各一处) | `ARIA_LEGACY_META_CORRUPT` 显式失败(迁移全有或全无) | -| 10 | 介质读故障被折叠成"文件不存在",于是 `dropInvalidSSTable` 把 meta 删掉(不可逆) | `load()` 的 catch 一律 `return null`,调用方无法区分"读失败"与"确实没有" | 读失败**向上抛** `ARIA_SSTABLE_READ_FAILED`;只有"确定不存在且仍被 manifest 引用"才自愈清理,并进恢复报告 | - -### H.4 验收(可复现) - -```bash -# 1) B-6 回归(96 项) -npx jest tests/v080-b6-single-commit-point.test.ts - -# 2) 变异验证:把每个修复回退到修复前行为,对应用例必须失败(42 项) -python3 scripts/mutation-b6.py - -# 3) 随机压力 / 检查点崩溃恢复(发现缺陷 1 的套件) -npx jest tests/engine/aria-repair-hardening.test.ts tests/engine/aria-kv-backend.test.ts - -# 4) 全量常规套件(不含重型与 e2e) -npx jest --testPathIgnorePatterns='/node_modules/|/tests/e2e/|aria-prod-load|kvstore-stress|aria-matrix-audit|aria-idx-flush-race' -``` - -**实测数字**(审查轮收尾复测,详见附录 I):常规套件 **1985 通过 / 93 套件**; -覆盖率 语句 90.59% / 分支 82.61% / 函数 94.14% / 行 93.50%(阈值 90/82/94/93,全部通过); -e2e 14/14;变异验证 **40/40** 被拦住。 - -**恢复报告**:`engine.getRecoveryReport()` 返回 -`{droppedSSTables, dataLossSuspected, walGaps, droppedWALRecords, legacyImported, manifestFallback}` —— -"损坏被自愈了多少、有没有真正丢数据"从此是**可读的返回值**,而不是只能翻日志。 - -**新增错误码**:`ARIA_MANIFEST_CORRUPT`、`ARIA_MANIFEST_WRITE_FAILED`、 -`ARIA_MANIFEST_NOT_LOADED`、`ARIA_LEGACY_META_CORRUPT`、`ARIA_SSTABLE_READ_FAILED`、 -`ARIA_SSTABLE_SAVE_CONTRACT`、`ARIA_WAL_GAP`、`ARIA_WRITE_LOST`、`STALE_INSTANCE`。 - ---- - -## 附录 I · v0.8.0 全量回归审查(发版前最后一轮) - -> 触发:用户要求"做完整全量的回归 review"。方法:**四个对抗性子代理**分头审查 -> (① 数据正确性与崩溃语义 ② 文档宣称 vs 实现 ③ 公共 API 契约 ④ 测试质量与 -> 覆盖盲区),每一条结论都要求**可复现证据**;我再逐条复核、写探针确认、并在 -> `src/` 上做**变异验证**(把修复回退 → 对应用例必须失败)。 -> 详细缺陷叙述见 `CHANGELOG.md` 的 v0.8.0「全量回归审查」一节;本节只列结论与证据。 - -### I.1 确认缺陷(14 项:1×P0 / 4×P1 / 9×P2) - -| 级别 | 缺陷 | 证据(可复现) | 变异 | -| --- | --- | --- | --- | -| **P0** | 事务活跃期间 `repair()` / `close()` / 周期 checkpoint 推进 WAL 水位 → 已 `COMMIT` 的事务整批消失,且恢复报告"干净" | `tests/v080-b6-single-commit-point.test.ts` ›「BEGIN → INSERT → repair() → COMMIT → 崩溃」;根因 `hasPendingFlushData()` 不看 `txnSnapshot` | R1 | -| P1 | WAL 前缀缺失把**整段活分片**丢掉(回退上一代 manifest 时 `kept` 为空) | 同套件「fromLsn > 0 时前缀缺失只记诊断,后缀分片照常重放」 | R4 | -| P1 | 孤儿回收门槛只看引擎层 `dataLossSuspected` → LSM 层丢过 SSTable 时仍会删"引用不到"的活页(不可逆) | 「有损坏迹象时:引用不到的东西一律保留」;统一判定 `describeRecoveryDamage()` | R3 | -| P1 | `vacuum()` 逐层压缩绕过维护链(与后台 compaction 并发写同一目标层) | 「vacuumLevels 的逐层压缩必须挂在维护链上」;**注意**:引擎层 `vacuum()` 的 `flush()` 已会 drain 维护链,故该用例锁定的是 LSM 层不变量 | — | -| P1 | `reclaimRetiredNow()` 无视在途读者(读者把"已退休"读成"文件损坏") | 「有在途读者时强制回收必须退化为延迟回收」 | R2 | -| P2 | WAL 记录级 CRC 损坏只打日志(恢复报告无感) | 「一条记录 CRC 失败 → corruptRecords > 0 且标记丢数据」 | R9 | -| P2 | 旧格式表结构记录**形状**损坏(数组 / null / 非对象条目)静默当空库 | 「旧格式表结构记录的形状校验」5 条 | R16 | -| P2 | **`bloomFilterBitsPerKey` 配置被接受却完全不生效**(构建器写死默认位数) | 「bloomFilterBitsPerKey 必须真的生效」2 条(段大小 + 无 false negative) | R17 / R18 | -| P2 | 丢弃损坏文件后不同步内存层数组(幽灵 meta) | 「compaction 丢弃损坏文件后 levels 不留幽灵 meta」 | R5 | -| P2 | 介质读故障被折叠成"文件损坏"的路径无用例 | 「介质读故障 ≠ 文件损坏:compaction 必须失败且一个 meta 都不许丢」 | R11 | -| P2 | manifest 回读校验两条守卫无用例(吞写 / 内容坏) | 「写成功但读不回来」「写下去的内容读回来是坏的」 | R8 | -| P2 | "文件名世代 ≠ 载荷世代"的世代无效判定无用例 | 「文件名世代与载荷世代不一致 → 该世代无效」 | R6 | -| P2 | `pageIdWatermark` 单调性无用例(水位可回退 → 页面 id 复用) | 「水位是单调下限:计数器回退也不许把 manifest 水位带回低值」 | R7 | -| P2 | 分片号两条真实不变量无用例(清空后不回退到 0 / 新写入不低于 manifest 水位) | 「整体清空后分片号绝不回退」「写入永不落到 manifest 水位下限之下」 | R13 / R14 | - -**语义澄清(不是缺陷,但此前文档与实现不一致)**: -- **"分片号只增不减"** 的准确语义是"**绝不回退、绝不低于 manifest 水位**", - 整体清空(`truncate()`)后允许复用**最后用过的那个号** —— 记录自带 LSN, - 旧世代记录按水位跳过,因此复用安全;真正致命的是"回退到 0"(新记录落在 - `startSegment` 之下 → 恢复时被过滤掉)。已同步 README / H.1 / CHANGELOG。 -- **尾部 WAL 分片丢失**无法从介质自身识别(没有"它本该存在"的证据), - `ARIA_WRITE_LOST` 只兜住"有未落盘冻结表却重放不到任何记录"的情况 —— 已写入已知限制。 -- **`vacuumLevels()` 里 `drainMaintenance()` 那一行未被变异验证**:它在实现上只是 - 优化(抢在链前重算层内文件数),去掉后仍然串行,只是会多排几个空任务。 - -### I.2 测试质量修正("绿"≠"有保护") - -- 3 条空壳用例(孤儿回收门槛 / 强制回收 / 页面映射退休)改为**值级断言**; -- 1 条"整层文件全损坏"用例实际**走的是缓存**(从未读介质)→ 拆成两条真用例 - (介质读故障 / 文件真残缺); -- 5 秒墙钟 `Promise.race` → 门控信号 + **失败上限**(超时只让测试失败, - 不会让它在实现错误时"碰巧通过");`setTimeout` 睡眠 → `whenIdle()` 确定性等待; -- `<=` 收紧为严格 `<`(冻结意图水位必须**严格低于**意图起点); -- 删除死代码(`const inner` / `void manifest`)与误名的"非 JSON"用例。 - -### I.2b 覆盖率口径的第二处漏洞(已根因修复) - -审查发现 README / `jest.config.cjs` / 本附录 G5 都声称"只排除两个纯类型文件、 -可执行语句为 0",但 `engine/interface.ts` 内含三个运行时函数 -(`cloneRow` / `cloneRowFallback` / `cloneRows`)并被 Memory 引擎与 AriaEngine 调用 -—— 即真实实现代码一直在覆盖率统计之外(与 G5 声称根治的问题同类)。 -处理:实现搬到 `src/engine/row_clone.ts`,`interface.ts` 变回纯类型; -搬完后门禁**真的失败了**(functions 93.84% < 94%),被迫补测退化路径 -(`tests/engine/row-clone.test.ts`,8 项),随后全绿。这是"口径修正 → 门禁真的咬人"的闭环。 - -### I.3 文档订正(第二轮 G6:16 条不成立的宣称) - -MVCC 快照隔离(docs.html ×3 + demo.html + `mvcc.ts` 文件头)、`backup()` -"一致性快照"(`interface.ts` 注释)、"空洞检测截断"、"~105KB / gzip ~27KB" -(实测 251,731 字节 / gzip 63,431 字节)、测试与覆盖率数字(README badge/正文、site 徽标与数字卡、 -CHANGELOG)、"5 种存储引擎"(→ 4 模式 + 3 后端)、"Tree-shakable"(→ 多格式输出)、 -错误码表缺 **16 个**码、CONTRIBUTING 变异数量(17 → 40)、PLAN 附录 G 的 -"12 项事故级"(→ 11 项,A1~A11)、G4"零丢失"(限定为矩阵覆盖范围内)、 -H.1"25 例"(→ 24 例)、"分片号只增不减"(→ 准确语义)。 - -**第二轮核对(独立子代理,只读审计)**:修正后再做一次"宣称 vs 实现"逐条核对, -又发现 11 条不一致(CONTRIBUTING 的旧体积/旧测试数、README 的"空洞检测截断"残留、 -`getRecoveryReport()` 字段枚举漏 `droppedWALRecords`、PLAN 的 94-vs-96、 -CHANGELOG 历史段落的"快照隔离"表述、docs.html 缺记录级 CRC 上报、 -以及上面 I.2b 的覆盖率口径问题)——**全部已订正**,其中覆盖率口径是改实现而非改文档。 - -### I.4 验证(v0.8.0 收尾复测,全部可复现) - -```bash -# 1) B-6 + 分片 WAL 套件(96 + 15 项) -npx jest tests/v080-b6-single-commit-point.test.ts tests/engine/aria-wal-segment.test.ts - -# 2) 全量常规套件(不含重型与 e2e) -npx jest --testPathIgnorePatterns='/node_modules/|/tests/e2e/|aria-prod-load|kvstore-stress|aria-matrix-audit|aria-idx-flush-race' -# → 93 套件 / 1985 用例全绿 - -# 3) 覆盖率(与 CI 常规 job 完全相同的命令) -npx jest --coverage --coverageReporters=text-summary --testPathIgnorePatterns='/node_modules/|/tests/e2e/|aria-prod-load|kvstore-stress|aria-matrix-audit|aria-idx-flush-race' -# → Statements 90.59% / Branches 82.61% / Functions 94.14% / Lines 93.50%(阈值 90/82/94/93) - -# 4) e2e(真实 Chromium + 真实 OPFS + CDP Page.crash) -npx playwright test # → 14/14 - -# 5) 变异验证(42 项;脚本自带正控、编译失败区分、超时、逐字节恢复校验、进程锁) -python3 scripts/mutation-b6.py # → 40/40 全部被拦住 - -# 6) 静态门禁 -npm run lint && npx tsc -p tsconfig.json --noEmit && npx tsc -p tsconfig.test.json --noEmit -``` - -### I.5 未修复 / 保留项(诚实清单) - -| 项 | 现状 | 为什么保留 | -| --- | --- | --- | -| e2e 未覆盖"manifest 写入窗口"崩溃 | 现有 e2e 覆盖 OPFS 的 `write()`/`commit()` 两个窗口(数据文件),未按**文件名**门控 manifest 的写入窗口 | 需要 harness 支持按 key 名门控 OPFS 写(`getFileHandle` 名追踪 + `armCrashOnOpfsWrite({file})`);收益有限(manifest 的先写后验 + 两代保留已在单测层用字节级损坏覆盖) | -| `clearAll()` 重置 `manifest.namespaces` 时 `nextSstableId` 归零 | 页面水位保留(媒体可能仍有页面),SSTable 编号在介质已清空的前提下重新开始 | 介质被整体抹掉,编号复用无冲突对象;真要严格只需把 `nextSstableId` 也纳入单调水位 —— 当前无收益 | -| 无 Web Locks 环境的多实例并发写 | 降级为提交点冲突检测(`STALE_INSTANCE`),**不支持**并发写 | 客户端没有跨标签页互斥原语时无法保证;已写入已知限制 | -| manifest 体积随 SSTable 数增长 | 单文件整体重写 + 保留两代,无增量/分层 | 属设计取舍(单一提交点的代价),已写入已知限制;收敛手段是 `vacuum()` 合并层级 | diff --git a/dist/metona-sqlark.cjs b/dist/metona-sqlark.cjs index 7fcb44c..b38c755 100644 --- a/dist/metona-sqlark.cjs +++ b/dist/metona-sqlark.cjs @@ -53,7 +53,7 @@ const VERSION = '0.8.0'; * @module engine/change-notifier * * ============================================================================ - * 为什么需要它(PLAN-v0.7.5.md A9) + * 为什么需要它(v0.8.0 审计 A9) * ============================================================================ * `db.subscribe(table, fn)` 此前对**本地写入永不触发**:全库只有一处调用 `emit`, * 而那一处在 BroadcastChannel 收到**其它标签页**消息时才执行。于是: @@ -402,7 +402,7 @@ function cloneRowFallback(value) { * @module query/sql-compare * * ============================================================================ - * 为什么需要它(PLAN-v0.7.5.md 根因 2 / PB-2) + * 为什么需要它(v0.8.0 审计根因 2 / PB-2) * ============================================================================ * 此前项目里并存**五套**值相等语义: * 1. `where-matcher` 的 `===` @@ -641,7 +641,7 @@ function sqlIn(value, list) { * @module query/where-matcher * * ============================================================================ - * v0.8.0 根治:为什么这里只剩**一个**递归求值器(PLAN-v0.7.5.md 根因 1/7、PB-2) + * v0.8.0 根治:为什么这里只剩**一个**递归求值器(v0.8.0 审计根因 1/7、PB-2) * ============================================================================ * 历史上有两套并存的实现: * - `matchWhere` —— 布尔版(自己的 `$and/$or/$not` 分支 + `matchField`); @@ -1078,7 +1078,7 @@ function unquoteIdentifier$1(text) { * @module table/validation * * ============================================================================ - * 为什么必须合并(PLAN-v0.7.5.md 根因 1:关系语义在引擎间重复实现) + * 为什么必须合并(v0.8.0 审计根因 1:关系语义在引擎间重复实现) * ============================================================================ * 修复前项目里存在**三份**行校验实现,覆盖范围各不相同: * @@ -3350,7 +3350,7 @@ class KVStore { * 实际数据已经落盘 —— 报错与事实相反,属于最有害的一类不一致 * (调用方据此重试会写入两次,或据"失败"丢弃业务状态)。 * - * 现在的语义(与 PLAN-v0.7.5.md 的 B-6 止血方案一致): + * 现在的语义(与 B-6 止血方案一致): * - 已确认的写入**不得**因为后台失败而报错; * - 失败被记录为 `lastBackgroundError`,由**下一次** `checkpoint()` * 显式报告(那是用户主动要求把数据压实到快照的时机,此时失败是真实问题); @@ -15096,7 +15096,7 @@ function parseWhereCondition(sql) { * @module query/column-value * * ============================================================================ - * 为什么必须只有一个实现(PLAN-v0.7.5.md 根因 1) + * 为什么必须只有一个实现(v0.8.0 审计根因 1) * ============================================================================ * "从一行里按名字取一列"曾是四处各写一份的实现,规则各不相同: * @@ -15167,7 +15167,7 @@ function resolveColumnValue(row, reference, opts) { * @module query/expression * * ============================================================================ - * 为什么必须换掉原实现(PLAN-v0.7.5.md 根因 3:字符串化 AST 表达式列) + * 为什么必须换掉原实现(v0.8.0 审计根因 3:字符串化 AST 表达式列) * ============================================================================ * 原 `parseCaseExpression` 用**正则**在原始 SQL 文本上切分 WHEN/THEN/ELSE: * diff --git a/dist/metona-sqlark.esm.js b/dist/metona-sqlark.esm.js index cdc5851..d929181 100644 --- a/dist/metona-sqlark.esm.js +++ b/dist/metona-sqlark.esm.js @@ -49,7 +49,7 @@ const VERSION = '0.8.0'; * @module engine/change-notifier * * ============================================================================ - * 为什么需要它(PLAN-v0.7.5.md A9) + * 为什么需要它(v0.8.0 审计 A9) * ============================================================================ * `db.subscribe(table, fn)` 此前对**本地写入永不触发**:全库只有一处调用 `emit`, * 而那一处在 BroadcastChannel 收到**其它标签页**消息时才执行。于是: @@ -398,7 +398,7 @@ function cloneRowFallback(value) { * @module query/sql-compare * * ============================================================================ - * 为什么需要它(PLAN-v0.7.5.md 根因 2 / PB-2) + * 为什么需要它(v0.8.0 审计根因 2 / PB-2) * ============================================================================ * 此前项目里并存**五套**值相等语义: * 1. `where-matcher` 的 `===` @@ -637,7 +637,7 @@ function sqlIn(value, list) { * @module query/where-matcher * * ============================================================================ - * v0.8.0 根治:为什么这里只剩**一个**递归求值器(PLAN-v0.7.5.md 根因 1/7、PB-2) + * v0.8.0 根治:为什么这里只剩**一个**递归求值器(v0.8.0 审计根因 1/7、PB-2) * ============================================================================ * 历史上有两套并存的实现: * - `matchWhere` —— 布尔版(自己的 `$and/$or/$not` 分支 + `matchField`); @@ -1074,7 +1074,7 @@ function unquoteIdentifier$1(text) { * @module table/validation * * ============================================================================ - * 为什么必须合并(PLAN-v0.7.5.md 根因 1:关系语义在引擎间重复实现) + * 为什么必须合并(v0.8.0 审计根因 1:关系语义在引擎间重复实现) * ============================================================================ * 修复前项目里存在**三份**行校验实现,覆盖范围各不相同: * @@ -3346,7 +3346,7 @@ class KVStore { * 实际数据已经落盘 —— 报错与事实相反,属于最有害的一类不一致 * (调用方据此重试会写入两次,或据"失败"丢弃业务状态)。 * - * 现在的语义(与 PLAN-v0.7.5.md 的 B-6 止血方案一致): + * 现在的语义(与 B-6 止血方案一致): * - 已确认的写入**不得**因为后台失败而报错; * - 失败被记录为 `lastBackgroundError`,由**下一次** `checkpoint()` * 显式报告(那是用户主动要求把数据压实到快照的时机,此时失败是真实问题); @@ -15092,7 +15092,7 @@ function parseWhereCondition(sql) { * @module query/column-value * * ============================================================================ - * 为什么必须只有一个实现(PLAN-v0.7.5.md 根因 1) + * 为什么必须只有一个实现(v0.8.0 审计根因 1) * ============================================================================ * "从一行里按名字取一列"曾是四处各写一份的实现,规则各不相同: * @@ -15163,7 +15163,7 @@ function resolveColumnValue(row, reference, opts) { * @module query/expression * * ============================================================================ - * 为什么必须换掉原实现(PLAN-v0.7.5.md 根因 3:字符串化 AST 表达式列) + * 为什么必须换掉原实现(v0.8.0 审计根因 3:字符串化 AST 表达式列) * ============================================================================ * 原 `parseCaseExpression` 用**正则**在原始 SQL 文本上切分 WHEN/THEN/ELSE: * diff --git a/dist/metona-sqlark.js b/dist/metona-sqlark.js index b86db7c..0647a15 100644 --- a/dist/metona-sqlark.js +++ b/dist/metona-sqlark.js @@ -55,7 +55,7 @@ * @module engine/change-notifier * * ============================================================================ - * 为什么需要它(PLAN-v0.7.5.md A9) + * 为什么需要它(v0.8.0 审计 A9) * ============================================================================ * `db.subscribe(table, fn)` 此前对**本地写入永不触发**:全库只有一处调用 `emit`, * 而那一处在 BroadcastChannel 收到**其它标签页**消息时才执行。于是: @@ -404,7 +404,7 @@ * @module query/sql-compare * * ============================================================================ - * 为什么需要它(PLAN-v0.7.5.md 根因 2 / PB-2) + * 为什么需要它(v0.8.0 审计根因 2 / PB-2) * ============================================================================ * 此前项目里并存**五套**值相等语义: * 1. `where-matcher` 的 `===` @@ -643,7 +643,7 @@ * @module query/where-matcher * * ============================================================================ - * v0.8.0 根治:为什么这里只剩**一个**递归求值器(PLAN-v0.7.5.md 根因 1/7、PB-2) + * v0.8.0 根治:为什么这里只剩**一个**递归求值器(v0.8.0 审计根因 1/7、PB-2) * ============================================================================ * 历史上有两套并存的实现: * - `matchWhere` —— 布尔版(自己的 `$and/$or/$not` 分支 + `matchField`); @@ -1080,7 +1080,7 @@ * @module table/validation * * ============================================================================ - * 为什么必须合并(PLAN-v0.7.5.md 根因 1:关系语义在引擎间重复实现) + * 为什么必须合并(v0.8.0 审计根因 1:关系语义在引擎间重复实现) * ============================================================================ * 修复前项目里存在**三份**行校验实现,覆盖范围各不相同: * @@ -3352,7 +3352,7 @@ * 实际数据已经落盘 —— 报错与事实相反,属于最有害的一类不一致 * (调用方据此重试会写入两次,或据"失败"丢弃业务状态)。 * - * 现在的语义(与 PLAN-v0.7.5.md 的 B-6 止血方案一致): + * 现在的语义(与 B-6 止血方案一致): * - 已确认的写入**不得**因为后台失败而报错; * - 失败被记录为 `lastBackgroundError`,由**下一次** `checkpoint()` * 显式报告(那是用户主动要求把数据压实到快照的时机,此时失败是真实问题); @@ -15098,7 +15098,7 @@ * @module query/column-value * * ============================================================================ - * 为什么必须只有一个实现(PLAN-v0.7.5.md 根因 1) + * 为什么必须只有一个实现(v0.8.0 审计根因 1) * ============================================================================ * "从一行里按名字取一列"曾是四处各写一份的实现,规则各不相同: * @@ -15169,7 +15169,7 @@ * @module query/expression * * ============================================================================ - * 为什么必须换掉原实现(PLAN-v0.7.5.md 根因 3:字符串化 AST 表达式列) + * 为什么必须换掉原实现(v0.8.0 审计根因 3:字符串化 AST 表达式列) * ============================================================================ * 原 `parseCaseExpression` 用**正则**在原始 SQL 文本上切分 WHEN/THEN/ELSE: * diff --git a/dist/react.js b/dist/react.js index c893637..789eafc 100644 --- a/dist/react.js +++ b/dist/react.js @@ -47,7 +47,7 @@ class DatabaseError extends Error { * @module engine/change-notifier * * ============================================================================ - * 为什么需要它(PLAN-v0.7.5.md A9) + * 为什么需要它(v0.8.0 审计 A9) * ============================================================================ * `db.subscribe(table, fn)` 此前对**本地写入永不触发**:全库只有一处调用 `emit`, * 而那一处在 BroadcastChannel 收到**其它标签页**消息时才执行。于是: @@ -396,7 +396,7 @@ function cloneRowFallback(value) { * @module query/sql-compare * * ============================================================================ - * 为什么需要它(PLAN-v0.7.5.md 根因 2 / PB-2) + * 为什么需要它(v0.8.0 审计根因 2 / PB-2) * ============================================================================ * 此前项目里并存**五套**值相等语义: * 1. `where-matcher` 的 `===` @@ -635,7 +635,7 @@ function sqlIn(value, list) { * @module query/where-matcher * * ============================================================================ - * v0.8.0 根治:为什么这里只剩**一个**递归求值器(PLAN-v0.7.5.md 根因 1/7、PB-2) + * v0.8.0 根治:为什么这里只剩**一个**递归求值器(v0.8.0 审计根因 1/7、PB-2) * ============================================================================ * 历史上有两套并存的实现: * - `matchWhere` —— 布尔版(自己的 `$and/$or/$not` 分支 + `matchField`); @@ -1072,7 +1072,7 @@ function unquoteIdentifier$1(text) { * @module table/validation * * ============================================================================ - * 为什么必须合并(PLAN-v0.7.5.md 根因 1:关系语义在引擎间重复实现) + * 为什么必须合并(v0.8.0 审计根因 1:关系语义在引擎间重复实现) * ============================================================================ * 修复前项目里存在**三份**行校验实现,覆盖范围各不相同: * @@ -3344,7 +3344,7 @@ class KVStore { * 实际数据已经落盘 —— 报错与事实相反,属于最有害的一类不一致 * (调用方据此重试会写入两次,或据"失败"丢弃业务状态)。 * - * 现在的语义(与 PLAN-v0.7.5.md 的 B-6 止血方案一致): + * 现在的语义(与 B-6 止血方案一致): * - 已确认的写入**不得**因为后台失败而报错; * - 失败被记录为 `lastBackgroundError`,由**下一次** `checkpoint()` * 显式报告(那是用户主动要求把数据压实到快照的时机,此时失败是真实问题); @@ -15081,7 +15081,7 @@ function parseWhereCondition(sql) { * @module query/column-value * * ============================================================================ - * 为什么必须只有一个实现(PLAN-v0.7.5.md 根因 1) + * 为什么必须只有一个实现(v0.8.0 审计根因 1) * ============================================================================ * "从一行里按名字取一列"曾是四处各写一份的实现,规则各不相同: * @@ -15152,7 +15152,7 @@ function resolveColumnValue(row, reference, opts) { * @module query/expression * * ============================================================================ - * 为什么必须换掉原实现(PLAN-v0.7.5.md 根因 3:字符串化 AST 表达式列) + * 为什么必须换掉原实现(v0.8.0 审计根因 3:字符串化 AST 表达式列) * ============================================================================ * 原 `parseCaseExpression` 用**正则**在原始 SQL 文本上切分 WHEN/THEN/ELSE: * diff --git a/dist/vue.js b/dist/vue.js index d708238..2b2cf4e 100644 --- a/dist/vue.js +++ b/dist/vue.js @@ -47,7 +47,7 @@ class DatabaseError extends Error { * @module engine/change-notifier * * ============================================================================ - * 为什么需要它(PLAN-v0.7.5.md A9) + * 为什么需要它(v0.8.0 审计 A9) * ============================================================================ * `db.subscribe(table, fn)` 此前对**本地写入永不触发**:全库只有一处调用 `emit`, * 而那一处在 BroadcastChannel 收到**其它标签页**消息时才执行。于是: @@ -396,7 +396,7 @@ function cloneRowFallback(value) { * @module query/sql-compare * * ============================================================================ - * 为什么需要它(PLAN-v0.7.5.md 根因 2 / PB-2) + * 为什么需要它(v0.8.0 审计根因 2 / PB-2) * ============================================================================ * 此前项目里并存**五套**值相等语义: * 1. `where-matcher` 的 `===` @@ -635,7 +635,7 @@ function sqlIn(value, list) { * @module query/where-matcher * * ============================================================================ - * v0.8.0 根治:为什么这里只剩**一个**递归求值器(PLAN-v0.7.5.md 根因 1/7、PB-2) + * v0.8.0 根治:为什么这里只剩**一个**递归求值器(v0.8.0 审计根因 1/7、PB-2) * ============================================================================ * 历史上有两套并存的实现: * - `matchWhere` —— 布尔版(自己的 `$and/$or/$not` 分支 + `matchField`); @@ -1072,7 +1072,7 @@ function unquoteIdentifier$1(text) { * @module table/validation * * ============================================================================ - * 为什么必须合并(PLAN-v0.7.5.md 根因 1:关系语义在引擎间重复实现) + * 为什么必须合并(v0.8.0 审计根因 1:关系语义在引擎间重复实现) * ============================================================================ * 修复前项目里存在**三份**行校验实现,覆盖范围各不相同: * @@ -3344,7 +3344,7 @@ class KVStore { * 实际数据已经落盘 —— 报错与事实相反,属于最有害的一类不一致 * (调用方据此重试会写入两次,或据"失败"丢弃业务状态)。 * - * 现在的语义(与 PLAN-v0.7.5.md 的 B-6 止血方案一致): + * 现在的语义(与 B-6 止血方案一致): * - 已确认的写入**不得**因为后台失败而报错; * - 失败被记录为 `lastBackgroundError`,由**下一次** `checkpoint()` * 显式报告(那是用户主动要求把数据压实到快照的时机,此时失败是真实问题); @@ -15081,7 +15081,7 @@ function parseWhereCondition(sql) { * @module query/column-value * * ============================================================================ - * 为什么必须只有一个实现(PLAN-v0.7.5.md 根因 1) + * 为什么必须只有一个实现(v0.8.0 审计根因 1) * ============================================================================ * "从一行里按名字取一列"曾是四处各写一份的实现,规则各不相同: * @@ -15152,7 +15152,7 @@ function resolveColumnValue(row, reference, opts) { * @module query/expression * * ============================================================================ - * 为什么必须换掉原实现(PLAN-v0.7.5.md 根因 3:字符串化 AST 表达式列) + * 为什么必须换掉原实现(v0.8.0 审计根因 3:字符串化 AST 表达式列) * ============================================================================ * 原 `parseCaseExpression` 用**正则**在原始 SQL 文本上切分 WHEN/THEN/ELSE: * diff --git a/src/engine/change-notifier.ts b/src/engine/change-notifier.ts index 1c0dda9..d22ccd2 100644 --- a/src/engine/change-notifier.ts +++ b/src/engine/change-notifier.ts @@ -3,7 +3,7 @@ * @module engine/change-notifier * * ============================================================================ - * 为什么需要它(PLAN-v0.7.5.md A9) + * 为什么需要它(v0.8.0 审计 A9) * ============================================================================ * `db.subscribe(table, fn)` 此前对**本地写入永不触发**:全库只有一处调用 `emit`, * 而那一处在 BroadcastChannel 收到**其它标签页**消息时才执行。于是: diff --git a/src/engine/kvstore/index.ts b/src/engine/kvstore/index.ts index 0ae36db..1433c6d 100644 --- a/src/engine/kvstore/index.ts +++ b/src/engine/kvstore/index.ts @@ -447,7 +447,7 @@ export class KVStore { * 实际数据已经落盘 —— 报错与事实相反,属于最有害的一类不一致 * (调用方据此重试会写入两次,或据"失败"丢弃业务状态)。 * - * 现在的语义(与 PLAN-v0.7.5.md 的 B-6 止血方案一致): + * 现在的语义(与 B-6 止血方案一致): * - 已确认的写入**不得**因为后台失败而报错; * - 失败被记录为 `lastBackgroundError`,由**下一次** `checkpoint()` * 显式报告(那是用户主动要求把数据压实到快照的时机,此时失败是真实问题); diff --git a/src/query/column-value.ts b/src/query/column-value.ts index 5c38ea8..a819fed 100644 --- a/src/query/column-value.ts +++ b/src/query/column-value.ts @@ -3,7 +3,7 @@ * @module query/column-value * * ============================================================================ - * 为什么必须只有一个实现(PLAN-v0.7.5.md 根因 1) + * 为什么必须只有一个实现(v0.8.0 审计根因 1) * ============================================================================ * "从一行里按名字取一列"曾是四处各写一份的实现,规则各不相同: * diff --git a/src/query/expression.ts b/src/query/expression.ts index 12ea61b..4efeca7 100644 --- a/src/query/expression.ts +++ b/src/query/expression.ts @@ -3,7 +3,7 @@ * @module query/expression * * ============================================================================ - * 为什么必须换掉原实现(PLAN-v0.7.5.md 根因 3:字符串化 AST 表达式列) + * 为什么必须换掉原实现(v0.8.0 审计根因 3:字符串化 AST 表达式列) * ============================================================================ * 原 `parseCaseExpression` 用**正则**在原始 SQL 文本上切分 WHEN/THEN/ELSE: * diff --git a/src/query/sql-compare.ts b/src/query/sql-compare.ts index 317ac12..90fe869 100644 --- a/src/query/sql-compare.ts +++ b/src/query/sql-compare.ts @@ -3,7 +3,7 @@ * @module query/sql-compare * * ============================================================================ - * 为什么需要它(PLAN-v0.7.5.md 根因 2 / PB-2) + * 为什么需要它(v0.8.0 审计根因 2 / PB-2) * ============================================================================ * 此前项目里并存**五套**值相等语义: * 1. `where-matcher` 的 `===` diff --git a/src/query/where-matcher.ts b/src/query/where-matcher.ts index 66a9220..8e3313d 100644 --- a/src/query/where-matcher.ts +++ b/src/query/where-matcher.ts @@ -3,7 +3,7 @@ * @module query/where-matcher * * ============================================================================ - * v0.8.0 根治:为什么这里只剩**一个**递归求值器(PLAN-v0.7.5.md 根因 1/7、PB-2) + * v0.8.0 根治:为什么这里只剩**一个**递归求值器(v0.8.0 审计根因 1/7、PB-2) * ============================================================================ * 历史上有两套并存的实现: * - `matchWhere` —— 布尔版(自己的 `$and/$or/$not` 分支 + `matchField`); diff --git a/src/table/validation.ts b/src/table/validation.ts index b08ee38..0d4e80e 100644 --- a/src/table/validation.ts +++ b/src/table/validation.ts @@ -3,7 +3,7 @@ * @module table/validation * * ============================================================================ - * 为什么必须合并(PLAN-v0.7.5.md 根因 1:关系语义在引擎间重复实现) + * 为什么必须合并(v0.8.0 审计根因 1:关系语义在引擎间重复实现) * ============================================================================ * 修复前项目里存在**三份**行校验实现,覆盖范围各不相同: * diff --git a/tests/e2e/opfs.spec.ts b/tests/e2e/opfs.spec.ts index fee0a5e..cc256a9 100644 --- a/tests/e2e/opfs.spec.ts +++ b/tests/e2e/opfs.spec.ts @@ -36,7 +36,7 @@ async function run(page: Page, method: string, args: unknown = null): Promise * * 修复前这里只是 `win.__ms = null; await page.close()` —— 那是**优雅关闭**: * 没有未完成的 I/O、不经过任何崩溃窗口,因此"崩溃恢复"的结论实际上是在 - * "正常关闭后重开"上得到的(PLAN-v0.7.5.md 根因 5:崩溃语义声称未被验证)。 + * "正常关闭后重开"上得到的(v0.8.0 审计根因 5:崩溃语义声称未被验证)。 * * 现在用 CDP `Page.crash` 直接终止渲染进程:进行中的 OPFS 写入、未 flush 的 * WAL 缓冲、同步访问句柄全部**立即**消失,与浏览器/标签页被强杀一致。 diff --git a/tests/engine/aria-opfs-backend.test.ts b/tests/engine/aria-opfs-backend.test.ts index 00eb34f..47a5425 100644 --- a/tests/engine/aria-opfs-backend.test.ts +++ b/tests/engine/aria-opfs-backend.test.ts @@ -14,7 +14,7 @@ // =================================================================== // v0.8.0: 删除本文件内的第三份 OPFS mock —— 改用共享的 storage-harness。 // -// 原实现的问题(见 PLAN-v0.7.5.md 工作流 C-1): +// 原实现的问题(见 v0.8.0 迭代工作流 C-1): // - 与 tests/helpers/opfs-mock.ts 重复(两份都在测同一件事,语义各自漂移); // - `close()` 是空函数、写入立即可见 → 无法表达"提交前崩溃"; // - 定义了 `writeCalls` 记录但**从未被任何断言使用**(死代码); diff --git a/tests/helpers/faulty-backend.ts b/tests/helpers/faulty-backend.ts index 8fcb49e..af4f5e4 100644 --- a/tests/helpers/faulty-backend.ts +++ b/tests/helpers/faulty-backend.ts @@ -2,7 +2,7 @@ * 测试共享 — 故障注入后端包装器(v0.8.0 工作流 C-1) * * ============================================================================ - * 为什么需要它(PLAN-v0.7.5.md 根因 5) + * 为什么需要它(v0.8.0 审计根因 5) * ============================================================================ * 项目所有"崩溃恢复"测试用的都是 `await engine.backend.close()`,而 OPFSBackend.close() * 会 `await this.writeQueue` 把在途写**全部刷完** —— 那是优雅停机,不是崩溃。 diff --git a/tests/helpers/storage-harness.ts b/tests/helpers/storage-harness.ts index b7900f7..dbfd111 100644 --- a/tests/helpers/storage-harness.ts +++ b/tests/helpers/storage-harness.ts @@ -2,7 +2,7 @@ * 测试共享 — OPFS 事务性文件存储(v0.8.0 根治版) * * ============================================================================ - * 为什么需要重写(审计结论,见 PLAN-v0.7.5.md 工作流 C-1) + * 为什么需要重写(审计结论,见 v0.8.0 迭代工作流 C-1) * ============================================================================ * 旧 mock(tests/helpers/opfs-mock.ts)有三处与真实 OPFS 语义不符,会掩盖真实缺陷: * 1. `read()` 返回内部 ArrayBuffer **引用**(真实 OPFS 返回快照副本) diff --git a/tests/sql/parser.test.ts b/tests/sql/parser.test.ts index 4a134d5..ed7cee9 100644 --- a/tests/sql/parser.test.ts +++ b/tests/sql/parser.test.ts @@ -3,7 +3,7 @@ * * v0.8.0 强化说明: * 1. 此前 12 处断言写着 `expect(ast.where).toBeDefined()` —— 既没有验证结构也没有验证 - * 值,属于"空断言"。审计(AUDIT-query-layer / SQL 层)指出:`parser.test.ts` 无一处 + * 值,属于"空断言"。审计(v0.8.0 · query 层)指出:`parser.test.ts` 无一处 * 断言混合 AND/OR 的**结构**,这正是 AND/OR 无优先级缺陷(总账第 11 项)能长期存活的原因。 * 2. 本文件此前 `parse()` 的返回类型是联合类型 `Statement`,直接访问 `.where` / `.columns` * 在类型上不成立(tests 从不做类型检查,所以没人发现)。现在用类型收窄辅助函数显式 diff --git a/tests/v080-b6-single-commit-point.test.ts b/tests/v080-b6-single-commit-point.test.ts index 126c5dd..2c28a91 100644 --- a/tests/v080-b6-single-commit-point.test.ts +++ b/tests/v080-b6-single-commit-point.test.ts @@ -1,7 +1,7 @@ /** * v0.8.0(B-6)回归套件 —— 存储层**单一提交点**(`__aria_manifest`)与 LSM 结构根治 * ============================================================================ - * 本套件覆盖 PLAN-v0.7.5.md 工作流 B 的 B-6 全部条目。每一条都对应一个**实测过** + * 本套件覆盖 v0.8.0 迭代工作流 B 的 B-6 全部条目。每一条都对应一个**实测过** * 的缺陷(或一条被写进 manifest 的持久不变量): * * | 编号 | 缺陷 / 不变量 | 本文件对应用例 | diff --git a/tests/v080-kvstore-commit-point.test.ts b/tests/v080-kvstore-commit-point.test.ts index 5d18053..bd10d1b 100644 --- a/tests/v080-kvstore-commit-point.test.ts +++ b/tests/v080-kvstore-commit-point.test.ts @@ -1,7 +1,7 @@ /** * v0.8.0 回归套件 —— B-6 存储提交点与崩溃语义(KVStore) * ============================================================================ - * 本套件覆盖 PLAN-v0.7.5.md §4「B-6」列出的 KVStore 三处止血,其中两处是 + * 本套件覆盖 v0.8.0 迭代 §4「B-6」列出的 KVStore 三处止血,其中两处是 * **P0 级数据丢失**: * * 1. `open()` 遇到损坏日志尾部会**清空整个日志**(`truncateLog()` 写空文件)。 diff --git a/tests/v080-query-layer.test.ts b/tests/v080-query-layer.test.ts index 07e4ff9..ff549e3 100644 --- a/tests/v080-query-layer.test.ts +++ b/tests/v080-query-layer.test.ts @@ -1,7 +1,7 @@ /** * v0.8.0 回归套件 —— 查询层缺陷根治(A22/A23/A25/A26/A27/A29/A30/A36) * ============================================================================ - * 本套件锁定 PLAN-v0.7.5.md §5 缺陷总账中查询层的 8 项修复。每一项都先给出 + * 本套件锁定 v0.8.0 迭代 §5 缺陷总账中查询层的 8 项修复。每一项都先给出 * **修复前的实测错误输出**,再断言正确结果 —— 这样即使将来重构执行器, * 失败的断言能直接告诉后来者"当初错在哪里"。 * diff --git a/tests/v080-sql-three-valued.test.ts b/tests/v080-sql-three-valued.test.ts index 1293164..be21ff4 100644 --- a/tests/v080-sql-three-valued.test.ts +++ b/tests/v080-sql-three-valued.test.ts @@ -1,7 +1,7 @@ /** * v0.8.0 回归套件:SQL 三值逻辑(NULL 语义)—— PB-2 * ============================================================================ - * 本套件锁定 PLAN-v0.7.5.md 根因 7("未解析/UNKNOWN 静默变 false")与 + * 本套件锁定 v0.8.0 审计根因 7("未解析/UNKNOWN 静默变 false")与 * 附录 §5 缺陷 A15 的修复契约,并对**四种存储引擎**逐一验证同一语义。 * * 历史缺陷(本套件即为其反例): diff --git a/tests/v080-unified-validation.test.ts b/tests/v080-unified-validation.test.ts index 7dbebd8..f70d2a1 100644 --- a/tests/v080-unified-validation.test.ts +++ b/tests/v080-unified-validation.test.ts @@ -1,7 +1,7 @@ /** * v0.8.0 回归套件 —— B-1 统一行校验(工作流 B 第 1 项) * ============================================================================ - * 背景(PLAN-v0.7.5.md 根因 1 + 缺陷 A12/A17): + * 背景(v0.8.0 审计根因 1 + 缺陷 A12/A17): * 修复前存在**三份**行校验实现,覆盖面各不相同 —— Memory/KVStore/Hybrid 共用 * 一份"只查类型"的实现(**没有** maxLength/min/max),Aria 走 checkFieldType * (有约束),schema.ts 是第三份。于是: