chore(docs): 删除 v0.7.4 审计与 v0.7.5 计划共 4 个 md 文档

删除:
- AUDIT-aria-lsm-v0.7.4.md(50KB)
- AUDIT-query-layer-v0.7.4.md(39KB)
- AUDIT-storage-engines-v0.7.4.md(39KB)
- PLAN-v0.7.5.md(98KB,含附录 G/H/I)

删除前先清除引用面,避免留下断链(共 18 处):
- 源码注释 7 处(change-notifier / kvstore index / column-value / expression /
  sql-compare / where-matcher / validation):保留设计意图,引用改为"v0.8.0 审计根因 N"
- 测试注释 9 处(opfs.spec / aria-opfs-backend / faulty-backend / storage-harness /
  v080-b6 / v080-kvstore / v080-query-layer / v080-sql-three-valued /
  v080-unified-validation / parser):同上
- CHANGELOG 3 处:改为不依赖已删除文档的自洽表述(B-6 交付物见各条;门禁订正三处
  按内容重写),并把变异数量同步为 42
- 校验:三个 md 之间无断链;仓库内已无 PLAN-v0.7.5/AUDIT-* 的任何引用
  (git 历史仍可追溯,需要时可 `git show <commit>:PLAN-v0.7.5.md` 找回)

验证:93 套件 / 1985 用例全绿;覆盖率 90.59 / 82.61 / 94.14 / 93.50(阈值 90/82/94/93);
e2e 14/14;lint + 两份 tsc 干净;dist 已重建(注释只影响非压缩产物,min 产物
251,731 B / gzip 63,431 B 不变)。

说明:审查记录的核心内容仍在 CHANGELOG.md("全量回归审查"与"现场失败修复"两节),
随 PLAN 一起删除的是附录 G/H/I 的详细表格(门禁逐条验收、交付物清单、未修复项表)。
This commit is contained in:
thzxx
2026-09-15 17:17:24 +08:00
parent c1c3036abd
commit 50b1864145
27 changed files with 58 additions and 2260 deletions
-558
View File
@@ -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.20.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 格式与读写
**布局(v2magic `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-36compaction 用,全量物化)
`GeneratorEntrySource`43-58v0.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 = 1MBindex.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<key,row>`**
- 事务内写只进快照 + `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.appendBatchfull=逐批落盘 / batch=缓冲 / none=不写)
└─ opCounter += n ; checkMemoryBudget()maxMemoryMB 超限 → 不等待的 lsm.flush()
└─ checkpointManager.tick() ← opCount>=interval 或 WAL 字节>=16MB
└─ CheckpointManager.checkpoint()
├─ lsm.flush() ← 内含 drainChain(),会等**全部**后台 compaction
├─ flushAll() ← 主 LSM + 全部二级索引 LSM 各自 flush()
└─ wal.checkpoint() ← 截断 WAL(活跃事务时 skipindex.ts:252-259
```
`LSM.flush()`580-620):先查 `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_<table>_<col>_`、meta key `__aria_lsm_meta_<ns>`、**独立 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'` 下**不落盘也不设上限**。
- **严重级别**P2OOM/GC 压力;"maxMemoryMB=64" 的宣称无法兑现)。
- **复现思路**:**验证方法**——建 10 个索引列,写入后断言
`Σ(lsm.getEstimatedMemory()) <= maxMemoryMB*1024*1024`;当前会超。
### P3-1 `applyWALRecord` 不做任何校验/墓碑语义转换,直接 `lsm.put`
- **文件:行号**`index.ts:1829-1856`。重放 INSERT/UPDATE 直接 `this.lsm.put(key, record.data)`
- **现象**WAL 中的行若含 schema 外列或已删除列(历史版本写过),重放会原样落库,
绕过 `validateRow`(与 v0.7.4 修"UPDATE 未知列静默入库"的方向相反)。
- **严重级别**:P3(恢复路径的防御缺口,需先有脏 WAL 才成立)。
- **复现思路**:**验证方法**——伪造一条含 `{ ghost: 1 }` 的 INSERT WAL 记录后重开,断言该列被丢弃。
### P3-2 `serialize()` 返回内部缓冲区引用;`page_sstable_store.load()` 会 `pageIds.delete(id)`
- **文件:行号**`bloom.ts:66-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` 会等到**链上全部后台任务**(含正在跑的整层 compactionN 次 `store.load` + 全量解析 +
构建 + `save`)结束。默认 `checkpointInterval = 1000`,即**每 1000 个操作就要等一次完整 compaction**。
这正是 CHANGELOG v0.6.1 记录的"10 万行 kv 插入 353s、每批 8~11s"的机制来源;
v0.6.1 只移除了"逐行 prefetch"这一半,`tick()` 里的等待仍在。
**观察点**`aria-prod-load.test.ts:320` 的日志与 240s 护栏就是它的间接度量。
### 3.2 一次扫描把**全表** SSTable 拉进缓存
`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<Entry | MISS>`,内部对每层调用
`await this.readerFor(meta)`(缓存命中直接用,未命中 `store.load` + CRC + 入缓存),
MISS 与"不存在"用不同类型区分;`rangeScanLazy` 改为 async generator
每块按需 `await` 读取(配合已有的 `scanLazy` 块级生成器,天然支持)。
一旦如此:引擎层所有 `drainChain()+prefetch*` 的调用点(7 处)全部可以删掉,
3.1 的"每批写等 compaction"与 3.2 的"全表预载"同时消失,
P1-1(缓存罚失当作不存在)从"靠约定"变成"结构上不可能"。
2. **把 `flushChain` 换成带意图记录的串行工作队列**`pending: Array<{kind:'flush'|'compact', level?, frozen?}>`
+ 每个任务的终态(成功/失败/待重试)显式记录,失败任务**保留在队列里可重试**
(直接解决 P0-3、P1-6),`compacting` 单标志换成 `Set<level>`(解决 P1-5),
并发 compaction 的层集合由队列调度器保证互斥。
配套(低成本、高收益):抽出唯一的 `keyCodec` 模块(`encodePk/encodeIdx/encodeIdxValueRange/tableRange`),
`sstable.ts` 的三份解析循环合并为一份"块迭代器"(`get`/`scanLazy`/`scanAll` 都基于它),
并在 `SSTableReader` 构造时加 debug 断言(索引键单调递增、块偏移在文件内、`minKey<=maxKey`)。
---
### 附:本次审计中已删除的探针清单(工作区无残留)
`tests/engine/` 下共 16 个临时测试文件,已全部 `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-*` 属其它会话产物,非本次创建。
-346
View File
@@ -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 1300 行。
方法:先逐行读码定位可疑路径,再写临时 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/offset353**,留到投影后重排。
后处理顺序(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` 执行子查询置换为 boolean1345-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 / RIGHT486),以及 ON 含 `$or`/`$not`/非等值(496/506)→ **右表无条件整表物化**429)。
### 1.5 投影 / 排序 / 分组键编码
- 行 = 扁平 `Record<string, unknown>`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)` 索引键 + 前缀 rangeScanaria/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_ERRORparser 只在 `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、3SQL 只应 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)` → 返回 2SQL 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-5SQL 中 `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 而非 NULLMIN/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 未索引列 → a1b1 + a2b2SQL 只应 a2b2
- `INNER JOIN` on 已索引列 → 仅 a2–c2(正确)
- `LEFT JOIN` on 未索引列 → a1b1、a2b2SQL 应为 a1NULL、a2b2
- `LEFT JOIN` on 已索引列 → a1NULL、a2c2(正确)
- 根因:两套连接实现各自解释 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=1SQL 应报 "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` 返回每组**第一行**的 salaryMySQL 式 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` | 就地改写调用方的 ASTwhere/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…SELECTP1-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)。
- 这些分流处**每一个**都对应上面的一个 P1P1-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/18DISTINCT 在投影前导致 P1-13LIMIT 下推到扫描导致 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-1OFFSET 结果错)→ P1-5/6NULL 三值逻辑)→ 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 中的绝大多数会作为"整类"消失,而不是逐条打补丁。
-243
View File
@@ -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<table, Map<pkString, row>>` —— 行存储,PK 直接由 `String(validatedRow[pkColumn])` 得到(memory.ts:163/177),**没有独立的主键索引结构**:PK map 就是行存储本身。
- `indexes: Map<table, Map<col, Map<value, Set<pk>>>>` —— 二级哈希索引;只有 `colDef.index || colDef.unique` 的列在 `createTable` 时建桶(memory.ts:81-85)。索引只记录非 null 值(memory.ts:780、793)。
- `schemas: Map<table, TableSchema>`createTable 时深拷贝列定义,memory.ts:75-79,防止 Hybrid 双引擎共享 schema 对象)。
- `uniqueIndexCols: Set<"table:col">` —— 仅标记 `CREATE UNIQUE INDEX` 来源的 uniquememory.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`**。
**insertmemory.ts:146-183**:v0.7.3 两阶段。阶段 1 逐行 `validateRow` + 批内 PK `Set` 互查(memory.ts:165+ `checkInsertUniqueness`(批内 Set + 索引查,memory.ts:309-341);阶段 2 落 row + `updateIndexes`
**updatememory.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`
**deletememory.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` 含自身不含 CRClog.ts:79)。操作码 PUT=1 / DELETE=2 / APPEND=3log.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<string, ArrayBuffer>`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 起水位只信快照内嵌 seqindex.ts:113-118)。
**写入与 checkpointindex.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,默认 16MBindex.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)。
**openkvstore_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` 立即 returnmemory.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-throughinsert 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=行 Mapcol→Set\<pk\> | 复用 Memory + `t:table:pk` 键 | 复用 Memory ×2 | LSM 二级索引 |
| 约束校验 | 自带 `validateRow/checkType`**无 maxLength/min/max**memory.ts:713-722 | 复用 Memory | 复用 Memory | 共享 `checkFieldType`(含 maxLength/min/maxaria/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 KVStoreEngineopen 回灌时的行级失败被静默吞掉** **[已复现]**
- 位置:`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 Hybridjson/数组等嵌套值在两个引擎间共享引用,调用方改动会被持久化** **[已复现]**
- 位置:`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/HybridP1-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 的高发区。
+6 -6
View File
@@ -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_<generation>` 单一提交点**:页面水位 + 各命名空间 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 新增/变更)
-1055
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -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/7PB-2
* v0.8.0 根治为什么这里只剩**一个**递归求值器v0.8.0 审计根因 1/7PB-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
*
+7 -7
View File
@@ -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/7PB-2
* v0.8.0 根治为什么这里只剩**一个**递归求值器v0.8.0 审计根因 1/7PB-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
*
+7 -7
View File
@@ -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/7PB-2
* v0.8.0 根治为什么这里只剩**一个**递归求值器v0.8.0 审计根因 1/7PB-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
*
+7 -7
View File
@@ -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/7PB-2
* v0.8.0 根治为什么这里只剩**一个**递归求值器v0.8.0 审计根因 1/7PB-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
*
Vendored
+7 -7
View File
@@ -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/7PB-2
* v0.8.0 根治为什么这里只剩**一个**递归求值器v0.8.0 审计根因 1/7PB-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
*
+1 -1
View File
@@ -3,7 +3,7 @@
* @module engine/change-notifier
*
* ============================================================================
* 为什么需要它(PLAN-v0.7.5.md A9
* 为什么需要它(v0.8.0 审计 A9
* ============================================================================
* `db.subscribe(table, fn)` 此前对**本地写入永不触发**:全库只有一处调用 `emit`,
* 而那一处在 BroadcastChannel 收到**其它标签页**消息时才执行。于是:
+1 -1
View File
@@ -447,7 +447,7 @@ export class KVStore {
* 实际数据已经落盘 —— 报错与事实相反,属于最有害的一类不一致
* (调用方据此重试会写入两次,或据"失败"丢弃业务状态)。
*
* 现在的语义(与 PLAN-v0.7.5.md 的 B-6 止血方案一致):
* 现在的语义(与 B-6 止血方案一致):
* - 已确认的写入**不得**因为后台失败而报错;
* - 失败被记录为 `lastBackgroundError`,由**下一次** `checkpoint()`
* 显式报告(那是用户主动要求把数据压实到快照的时机,此时失败是真实问题);
+1 -1
View File
@@ -3,7 +3,7 @@
* @module query/column-value
*
* ============================================================================
* 为什么必须只有一个实现(PLAN-v0.7.5.md 根因 1
* 为什么必须只有一个实现(v0.8.0 审计根因 1
* ============================================================================
* "从一行里按名字取一列"曾是四处各写一份的实现,规则各不相同:
*
+1 -1
View File
@@ -3,7 +3,7 @@
* @module query/expression
*
* ============================================================================
* 为什么必须换掉原实现(PLAN-v0.7.5.md 根因 3:字符串化 AST 表达式列)
* 为什么必须换掉原实现(v0.8.0 审计根因 3:字符串化 AST 表达式列)
* ============================================================================
* 原 `parseCaseExpression` 用**正则**在原始 SQL 文本上切分 WHEN/THEN/ELSE
*
+1 -1
View File
@@ -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` 的 `===`
+1 -1
View File
@@ -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`);
+1 -1
View File
@@ -3,7 +3,7 @@
* @module table/validation
*
* ============================================================================
* 为什么必须合并(PLAN-v0.7.5.md 根因 1:关系语义在引擎间重复实现)
* 为什么必须合并(v0.8.0 审计根因 1:关系语义在引擎间重复实现)
* ============================================================================
* 修复前项目里存在**三份**行校验实现,覆盖范围各不相同:
*
+1 -1
View File
@@ -36,7 +36,7 @@ async function run<T>(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 缓冲、同步访问句柄全部**立即**消失,与浏览器/标签页被强杀一致。
+1 -1
View File
@@ -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` 记录但**从未被任何断言使用**(死代码);
+1 -1
View File
@@ -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` 把在途写**全部刷完** —— 那是优雅停机,不是崩溃。
+1 -1
View File
@@ -2,7 +2,7 @@
* 测试共享 — OPFS 事务性文件存储(v0.8.0 根治版)
*
* ============================================================================
* 为什么需要重写(审计结论,见 PLAN-v0.7.5.md 工作流 C-1
* 为什么需要重写(审计结论,见 v0.8.0 迭代工作流 C-1
* ============================================================================
* 旧 mocktests/helpers/opfs-mock.ts)有三处与真实 OPFS 语义不符,会掩盖真实缺陷:
* 1. `read()` 返回内部 ArrayBuffer **引用**(真实 OPFS 返回快照副本)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* v0.8.0B-6 ****`__aria_manifest` LSM
* ============================================================================
* PLAN-v0.7.5.md B B-6 ****
* v0.8.0 B B-6 ****
* manifest
*
* | | / | |
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* v0.8.0 B-6 KVStore
* ============================================================================
* PLAN-v0.7.5.md §4B-6 KVStore
* v0.8.0 §4B-6 KVStore
* **P0 **
*
* 1. `open()` ****`truncateLog()`
+1 -1
View File
@@ -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
* **** 使
* "当初错在哪里"
*
+1 -1
View File
@@ -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 ****
*
*
+1 -1
View File
@@ -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/maxAria checkFieldType
* schema.ts