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:
@@ -1,558 +0,0 @@
|
|||||||
# AriaEngine LSM-Tree 索引子系统审计报告(v0.7.4)
|
|
||||||
|
|
||||||
> 审计范围:`src/engine/aria/index.ts`(2282 行)、`index/lsm.ts`(801)、`index/memtable.ts`(511)、
|
|
||||||
> `index/sstable.ts`(334)、`index/sstable_builder.ts`(253)、`index/merge_iterator.ts`(191)、
|
|
||||||
> `index/bloom.ts`(113)、`types.ts`(249),以及 15 个 aria 测试套件与 CHANGELOG v0.7.2–0.7.4。
|
|
||||||
>
|
|
||||||
> 标注约定:**【已读代码确认】**=直接从代码推出;**【已实测确认】**=我写了临时探针跑出可复现结果
|
|
||||||
> (探针已删除,工作区无残留);**【推测】**=机制上成立但未复现,附验证方法。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 架构概览
|
|
||||||
|
|
||||||
### 1.1 MemTable:红黑树
|
|
||||||
|
|
||||||
- **结构**:`RBNode`(`memtable.ts:14-26`)+ `RedBlackTree`(`memtable.ts:32-411`)。私有 `root` / `_size`,
|
|
||||||
颜色枚举 `Color{RED,BLACK}`,节点带 `parent` 指针,**无哨兵 NIL 节点**(nil 用 `null` 表示)。
|
|
||||||
- **比较器**:全部用 JS 原生 `<` / `>` / `>=` 直接作用于 `string`(`memtable.ts:54,56,80,82,127,136,137`)。
|
|
||||||
即 **UTF-16 码元序**,与 `Array.prototype.sort` 默认序、`SSTable` 索引键序完全一致 ——
|
|
||||||
这一点是全链路唯一真正统一的东西(builder 依赖"调用方已排序",见 1.2)。
|
|
||||||
- **插入** `insert(key,value)`(39-74):迭代下降找位置;命中则**原地替换 value 并 return,不改结构、不加 size**
|
|
||||||
(58-62)。新节点默认 RED,挂到 parent 后 `fixInsert`(231-272)走标准三情形(叔红上溢 / LL / LR)。
|
|
||||||
根节点强制染黑(271)。
|
|
||||||
- **查找** `find`(77-89)与 `findNode`(161-173)是同一算法写了两遍(重复代码)。
|
|
||||||
- **删除** `delete(key)`(92-100)→ `deleteNode`(175-213):零/单子节点走 `transplant`(215-224);
|
|
||||||
双子节点取中序后继、先摘后继再顶替(187-212)。后继颜色为黑时进入 `fixDelete`(274-357),
|
|
||||||
含四情形标准双黑修复(兄弟红 / 双侄黑 / 近侄红 / 远侄红),镜像分支在 319-353。
|
|
||||||
CHANGELOG v0.7.4 修的就是这里:`x`(双黑起点)与 `xParent` 必须在 `transplant` 重连**之前**捕获
|
|
||||||
(193-194, 209-210),否则 `null` 占位会以 `parent=null` 传入 → `fixDelete` 在 280 行 `break` 掉,
|
|
||||||
黑色节点删除后整棵树黑高失衡。
|
|
||||||
- **注意**:**`RedBlackTree.delete` 在生产路径上是死代码** —— `LSM.delete` 写墓碑(`lsm.ts:155-164`,
|
|
||||||
`{__tombstone:true}`)而不是树删除。只有单测 `aria-index.test.ts:104-112,142-149` 直接调用 `MemTable.delete`。
|
|
||||||
即:这棵树上最容易出错的那一半代码,生产环境从未被走到,而它仍带着一处**确定的大小记账缺陷**(见 2.2)。
|
|
||||||
- **遍历**:
|
|
||||||
- `inorder`(104-106)/`getAllEntries`(147-151):递归中序,物化数组。flush 走这条路径
|
|
||||||
(`lsm.ts:221`)。
|
|
||||||
- `rangeScan`(109-115)→ `_rangeScan`(400-410):递归 + 剪枝。注意剪枝用**严格不等号**:
|
|
||||||
`node.key > start` 才递归左子树、`node.key < end` 才递归右子树(407、409),
|
|
||||||
等于边界的子树被跳过 —— 对本函数是安全的(等于边界的那个节点本身会在 408 行被回调)。
|
|
||||||
- **`scanLazy`(121-144)**:v0.7.4 新增。显式栈中序迭代器;先沿路把"≥startKey 的最左祖先链"压栈
|
|
||||||
(124-132),然后弹栈产出;`node.key > endKey` 直接 `break`(136)剪掉剩余子树。
|
|
||||||
这是 `findStream` 真流式的底层。
|
|
||||||
|
|
||||||
### 1.2 SSTable 格式与读写
|
|
||||||
|
|
||||||
**布局(v2,magic `SSTC`)**:`[Data Block 0..n][Index Block][Bloom Filter][Footer 32B]`
|
|
||||||
(`sstable_builder.ts:15-34`)。
|
|
||||||
|
|
||||||
- **Data Block**:`entryCount(u32)` + 重复 `[keyLen(u32)|key|valLen(u32)|val]`,key/value 均为 **UTF-8 字节**,
|
|
||||||
value 是 `JSON.stringify(row)` 的字节(builder 77-81、185-222)。
|
|
||||||
- **Index Block**:`entryCount(u32)` + `[keyLen(u32)|key|blockOffset(u32)|blockSize(u32)]`;
|
|
||||||
**索引键 = 该块内最后一个 key**(94-103),这是 v0.6.1 那个 P0 漏读 bug 的根源约束。
|
|
||||||
- **Footer 32B**:`index_offset|index_size|bloom_offset|bloom_size|bloom_hash_count|entry_count|magic|checksum`
|
|
||||||
(133-149)。
|
|
||||||
- **分块策略**(167-182):累加 UTF-8 字节,`>=blockSizeLimit` 且块内已有 >1 条时,把**除最后一条外**的全部
|
|
||||||
封块(174-177)。因此块大小 ≈ blockSize(默认 4096),单条超大 value 独占一块。
|
|
||||||
【已实测确认】用 90 字节行插入 10 万行(`aria-prod-load.test.ts:345` 的形态)时,
|
|
||||||
builder 的 4096 字节上限被**完全绕过**:单块最多可达 ~2×4096+header,索引条目数因此约减半
|
|
||||||
(见 3.5,属性能/密度问题而非正确性问题)。
|
|
||||||
- **CRC**:整文件 CRC-32 覆盖**除最后 4 字节(checksum 自身)外的全部字节**(146-149),
|
|
||||||
写 0 时改写成 1 以区分旧格式;读取端 `storedChecksum === 0` **直接放行不校验**
|
|
||||||
(`sstable.ts:41-46`)——这是 v1/早期 v2 文件的兼容豁口(对**新建**文件不生效)。
|
|
||||||
- **读取**(`sstable.ts`):
|
|
||||||
- `parseFooter`(209-251):`byteLength < 32` 抛错;magic 决定 u16/u32 长度字段宽度;
|
|
||||||
索引越界则**静默 return**(235-237,残缺文件退化为空表);bloom 越界/解析失败也只是降级为无 bloom(243-250)。
|
|
||||||
- `parseIndexBlock`(253-277):逐个读,越界即 `break`;指向文件外的块条目被 `continue` 跳过(273)。
|
|
||||||
- `get`(62-103):bloom 先否定 → `locateBlock` 二分 → 块内**线性扫描**(79-100)。
|
|
||||||
- `rangeScan`(106-115)只是 `scanLazy` 的包装;`scanLazy`(121-168)用
|
|
||||||
`locateBlockGE(start)` 起、`min(last, locateBlockLE(end)+1)` 止(126-131),
|
|
||||||
多扫一块以规避"块尾 key 越界"漏读,条目级 `key>=start && key<=end` 过滤(158)。
|
|
||||||
- `locateBlock`(294-313)/`locateBlockGE`(315-323)/`locateBlockLE`(325-333):三套二分,第三个函数
|
|
||||||
在"endKey 小于全部索引键"时返回 **0 而非 -1**(332),靠调用方 `Math.max/clip` 兜住 —— 脆弱但当前正确
|
|
||||||
(【已实测确认】13 组范围/边界探针全绿)。
|
|
||||||
|
|
||||||
### 1.3 MergeIterator 语义
|
|
||||||
|
|
||||||
- 数据源抽象 `EntrySource{next,reset}`(12-17);`ArrayEntrySource`(20-36,compaction 用,全量物化)
|
|
||||||
与 `GeneratorEntrySource`(43-58,v0.7.4,流式扫描用)。
|
|
||||||
- 最小堆 `MinHeap`(71-122)仅按 `key` 比较(100、113-114),**sourceIndex 不参与堆序**。
|
|
||||||
- `next()`(148-168):弹出最小项后立刻从**同一 source** 补种(156),再把堆顶所有 `key === 当前 key`
|
|
||||||
的重复项一次性抽干(159-165),其中 `sourceIndex` 最小者胜出(162-164)。因此:
|
|
||||||
- **去重只在"同一次 `next()` 调用内"发生**;语义是"同一 key 只吐一条,取最新源"。
|
|
||||||
- 顺序保证依赖**源加入顺序 = 新鲜度降序**:`LSM.rangeScanLazy`(447-467)先 MemTable、再
|
|
||||||
`frozenMemtables` 由新到旧、再 L0→L6,且每层内按 id 降序(`init` 128 行、flush `unshift` 253 行)。
|
|
||||||
- **墓碑处理**:MergeIterator 自己**不认识**墓碑(它只是普通 value)。过滤在两处:
|
|
||||||
- 点查:`unwrapTombstone`(`lsm.ts:727-731`)→ `get` 命中墓碑返回 `null`;
|
|
||||||
- 扫描:`rangeScanLazy` 内 `if (!v.__tombstone) callback(...)`(472-476)。
|
|
||||||
- **compaction 不丢墓碑**(`compactLevelAsync` 把 `drain()` 的全部条目写进新文件,537-552),
|
|
||||||
因此删除不会被旧数据"复活",代价是墓碑与历史版本**永久留存**(见 3.4)。
|
|
||||||
- 快照过滤:MergeIterator **无任何 MVCC 概念**;快照隔离完全在 `AriaEngine.find/getAllRows` 的
|
|
||||||
`mergeTxnSnapshot`(index.ts:1620-1638)里做——即先取磁盘视图,再把 `txnSnapshot` 的写/删标记覆盖上去。
|
|
||||||
|
|
||||||
### 1.4 端到端读取路径
|
|
||||||
|
|
||||||
| 路径 | 链路 |
|
|
||||||
|---|---|
|
|
||||||
| **点查(PK 等值)** | `find`→`tryIndexLookup`(1993-2006) → `lsm.prefetchKeys([key])`(内含 `drainChain`)→ `lsm.get` → MemTable→frozen→L0..L6,命中 `unwrapTombstone` → `mergeTxnSnapshot` → `matchWhere` → `orderBy` → `slice` |
|
|
||||||
| **二级索引等值** | `tryIndexLookup`(2041-2053) → `indexScanToRows`(2099-2120):`idxLsm.prefetchRange`→`idxLsm.rangeScan`→收集 pk→`lsm.prefetchKeys(pks)`→逐个 `lsm.get` |
|
|
||||||
| **二级索引范围** | `tryIndexLookup`(2085-2092):**放弃下推**,`indexScanToRows(idxLsm,'','\uffff')` 全索引扫描 + 逐行 `lsm.get` + 行级 `matchWhere` |
|
|
||||||
| **全表** | `getAllRows`/`find`→`lsm.prefetchRange(prefix,prefix+\uffff)`→`lsm.rangeScan`(**物化数组**)→逐行重建 PK → `mergeTxnSnapshot` |
|
|
||||||
| **流式** | `findStream`(1172-1227):非事务且无索引命中时 `lsm.prefetchRange` + `rangeScanLazy`,回调返回 `false` 提前终止;事务中回退 `find` 全量物化(1199-1206) |
|
|
||||||
|
|
||||||
### 1.5 Compaction 与后台链
|
|
||||||
|
|
||||||
- **层级**:固定 7 层(`MAX_LSM_LEVELS`),**`levelSizeMultiplier` 字段被读取后从未使用**
|
|
||||||
(`lsm.ts:71,90`,全仓 grep 仅赋值无消费)—— 即**没有基于体积的层级容量策略**,
|
|
||||||
只有"某层文件数 ≥4 就整层合并到下一层"(258、275-281)。
|
|
||||||
- **触发**:`put/delete` 时 `levels[0].length >= 8` → `enqueueCompact(0)`(145-147、156-158);
|
|
||||||
flush 完成时 `levels[0].length >= 4` → `scheduleCompact(0)`(258-260);
|
|
||||||
完成后在 `finally` 里级联检查本层与下一层(272-282)。`compactLevelAsync(level, minFiles=4)`
|
|
||||||
(498-577):`splice` 整层 → 逐个"缓存优先、`store.load` 兜底" → `verifyChecksum` → `scanAll` 物化 →
|
|
||||||
`MergeIterator.drain()` → 建成新 SSTable → `save`+`saveMeta` → `levels[level+1].unshift` →
|
|
||||||
**无条件删除整层旧文件**(572-576)。
|
|
||||||
- **后台链序列化**:所有 flush/compaction 挂在单条 Promise 链 `flushChain` 上
|
|
||||||
(`enqueueOnChain` 194-204)。`drainChain`(211-217)循环 `await` 直到链引用不再变化
|
|
||||||
(因为任务完成时可能级联追加新任务)。`prefetchRange/prefetchPrefixRanges` 开头都 `await drainChain()`
|
|
||||||
(315、334、372)—— 这是"读之前先把后台任务排空"的**唯一一致性手段**,也是 3.1 性能悬崖的直接来源。
|
|
||||||
- **背压**:`compactLevelAsync` 无体积/耗时上限;`LSM.put` 只在 L0≥8 时排队一次 compaction,
|
|
||||||
没有限流、没有拒绝写入、没有反压信号给调用方;`cacheLimitBytes` 默认 = `bufferPoolPages × pageSize`
|
|
||||||
= 256×4096 = 1MB(index.ts:168)。
|
|
||||||
|
|
||||||
### 1.6 MVCC 分层(实际形态)
|
|
||||||
|
|
||||||
- `MVCCManager`(`transaction/mvcc.ts`)维护 `versionStore` / `activeTxns` / `globalCommitLsn` /
|
|
||||||
`txnWriteKeys`。`writeVersion` 建链(115-135)、`deleteVersion` 写 `__mvcc_tombstone`(140-142)。
|
|
||||||
- **关键事实:AriaEngine 的事务隔离不靠 MVCC,靠 `txnSnapshot`(一个 `Map<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.appendBatch(full=逐批落盘 / batch=缓冲 / none=不写)
|
|
||||||
└─ opCounter += n ; checkMemoryBudget()(maxMemoryMB 超限 → 不等待的 lsm.flush())
|
|
||||||
└─ checkpointManager.tick() ← opCount>=interval 或 WAL 字节>=16MB
|
|
||||||
└─ CheckpointManager.checkpoint()
|
|
||||||
├─ lsm.flush() ← 内含 drainChain(),会等**全部**后台 compaction
|
|
||||||
├─ flushAll() ← 主 LSM + 全部二级索引 LSM 各自 flush()
|
|
||||||
└─ wal.checkpoint() ← 截断 WAL(活跃事务时 skip,index.ts:252-259)
|
|
||||||
```
|
|
||||||
`LSM.flush()`(580-620):先查 `lastBackgroundError` → `drainChain` → 把 `immutableMemtable` 入链 →
|
|
||||||
若活跃 memtable 非空则 `freezeMemtable()` 并把新的 immutable 入链 → 再 `drainChain` → 清理空 frozen。
|
|
||||||
`freezeMemtable`(182-191)把当前 memtable 冻结、塞进 `frozenMemtables`、用 `memtableSizeThreshold`
|
|
||||||
新建一张(v0.4.2 修的就是"新表阈值衰减")。
|
|
||||||
|
|
||||||
### 1.8 二级索引 LSM 与主 LSM 的关系
|
|
||||||
|
|
||||||
- 每个 `index/unique` 列一个独立 `LSM` 实例(index.ts:194-204 重启恢复、452-461 建表、
|
|
||||||
1353-1362 createIndex),**独立 SSTableStore 命名空间**(`createSSTableStore`,1719-1814:
|
|
||||||
文件前缀 `sst_idx_<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'` 下**不落盘也不设上限**。
|
|
||||||
- **严重级别**:P2(OOM/GC 压力;"maxMemoryMB=64" 的宣称无法兑现)。
|
|
||||||
- **复现思路**:**验证方法**——建 10 个索引列,写入后断言
|
|
||||||
`Σ(lsm.getEstimatedMemory()) <= maxMemoryMB*1024*1024`;当前会超。
|
|
||||||
|
|
||||||
### P3-1 `applyWALRecord` 不做任何校验/墓碑语义转换,直接 `lsm.put`
|
|
||||||
- **文件:行号**:`index.ts:1829-1856`。重放 INSERT/UPDATE 直接 `this.lsm.put(key, record.data)`。
|
|
||||||
- **现象**:WAL 中的行若含 schema 外列或已删除列(历史版本写过),重放会原样落库,
|
|
||||||
绕过 `validateRow`(与 v0.7.4 修"UPDATE 未知列静默入库"的方向相反)。
|
|
||||||
- **严重级别**:P3(恢复路径的防御缺口,需先有脏 WAL 才成立)。
|
|
||||||
- **复现思路**:**验证方法**——伪造一条含 `{ ghost: 1 }` 的 INSERT WAL 记录后重开,断言该列被丢弃。
|
|
||||||
|
|
||||||
### P3-2 `serialize()` 返回内部缓冲区引用;`page_sstable_store.load()` 会 `pageIds.delete(id)`
|
|
||||||
- **文件:行号**:`bloom.ts:66-68`(`return this.bits`,未拷贝)、
|
|
||||||
`store/page_sstable_store.ts:81`(`this.pageIds.delete(id)` 在**读**路径上执行)。
|
|
||||||
- **现象**:前者使调用方一旦持有序列化结果后再 `insert` 就会修改"已写出"的字节;
|
|
||||||
当前 `build()` 的调用顺序(108 行取 bloom → 113 行分配 buf → 119-130 写出,**不再 insert**)恰好安全。
|
|
||||||
后者使"同一 SSTable 先 `load` 再 `saveMeta`"会丢掉 `pageIds`(`index.ts:1800` 取不到 →
|
|
||||||
meta 无 pageIds → 页面变孤儿)。当前 `save → saveMeta` 紧邻(`lsm.ts:249-250`)故安全,
|
|
||||||
但 `compactLevelAsync` 的 `load`(511)与本 LSM 的 `saveMeta` 之间**隔着整个合并流程**,
|
|
||||||
一旦将来把 `pageStore.load` 复用到这里就会踩雷。
|
|
||||||
- **严重级别**:P3(当前安全,属"靠调用顺序维持"的隐式契约)。
|
|
||||||
- **复现思路**:**验证方法**——单测:`bf.serialize()` 后再 `bf.insert('x')`,断言先前返回的字节未变(应失败);
|
|
||||||
`pageStore.load(id, ids, size)` 后 `getPageIds(id)` 应仍返回 ids(应失败)。
|
|
||||||
|
|
||||||
### 已排除的怀疑(避免误导)
|
|
||||||
|
|
||||||
| 怀疑 | 结论 | 依据 |
|
|
||||||
|---|---|---|
|
|
||||||
| Bloom Filter 会产生 false negative | **排除(对未损坏文件)** | 【已实测确认】`h1 ∈ [0,2^32)`、`h2 ∈ [0,2^31)`,`h1 + i*h2` 永不溢出双精度安全整数,`% bits` 结果**恒非负**,故 `Math.abs` 是冗余而非错误;`bits` 恒为 8 的倍数,索引恒在界内。30k 随机 key 单键过滤器 + 5k 随机 key 密集过滤器 + 5 种真实 key 形态各 2000 key,**FN 全部为 0**;真实 SSTable 2000 key 全覆盖读取 0 缺失 |
|
|
||||||
| `Math.abs((h1+i*h2) % bits)` 是哈希 bug(负余数取绝对值) | 排除(同上) | 同上;但 `fromData` 若传入与写入端**不同**的 `numHashes` 会 100% 产生 FN(实测 10 键 9 个 FN)—— 当前文件路径读写都用 footer 里的同一值(`sstable_builder.ts:138` ↔ `sstable.ts:230,246`),故不成立 |
|
|
||||||
| 崩溃中断 compaction 会让旧文件遮蔽新数据 | **排除** | 【已实测确认】逐时点验证层间新鲜度:新产物恒为被删文件的超集且更新,同名 key 的新值必在新文件中 |
|
|
||||||
| PK 删除墓碑 `t:k1\uffff` 会误删 `t:k10` | **排除** | 【已实测确认】`key <= endKey` 的字符串比较下 `t:k1 < t:k10`(第 4 字符 `'\uffff'` vs `'0'`),memtable 与 compaction 后均只删 1 行 |
|
|
||||||
| 红黑树删除/旋转会破坏中序有序性 | **排除(4000 步压力下)** | 【已实测确认】400 键 × 4000 次随机 put/delete 后 `getAllEntries` 与排序参照集**完全一致**,`_size` 准确,`rangeScan` 与过滤结果一致 |
|
|
||||||
| `rangeScan` 边界/`locateBlock` 系列二分有 off-by-one | **排除** | 【已实测确认】`aria-sstable.test.ts` 的 13 组边界(块尾、跨前缀、空表、末块)+ 我的补充探针全绿 |
|
|
||||||
| LSM 端到端会产生错误结果 | **当前未发现(常规路径)** | 【已实测确认】8 组差分 fuzz(插入/更新/删除/按索引列批量更新/按 tag 查索引,400-600 步 × 含/不含 `compactLevel(0..2)`,与参照 Map 全量比对 + 每列索引计数比对):**problems = 0** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 性能议题
|
|
||||||
|
|
||||||
### 3.1 每次写批次都 `drainChain`,compaction 期间写路径整体停摆
|
|
||||||
`checkpointManager.tick()`(`index.ts:664`)→ `CheckpointManager.checkpoint()`(`checkpoint.ts:62-69`)
|
|
||||||
→ `lsm.flush()`(`lsm.ts:580`)→ `await this.drainChain()`(592)。
|
|
||||||
`drainChain` 会等到**链上全部后台任务**(含正在跑的整层 compaction:N 次 `store.load` + 全量解析 +
|
|
||||||
构建 + `save`)结束。默认 `checkpointInterval = 1000`,即**每 1000 个操作就要等一次完整 compaction**。
|
|
||||||
这正是 CHANGELOG v0.6.1 记录的"10 万行 kv 插入 353s、每批 8~11s"的机制来源;
|
|
||||||
v0.6.1 只移除了"逐行 prefetch"这一半,`tick()` 里的等待仍在。
|
|
||||||
**观察点**:`aria-prod-load.test.ts:320` 的日志与 240s 护栏就是它的间接度量。
|
|
||||||
|
|
||||||
### 3.2 一次扫描把**全表** SSTable 拉进缓存
|
|
||||||
`getAllRows`(`index.ts:1601-1614`)与 `findStream`(1219)都先
|
|
||||||
`prefetchRange(prefix, prefix+'\uffff')` → 遍历**所有层所有 meta**(`lsm.ts:318-327`)→
|
|
||||||
逐个 `preloadSSTable`(整文件字节读入 + `verifyChecksum` 全文件 CRC + 构造 Reader)。
|
|
||||||
对 100k 行表意味着把整个表读进 1MB 上限的缓存(随后被 `trimCache` 清掉),
|
|
||||||
每次范围查询重复一次全量 I/O。**没有按需/分块读取**。
|
|
||||||
|
|
||||||
### 3.3 二级索引范围查询退化为"全索引扫描 + 逐行主表点查"
|
|
||||||
`tryIndexLookup`(`index.ts:2085-2092`):`$gt/$gte/$lt/$lte` 走
|
|
||||||
`indexScanToRows(idxLsm, '', '\uffff')` —— **扫全部索引条目**(`prefetchRange('','\uffff')` 把整个
|
|
||||||
索引拉进缓存),然后对每个 pk 做一次 `lsm.get`(`index.ts:2115-2118`,每次 `get` 会在每层
|
|
||||||
最多做一次 bloom + 一次块内线性扫描)。复杂度 = O(索引全量) + O(命中行数 × 层数 × 块内扫描)。
|
|
||||||
更根本的问题在**索引键编码**:`${String(value)}:${pk}` 是**字典序**,
|
|
||||||
数值范围在字典序下不可用(`"10" < "9"`),所以就算把下推逻辑修好也无法做数值区间扫描。
|
|
||||||
注释(2086-2089)也坦白这是"为修复 v0.6.2 的丢数据而改为全扫"。
|
|
||||||
|
|
||||||
### 3.4 compaction 不做版本/墓碑回收 → 空间与写放大随层数累积
|
|
||||||
见 P2-2。每一层的"整层合并"都会把该层**所有**物理条目(含无数历史版本与墓碑)重写一遍,
|
|
||||||
`compactLevelAsync` 还会先把整层 `scanAll` **物化到内存数组**(531-534)。
|
|
||||||
在"更新同一批行"的负载下,每次 L0→L1 的产物体积 ≈ L0 体积 + L1 既有体积,
|
|
||||||
形成典型写放大螺旋;`aria-cache.test.ts:212-237`(同一行更新 20 次)之所以能过,
|
|
||||||
是因为数据量小到看不出来。
|
|
||||||
|
|
||||||
### 3.5 SSTable 块密度低于配置值(索引条目 ≈ 2× 需要)
|
|
||||||
`splitIntoBlocks`(`sstable_builder.ts:171-178`)在"加入当前条目后 ≥ 上限"才封块,
|
|
||||||
且封块时**把最后一条留到下一块**(175-176),于是首块只含 1 条、其余块 ≈ 上限+1 条。
|
|
||||||
【已实测确认】用 ~90 字节的行、4096 上限构建时,单块实际可达 ~8.2KB,
|
|
||||||
块数因此约为理想值的 **1/2**:`IndexEntry` 数组更大(内存)、索引块更大、
|
|
||||||
`locateBlock` 的二分目标更多、每次点查的"块内线性扫描"更长(`sstable.ts:80`)。
|
|
||||||
此外 `indexEntries` 在 Reader 构造时**全量物化**(`sstable.ts:253-277`),
|
|
||||||
每个 Reader 都持有一份"每块一个 key"的索引键数组(按上述密度,10 万行约 1.2 万条/文件)——
|
|
||||||
**每层每个文件各一份**,且 `prefetchRange` 会把命中的文件全部构造 Reader。
|
|
||||||
(我把"分块策略错误"降级为性能问题:它的正确性被 `aria-sstable.test.ts:88-159` 的跨块用例覆盖了。)
|
|
||||||
|
|
||||||
### 3.6 其他可量化热点
|
|
||||||
- **`MemTable.put` 每次做两次 `Object.entries` + 一次额外树查找**(`memtable.ts:429-431` +
|
|
||||||
`497-510`):`estimateEntrySize` 对每个字段做 `typeof` 分支,且 `this.tree.find(key)` 是完整一次下降。
|
|
||||||
- **WAL 编码逐条 `JSON.stringify` + 逐条 `TextEncoder`**(`wal/log.ts:90-109`),
|
|
||||||
大批量插入时 `appendBatch` 会把整批序列化两遍(编码 + 合并)。
|
|
||||||
- **`analyzeTable` 的 `avgRowSize` 用 `JSON.stringify(row).length` 逐行再序列化**(`index.ts:2177`),
|
|
||||||
且 `columnStats` 用 `new Set(rows.map(String))` 再扫一遍 —— 大表上 ANALYZE 是 3~4 遍全表 CPU。
|
|
||||||
- **`count()` 恒定物化全表**(`index.ts:1229-1236`),无 where 时依然走 `getAllRows`。
|
|
||||||
- **`alterTable`、`applyDropTableRecovery`、`dropTable` 都是"扫描全表 → 逐行 put/delete"**
|
|
||||||
(`index.ts:1314-1338`、1874-1878、483-487):DROP TABLE 10 万行 = 10 万次 `lsm.put`/`delete`
|
|
||||||
(每次都带 `estimateEntrySize`)+ 10 万条 WAL。
|
|
||||||
- **`update` 的批内唯一互查/计划阶段对每行调用 `checkUniqueSync`**(`index.ts:1900-1923`),
|
|
||||||
其中 `idxLsm.rangeScan` 每次都会走完整 `prefetchRange`+`rangeScanLazy`(虽然是单前缀)。
|
|
||||||
- **`trimAllCaches` 在 `find/update/delete` 末尾各遍历一次全部二级索引**(`index.ts:2136-2141`),
|
|
||||||
与 `prefetch*` 里的 `trimCache` 叠加导致"装入-驱逐-再装入"抖动。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 系统性观察
|
|
||||||
|
|
||||||
### 4.1 反复出 bug 的结构性原因
|
|
||||||
|
|
||||||
1. **键编码散落在 10+ 处,没有单一编码模块。** 主键 `t:pk`(`index.ts:571,1006,1223`…)、
|
|
||||||
二级索引 `value:pk`(1382、1953、2237)、索引前缀探针 `v:` ~ `v:\uffff`(605-606、751-758、2063)、
|
|
||||||
删除 `v:pk`(1945)、唯一性检查(1911-1912)、表范围 `prefix` ~ `prefix+\uffff`(1315、1605、1873、2023)、
|
|
||||||
命名空间 `idx_${table}_${col}`(200)。任何一处想改(比如给值加类型前缀以修复字典序问题)
|
|
||||||
都要同步 10 处,漏一处就是静默丢数据 —— v0.6.2 的"索引范围查询丢数据"就是这个模式。
|
|
||||||
2. **SSTable 解析循环被抄了三份**(`sstable.ts:80/145/182`),越界策略各不相同。
|
|
||||||
v0.6.1 的 P0(块尾 key 越界漏读)只需要修一处,但另外两处仍在,下一次同类 bug 仍会出现。
|
|
||||||
3. **不变量没有被断言,也没有被文档化。** 至少五条隐式不变量:① 层号越小越新;
|
|
||||||
② 同一层文件区间不重叠;③ 索引键 = 块内最后一个 key;④ `minKey/maxKey` 覆盖文件全部内容;
|
|
||||||
⑤ bloom 的 `numHashes`/位数与写入端一致。它们散落在注释里,没有任何 `assert`、
|
|
||||||
没有 debug 模式校验、没有属性测试。P0-1 / P1-1 / P2-3 全都是"不变量没人守"的直接后果。
|
|
||||||
4. **后台任务模型是"fire-and-forget + 吞错 + 单标志互斥"。**
|
|
||||||
`compacting`(一个 boolean 管 6 个层)、`lastBackgroundError`(一次性报告、语义含混)、
|
|
||||||
`flushChain`(Promise 链自我修补)、`setTimeout` 曾被用来分片后来又被移除(v0.4.3 注释)——
|
|
||||||
v0.4.2/v0.4.3/v0.6.1/v0.6.3 的修复记录(链被 rejected 卡死、close 后定时器写库、
|
|
||||||
meta 竞态丢 75% 数据、重复 flush 出 3 个文件)**全部**出自这一块。
|
|
||||||
5. **"缓存未命中 ⇒ 当作不存在"是本子系统最危险的隐式约定。** 为了保持 `get/rangeScan` 同步,
|
|
||||||
引擎被迫在**每次**读取前 `drainChain + prefetch`(`index.ts:574,1219,1605,2024,2106,2113,2077`),
|
|
||||||
这既制造了 3.1/3.2 的性能悬崖,也让"漏一次 prefetch 就静默丢数据"成为常态风险。
|
|
||||||
`compactLevelAsync` 已经被迫单独修过这个问题(508-515),而 `get` 仍未修 —— 同类修复只做一半。
|
|
||||||
6. **测试是端到端 + 弱断言为主。** 大量 `expect(x).toBeGreaterThanOrEqual(0)` 形态
|
|
||||||
(如 `aria-maintenance.test.ts:41,72`),性能测试用 240s 宽护栏掩盖机制性问题,
|
|
||||||
没有针对 compaction/bloom/块边界的**属性测试**。这解释了为什么
|
|
||||||
"10 万行 0 错误" 与 "同一个函数里有 3 份解析循环" 能长期共存。
|
|
||||||
|
|
||||||
### 4.2 最高杠杆的架构改动(一条)
|
|
||||||
|
|
||||||
> **把 LSM 从"同步读 + 调用方负责预取"改成"自洽的异步读",并让后台工作变成显式持久化的任务队列。**
|
|
||||||
|
|
||||||
具体分两步、但核心是第一步:
|
|
||||||
|
|
||||||
1. **让 `get` / `rangeScan` 自己负责 I/O 与缓存罚失**:`LSM.get` 改为
|
|
||||||
`async get(key): Promise<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-*` 属其它会话产物,非本次创建。
|
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
# MetonaSqlark v0.7.4 — 查询层(AST / Builder / Compiler / Executor / Where-Matcher)深度审计报告
|
|
||||||
|
|
||||||
审计范围:`src/query/{ast,builder,compiler,executor,where-matcher,index}.ts`(逐行读完)、
|
|
||||||
`tests/query/query-system.test.ts`、`tests/{join,subquery,groupby}.test.ts`、
|
|
||||||
`tests/v07{0,1,2,3,4}-*.test.ts`、`tests/{foreign-key-cascade,transaction-rollback}.test.ts`、
|
|
||||||
README(核心特性 / API 速览 / 已知限制)、CHANGELOG 1–300 行。
|
|
||||||
|
|
||||||
方法:先逐行读码定位可疑路径,再写临时 jest 探针(`tests/zz-audit-probe*.test.ts`,已删除)实跑验证。
|
|
||||||
下文每条标注 **[已验证]**(有实跑输出)或 **[读码确认]**(未实跑,但根因路径可指到具体行)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 架构概览
|
|
||||||
|
|
||||||
### 1.1 SQL 字符串 → 解析 → 编译 → 引擎
|
|
||||||
|
|
||||||
| 阶段 | 位置 | 实际做的事 |
|
|
||||||
|---|---|---|
|
|
||||||
| 入口 | `core.ts:207-242` | `bindParameters`(`sql/params.ts`)→ `parseAll` → 逐条 `executor.execute(stmt)`;**多语句顺序执行、返回最后一条结果、无事务包裹** |
|
|
||||||
| 解析 | `sql/parser.ts`(递归下降,1331 行) | 产出 `query/ast.ts` 的 AST。子查询不建关系算子,而是打成 `$subquery` / `$col` / `$exists` 标记(parser.ts:845/920/961/977) |
|
|
||||||
| 编译 | `query/compiler.ts:21-57` | **只做平铺映射**:`{table, columns, where, orderBy, limit, offset}`。没有算子树、没有投影/表达式编译、没有代价模型;JOIN/GROUP BY/DISTINCT/HAVING/聚合全部被丢弃。只有 SELECT/DELETE/UPDATE 可编译,其余抛 `COMPILE_ERROR` |
|
|
||||||
| 执行 | `query/executor.ts` | 关系语义全部在 JS 里做:JOIN、GROUP BY、DISTINCT、HAVING、关联子查询、UNION、投影 |
|
|
||||||
| Builder 旁路 | `builder.ts:97-113` | `SelectQueryBuilder.execute()`:**有 JOIN 才走 Executor,无 JOIN 直接 `engine.find`**(builder.ts:105);`UpdateQueryBuilder`/`DeleteQueryBuilder` 永远直通引擎(`builder.ts:160` / `builder.ts:199`)→ 同一语义两套路径 |
|
|
||||||
|
|
||||||
`compileStatement` 的产物 `QueryPlan` 只描述单表扫描;`stmt.joins/groupBy/distinct/having` 从不进 plan。因此"编译"在本项目里约等于"把 WHERE 交给引擎",不存在下推优化器(唯一的优化是 JOIN 查询里主表别名等值条件的抽取下推,`executor.ts:413-417 / 457-471`)。
|
|
||||||
|
|
||||||
### 1.2 Executor 如何逐子句处理(`executeSelect`,`executor.ts:282-400`)
|
|
||||||
|
|
||||||
决策树(282-356):
|
|
||||||
|
|
||||||
1. `fromSubquery`(派生表)→ 先跑子查询取行;有 JOIN 则给行加 `<别名>.` 前缀后进入 JOIN 路径(297-307)。
|
|
||||||
2. 无 FROM 且无 JOIN(`SELECT 1`)→ 单行空上下文(308-310)。
|
|
||||||
3. 有 JOIN → `executeJoinSelect`(311-313)。
|
|
||||||
4. 普通单表(314-355):先把 WHERE / ORDER BY / GROUP BY / SELECT 列里的主表别名前缀剥掉(316-336);若 WHERE 命中 `$col`,走 `filterCorrelated` 逐行求值(339-345),否则 `resolveSubqueries` 后交给 `engine.find`(346-355)。
|
|
||||||
- **`needsRawRows`(有 CASE)/`hasSelectAlias`(有 `AS`)时强制 `plan.columns = ['*']`,即放弃引擎投影(352)**,投影改在 executor 端做。
|
|
||||||
- **`orderByAlias` 为真时清掉 plan 的 orderBy/limit/offset(353)**,留到投影后重排。
|
|
||||||
|
|
||||||
后处理顺序(358-398,**注意与 SQL 标准顺序不一致**):
|
|
||||||
|
|
||||||
```
|
|
||||||
聚合(无 GROUP BY) 359-361 → GROUP BY 363 → DISTINCT 364 → HAVING 365-378
|
|
||||||
→ ORDER BY 379 → 投影 383-386 → ORDER BY(别名, 第二次) 388-390
|
|
||||||
→ slice(offset, offset+limit) 391-393 → maxRowsPerQuery 截断 396-398
|
|
||||||
```
|
|
||||||
|
|
||||||
关键偏差:**DISTINCT 在投影之前**(364 vs 383)、**LIMIT/OFFSET 同时下推给引擎又在此再切一次**、**HAVING 是对投影后的组行做 `matchWhere`**。
|
|
||||||
|
|
||||||
### 1.3 子查询 / 关联引用
|
|
||||||
|
|
||||||
- `resolveSubqueries`(1336-1385)递归遍历 WHERE:`$exists` 执行子查询置换为 boolean(1345-1356);`$and/$or/$not` 递归;字段级交给 `resolveOperatorSubqueries`(1390-1439)。
|
|
||||||
- `resolveOperatorSubqueries`:`$in/$nin` 取子查询**第一列**的值列表(1419-1423),其它运算符取**第一行第一列**(1425-1431);空结果标量 → `null`。
|
|
||||||
- `$col` 绑定:`bindColumnRefs`(1291-1309)从 `contextRow` 取列值。
|
|
||||||
- 关联上下文只有一条通路:`hasCorrelatedRefs`(1159-1181,纯语法嗅探)→ `filterCorrelated`(1226-1238)逐行 `bindWhereRefs` + 执行 `$exists`。**`resolveOperatorSubqueries` 调 `executeSelect(subStmt)` 时(1417)不传外层行**,所以 `IN (SELECT … WHERE 内层列 = 外层列)` / 标量关联子查询拿不到外层上下文(见缺陷 P1-4)。
|
|
||||||
- 代价:关联路径 = 外层每行一次子查询执行,无缓存(1228-1236)。
|
|
||||||
|
|
||||||
### 1.4 JOIN 实现
|
|
||||||
|
|
||||||
- 主表:整表(或仅主表别名等值条件下推)物化并加前缀(408-418);非 JOIN 分支不做前缀。
|
|
||||||
- 每个 JOIN:`tryHashJoin`(480-566)→ 条件为"纯等值列对"且右表**至少一列有索引/主键/唯一**时,收集左表探测列去重值 → 一次 `$in` 查询右表 → 复合键 `Map`(键为 `String(v ?? '\0')` 拼接,543/553)→ 左行探测;LEFT 无命中补右表全 null 行(560-562)。
|
|
||||||
- 否则 `joinRows` 嵌套循环(569-612):`matchWhere(merged, on, {$col:true})`;LEFT 用**右表第一行的键集**补 null(593);RIGHT 再对右表全扫一遍找未匹配行(598-610),并把它们**追加在末尾**。
|
|
||||||
- 不支持哈希:CROSS / RIGHT(486),以及 ON 含 `$or`/`$not`/非等值(496/506)→ **右表无条件整表物化**(429)。
|
|
||||||
|
|
||||||
### 1.5 投影 / 排序 / 分组键编码
|
|
||||||
|
|
||||||
- 行 = 扁平 `Record<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)` 索引键 + 前缀 rangeScan(aria/index.ts:2045/2062) |
|
|
||||||
| 流式 | `findStream` 同源实现(memory.ts:213-233) | 真惰性 LSM 扫描(aria/index.ts:1172-1227) |
|
|
||||||
| LIMIT/OFFSET | 引擎内 `slice`(memory.ts:203-205) | 引擎内 `slice`(aria/index.ts:696-699)—— 与 executor 重复 |
|
|
||||||
| 未解析 `$col/$subquery` 防护 | 仅 update/delete 有(memory.ts:245) | 仅 update/delete 有(aria/index.ts:721) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 缺陷清单
|
|
||||||
|
|
||||||
### P0 — 崩溃
|
|
||||||
|
|
||||||
**P0-1 `MIN`/`MAX` 大分组栈溢出(崩溃)** **[已验证]**
|
|
||||||
- 文件行号:`src/query/executor.ts:677-678`
|
|
||||||
- 现象:`SELECT MAX(v) FROM big`(20 万行同组)抛 `RangeError: Maximum call stack size exceeded`;`SUM`/`AVG` 正常(reduce 实现)。
|
|
||||||
- 根因:`Math.min(...distinctNums)` / `Math.max(...distinctNums)` 把整组值展开成实参,超过 JS 引擎实参上限。
|
|
||||||
- 复现:`CREATE TABLE big (id NUMBER PRIMARY KEY, v NUMBER)`,`insertMany` 20 万行,`SELECT MAX(v) FROM big`。
|
|
||||||
- 备注:函数签名 `computeAggregate(...): number`(655)也说明聚合返回类型被硬编码为 number(见 P1-8)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### P1 — 错误结果 / 静默数据丢失(全部有实跑复现)
|
|
||||||
|
|
||||||
**P1-1 LIMIT/OFFSET 被应用两次(OFFSET 结果截断)** **[已验证]**
|
|
||||||
- 行号:`executor.ts:391-393`(executor 再切一次);`compiler.ts:40-41`(limit/offset 进 plan);`engine/memory.ts:203-205`、`engine/aria/index.ts:696-699`(引擎已切过)
|
|
||||||
- 现象:id=1..4 的表,`SELECT id FROM t ORDER BY id LIMIT 2 OFFSET 1` → `[{id:3}]`(应为 2,3);`LIMIT 10 OFFSET 3` → `[]`(应为 4,5);`OFFSET 2` → 只剩 1 行。
|
|
||||||
- 根因:plan 带着 offset/limit 交给 `engine.find`(354),引擎 `slice(offset, offset+limit)` 之后 executor 又 `rows.slice(offset, offset+limit)`(391-393)。只有 `orderByUsesSelectAlias` 为真的查询在 353 行清掉了 plan 的 limit/offset 因而"碰巧正确"。
|
|
||||||
- 反证(同 executor 内自相矛盾):JOIN 路径(405-432 的 `engine.find` 不传 limit)与派生表路径结果正确;`SELECT a.id FROM a JOIN b ON a.k=b.k ORDER BY a.id LIMIT 2 OFFSET 1` → 2,3 正确。
|
|
||||||
- 复现:`SELECT id FROM t ORDER BY id LIMIT 2 OFFSET 1`。
|
|
||||||
|
|
||||||
**P1-2 LIMIT 被下推到 DISTINCT / GROUP BY / 聚合之下** **[已验证]**
|
|
||||||
- 行号:`executor.ts:352`(`plan.columns=['*']` 但保留 limit)+ `364`(DISTINCT 在切片之前)+ `391-393`
|
|
||||||
- 现象:`SELECT DISTINCT dept FROM e LIMIT 2` → 1 行(Eng);`SELECT dept, COUNT(*) AS c FROM e GROUP BY dept LIMIT 2` → 1 组(先被引擎截到 2 行原始行再分组)。
|
|
||||||
- 根因:LIMIT 属于最终结果集,却被编译进单表扫描计划(compiler.ts:41),在分组/去重之前就截断了输入行。
|
|
||||||
- 复现:5 行 3 个 dept 的表执行上述两条 SQL。
|
|
||||||
|
|
||||||
**P1-3 `WHERE 表别名.a = 表别名.b`(列对列)在普通单表查询中恒返回 0 行** **[已验证]**
|
|
||||||
- 行号:`executor.ts:339-345`(关联路径)→ `344` 把 `stripCorrelatedExists(stmt.where)` 交给引擎;`executor.ts:1205-1223`(该函数只剥 `$exists` 与 CASE 键,**保留 `$col` 字段条件**);`where-matcher.ts:139-150`(`$col` 只有在 `options.$col` 为真时才绑定)
|
|
||||||
- 现象:`SELECT * FROM t1 WHERE t1.x = t1.y`((1,5,5),(2,10,3))→ `[]`(应为 id=1)。`WHERE x = y` 直接 PARSE_ERROR(parser 只在 `ident.ident` 形式才识别列引用,parser.ts:956-963)。
|
|
||||||
- 根因:引擎层先执行了带未绑定 `$col` 的条件(`value === {$col:'y'}` 恒 false),把行全部过滤光,`filterCorrelated` 拿到的是空数组。
|
|
||||||
- 附带:`tests/v073-fixes.test.ts:267-278` 是这条语义的"回归护栏",但它只比较 `query()` 与 `queryStream()` 的行数(都是 0)→ **该测试是空断言,掩盖了错误**。
|
|
||||||
- 复现:见上(注意必须带表别名前缀,否则解析失败)。
|
|
||||||
|
|
||||||
**P1-4 关联引用出现在 `IN (SELECT …)` / 标量子查询中被静默丢弃(恒 0 行)** **[已验证]**
|
|
||||||
- 行号:`executor.ts:1415-1423`(`$in` 分支)与 `1425-1431`(标量分支)都调 `this.executeSelect(subStmt)`(1417),**不传 contextRow**;对比 `$exists` 分支(1351-1353)显式 `bindWhereRefs` 外层行。
|
|
||||||
- 现象:`SELECT id FROM u WHERE id IN (SELECT user_id FROM o WHERE o.user_id = u.id)` → `[]`(应为 1,2);EXISTS 形式(同样的 SQL 结构)能正确返回 1,2。
|
|
||||||
- 根因:子查询内 `u.id` 被当作**子查询自身行**的列(`bindColumnRefs` 用子查询行的 contextRow → `undefined`),条件恒 false。
|
|
||||||
- 复现:users(1,2,3) / orders(o1→1,o2→1,o3→2),执行上述 SQL。
|
|
||||||
|
|
||||||
**P1-5 完全没有三值逻辑:`!=` / `NOT IN` / `NOT LIKE` / `NOT(…)` 把 NULL 行当"真"** **[已验证]**
|
|
||||||
- 行号:`where-matcher.ts:161-177`(`$eq/$ne/$in/$nin/$like` 全用 JS `===`/`includes`)、`98-101`(`$not` 是布尔取反)、`132-134`(裸值走 `===`)
|
|
||||||
- 现象(表 (1,'Alice'),(2,'Bob'),(3,NULL)):
|
|
||||||
- `WHERE name != 'Alice'` → 2、3(SQL 只应 2)
|
|
||||||
- `WHERE name NOT IN ('Alice')` / `NOT LIKE 'A%'` / `NOT (name='Alice')` → 同上
|
|
||||||
- `WHERE n IN (1, NULL)`(n 为 1、2、NULL)→ 返回 1 和 NULL 行(SQL 只应 1)
|
|
||||||
- `WHERE n NOT IN (1, NULL)` → 返回 2(SQL 0 行)
|
|
||||||
- `WHERE name = NULL` → 返回 NULL 行,`!= NULL` → 返回所有非 NULL 行(`= NULL`/`!= NULL` 与 IS NULL/IS NOT NULL 不可区分)
|
|
||||||
- 根因:没有任何 UNKNOWN 态;NULL 被当作普通值参与 `===`。`$eq: null` 恰好等价于 IS NULL,是"顺带正确"。
|
|
||||||
- 复现:上述 6 条。
|
|
||||||
|
|
||||||
**P1-6 `NOT IN (子查询)` 遇子查询结果含 NULL → 多返回行** **[已验证]**
|
|
||||||
- 行号:`executor.ts:1419-1423`(原样把第一列值列表塞进 `$nin`)→ `where-matcher.ts:170`
|
|
||||||
- 现象:users(1,2,3)、orders(o1→'1', o2→NULL):`SELECT id FROM u WHERE id NOT IN (SELECT user_id FROM o)` → `[2,3]`(SQL 应为 0 行)。
|
|
||||||
- 根因:同 P1-5;SQL 中 `x NOT IN (…, NULL)` 永不为真。`$in` 侧(`id IN (SELECT …)`)恰好与 SQL 一致,说明差异纯粹来自 NULL 语义缺失。
|
|
||||||
|
|
||||||
**P1-7 HAVING 不能引用"未出现在 SELECT 列表里的聚合"** **[已验证]**
|
|
||||||
- 行号:`executor.ts:628-651`(`executeGroupBy` 只为 `stmt.columns` 里的聚合表达式算值并写入组行)、`365-378`(HAVING 用 `matchWhere` 对组行求值)
|
|
||||||
- 现象:`SELECT dept FROM e GROUP BY dept HAVING COUNT(*) > 1` → `[]`(应为 Eng、Sales);`HAVING SUM(salary) > 2000` → `[]`(应为 Eng)。而 `SELECT dept, COUNT(*) AS c … HAVING COUNT(*) > 1` 正常。
|
|
||||||
- 根因:HAVING 的键 `'COUNT(*)'`(parser.ts:1058-1067 生成)在组行里不存在 → `matchField(undefined, {$gt:1})` → false;`_aggAliasMap`(626-651)只做"表达式键→别名键"的重命名,不能补算缺失聚合。
|
|
||||||
- 复现:employees(id,dept,salary) 5 行,执行上述两条。
|
|
||||||
|
|
||||||
**P1-8 聚合的 NULL/类型语义:空集/全 NULL 返回 0 而非 NULL;MIN/MAX 走 `Number()` 数值化** **[已验证]**
|
|
||||||
- 行号:`executor.ts:660-680`(`argCol` 原样取列;`663` 过滤 null;`672` `rawValues.map(Number)`;`675-678` SUM 初值 0、AVG/MIN/MAX 空集返回 0、MIN/MAX 用 `Math.min/max`)
|
|
||||||
- 现象:
|
|
||||||
- 空集或全 NULL:`SUM/AVG/MIN/MAX` → `0`(SQL 为 NULL)
|
|
||||||
- 文本列:`MIN(s)/MAX(s)` over ('9','10') → `9 / 10`(文本序应为 '10' / '9');列表含 'abc' 时整列变 `NaN`(JSON 序列化为 `null`)
|
|
||||||
- `MIN(id)/MAX(id)` 对字符串主键返回数字
|
|
||||||
- `AVG(文本列)` → NaN
|
|
||||||
- 合计:COUNT(col) 跳过 NULL(正确)、COUNT(*) = 行数(正确)
|
|
||||||
- 根因:聚合只实现了数值语义;`computeAggregate` 返回类型硬编码 `number`(655),没有 NULL 传播、没有"文本聚合 vs 数值聚合"分支。
|
|
||||||
- 复现:`SELECT MIN(s), MAX(s) FROM t`(s = '9','10','abc')。
|
|
||||||
|
|
||||||
**P1-9 非 JOIN 路径下"带表前缀的聚合参数"恒为 0** **[已验证]**
|
|
||||||
- 行号:`executor.ts:328-336`(`/^(COUNT|SUM|AVG|MIN|MAX)\(/` 的列原样保留,不剥前缀)→ `662` `r[argCol]`,此时行键是裸列名
|
|
||||||
- 现象:`SELECT COUNT(o.id) FROM orders o` → 0(应为 3);`SELECT SUM(o.amount) FROM orders o` → 0(应为 300);`COUNT(*)` 正常。
|
|
||||||
- 反证:JOIN 路径行键带前缀(447-451),同样的 `COUNT(o.id)` 在 JOIN 查询里是对的 → 路径分叉。
|
|
||||||
- 复现:orders(o1,100),(o2,200),(o3,NULL)。
|
|
||||||
|
|
||||||
**P1-10 JOIN 的 NULL 键:嵌套循环认为 NULL=NULL 成立,哈希连接认为不成立 → 结果取决于右表是否有索引** **[已验证]**
|
|
||||||
- 行号:`where-matcher.ts:139-150`(`options.$col` 分支 `value === row[col]`,NULL===NULL 为真)用在 `executor.ts:586/602`;哈希路径 `executor.ts:531`(左值滤掉 null/undefined)、`543/553`(`String(v ?? '\0')`)
|
|
||||||
- 现象(a(a1,NULL),(a2,'x');b/c 同为 (·,NULL),(·,'x'),区别是 c.k 有索引):
|
|
||||||
- `INNER JOIN` on 未索引列 → a1–b1 + a2–b2(SQL 只应 a2–b2)
|
|
||||||
- `INNER JOIN` on 已索引列 → 仅 a2–c2(正确)
|
|
||||||
- `LEFT JOIN` on 未索引列 → a1–b1、a2–b2(SQL 应为 a1–NULL、a2–b2)
|
|
||||||
- `LEFT JOIN` on 已索引列 → a1–NULL、a2–c2(正确)
|
|
||||||
- 根因:两套连接实现各自解释 NULL;`$col` 比较缺三值逻辑,哈希键用 `'\0'` 哨兵代替 NULL(还可能把 NULL 与字符串 "\0" 混同)。
|
|
||||||
- 复现:同一份数据建 3 张表(右表索引与否不同)跑同一条 JOIN。
|
|
||||||
|
|
||||||
**P1-11 派生表(FROM 子查询)的别名限定:WHERE 恒空、投影出空对象** **[已验证]**
|
|
||||||
- 行号:`executor.ts:297-307`(非 JOIN 派生路径不加前缀、不剥别名,只在 303-307 对 where 做 `resolveSubqueries` 后 `matchWhere`);`where-matcher.ts:214-228`(`projectColumns` 的 `key.endsWith('.'+col)` 对 `col='d.age'` 永不成立)
|
|
||||||
- 现象:`SELECT * FROM (SELECT id, age FROM users) d WHERE d.age > 25` → `[]`(应为 2 行);`SELECT d.age FROM (SELECT id, age FROM users) d` → `[{},{},{}]`;去掉前缀(`WHERE age > 25`)才正确。
|
|
||||||
- 根因:外层 WHERE/投影里的 `d.age` 从未被映射到派生行的裸键;JOIN 分支因为先 `prefixRow(row, stmt.alias ?? '')`(301)才"看起来正常",但别名缺省时会生成 `.col` 这种坏键。
|
|
||||||
- 复现:见上。
|
|
||||||
|
|
||||||
**P1-12 JOIN 查询里的"未限定列名"静默失效(WHERE 空集 / GROUP BY 并成一组 / ORDER BY 不排序)** **[已验证]**
|
|
||||||
- 行号:`executor.ts:317-336`(别名剥离只发生在非 JOIN 分支)、`447-451`(JOIN 行键全部带前缀)、`379`(排序按裸键取不到值)
|
|
||||||
- 现象:
|
|
||||||
- `SELECT u.id FROM u INNER JOIN o ON u.id=o.user_id WHERE dept = 'Eng'` → `[]`(应为 3 行)
|
|
||||||
- `SELECT dept, COUNT(*) AS c FROM u INNER JOIN o ON u.id=o.user_id GROUP BY dept` → `[{c:4}]`(应为 Eng=3、Sales=1;所有行落进 `undefined` 组)
|
|
||||||
- `SELECT users.name FROM users INNER JOIN departments … ORDER BY name DESC` → 顺序未变(静默不排序)
|
|
||||||
- `SELECT name FROM users JOIN departments …` → 静默取 `users.name`(SQL 应报歧义;`projectColumns` 取第一个 `endsWith('.name')` 的键)
|
|
||||||
- 根因:列标识在不同路径下分别是"裸名/前缀名",匹配全靠字符串;JOIN 路径没有做"未限定名→唯一候选列"的解析。
|
|
||||||
- 复现:见上。
|
|
||||||
|
|
||||||
**P1-13 DISTINCT 作用在"原始行"而不是"投影后的行"** **[已验证]**
|
|
||||||
- 行号:`executor.ts:290-295 + 352`(有 `AS` 别名或 CASE → `plan.columns=['*']`)→ `364`(DISTINCT 在 `383-386` 投影之前)
|
|
||||||
- 现象:employees(1,Eng),(2,Eng),(3,Sales):`SELECT DISTINCT dept AS d FROM e` → 3 行(应为 2);`SELECT DISTINCT g AS k FROM t ORDER BY k DESC` → `[b,a,a]`(应为 b,a)。
|
|
||||||
- 根因:去重键用整行 `Object.values(row)`(689),把未被投影的 id 也算进去。
|
|
||||||
- 复现:见上(注意 `SELECT DISTINCT dept FROM e`(无别名)因为引擎已投影所以是对的 → 又是路径分叉)。
|
|
||||||
|
|
||||||
**P1-14 UNION:语句级 ORDER BY/LIMIT 挂到最后一个 SELECT 上;列数不校验;空左操作数改变结果列名** **[已验证]**
|
|
||||||
- 行号:`sql/parser.ts:366-388 + 394-409`(`parseSelect` 先吃掉 ORDER BY/LIMIT,再在 386 判 UNION,于是尾部的 ORDER BY/LIMIT 属于**右侧** SELECT);`executor.ts:153-200`(`executeSelectUnion` 全程不看 orderBy/limit/offset,也不做 maxRows 保护);`158`(`leftCols` 取自左结果第一行)、`192-200`(`projectUnionRow` 按位置映射、多余列丢弃)
|
|
||||||
- 现象:
|
|
||||||
- AST 实证:`SELECT v FROM t1 UNION SELECT v FROM t2 ORDER BY v LIMIT 2` → `right = {orderBy:[v], limit:2}`、`left` 无排序无限制
|
|
||||||
- `SELECT v FROM t1 UNION SELECT v FROM t2 ORDER BY v` → `[c,a,b,d]`(乱序);`… LIMIT 2` → 4 行;`UNION ALL … LIMIT 2` → 4 行
|
|
||||||
- `SELECT v FROM t1 UNION SELECT v, id FROM t2` → 不报错(SQL 应报列数不匹配)
|
|
||||||
- 左侧为空时 `leftCols=[]` → 右侧行保持自己的列名:`SELECT v FROM t1 WHERE id<'0' UNION SELECT amount FROM t2` → `[{amount:42}]`(左侧非空时为 `[{v:'x'},{v:42}]`)
|
|
||||||
- 根因:UNION 没有自己的 ORDER BY/LIMIT 承载结构(ast.ts:153-161 的 `SelectUnionStatement` 无 orderBy/limit 字段),执行器也不对合并集合做收尾。
|
|
||||||
- 复现:见上。
|
|
||||||
|
|
||||||
**P1-15 `SELECT <数字常量>` 投影出空对象** **[已验证]**
|
|
||||||
- 行号:`sql/parser.ts:1044-1049`(数字常量作为列文本 '1')→ `executor.ts:1017-1042`(`projectRow` 只处理 `*` / CASE / `AS` / 字符串常量,没有数字分支)→ `1046` `projectColumns` 找不到 '1' 键 → `{}`
|
|
||||||
- 现象:`SELECT 1 FROM t` → `[{},{}]`;`SELECT 1 AS one FROM t` → `[{}]`(别名分支取 `row['1']` → undefined);`SELECT id, 1 AS one FROM t` → `[{id:'1'},{id:'2'}]`(常量列消失)。
|
|
||||||
- 备注:EXISTS 子查询只关心行数所以掩盖了这个问题;注释(parser.ts:1044)明确把它当特性。
|
|
||||||
- 复现:见上。
|
|
||||||
|
|
||||||
**P1-16 标量子查询:多行不报错(静默取第一行)、空结果被当 NULL 值匹配** **[已验证]**
|
|
||||||
- 行号:`executor.ts:1425-1431`
|
|
||||||
- 现象:`SELECT id FROM u WHERE age = (SELECT age FROM u)`(3 行年龄)→ 返回 id=1(SQL 应报 "subquery returned more than one row");`age > (SELECT age FROM u)` → 取第一行年龄比较;`age = (SELECT age FROM u WHERE id='nope')`(空)→ `$eq: null` → 返回 age IS NULL 的行(SQL 0 行)。
|
|
||||||
- 根因:标量语义被实现为"第一行第一列/空则 null",没有基数检查,也没有 UNKNOWN 语义。
|
|
||||||
- 复现:见上。
|
|
||||||
|
|
||||||
**P1-17 AND/OR 无优先级(左结合)** **[已验证]**
|
|
||||||
- 行号:`sql/parser.ts:780-797`(`while (AND|OR)` 逐个子条件左折,不区分优先级)
|
|
||||||
- 现象:AST 实证 `a = 1 OR b = 1 AND id = 'r2'` → `{$and:[{$or:[a,b]}, {id:'r2'}]}`(SQL 应为 `a OR (b AND id)`);数据 r1(a=1,b=0)、r2(a=0,b=1)、r3(a=0,b=0) 时返回 `[r2]`(应为 r1、r2)。
|
|
||||||
- 根因:`parseCondition` 缺少 `parseOr → parseAnd → parseSimple` 分层。
|
|
||||||
- 复现:见上。影响所有引擎与所有语句(WHERE/ON/HAVING 共用 `parseCondition`)。
|
|
||||||
|
|
||||||
**P1-18 GROUP BY 下"带别名的非聚合列"被静默改名或整个丢弃** **[已验证]**
|
|
||||||
- 行号:`executor.ts:631-648`(`colExpr` 与 `stmt.groupBy` 做**字符串全等**比较;不匹配就走 646 的 `aggregated[colExpr] = groupRows[0][colExpr]`)
|
|
||||||
- 现象:
|
|
||||||
- `SELECT dept AS d, COUNT(*) AS c FROM e GROUP BY dept` → `[{dept:'Eng',c:2}]`(别名 d 丢失,键变成 dept)
|
|
||||||
- `SELECT salary AS s, COUNT(*) AS c FROM e GROUP BY dept` → s 列**整列消失**(值为 undefined,JSON 序列化时被抹掉,无报错)
|
|
||||||
- `ORDER BY d`(分组列的别名)静默不排序
|
|
||||||
- 根因:组行的键空间是"SELECT 列表字符串 + groupBy 字符串"的拼接,别名/前缀/空白只要不一致就落到 first-row 分支;`ORDER BY` 在投影前按裸键取值(379)。
|
|
||||||
- 复现:见上。
|
|
||||||
|
|
||||||
**P1-19 `maxRowsPerQuery` 会静默截断 `INSERT … SELECT` 的源数据(静默丢数据)** **[已验证]**
|
|
||||||
- 行号:`executor.ts:707`(`executeInsert` 用 `executeSelectPart` 取源行)→ `396-398`(`maxRowsPerQuery` 截断在 `executeSelect` 内)
|
|
||||||
- 现象:`maxRowsPerQuery: 2` 时,`INSERT INTO b SELECT id FROM a`(a 有 4 行)**只插入 2 行且不报错**。
|
|
||||||
- 根因:行数上限保护放在"查询结果"层,而读路径同时被写语句复用,没有区分"面向用户的结果集"与"内部行源"。
|
|
||||||
- 复现:配置 maxRowsPerQuery=2,见上。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### P2 — 边界与健壮性
|
|
||||||
|
|
||||||
| # | 文件:行号 | 现象 / 根因 | 状态 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| P2-1 | `executor.ts:645-647` | `SELECT dept, salary FROM e GROUP BY dept` 返回每组**第一行**的 salary(MySQL 式 ANY_VALUE),标准 SQL 应报错;结果依赖扫描顺序 → 引擎相关 | [已验证] |
|
|
||||||
| P2-2 | `executor.ts:383-386 + 447-451` | JOIN 查询 `SELECT *` 返回 `users.id`/`departments.name` 这类**带前缀列名**,与单表 `SELECT *` 的裸列名不一致 | [已验证] |
|
|
||||||
| P2-3 | `where-matcher.ts:220-225` | 未限定列名在 JOIN 中静默取"第一个后缀匹配"的列(歧义应报错) | [已验证] |
|
|
||||||
| P2-4 | `executor.ts:591-595, 602-609` | LEFT/RIGHT 的 NULL 补行用**第一行的键集**(`rightRows[0]`/`leftRows[0]`)构造,键集异质(ALTER ADD/DROP 后、空表)时补行缺列;RIGHT 未匹配行被追加在结果末尾(顺序非 SQL 语义) | [读码确认] |
|
|
||||||
| P2-5 | `executor.ts:1169-1174` + `766-777` | `hasCorrelatedRefs` 把**任何** `$exists` 都判为关联 → `DELETE FROM t WHERE EXISTS (SELECT 1 FROM u)`(完全不相关)抛 `NOT_SUPPORTED`;同时非关联 EXISTS 被逐行重复执行(见 P3-2) | [已验证] |
|
|
||||||
| P2-6 | `builder.ts:105-112` + `memory.ts:192-210` | QueryBuilder 非 JOIN 路径直通 `engine.find`,**find 没有 `containsUnresolvedSubqueries` 防护**(只有 update/delete 有)→ `.where({id:{$eq:{$col:'v'}}})` 静默返回 `[]` | [已验证] |
|
|
||||||
| P2-7 | `executor.ts:305,317,322-336,367,375,440,651` | 就地改写调用方的 AST(where/columns/orderBy/groupBy),并在 `stmt` 上挂 `_aggAliasMap` 侧信道 → 同一 AST 二次执行(`toAST()` 复用、EXPLAIN + 执行)语义漂移 | [读码确认] |
|
|
||||||
| P2-8 | `engine/aria/index.ts:1223,2028` vs `1998,2005` | Aria 主键类型随访问路径翻转:全表/范围扫描 `key.slice()` → 字符串;`$eq` 快速路径用查询字面量原类型 → 同一列在 `SELECT *` 与 `WHERE id = 3` 下分别是 `'3'` 和 `3`;memory/disk/hybrid 恒为原类型 | [已验证] |
|
|
||||||
| P2-9 | 无 SQL 层保证 | 无 ORDER BY 时行序引擎相关(memory/disk/hybrid 插入序 vs aria 主键序)→ `LIMIT/OFFSET` 选出的行引擎间不一致;README"已知限制"未提 | [已验证] |
|
|
||||||
| P2-10 | `where-matcher.ts:18-28` | `LIKE` 编译为正则带 `i` 标志 → **大小写不敏感**('C%' 命中 'c');与 PostgreSQL 不同、与 SQLite ASCII 行为相近,但没有任何文档说明 | [已验证] |
|
|
||||||
| P2-11 | `executor.ts:665-670` | `COUNT(DISTINCT *)` 在第 666 行就 `return rows.length`,DISTINCT 被忽略(SQL 应语法报错) | [已验证] |
|
|
||||||
| P2-12 | `executor.ts:668` | `COUNT(DISTINCT col)` 用 `String(v)` 去重(无类型前缀)→ `1` 与 `'1'`、`true` 与 `'true'` 合并;与 v0.7.4 专门引入 `encodeGroupKey` 的理由自相矛盾 | [读码确认] |
|
|
||||||
| P2-13 | `core.ts:218-241` | 分号多语句**逐条执行、无事务**,失败时前面的语句已提交(实测 `INSERT; INSERT` 第二条 DUPLICATE_KEY,第一条留存);返回值只有最后一条结果 | [已验证] |
|
|
||||||
| P2-14 | `executor.ts:829-841` | ALTER DROP COLUMN 的通用路径靠"`engine.find` 返回行引用,直接 `delete row[col]`"清理数据;注释自认依赖 Memory 的引用语义 → 换引擎即静默失效(Aria 走引擎 alterTable 才没事) | [读码确认] |
|
|
||||||
| P2-15 | `sql/parser.ts:828-833` | `(CASE … END) = 'x'` PARSE_ERROR(括号分支返回内层条件后不再继续解析运算符);`ORDER BY COUNT(*)`、`(SELECT …) > 0`、任何算术表达式(`a+1`、`n/0`)都 PARSE_ERROR → executor 里为 CASE/表达式准备的分支(1010-1063、1240-1266)只能被很窄的语法触达 | [已验证] |
|
|
||||||
| P2-16 | `executor.ts:1070-1072` | `_hasAggregateColumn` 要求列文本**以聚合名开头** → `SELECT CASE WHEN COUNT(*) > 1 THEN …` 不触发聚合路径;组内 `evaluateCase` 把 `COUNT(*)` 当列引用(`resolveCaseValue` 95-97)→ null | [读码确认] |
|
|
||||||
| P2-17 | `executor.ts:95-97` | CASE 的 THEN/条件里的列引用按**精确键**取(`row[v]`)→ JOIN 场景下未限定列取不到(`THEN id` 在带前缀行里是 undefined → null) | [读码确认] |
|
|
||||||
| P2-18 | `executor.ts:1269-1288` | `caseConditionMatches` 对未知操作符 `default: break`(静默视为满足),与 v0.7.2 让 `matchOperator` 抛 `QUERY_ERROR` 的硬化方向不一致 | [读码确认] |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### P3 — 性能
|
|
||||||
|
|
||||||
| # | 文件:行号 | 问题 |
|
|
||||||
|---|---|---|
|
|
||||||
| P3-1 | `executor.ts:429`、`569-612` | JOIN 非哈希时**每张右表整表物化**再嵌套循环,每对候选都 `{...l,...r}` 造对象(585);RIGHT JOIN 再全扫一遍右表(598-610)→ O(N·M) 两遍 |
|
|
||||||
| P3-2 | `executor.ts:1226-1238` | `filterCorrelated` 外层**每行执行一次子查询**(含完全不相关的 EXISTS,因 1169-1174 的过宽判定),无缓存/去相关 |
|
|
||||||
| P3-3 | `executor.ts:379` + `388-390` | ORDER BY 别名场景排序两次,第一次作用在投影前的行上(键全 undefined)纯属浪费 |
|
|
||||||
| P3-4 | `memory.ts:195-208`、`aria/index.ts:686-704` | 同样的 order/limit/project 在引擎里做一遍、executor 再做一遍(复制 + 排序 + 切片 ×2) |
|
|
||||||
| P3-5 | `where-matcher.ts:16-28` | `likeCache` 是**模块级无上界 Map**,动态(用户输入)LIKE 模式持续增长 → 内存泄漏 |
|
|
||||||
| P3-6 | `executor.ts:211-215` | `EXPLAIN SELECT` **真执行查询**;`estimatedRows` 就是真实行数;`EXPLAIN` 还会跑 `resolveWriteWhere`(218-223)触发子查询执行 |
|
|
||||||
| P3-7 | `executor.ts:153-200, 297-307`;`core.ts:307-324` | UNION / 派生表 / 任何带 JOIN、ORDER BY、DISTINCT、聚合的查询都全量物化;`queryStream` 只对最简 SELECT 走真流式 |
|
|
||||||
| P3-8 | `executor.ts:677-678` | `Math.min(...arr)` 展开实参:O(n) 栈 + 崩溃(P0-1) |
|
|
||||||
| P3-9 | `executor.ts:1010-1063`、`59-83` | 投影表达式**逐行重新正则解析**(`parseCaseExpression` 每行每列一次)、`projectColumns` 未命中时对每行键做 O(cols×keys) 扫描;无预编译投影/表达式 |
|
|
||||||
| P3-10 | `executor.ts:31-39, 37` | `encodeGroupKey` 对 object 值 `JSON.stringify`(每行每值),分组/去重键无长度前缀 |
|
|
||||||
| P3-11 | `executor.ts:531-538` | 哈希连接把所有左表探测值(可能百万级)一次性放进单个 `$in` 查询,无分块 |
|
|
||||||
| P3-12 | `executor.ts:457-471` | WHERE 下推只覆盖"主表别名前缀 + 普通等值",其余(未限定列、$and 内部非等值、JOIN 表条件)全部在内存里逐行过滤 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 疑似(未实跑,读码推断)
|
|
||||||
|
|
||||||
- **S-1 分组/去重键可被伪造**:`encodeGroupKey` 各列值用 `'\x1f'` 连接且无长度前缀/转义(`executor.ts:621/689/170/178`),字符串值自身含 `'\x1f'` 时不同列组合可撞键(如 `('a\x1fss','b')` 与 `('a','ss\x1fb')`)→ DISTINCT/GROUP BY/UNION 误合并。哈希连接键(543/553)与 `$in` 探测用 `'\0'` 哨兵,同类问题且会把 NULL 与真实 `"\0"` 混同。
|
|
||||||
- **S-2 json 列分组**:`JSON.stringify` 的对象键顺序不同 → 逻辑相等的对象落入不同组(37)。
|
|
||||||
- **S-3 行键顺序敏感**:DISTINCT/UNION 用 `Object.values(row)`(689/170),同值但键序不同的行(ALTER ADD/DROP 后、异质行)不会去重。
|
|
||||||
- **S-4 Aria 索引键 `String()` 归一**:`1`/`'1'`、`true`/`'true'` 在二级索引里同一键(aria/index.ts:2045/2062);当前只造成多余候选(后续 `matchWhere` 会过滤),但语义上是类型混同。
|
|
||||||
- **S-5 `hasSelectAlias` 正则误判**:`/\s+AS\s+\w+$/i`(executor.ts:295)对含 " AS " 的列文本可能误判(已实测字符串常量 `'a AS b'` 不会误判,因为结尾是引号;但其它未加引号文本未穷举)。
|
|
||||||
- **S-6 混合类型排序**:`compare` 回退 `localeCompare(String(a),String(b))`(where-matcher.ts:207)→ 数字/字符串混合列按字典序、且依赖 locale。
|
|
||||||
- **S-7 哈希连接列对方向**:`keyIsLeft` 只看 `mainAlias` 前缀(executor.ts:508-512),第二个 JOIN 的 ON 若引用前一个 JOIN 表,`pairs` 会左右颠倒;目前通常因"取值列表为空"回退嵌套循环(531-532),但这是巧合而非保证。
|
|
||||||
- **S-8 `$like` 未处理 `\` 转义与 `%`/`_` 字面量**(where-matcher.ts:21-24 先转义正则字符再替换通配符;无 ESCAPE 支持),且正则无回溯保护 → 恶意模式(如 `%a%a%a%…`)可能触发灾难性回溯。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 性能议题(汇总)
|
|
||||||
|
|
||||||
1. **JOIN 是"整表物化 + 嵌套循环"**(P3-1):右表无条件全量读入(executor.ts:429),只有"ON 为纯等值 + 右表有索引"时才退化为一次 `$in` 探测的哈希连接(480-566)。没有块嵌套、没有排序合并、没有把 WHERE 下推到连接前(除了主表别名等值那一种)。
|
|
||||||
2. **完全不做投影下推**:`needsRawRows || hasSelectAlias` 时 `plan.columns=['*']`(352),GROUP BY/聚合路径也强制 `['*']`(340/351)→ 引擎把整行读出来再由 JS 投影/分组。列存式裁剪无从谈起。
|
|
||||||
3. **LIMIT 下推错位**(P1-2):本该减少工作量的下推反而造成错误结果;正确做法是只在"无 DISTINCT/GROUP BY/ORDER BY 别名"时下推。
|
|
||||||
4. **重复劳动 ×2**:引擎与 executor 各排一次序、各切一次片、各投影一次(P3-4、P3-3)。
|
|
||||||
5. **关联子查询逐行执行**(P3-2):N 行 = N 次子查询(每次都是一次完整 SELECT),且**不相关的 EXISTS 也被当成相关**。
|
|
||||||
6. **全量物化点**:UNION、派生表、ORDER BY 别名、DISTINCT、GROUP BY、JOIN 全部先物化再处理(P3-7);`queryStream` 只在最简 SELECT 上真流式。
|
|
||||||
7. **无上界增长**:`likeCache`(P3-5)、哈希连接 `$in` 值数组(P3-11)、分组 Map(每行都进 `groups`,输出组数无上限)、`maxRowsPerQuery` 是唯一的结果集上限(且会误伤 INSERT…SELECT,P1-19)。
|
|
||||||
8. **逐行重解析**:表达式/别名/CASE 的文本解析在行循环里(P3-9);`executor.ts:59` 的 CASE 正则每个 CASE 列每行跑一次,且用 `[\s\S]*?` 惰性匹配 + `exec` 循环。
|
|
||||||
9. **EXPLAIN 变成真执行**(P3-6):成本翻倍,且给不出真实估算(`estimatedRows` = 实际行数)。
|
|
||||||
10. **错误路径上的性能悬崖**:P0-1 的 `Math.min(...)` 同时也是 O(n) 实参构造;索引命中后仍要全条件 `matchWhere`(memory.ts:197-199 / aria:687-689),等值索引查出的行本可跳过该列比较。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 系统性观察:为什么这一层反复出 bug,哪里一刀能砍掉一整类
|
|
||||||
|
|
||||||
1. **同一语义有两份实现,且按"查询长什么样"分流**
|
|
||||||
- 有 JOIN → executor;无 JOIN → 引擎(builder.ts:99-113 与 executor.ts:311-355)。
|
|
||||||
- 有 `AS`/CASE → 引擎不投影、executor 投影;否则引擎投影(executor.ts:290-295/352)。
|
|
||||||
- ORDER BY 用别名 → 清空 plan 的 order/limit;否则把 limit 下推(353)。
|
|
||||||
- 这些分流处**每一个**都对应上面的一个 P1(P1-1/2/9/12/13 全是"同一个 SQL 换条路径就对")。LIMIT 下推是典型:JOIN 路径对、单表路径错。
|
|
||||||
- **一刀**:把"扫描→过滤→连接→分组→去重→投影→排序→截断"写成一条固定管线,且 **limit/offset/projection 只在一个地方执行**;引擎退化为"只提供带索引的行迭代器 + WHERE 匹配"。
|
|
||||||
2. **没有 NULL / 三值逻辑模型**
|
|
||||||
- `where-matcher` 用 JS `===`,哈希连接用 `String(v ?? '\0')`,`$in/$nin` 用 `includes`,分组键另起一套 `encodeGroupKey`,`COUNT(DISTINCT)` 又用 `String(v)`。P1-5/6/10 以及 S-1/S-4 都是同一根因的不同外显。
|
|
||||||
- **一刀**:引入唯一的 `sqlEquals/sqlCompare`(三态:TRUE/FALSE/UNKNOWN)+ 唯一的值编码(含 NULL 与类型)。所有比较、IN/NOT IN、JOIN 键、分组键、DISTINCT 键都调它 → NULL 类 bug 整类消失。
|
|
||||||
3. **列/表达式身份是"字符串",没有绑定阶段**
|
|
||||||
- 列是文本:`'dept AS d'`、`'COUNT(o.id)'`、`'CASE … END AS k'`;于是别名要在 6 处正则里再解析(295/633/977/1017/1027/1079),GROUP BY 靠字符串全等比较(645),HAVING 靠字符串映射(369-376),表前缀靠 `startsWith` 剥(1149-1156)。P1-9/18、P2-3/16/17 都源于此。
|
|
||||||
- **一刀**:解析期把列解析成 `{table?, column|expr, alias}` 并在编译期绑定到输出序号;GROUP BY/ORDER BY/HAVING 全部按"列序号"而非字符串对齐。
|
|
||||||
4. **行编码是"临时约定的键字符串"**
|
|
||||||
- JOIN 前缀键(447-451)、裸键、`undefined` vs `null`、`Object.values` 顺序、引擎投不投影 —— 全都会改变分组/去重/排序结果(P1-12/13、P2-4、S-3)。
|
|
||||||
- **一刀**:行 = 定长元组 + 列清单(schema of the projection),投影/去重/排序按**位置**进行;"前缀"只存在于连接阶段的符号表里,不进入结果行。
|
|
||||||
5. **关联子查询靠"语法嗅探 + 特殊通道"**
|
|
||||||
- `hasCorrelatedRefs`(1159-1181)是纯文本检测,命中就把整条 WHERE 丢进逐行路径;而 `$exists` 与 `$in`/标量走了两条不同的执行分支(1351-1356 vs 1417),其中一条忘了传外层行 → P1-4;过宽判定 → P2-5/P3-2;`stripCorrelatedExists`(1205-1223)想"先粗筛再精筛"却漏了 `$col` → P1-3。
|
|
||||||
- **一刀**:统一的表达式求值器携带 `outer row` 参数(子查询执行时传入),WHERE 的求值=对每行调用同一函数;不需要"是否相关"的预判,也就没有漏传上下文/粗筛过滤光的问题。
|
|
||||||
6. **SQL 子句顺序没有被编码**
|
|
||||||
- 代码里的顺序是"聚合→分组→DISTINCT→HAVING→排序→投影→再排序→截断",而 SQL 是 FROM→WHERE→GROUP→HAVING→SELECT→DISTINCT→ORDER→LIMIT。HAVING 在投影后求值导致 P1-7/18,DISTINCT 在投影前导致 P1-13,LIMIT 下推到扫描导致 P1-2。
|
|
||||||
- **一刀**:把上面那条标准顺序写成显式的 stage 列表(每个 stage 有明确的输入/输出列集),任何新特性只需插到正确位置。
|
|
||||||
7. **测试固化的是"长度/不抛错"而不是值**
|
|
||||||
- `tests/join.test.ts:57-99` 只断言行数(注释里甚至写 "column projection may vary");`tests/v073-fixes.test.ts:267-278` 比较两条同样错的路径;`groupby.test.ts` 只测 HAVING 引用了 SELECT 里已有的聚合。
|
|
||||||
- 结果:P1-3/7/12/13/14 这类"结果错但不崩溃"的语义长期存活,且每次 CHANGELOG 的"P1 修复"都只在某一条路径上打补丁(v0.7.4 修了 queryStream/写路径的子查询,却留下 P1-4 的关联 IN)。
|
|
||||||
- **建议**:补"值级 golden 测试",重点覆盖 NULL/三值逻辑、别名与未限定列、UNION + ORDER/LIMIT、LIMIT/OFFSET(含 OFFSET>0 与左侧为空的 UNION)、空集聚合、以及**同一 SQL 在 join/非 join、memory/aria 两种路径下的结果一致性**。
|
|
||||||
|
|
||||||
### 优先级建议(若要排修复顺序)
|
|
||||||
|
|
||||||
1. P0-1(崩)→ P1-19(静默丢数据)→ P1-1(OFFSET 结果错)→ P1-5/6(NULL 三值逻辑)→ P1-4/3(关联子查询静默空)→ P1-17(AND/OR 优先级,影响面最大且最易修)→ P1-7/8/13/14/18(聚合与集合语义)→ P1-9/11/12(路径分叉)→ P1-15/16。
|
|
||||||
2. 结构性投入(收益最大):统一值比较/编码(第 2 条)+ 列绑定(第 3 条)+ 单条执行管线(第 1、6 条)。这三件事落地后,本报告 P1 中的绝大多数会作为"整类"消失,而不是逐条打补丁。
|
|
||||||
@@ -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` 来源的 unique(memory.ts:26、593),供 `dropIndex` 区分「建表约束」与「索引来源约束」(memory.ts:613-623)。
|
|
||||||
- `metaStore`、`snapshot`(事务快照,memory.ts:29-33)。
|
|
||||||
|
|
||||||
**查找路径(memory.ts:192-210, 725-772)**:`find` 先 `tryIndexLookup`:递归展开顶层 `$and`(memory.ts:734-745)收集等值条件 → 命中某列索引则用 `colIndex.get(value)` 取 pk 集合回表(memory.ts:763-767);**索引未命中直接 `return []`(memory.ts:768)——正确性完全依赖索引与行一致**;null/undefined 条件跳过索引回退全表(memory.ts:759)。随后仍用完整 `matchWhere` 过滤(子集语义安全)、`applyOrderBy`、`slice(offset,limit)`、`projectColumns`。`findStream`(memory.ts:213-233)单次迭代不物化,但**完全忽略 `query.orderBy`**。
|
|
||||||
|
|
||||||
**insert(memory.ts:146-183)**:v0.7.3 两阶段。阶段 1 逐行 `validateRow` + 批内 PK `Set` 互查(memory.ts:165)+ `checkInsertUniqueness`(批内 Set + 索引查,memory.ts:309-341);阶段 2 落 row + `updateIndexes`。
|
|
||||||
|
|
||||||
**update(memory.ts:235-302)**:`stripUndefinedUpdates` → 未知列报错(254-258)→ 未解析子查询报错(245-250)→ 阶段 1 逐行匹配/校验/唯一预检/**新主键撞已有主键检查**(memory.ts:274)→ 阶段 1b `checkUpdateRestrict`(RESTRICT 与 SET NULL+required 整体拒绝,memory.ts:391-421)→ 阶段 2 先 `removeIndexEntries` 再(主键变更时)`applyUpdateCascade`、`table.delete(old)`、`table.set(new)`、`updateIndexes`。
|
|
||||||
|
|
||||||
**delete(memory.ts:450-486)**:先扫出全部待删行 → 对每行 `checkCascadeRestrict` 递归预检(memory.ts:492-529)→ 通过后才 `removeIndexEntries` + `cascadeDelete`(memory.ts:810-878)→ 最后删父行。返回 `toDelete.length + cascadeCount`。
|
|
||||||
|
|
||||||
**事务(memory.ts:633-653)**:`beginTransaction` 深拷贝 tables(浅拷贝行对象 `{...iv}`,memory.ts:661)、浅拷贝 schemas(`new Map(this.schemas)`,memory.ts:638)、深拷贝 indexes;`rollback` 直接换回三个 Map;`commit` 只丢弃快照。**没有写锁、没有快照隔离**:事务期间任何非事务写入直接落到同一份 live Map 上。
|
|
||||||
|
|
||||||
**外键级联**:`checkCascadeRestrict`(预检)与 `cascadeDelete`(执行)都在表循环里 `if (refTableName === tableName) continue;`(memory.ts:498、822),即**自引用外键被整体跳过**;`applyUpdateCascade` 只处理直接引用被改表的列(memory.ts:430-448),不递归。
|
|
||||||
|
|
||||||
### 1.2 KVStore(`src/engine/kvstore/*`)
|
|
||||||
|
|
||||||
**磁盘格式**
|
|
||||||
- 键空间:`__kv_log` / `__kv_snapshot` / `__kv_meta`(index.ts:32-34)。
|
|
||||||
- **日志记录**(log.ts:9-18, 47-92):`[recordLen u32][seq u32][entryCount u32] (每条 entry: [op u8][keyLen][key][valueLen][value]) [crc u32]`,大端,`crc32` 覆盖除 CRC 外全部字节;`recordLen` 含自身不含 CRC(log.ts:79)。操作码 PUT=1 / DELETE=2 / APPEND=3(log.ts:23-28)。**一条记录 = 一个原子事务**(putMany/deleteMany/writeBatch 都编码成单条记录,index.ts:209-242)。
|
|
||||||
- **快照**(snapshot.ts:8-13, 29-59):`[magic "KVSN"][seq u32][entryCount] (keyLen/key/valueLen/value)* [crc]`;`seq` 是内嵌日志水位。
|
|
||||||
- **介质抽象**(`src/engine/aria/store/backend.ts:13-48`):`IStorageBackend`(open/close/read/write/append?/writeMany/delete/deleteMany/listKeys/exists/clear)。浏览器走 `OPFSBackend`(每 key 一个文件,`write` 用 createWritable COW,`append` 用 keepExistingData+seek 到 `existing.size`,opfs_backend.ts:84-113);Node/测试走 `SharedMemoryBackend`(模块级 registry 跨实例共享、close 不清数据,shared_memory_medium.ts:15-43),默认选择逻辑在 index.ts:44-50。
|
|
||||||
- **内存索引**:`index: Map<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 起水位只信快照内嵌 seq,index.ts:113-118)。
|
|
||||||
|
|
||||||
**写入与 checkpoint(index.ts:334-391, 261-280)**:所有写经 `enqueue` 串行队列(index.ts:327-331)→ `seq++` → `medium.append`(真追加,否则 read+拼+write 回退,index.ts:344-356)→ 成功后更新内存索引与 `logBytes`;失败 `seq--`、记 `lastBackgroundError` 并抛 `KV_LOG_ERROR`(内存不更新)。`checkpoint` = 写快照 → 写 meta → 截断日志;`appendRecord` 内还有一条**按字节阈值的自动 checkpoint**(index.ts:385-390,默认 16MB,index.ts:37),**这段不在 try/catch 内**。
|
|
||||||
|
|
||||||
### 1.3 KVStoreEngine(`src/engine/kvstore_engine.ts`)
|
|
||||||
|
|
||||||
**三层缓存**:介质文件 → `KVStore.index`(ArrayBuffer 权威视图)→ `MemoryEngine`(解码后的行 + 二级索引)。**读全部走 MemoryEngine**(kvstore_engine.ts:292-300, 419-422),介质只在 open/checkpoint 碰。
|
|
||||||
|
|
||||||
**键布局**:`__schema`(全部表 schema 的 JSON 一次写全)、`__meta:{key}`、`t:{table}:{pk}`(kvstore_engine.ts:28-29, 65-71)。
|
|
||||||
|
|
||||||
**open(kvstore_engine.ts:75-123)**:`kv.open` → `memory.open` → 解析 `__schema`(JSON 失败抛 `KV_SCHEMA_ERROR`)→ 逐 key 解析行名(`key.indexOf(':', 2)` 取第一个冒号,kvstore_engine.ts:99-101)→ 表存在则 `memory.insert(table,[row])`,**异常被 catch 静默吞掉**(kvstore_engine.ts:103-108)→ 之后一段「重建二级索引」循环(kvstore_engine.ts:111-120)实际是死代码(`createTable` 已建桶且 `createIndex` 见 `colDef.index/unique` 立即 return,memory.ts:562)。`this.opened = true` 在**最后**才置位(kvstore_engine.ts:122)。
|
|
||||||
|
|
||||||
**写入(非事务)**:insert = memory 先行 + `putMany` 持久化 **memory 中的 validated 行**(kvstore_engine.ts:283-288);update = 先 `collectMatchingPks` 预取受影响主键(kvstore_engine.ts:316)→ `memory.update` → 主键变更走 `affectedTables` 传递闭包 + `collectTableDiff` 整表 diff,普通更新走「单次全表扫描 + 受影响集合过滤 + 剩余主键删除」(kvstore_engine.ts:345-380);delete = 预取主键 + memory.delete + 本级表按主键删 + 级联表整表 diff,合并成一次 `writeBatch`(kvstore_engine.ts:406-415)。
|
|
||||||
|
|
||||||
**事务**:`beginTransaction` = memory 快照 + 复位 4 个 tx 集合(kvstore_engine.ts:476-485);写入路径按 `txActive` 分叉,把变更记入 `txChanges`(行级 put/delete)或降级为 `txFullTables`(主键变更/级联影响表)或 `txClearedTables`(事务内 clear);`commitTransaction` 把这些合并成 **一次** `kv.writeBatch`,随后**单独** `persistSchema`(kvstore_engine.ts:545-549),最后 `memory.commitTransaction`;`rollbackTransaction` 只回滚内存与集合(kvstore_engine.ts:561-571)。`setMeta` 是直写 `kv.put('__meta:*')`(kvstore_engine.ts:181-183),与事务无关。
|
|
||||||
|
|
||||||
### 1.4 HybridEngine(`src/hybrid/index.ts`)
|
|
||||||
|
|
||||||
`memoryEngine: MemoryEngine` + `diskEngine: KVStoreEngine`(hybrid/index.ts:30-35)。读全部走内存(222-225);写 = 内存先行、磁盘 write-through(insert 211-220 / update 232-241 / delete 243-252 / clear 258-265 / DDL 141-189);磁盘失败回调 `recoverMemoryAfterDiskError`:`reloadMemoryFromDisk()` 把内存对齐磁盘后重抛原错误(hybrid/index.ts:199-209)。`reloadMemoryFromDisk`(hybrid/index.ts:56-85)= 关/开内存引擎 → `disk.reload()` → 遍历磁盘表 `createTable` + `find` 全表 + `insert` 回灌,**单表回灌失败只 `console.warn`**(79-82)。事务 = 双引擎 begin(293-303,磁盘失败补偿回滚内存)/ commit(磁盘先、内存后,内存失败抛 `TX_COMMIT_ERROR` 且磁盘已提交,305-319)/ rollback(内存先、磁盘后,321-324)。
|
|
||||||
|
|
||||||
### 1.5 语义对照(同为「关系语义」的四份实现)
|
|
||||||
|
|
||||||
| 语义 | Memory | KVStore/KVStoreEngine | Hybrid | Aria(对照) |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 行/索引 | PK=行 Map;col→Set\<pk\> | 复用 Memory + `t:table:pk` 键 | 复用 Memory ×2 | LSM 二级索引 |
|
|
||||||
| 约束校验 | 自带 `validateRow/checkType`(**无 maxLength/min/max**,memory.ts:713-722) | 复用 Memory | 复用 Memory | 共享 `checkFieldType`(含 maxLength/min/max,aria/index.ts:1672) |
|
|
||||||
| 唯一 | 建表标记 + `uniqueIndexCols` | schema 持久化后再由 createTable 建桶 | 双份 | 自有重建 |
|
|
||||||
| 事务内 DDL | ALTER/CREATE/DROP INDEX 拒绝(memory.ts:114/552/599),create/dropTable 可回滚 | ALTER/CREATE/DROP INDEX 拒绝(kvstore_engine.ts:236/442/459),create/dropTable 随 commit | 委托两侧 | ALTER 拒绝 |
|
|
||||||
| 事务原子性 | 快照交换 | 内存快照 + 单条日志批 + **schema 另写** | 双引擎 | MVCC + WAL |
|
|
||||||
| 失败中途 | 语句级两阶段;级联两阶段 | 内存先行,落盘失败回滚不了内存 | 重载内存对齐 | WAL 领先内存 |
|
|
||||||
| 级联影响面 | 直接引用表(自引用被跳过、ON UPDATE 只一层) | `affectedTables` 传递闭包(整表 diff) | 委托 | 自有实现 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 缺陷清单
|
|
||||||
|
|
||||||
### P0 — 静默丢数据 / 破坏崩溃恢复
|
|
||||||
|
|
||||||
**P0-1 单条 UPDATE 把多行改到同一新主键 → 静默覆盖丢行** **[已复现]**
|
|
||||||
- 位置:`src/engine/memory.ts:274`(阶段 1 只检查 `table.has(newPk)`)、`memory.ts:289-300`(阶段 2 直接 `set`)。
|
|
||||||
- 现象:`UPDATE t SET id='X'`(匹配 2 行)返回 affected=2,但表中只剩 1 行;SQL 路径与引擎路径一致。
|
|
||||||
- 根因:阶段 1 只与「语句执行前的表」比对,批内新主键互查缺失;阶段 2 逐行 `table.delete(old); table.set(new, updated)`,后者覆盖前者的成果。`primaryKey: true` 不隐含 `unique: true`,`checkUpdateUniqueness`(memory.ts:348-385)只遍历 `colDef.unique` 列,兜不住。
|
|
||||||
- 复现:2 行表 → `engine.update('t',{table:'t'},{id:'X'})` → `find` 返回 1 行。(INSERT 路径 v0.7.3 已做批内 PK `Set` 互查,UPDATE 漏了。)
|
|
||||||
|
|
||||||
**P0-2 唯一约束永久失效 → 重启静默丢行(组合链)** **[已复现]**
|
|
||||||
- 位置:`memory.ts:562`(`if (colDef.index || colDef.unique) return;`)、`memory.ts:122-127`(ALTER ADD 只写 `schema.columns`,**不建索引桶**)、`memory.ts:316-339`(唯一预检依赖 `tableIndexes.get(colName)`,桶缺失即 `colIndex===undefined` → 整段跳过)、`kvstore_engine.ts:103-108`(open 回灌 `insert` 异常被吞)。
|
|
||||||
- 现象(端到端实测):`ALTER TABLE t ADD COLUMN email STRING UNIQUE` → 连续插入两条 `email='a@x.com'` **都不报错**(rows=2)→ close/reopen → 只剩 1 行,**无任何错误或警告**。
|
|
||||||
- 根因:唯一性检查完全由二级索引桶承载,而 ALTER ADD 不建桶;重启时 `createTable` 依据 schema 建桶,回灌第 2 行触发 `UNIQUE_VIOLATION`,异常被 `catch {}` 吞掉 → 该行永久消失。同理 `CREATE UNIQUE INDEX` 落在一个已有普通索引的列上会静默 no-op(`memory.ts:562`),约束不生效。
|
|
||||||
- 复现:见上(`tests` 临时用例已验证 `dupErr='' | before=2 | after=1`)。
|
|
||||||
|
|
||||||
**P0-3 `KVStore.open()` 命中损坏日志后把整条日志清零 → 已确认数据只剩内存,再崩溃即永久丢失** **[已复现]**
|
|
||||||
- 位置:`src/engine/kvstore/index.ts:135-138`(检测到损坏 → `truncateLog()`)、`index.ts:394-399`(`truncateLog` 写 `new ArrayBuffer(0)`,**不是** `log.subarray(0, validBytes)`)。
|
|
||||||
- 现象:日志尾部有一条坏记录时,open 会先把坏记录之前**全部有效记录**应用到内存,然后清空整条日志;此时快照可能是上一次 checkpoint 的旧值甚至不存在。随后若进程崩溃(未 close、未 checkpoint),这些「已确认」的数据没有任何持久副本 → 永久丢失。
|
|
||||||
- 佐证:`tests/engine/kvstore.test.ts:176-210` 只验证了**同一会话内** `confirmed/confirmed2` 仍在、且新写的 `after` 能跨一次重启(因为 after 是截断后新追加的记录),**从未验证第二次重启后 confirmed 仍在**。实测:`kv2.open()` 后日志字节数 = 0,第二次重启 `get('confirmed') === null`。
|
|
||||||
- 附带影响:`KVStore.reload()`(index.ts:148-156)会清空索引再重新 open,若此前发生过错损截断,reload 直接把库变成空库——而 Hybrid 的跨标签页同步(`src/core.ts:86-90`)会周期性调用它。
|
|
||||||
- 修复方向:open 截断应写回「有效前缀」或先落一次快照再清日志。
|
|
||||||
|
|
||||||
**P0-4 自动 checkpoint 失败发生在「日志已追加 + 内存已更新」之后 → 报错为假、事务回滚无效(数据复活)** **[已复现]**
|
|
||||||
- 位置:`kvstore/index.ts:334-362`(try/catch 只包住 `medium.append`,末尾 `logBytes +=` 与自动 checkpoint 在 catch 之外)、`kvstore/index.ts:385-390`(快照/meta/truncate 失败会以异常形式冒泡给 `put/putMany/writeBatch` 调用方)、`kvstore_engine.ts:545-553`(commit 里 `writeBatch` 抛错 → 内存 `commitTransaction` 未执行,调用方按惯例回滚)。
|
|
||||||
- 现象(端到端实测):事务 insert 一行 → 让快照写入失败 → `commitTransaction()` 抛错 → `rollbackTransaction()` 后内存 count=0 → **重启后 count=1**(被回滚的事务复活)。裸 `KVStore.put` 同样:rejects 但 `get` 有值、重启后 durable。
|
|
||||||
- 根因:原子语义要求「日志追加失败 ⇒ 内存不更新」,但反向窗口(内存/日志已提交 ⇒ 必须报成功)没有保证;checkpoint 失败被算作写失败。`lastBackgroundError` 也只覆盖 append 失败路径。
|
|
||||||
- 复现:注入 `medium.write('__kv_snapshot')` 抛错 + `checkpointThreshold` 设小。
|
|
||||||
|
|
||||||
### P1 — 结果错误 / 约束与事务语义破坏
|
|
||||||
|
|
||||||
**P1-1 maxLength / min / max 约束在 Memory/KVStore/Hybrid 上完全失效** **[已复现]**
|
|
||||||
- 位置:`memory.ts:691-711` 自带 `validateRow` + `memory.ts:713-722` `checkType` **只做类型判断**;而共享实现 `src/table/schema.ts:129-202` 的 `checkFieldType` 才校验 `maxLength`(146)、`min`(161)、`max`(167)。AriaEngine 走共享版(`src/engine/aria/index.ts:12,1672`)。
|
|
||||||
- 现象:`maxLength:3` 的列插入 8 字符、`min:10/max:20` 的列插入 999 均成功(Aria 同 schema 会抛错)。
|
|
||||||
- 影响面:README:56 明确宣称「输入校验(required / maxLength / min / max)」;三引擎静默接受越界数据 = 数据完整性缺口 + 引擎间行为漂移。
|
|
||||||
|
|
||||||
**P1-2 `find` 返回存储行引用(读到即能改库)** **[已复现]**
|
|
||||||
- 位置:`memory.ts:209`(无投影直接 `return results`)、`memory.ts:765-766`(索引路径回表得到的就是存储行对象)、`memory.ts:228`(`findStream` 回调传存储行)、`hybrid/index.ts:224`、`kvstore_engine.ts:294`(纯委托)。
|
|
||||||
- 现象:`const rows = await db.query('SELECT * FROM t'); rows[0].name='HACKED'` → 再查已是 HACKED(在 Hybrid 下更严重:见 P2-4,调用方的改动会被后续无关 update 持久化到磁盘)。
|
|
||||||
- 影响:绕过所有校验/钩子/审计、破坏事务快照(见 P2-5)、`find` 结果集共享导致的隐蔽耦合。`projectColumns`(`src/query/where-matcher.ts:214-228`)只做浅拷贝,嵌套 json 值仍共享。
|
|
||||||
|
|
||||||
**P1-3 自引用外键(同表 `references` 本表)的级联/RESTRICT 全部不生效** **[已复现]**
|
|
||||||
- 位置:`memory.ts:498`(`checkCascadeRestrict` 跳过同表)、`memory.ts:822`(`cascadeDelete` 跳过同表)、`memory.ts:432`(`applyUpdateCascade` 跳过同表)、`memory.ts:393`(`checkUpdateRestrict` 同样 `if (refTableName === tableName) continue`)。
|
|
||||||
- 现象(实测三种):`nodes.parent REFERENCES nodes(id) ON DELETE CASCADE` 删父行后子行残留(悬空引用);`ON DELETE RESTRICT` 不抛错直接删掉父行;`ON UPDATE CASCADE` 改主键后子行 `parent` 仍是旧值。
|
|
||||||
- 根因:三处「避免自引用无限递归」的保守跳过,用 `visited` 集合本可安全处理(`visited` 已经在用,memory.ts:493-495/817-818)。
|
|
||||||
|
|
||||||
**P1-4 ON UPDATE CASCADE 只级联一层 → 多层引用链断裂** **[已复现]**
|
|
||||||
- 位置:`memory.ts:430-448`:只遍历「列 `references` 的第一段 === 被改表」的直接引用表,修改的是引用表的**普通列**(如 `orders.user_id`),不会继续对 `orders.user_id` 的下游引用(如 `shipments.order_user REFERENCES orders.user_id ON UPDATE CASCADE`)做传递处理,也不进入 `planned`/预检体系。
|
|
||||||
- 现象:`users.id: u1→u2` 后 `orders.user_id=u2`(已改),`shipments.order_user` 仍为 `u1` → 悬空引用。对照 `kvstore_engine.ts:599-621` 的 `affectedTables` 反而做了传递闭包(两边语义不一致:磁盘会重写孙表但内容仍是错的)。
|
|
||||||
|
|
||||||
**P1-5 事务期间的非事务写入被卷入事务并一起回滚** **[已复现]**
|
|
||||||
- 位置:`memory.ts` 所有写路径都**不检查 `snapshot`**(只有 DDL 检查,memory.ts:114/552/599);`kvstore_engine.ts:261/318/389/427` 用全局 `txActive` 决定写入是否进 tx 变更集。
|
|
||||||
- 现象:`beginTransaction(); insert('in-tx'); insert('concurrent-no-tx'); rollbackTransaction()` → 两行都没了。任何在事务 await 间隙由「另一个调用方」发起的写,都会被并进当前事务并随回滚消失(或随提交意外生效)。
|
|
||||||
- 影响:引擎实例级「一个全局事务」的隐含假设;`db.transaction()` 与并发的 `db.table().insert()` 交错即触发。
|
|
||||||
|
|
||||||
**P1-6 KVStoreEngine:事务 commit 的 schema 落盘不在原子批内 → 部分提交 + 孤儿行** **[已复现]**
|
|
||||||
- 位置:`kvstore_engine.ts:545`(`writeBatch(puts,deletes)` 一条日志记录)与 `kvstore_engine.ts:547-549`(`persistSchema()` = 另一次 `kv.put('__schema')`)。CHANGELOG v0.6.1 宣称「一条日志记录 = 真原子,多表事务中途崩溃不会部分提交」,但 DDL 事务的这一半在批外。
|
|
||||||
- 现象(实测):事务内 `createTable('newt') + insert`,让 `__schema` 写入失败 → commit 抛错 → rollback → 重启后表不存在、**但 `t:newt:1` 已永久留在日志/快照里**(孤儿数据,open 时因表不存在被跳过,永不回收)。
|
|
||||||
- 附带:反向窗口(行批失败、schema 已写)会留下「有表无行」的空表。
|
|
||||||
|
|
||||||
**P1-7 KVStoreEngine:非事务写落盘失败 → 内存/磁盘分叉,同一会话可读到幻影行** **[已复现]**
|
|
||||||
- 位置:`kvstore_engine.ts:258-290`(`memory.insert` 先提交,`kv.putMany` 后失败无补偿)、同类 `update`(302-382)/`delete`(384-417)/`clear`(424-436)。
|
|
||||||
- 现象:注入 append 失败 → `insert` rejects,但 `find` 仍返回该行(同一会话可见),磁盘上没有;重启后行消失。Hybrid 有 v0.7.2 的 `recoverMemoryAfterDiskError` 补偿(hybrid/index.ts:199-209,实测有效),**纯 disk 模式没有这层保护**。
|
|
||||||
|
|
||||||
**P1-8 KVStoreEngine:open 回灌时的行级失败被静默吞掉** **[已复现]**
|
|
||||||
- 位置:`kvstore_engine.ts:103-108`(`try { JSON.parse; memory.insert } catch { /* 单行损坏跳过 */ }`)——注释设想的是「JSON 损坏」,但真实触发点还包括唯一冲突、类型不符、required 缺失等 schema 语义冲突。
|
|
||||||
- 现象:schema 收紧(`email` 加 `unique`)后重启,磁盘 2 行、内存 1 行,**无日志、无计数、`repair()` 也不报**。与 P0-2 组成完整丢数据链。
|
|
||||||
|
|
||||||
**P1-9 KVStoreEngine:事务进行中 reload → commit 静默成功但一行未写(事务数据静默丢失)** **[已复现]**
|
|
||||||
- 位置:`kvstore_engine.ts:148-157`(`reload()` 无 `txActive` 守卫,`memory.close()/open()` 清空内存态但 `txChanges` 仍在)、`memory.ts:43-45`(`close()` **不清 `snapshot`**,所以后续 `memory.commitTransaction()` 反而成功)、`kvstore_engine.ts:540-542`(残留的 `'put'` 项被丢弃,既不 put 也不 delete)。
|
|
||||||
- 现象:`beginTransaction(); insert('a'); reload(); commitTransaction()` → **不抛错**,count=0,数据消失。
|
|
||||||
- 可达路径:`Hybrid.reloadMemoryFromDisk()` 由 BroadcastChannel 外部变更事件触发(`src/core.ts:79-91`),**任何时刻**(含事务中途)都可能打断正在执行的事务。
|
|
||||||
|
|
||||||
**P1-10 `setMeta` 不受事务回滚影响** **[已复现]**
|
|
||||||
- 位置:`kvstore_engine.ts:181-183`(直写 `kv.put`,无 tx 记录)、`176-179`(读同一层)。
|
|
||||||
- 现象:`begin; setMeta('version','9'); rollback` → `getMeta('version') === '9'`。
|
|
||||||
- 影响:迁移版本号(core.ts:107-114 `__metona_version`)一旦在事务内推进就无法回滚 → 迁移重跑判定失效(数据未迁移但版本已升)。
|
|
||||||
|
|
||||||
**P1-11 `MemoryEngine.close()` 遗留事务快照 → 重开后永久 `TX_ACTIVE`** **[已复现]**
|
|
||||||
- 位置:`memory.ts:43-45`(只清 tables/schemas/indexes/metaStore/opened,不动 `this.snapshot`)。
|
|
||||||
- 现象:`beginTransaction(); close(); open();` → `beginTransaction()` 永远抛「Transaction already in progress」,`alterTable/createIndex/dropIndex` 永远抛 `NOT_SUPPORTED`(memory.ts:114/552/599)。
|
|
||||||
- 可达路径:`KVStoreEngine.close()` 虽会先 rollback,但 `KVStoreEngine.reload()`/`repair()` 直接 `memory.close()`(kvstore_engine.ts:153、163)——这正是 P1-9 的成因之一。
|
|
||||||
|
|
||||||
**P1-12 介质层错误语义被折叠 → 不可恢复的「假空库」** **[读码确认]**
|
|
||||||
- 位置:`src/engine/aria/store/opfs_backend.ts:69-78`(`read` 把所有异常折叠为 `null`:文件不存在与 I/O 错误无法区分)、`opfs_backend.ts:84-113`(`dbDir===null`(已 close)时 `write/append` **静默 return**,调用方以为写成功)。
|
|
||||||
- 链路:`kvstore/index.ts:98-111`(`read` 返回 null ⇒ 认为「无快照」)→ `index.ts:119-139`(无日志 ⇒ 空索引)→ 下一次 `checkpoint()`(index.ts:272-278)把空索引写成新快照并截断日志 ⇒ 介质上原有数据被永久覆盖。同理 close 之后的写入在 KVStore 内存索引里「成功」,重启即丢。
|
|
||||||
- 建议:介质接口区分「不存在」与「读取失败」,写路径检查 `isOpen()` 并抛错。
|
|
||||||
|
|
||||||
### P2 — 健壮性 / 一致性
|
|
||||||
|
|
||||||
**P2-1 KVStoreEngine.open 失败后实例永久不可用,且真实错误被掩盖** **[已复现]**
|
|
||||||
- 位置:`kvstore_engine.ts:122`(`opened` 末尾才置位)、`75/79/80`(`kv.open`/`memory.open` 幂等早退)、`84-93`(catch 把 `createTable` 抛的「Table "a" already exists」重新包装成 `Corrupted schema in KVStore`)。
|
|
||||||
- 现象:schema 半损坏 → 第一次 open 抛 KV_SCHEMA_ERROR;**第二次 open 仍抛同一个错误**(因为上次已建好的表再次 `createTable` 失败),永远无法打开,除非换实例。错误信息指向「schema 损坏」,真实原因是「表已存在」,可诊断性差。
|
|
||||||
|
|
||||||
**P2-2 行键编码用冒号拼接,表名含 `':'` 时重启串表/丢行** **[已复现]**
|
|
||||||
- 位置:`kvstore_engine.ts:65-71`(`t:${table}:${pk}`)与 `kvstore_engine.ts:99-101`(按**第一个**冒号切成 table/pk)。
|
|
||||||
- 现象(实测两种):表 `a:b` 的行键 `t:a:b:1` 在重启时被解析成 table=`a`、pk=`b:1`;`a` 不存在则整行跳过(`count('a:b')` 1→0,数据蒸发);`a` 存在则**行被塞进表 `a`**(实测 `count('a')=1`、`rows(a)=[{"id":"1"}]`),即跨表数据污染。`collectTableDiff` 的 `startsWith(rowPrefix(table))` 前缀匹配(kvstore_engine.ts:638-661)同类风险(删表 `a` 会连带删掉 `a:1` 表的行)。
|
|
||||||
|
|
||||||
**P2-3 KVStore 的「假失败」还会留下重复 seq** **[读码确认 + 部分复现]**
|
|
||||||
- 位置:`kvstore/index.ts:357-362`(append 抛错即 `this.seq--`)。
|
|
||||||
- 若失败发生在「数据已写但 API 抛错」(OPFS `close()` 阶段报错、配额错误等),日志里已有 seq=N,而内存水位退回 N-1;下一次成功写入又是 seq=N → 日志出现两条同 seq 记录。恢复按顺序应用(后者覆盖前者),最终值不一定等于写成功的那一条;`repair()`/`findValidLogLength` 也不会检测 seq 单调性。
|
|
||||||
|
|
||||||
**P2-4 Hybrid:json/数组等嵌套值在两个引擎间共享引用,调用方改动会被持久化** **[已复现]**
|
|
||||||
- 位置:`hybrid/index.ts:75-83`(`diskEngine.find` 返回的**存储行引用**直接交给 `memoryEngine.insert`)、`memory.ts:691-711`(`validateRow` 只浅拷贝顶层,嵌套对象按引用传递)。
|
|
||||||
- 现象:`h.find(...)` 取出 `meta` 对象就地改 `flag`(不是一次 DB 写)→ 之后任意一次无关 `h.update(...,{v:2})` → 磁盘引擎把被污染的 `meta` 落盘,重启后读回 `MUTATED-BY-CALLER`。
|
|
||||||
|
|
||||||
**P2-5 事务快照是浅拷贝,嵌套值不在回滚范围内** **[读码确认]**
|
|
||||||
- 位置:`memory.ts:661`(`{...iv}`)、`memory.ts:638`(`new Map(this.schemas)`,schema/列定义对象共享)。
|
|
||||||
- 影响:与 P1-2/P2-4 组合——事务内通过读结果就地修改嵌套对象,`rollbackTransaction` 无法还原;`schemas` 的共享对象也是 v0.7.2 必须「事务内禁止 DDL」的根因(memory.ts:111-119 注释自陈)。
|
|
||||||
|
|
||||||
**P2-6 Hybrid 双引擎分叉无任何校验/补偿** **[读码确认]**
|
|
||||||
- 位置:`hybrid/index.ts:232-241`、`243-252`(只返回内存引擎的 count,忽略磁盘引擎的 count)、`321-324`(rollback 内存先、磁盘后,内存抛错则磁盘不回滚)。
|
|
||||||
- 影响:一旦两侧状态因任何原因(P1-7、失败补偿、reload)分叉,后续每次读写都在扩大分叉,且没有任何一致性检查点可用(`repair()` 只对齐一侧:hybrid/index.ts:99-104 修磁盘后重载内存)。
|
|
||||||
|
|
||||||
**P2-7 SharedMemoryBackend 的 materialized 缓存跨实例失效** **[读码确认]**
|
|
||||||
- 位置:`shared_memory_medium.ts:49-67`(`read` 缓存拼接结果)、`74-83`(`append` 只失效**本实例**缓存)、`15`(chunks 存于模块级 registry)。
|
|
||||||
- 影响:A 实例读过 `__kv_log` 后,B 实例写入 → A 再 `read` 仍拿到旧缓冲;只有 `open()` 会重置缓存(`shared_memory_medium.ts:36`)。KVStore 的 `reload()` 恰好会 `medium.open()` 所以侥幸绕过,但 `repair()`、`listKeys` 类直接介质调用会有陈旧视图。仅测试/Node 介质,故 P2。
|
|
||||||
|
|
||||||
**P2-8 KVStoreEngine 非事务路径与介质层都缺少事务/生命周期守卫** **[读码确认]**
|
|
||||||
- `kvstore_engine.ts:170-174`(`clearAll` 在事务中直接 `kv.clear()` + `memory.clearAll()`,snapshot 仍在,rollback 会「复活」旧结构)、`kvstore_engine.ts:176-183`(`getMeta/setMeta` 不加 `ensureOpen`,未 open 也能写)。KVStore 侧 `put/delete/writeBatch` 不检查 `opened`(index.ts:202-254),close 后仍更新内存索引。
|
|
||||||
|
|
||||||
**P2-9 重启后 `CREATE UNIQUE INDEX` 来源标记丢失 → 无法再 DROP 该索引** **[已复现]**
|
|
||||||
- 位置:`memory.ts:26`(`uniqueIndexCols` 纯内存)、`kvstore_engine.ts:84-93`(open 只回灌 schema,不回灌来源标记)、`memory.ts:617-623`。
|
|
||||||
- 现象:同一次生命周期内 `createIndex('t','email',true)` → `dropIndex` 可用;重启后同一个索引 `dropIndex` 抛 `NOT_SUPPORTED`。CHANGELOG v0.7.4 已把「重启后一律按建表约束保护」写成显式取舍,但这是一处「重启前后行为不同」的功能回退(P3 级体验,P2 级一致性)。
|
|
||||||
|
|
||||||
### P3 — 契约/可维护性(仅列有实际语义影响的)
|
|
||||||
|
|
||||||
- **P3-1 `findStream` 忽略 `orderBy`**:`memory.ts:213-233` 从不读 `query.orderBy`,且 `limit/offset` 在**未排序**的迭代序上生效;接口文档却承诺「有 orderBy 时实现可回退物化」(`interface.ts:46-47`)。当前 SQL 侧不可达(`core.ts:307-309` 把 ORDER BY 判为不可流式),但 `table().stream()` 之外任何未来调用方都会静默拿到错误结果。
|
|
||||||
- **P3-2 KVStoreEngine.open 的「重建二级索引」是死代码**(`kvstore_engine.ts:111-120`):`createTable` 已建桶,`createIndex` 见 `colDef.index/unique` 立即 return(`memory.ts:562`),该循环从未起作用——但注释给人以「索引有独立重建步骤」的错误印象(真实重建靠 `createTable` + 回灌 insert)。
|
|
||||||
- **P3-3 `__kv_meta` 只写不读**(`index.ts:275-276` 写、`95-118` 恢复时明确忽略),是纯冗余状态;`logBytes` 记账同样与实际日志长度脱节(`index.ts:132-134`、`382`、`398`:截断失败也置 0)。
|
|
||||||
- **P3-4 CREATE INDEX 幂等静默 / DROP 不存在报错不对称**(`memory.ts:562` vs `memory.ts:610-612`);SQLite 语义下重复 CREATE INDEX 应报错。
|
|
||||||
- **P3-5 delete 返回「含级联」的行数**(`memory.ts:485`、`kvstore_engine.ts:416`),与 SQL「affected rows 仅指目标表」不同;Aria 测试已把级联计数钉死(`tests/aria-cascade.test.ts:26,44`),属四引擎共同语义债。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 性能议题
|
|
||||||
|
|
||||||
1. **主键变更 = 整表重写(写放大 ~1.8 万倍)** **[已实测]**:`kvstore_engine.ts:347-353` 走 `affectedTables` 传递闭包 + `collectTableDiff` 全表 put。2 万行表改 1 行主键 → **单条日志记录 1,877,821 字节**;同一表普通单行更新 103 字节。风险不止带宽:`encodeLogRecord`(`log.ts:47-92`)会一次性物化整条记录的 `Uint8Array`(含逐值 `new Uint8Array(e.value)` 拷贝),大表事务/级联删除会在内存里再造一份全表;10 万行级库(`tests/engine/kvstore-stress.test.ts` 只做 insert/update/delete,未覆盖主键变更与级联)可能直接 OOM 或产生数十 MB 单记录。
|
|
||||||
2. **每次 UPDATE/DELETE 都是多趟全表扫描**:`kvstore_engine.ts:316`(`collectMatchingPks` → 一次 `find`)→ `memory.update` 的阶段 1 + 阶段 2(`memory.ts:267`、`289`)→ 落盘前再扫一遍(`kvstore_engine.ts:359`)。Hybrid 下每趟再 ×2(内存引擎 + 磁盘引擎各跑一次)。有索引时 `collectMatchingPks` 那趟能走索引,其余趟仍是全表。
|
|
||||||
3. **`count()` 永不使用索引**(`memory.ts:531-538`):`COUNT(*) WHERE indexed_col = ?` 全表扫描,而同样的 `find` 可以 O(1)。
|
|
||||||
4. **`find` 无 where 时 `Array.from(table.values())` 全量物化**(`memory.ts:729/771`),即使 `limit:1`;`tryIndexLookup` 命中索引时也会新建结果数组(`memory.ts:764`)。10 万行库上「取第一行」是 O(n) 分配。
|
|
||||||
5. **级联路径是 O(n·m) 双倍扫描**:`delete` 先 `checkCascadeRestrict` 对每个待删行扫描每一张引用表(`memory.ts:505-508`),执行阶段 `cascadeDelete` 再扫一遍(`memory.ts:834-839`);`applyUpdateCascade`/`checkUpdateRestrict` 同样各扫一遍全表(`memory.ts:400-410`、`440-445`)。且级联按列嵌套循环(表 × 列),没有基于索引的定位。
|
|
||||||
6. **`affectedTables` 是 O(T²·C) 的定点迭代**(`kvstore_engine.ts:599-621`),且每张表都 `await getTableSchema`(每次一次 Promise 往返);它把「所有可能受影响的表」整表 diff,而实际只有极少数行会变。
|
|
||||||
7. **每行一次 async/await + 序列化**:`KVStoreEngine.open` 恢复循环每行一次 `await memory.hasTable` + `await memory.insert`(`kvstore_engine.ts:97-109`),10 万行 = 20 万次微任务 + 每行 `JSON.parse`;写路径每行 `JSON.stringify` + `TextEncoder.encode`(`kvstore_engine.ts:286/363/534/650`)。
|
|
||||||
8. **checkpoint 是全量 O(库大小) 且阻塞所有写**:`encodeSnapshot`(`snapshot.ts:29-59`)逐值复制整库;它跑在 `opQueue` 里(`index.ts:327-331`),期间任何 `put` 都要排队等待;`close()`/`commit` 前后的大库 checkpoint 直接体现为写延迟尖刺。自动阈值 16MB(`index.ts:37`)意味着单次 checkpoint 可能处理数万行。
|
|
||||||
9. **大表 diff 的单记录上限风险**:`writeBatch` 把所有 put/delete 编进**一条**记录(`index.ts:237-242`)。行数越多,单个 append 越大,OPFS 的 COW 写需要整文件复制语义(`opfs_backend.ts:102-113`),峰值内存 ≈ 原日志 + 新记录;没有分片/批量上限保护。
|
|
||||||
10. **测试对 10 万级只做了「写入 + 抽样读」**(`tests/engine/kvstore-stress.test.ts:23-61` 采样 4 个 key;第 2 例只校验 1001 个值)——没有覆盖主键变更、级联、事务回滚、自动 checkpoint 失败等重路径,性能悬崖(上述 1/8)都在覆盖面之外。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 系统性观察:四份「关系语义」实现的漂移与收敛点
|
|
||||||
|
|
||||||
**已发生的行为漂移(可举证的重复实现代价)**
|
|
||||||
1. **校验规则两份**:`memory.ts:691-722` 的私有 `validateRow/checkType` 与 `src/table/schema.ts:87-202` 的共享版并存,前者漏掉 `maxLength/min/max` —— 漂移已经真实发生(P1-1),且 README 的宣称只对 Aria 成立。
|
|
||||||
2. **主键解析三份**:`memory.ts:686-689`、`table/schema.ts:66-71`、`kvstore_engine.ts:579-584` 三处同样的「找 primaryKey,否则第一列」。
|
|
||||||
3. **「受影响表」三种定义**:MemoryEngine 的级联只认「列 references 的第一段 === 被删/改表」(`memory.ts:497-503`、`433-436`,自引用被跳过、ON UPDATE 不传递)↔ KVStoreEngine 的 `affectedTables` 做**传递闭包**(`kvstore_engine.ts:599-621`)↔ Aria 自有实现。结果:同一句 `DELETE`/`UPDATE`,内存里的级联范围与磁盘重写范围不同(P1-4 里磁盘重写了孙表、但内容依旧错误)。
|
|
||||||
4. **事务内 DDL 三种语义**:Memory/KVStore 拒绝 ALTER/CREATE INDEX/DROP INDEX 但允许 create/dropTable(`memory.ts:114/552/599`、`kvstore_engine.ts:236/442/459`),Aria 拒绝 ALTER;而 KVStore 的 createTable 在事务里可回滚、schema 却在 commit 的原子批之外(P1-6)。
|
|
||||||
5. **唯一索引三处状态机**:`colDef.unique`(schema 标志)+ `indexes` 桶 + `uniqueIndexCols`(来源)+ 重启后的「一律视为建表约束」(P0-2、P2-9)。任一环缺失就出现「约束静默失效」或「重启后不可 DROP」。
|
|
||||||
6. **索引一致性靠人工纪律**:`removeIndexEntries/updateIndexes` 的调用点分散在 update 阶段 2、cascadeDelete、applyUpdateCascade、clear、alterTable DROP、dropIndex 共 8 处(`memory.ts:135/179/291/298/442/444/481/544/628/791/855/868`),CHANGELOG 里 v0.3.3/v0.6.3/v0.7.3 连续三个版本都在修「某条路径忘了清/建索引」——这是典型的、还会继续发生的缺陷类别。
|
|
||||||
7. **行/键编解码两份**:Memory 用 `String(pk)`,KVStoreEngine 用 `t:${table}:${pk}` 且按第一个冒号解析(P2-2),Aria 另有布局;`String()` 归一也意味着 number PK 与 string PK 在键空间理论上可碰撞(当前被 `checkType` 挡住)。
|
|
||||||
|
|
||||||
**收敛建议(按性价比排序)**
|
|
||||||
1. **统一校验**:删除 `MemoryEngine.validateRow/checkType`,改用 `table/schema.ts` 的 `validateRow`(已含 default/required/PK 非空/maxLength/min/max),一处修复即同时修复 Memory/KVStore/Hybrid(P1-1),并让四引擎的 `VALIDATION_ERROR` 文案一致。
|
|
||||||
2. **抽出共享「外键规则引擎」**:输入(schemas + 变更集合 + 方向),输出(RESTRICT 违规 / 需 CASCADE 的行 / 需 SET NULL 的行),**递归 + 自引用 + visited 都在同一处实现**,供 Memory 与 Aria 直接调用,供 KVStoreEngine 生成「受影响主键集合」而不是「整表 diff」。这能一次性消灭 P1-3/P1-4、第 3 节的写放大与 O(n·m) 扫描,并让四个引擎的 delete/update 计数语义统一。
|
|
||||||
3. **让 MemoryEngine 自己产出变更日志(change hook)**:insert/update/delete 时回调「(table, pk, 'put'|'delete')」,KVStoreEngine 只负责把这份日志刷成日志记录。这样 `txChanges/txFullTables/txClearedTables/collectMatchingPks/collectTableDiff/affectedTables` 六个启发式结构(以及它们导致的 P1-6/P1-7/P1-9)可以整体删除,「持久化 = 内存变更的重放」成为结构性保证而非逐路径补丁。
|
|
||||||
4. **统一行键编解码**:零依赖的长度前缀或转义编码(或干脆把 table 名放进独立键段),消灭 P2-2 一类「拼接分隔符」缺陷;`String(pk)` 归一集中一处并显式声明 PK 的键空间规则。
|
|
||||||
5. **把 schema 纳入同一条原子记录**:commit 时把 `__schema` 与行变更放进同一 `writeBatch`(或在快照内统一版本化),P1-6 的孤儿数据与「有表无行」窗口即消失。
|
|
||||||
6. **内存引擎补写锁/事务互斥**:至少让写路径在 `snapshot !== null` 时拒绝或加入事务(`txActive` 显式化),并在 `close()` 中清空快照;这消除 P1-5、P1-9、P1-11 一类「状态泄漏 → 静默丢数据」问题。
|
|
||||||
7. **KVStore 恢复路径的原子化契约**:open 截断必须落盘等价状态(先快照或写回有效前缀),写失败不得在内存/日志之间产生「半提交」,介质接口区分「不存在/读失败/已关闭」。这是 P0-3、P0-4、P2-3、P2-6 的共同根因——**恢复路径缺少「内存状态 ↔ 介质状态」的等价性证明**,目前依赖调用方(KVStoreEngine.close)恰好会 checkpoint。
|
|
||||||
|
|
||||||
**测试覆盖的结构性缺口**(直接影响上述风险的可回归性):现有用例把「结果」钉死了,但缺**第二次崩溃**(`kvstore.test.ts:176-210` 只重启一次)、缺**注入 checkpoint 失败**、缺 **reload/close 期间的事务**、缺 **ALTER 后的约束行为**、缺 **自引用/多层外键**、缺 **10 万级的主键变更与级联**。这七类恰是本次 P0/P1 的高发区。
|
|
||||||
+6
-6
@@ -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
|
> 完成的一次系统性迭代。**这一版的重点不是"再修一批 bug",而是砍掉让同类 bug
|
||||||
> 必然复发的结构**:多处并存的语义实现被收敛为唯一实现,并第一次让崩溃语义、
|
> 必然复发的结构**:多处并存的语义实现被收敛为唯一实现,并第一次让崩溃语义、
|
||||||
> 错误码一致性、入口等价性变成可机器验证的门禁。
|
> 错误码一致性、入口等价性变成可机器验证的门禁。
|
||||||
@@ -46,7 +46,7 @@ All notable changes to MetonaSqlark will be documented in this file.
|
|||||||
并统一分隔标识符语义:引号只在"解析→执行"边界脱去。
|
并统一分隔标识符语义:引号只在"解析→执行"边界脱去。
|
||||||
顺带补上 **ORDER BY 的列存在性/歧义校验**(此前 JOIN 里裸写两表同名列既不报错
|
顺带补上 **ORDER BY 的列存在性/歧义校验**(此前 JOIN 里裸写两表同名列既不报错
|
||||||
也不确定按哪列排)。
|
也不确定按哪列排)。
|
||||||
- **B-6 存储提交点(完整实施,非降级方案)** — 见 `PLAN-v0.7.5.md` 附录 H。
|
- **B-6 存储提交点(完整实施,非降级方案)** — 交付物与提交顺序见下方各条。
|
||||||
- **`__aria_manifest_<generation>` 单一提交点**:页面水位 + 各命名空间 SSTable
|
- **`__aria_manifest_<generation>` 单一提交点**:页面水位 + 各命名空间 SSTable
|
||||||
元数据 + 表结构 + WAL 起始位置 + 待落盘冻结表意图,一次原子提交
|
元数据 + 表结构 + WAL 起始位置 + 待落盘冻结表意图,一次原子提交
|
||||||
(头部/载荷双 CRC、先写后验、保留两代)。顺序固定为
|
(头部/载荷双 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`、
|
`ARIA_SSTABLE_SAVE_CONTRACT`、`ARIA_DB_NOT_OPEN`、`ARIA_OPEN_ERROR`、
|
||||||
`DB_NOT_OPEN`、`KV_*`、`TX_*`、`SAVEPOINT_*`、`UNKNOWN_STATEMENT`、
|
`DB_NOT_OPEN`、`KV_*`、`TX_*`、`SAVEPOINT_*`、`UNKNOWN_STATEMENT`、
|
||||||
`COLUMN_EXISTS`、`FOREIGN_KEY_VIOLATION`)→ 全部补齐。
|
`COLUMN_EXISTS`、`FOREIGN_KEY_VIOLATION`)→ 全部补齐。
|
||||||
- **维护脚本与门禁**:CONTRIBUTING 的变异数量 17 → 40,并补上脚本自身的保证
|
- **维护脚本与门禁**:CONTRIBUTING 的变异数量 17 → 42,并补上脚本自身的保证
|
||||||
(正控 / 编译失败区分 / 超时 / 逐字节恢复校验 / 进程锁);PLAN 附录 G 的
|
(正控 / 编译失败区分 / 超时 / 逐字节恢复校验 / 进程锁);门禁表述订正三处 ——
|
||||||
"12 项事故级"改为 11 项(与总账 A1~A11 一致)、G4 的"零丢失"限定为"故障注入
|
"12 项事故级"改为 11 项(与缺陷总账 A1~A11 一致)、"已确认写入零丢失"限定为
|
||||||
矩阵覆盖范围内"(并记录矩阵之外发现的那处 P0)、H.1 的"25 例"改为 24 例。
|
"故障注入矩阵覆盖范围内"(并记录矩阵之外发现的那处 P0)、表驱动用例数 25 → 24。
|
||||||
|
|
||||||
### 已知限制(v0.8.0 新增/变更)
|
### 已知限制(v0.8.0 新增/变更)
|
||||||
|
|
||||||
|
|||||||
-1055
File diff suppressed because it is too large
Load Diff
Vendored
+7
-7
@@ -53,7 +53,7 @@ const VERSION = '0.8.0';
|
|||||||
* @module engine/change-notifier
|
* @module engine/change-notifier
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 为什么需要它(PLAN-v0.7.5.md A9)
|
* 为什么需要它(v0.8.0 审计 A9)
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* `db.subscribe(table, fn)` 此前对**本地写入永不触发**:全库只有一处调用 `emit`,
|
* `db.subscribe(table, fn)` 此前对**本地写入永不触发**:全库只有一处调用 `emit`,
|
||||||
* 而那一处在 BroadcastChannel 收到**其它标签页**消息时才执行。于是:
|
* 而那一处在 BroadcastChannel 收到**其它标签页**消息时才执行。于是:
|
||||||
@@ -402,7 +402,7 @@ function cloneRowFallback(value) {
|
|||||||
* @module query/sql-compare
|
* @module query/sql-compare
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 为什么需要它(PLAN-v0.7.5.md 根因 2 / PB-2)
|
* 为什么需要它(v0.8.0 审计根因 2 / PB-2)
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 此前项目里并存**五套**值相等语义:
|
* 此前项目里并存**五套**值相等语义:
|
||||||
* 1. `where-matcher` 的 `===`
|
* 1. `where-matcher` 的 `===`
|
||||||
@@ -641,7 +641,7 @@ function sqlIn(value, list) {
|
|||||||
* @module query/where-matcher
|
* @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`);
|
* - `matchWhere` —— 布尔版(自己的 `$and/$or/$not` 分支 + `matchField`);
|
||||||
@@ -1078,7 +1078,7 @@ function unquoteIdentifier$1(text) {
|
|||||||
* @module table/validation
|
* @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()`
|
* - 失败被记录为 `lastBackgroundError`,由**下一次** `checkpoint()`
|
||||||
* 显式报告(那是用户主动要求把数据压实到快照的时机,此时失败是真实问题);
|
* 显式报告(那是用户主动要求把数据压实到快照的时机,此时失败是真实问题);
|
||||||
@@ -15096,7 +15096,7 @@ function parseWhereCondition(sql) {
|
|||||||
* @module query/column-value
|
* @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
|
* @module query/expression
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 为什么必须换掉原实现(PLAN-v0.7.5.md 根因 3:字符串化 AST 表达式列)
|
* 为什么必须换掉原实现(v0.8.0 审计根因 3:字符串化 AST 表达式列)
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 原 `parseCaseExpression` 用**正则**在原始 SQL 文本上切分 WHEN/THEN/ELSE:
|
* 原 `parseCaseExpression` 用**正则**在原始 SQL 文本上切分 WHEN/THEN/ELSE:
|
||||||
*
|
*
|
||||||
|
|||||||
Vendored
+7
-7
@@ -49,7 +49,7 @@ const VERSION = '0.8.0';
|
|||||||
* @module engine/change-notifier
|
* @module engine/change-notifier
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 为什么需要它(PLAN-v0.7.5.md A9)
|
* 为什么需要它(v0.8.0 审计 A9)
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* `db.subscribe(table, fn)` 此前对**本地写入永不触发**:全库只有一处调用 `emit`,
|
* `db.subscribe(table, fn)` 此前对**本地写入永不触发**:全库只有一处调用 `emit`,
|
||||||
* 而那一处在 BroadcastChannel 收到**其它标签页**消息时才执行。于是:
|
* 而那一处在 BroadcastChannel 收到**其它标签页**消息时才执行。于是:
|
||||||
@@ -398,7 +398,7 @@ function cloneRowFallback(value) {
|
|||||||
* @module query/sql-compare
|
* @module query/sql-compare
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 为什么需要它(PLAN-v0.7.5.md 根因 2 / PB-2)
|
* 为什么需要它(v0.8.0 审计根因 2 / PB-2)
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 此前项目里并存**五套**值相等语义:
|
* 此前项目里并存**五套**值相等语义:
|
||||||
* 1. `where-matcher` 的 `===`
|
* 1. `where-matcher` 的 `===`
|
||||||
@@ -637,7 +637,7 @@ function sqlIn(value, list) {
|
|||||||
* @module query/where-matcher
|
* @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`);
|
* - `matchWhere` —— 布尔版(自己的 `$and/$or/$not` 分支 + `matchField`);
|
||||||
@@ -1074,7 +1074,7 @@ function unquoteIdentifier$1(text) {
|
|||||||
* @module table/validation
|
* @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()`
|
* - 失败被记录为 `lastBackgroundError`,由**下一次** `checkpoint()`
|
||||||
* 显式报告(那是用户主动要求把数据压实到快照的时机,此时失败是真实问题);
|
* 显式报告(那是用户主动要求把数据压实到快照的时机,此时失败是真实问题);
|
||||||
@@ -15092,7 +15092,7 @@ function parseWhereCondition(sql) {
|
|||||||
* @module query/column-value
|
* @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
|
* @module query/expression
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 为什么必须换掉原实现(PLAN-v0.7.5.md 根因 3:字符串化 AST 表达式列)
|
* 为什么必须换掉原实现(v0.8.0 审计根因 3:字符串化 AST 表达式列)
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 原 `parseCaseExpression` 用**正则**在原始 SQL 文本上切分 WHEN/THEN/ELSE:
|
* 原 `parseCaseExpression` 用**正则**在原始 SQL 文本上切分 WHEN/THEN/ELSE:
|
||||||
*
|
*
|
||||||
|
|||||||
Vendored
+7
-7
@@ -55,7 +55,7 @@
|
|||||||
* @module engine/change-notifier
|
* @module engine/change-notifier
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 为什么需要它(PLAN-v0.7.5.md A9)
|
* 为什么需要它(v0.8.0 审计 A9)
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* `db.subscribe(table, fn)` 此前对**本地写入永不触发**:全库只有一处调用 `emit`,
|
* `db.subscribe(table, fn)` 此前对**本地写入永不触发**:全库只有一处调用 `emit`,
|
||||||
* 而那一处在 BroadcastChannel 收到**其它标签页**消息时才执行。于是:
|
* 而那一处在 BroadcastChannel 收到**其它标签页**消息时才执行。于是:
|
||||||
@@ -404,7 +404,7 @@
|
|||||||
* @module query/sql-compare
|
* @module query/sql-compare
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 为什么需要它(PLAN-v0.7.5.md 根因 2 / PB-2)
|
* 为什么需要它(v0.8.0 审计根因 2 / PB-2)
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 此前项目里并存**五套**值相等语义:
|
* 此前项目里并存**五套**值相等语义:
|
||||||
* 1. `where-matcher` 的 `===`
|
* 1. `where-matcher` 的 `===`
|
||||||
@@ -643,7 +643,7 @@
|
|||||||
* @module query/where-matcher
|
* @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`);
|
* - `matchWhere` —— 布尔版(自己的 `$and/$or/$not` 分支 + `matchField`);
|
||||||
@@ -1080,7 +1080,7 @@
|
|||||||
* @module table/validation
|
* @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()`
|
* - 失败被记录为 `lastBackgroundError`,由**下一次** `checkpoint()`
|
||||||
* 显式报告(那是用户主动要求把数据压实到快照的时机,此时失败是真实问题);
|
* 显式报告(那是用户主动要求把数据压实到快照的时机,此时失败是真实问题);
|
||||||
@@ -15098,7 +15098,7 @@
|
|||||||
* @module query/column-value
|
* @module query/column-value
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 为什么必须只有一个实现(PLAN-v0.7.5.md 根因 1)
|
* 为什么必须只有一个实现(v0.8.0 审计根因 1)
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* "从一行里按名字取一列"曾是四处各写一份的实现,规则各不相同:
|
* "从一行里按名字取一列"曾是四处各写一份的实现,规则各不相同:
|
||||||
*
|
*
|
||||||
@@ -15169,7 +15169,7 @@
|
|||||||
* @module query/expression
|
* @module query/expression
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 为什么必须换掉原实现(PLAN-v0.7.5.md 根因 3:字符串化 AST 表达式列)
|
* 为什么必须换掉原实现(v0.8.0 审计根因 3:字符串化 AST 表达式列)
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 原 `parseCaseExpression` 用**正则**在原始 SQL 文本上切分 WHEN/THEN/ELSE:
|
* 原 `parseCaseExpression` 用**正则**在原始 SQL 文本上切分 WHEN/THEN/ELSE:
|
||||||
*
|
*
|
||||||
|
|||||||
Vendored
+7
-7
@@ -47,7 +47,7 @@ class DatabaseError extends Error {
|
|||||||
* @module engine/change-notifier
|
* @module engine/change-notifier
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 为什么需要它(PLAN-v0.7.5.md A9)
|
* 为什么需要它(v0.8.0 审计 A9)
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* `db.subscribe(table, fn)` 此前对**本地写入永不触发**:全库只有一处调用 `emit`,
|
* `db.subscribe(table, fn)` 此前对**本地写入永不触发**:全库只有一处调用 `emit`,
|
||||||
* 而那一处在 BroadcastChannel 收到**其它标签页**消息时才执行。于是:
|
* 而那一处在 BroadcastChannel 收到**其它标签页**消息时才执行。于是:
|
||||||
@@ -396,7 +396,7 @@ function cloneRowFallback(value) {
|
|||||||
* @module query/sql-compare
|
* @module query/sql-compare
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 为什么需要它(PLAN-v0.7.5.md 根因 2 / PB-2)
|
* 为什么需要它(v0.8.0 审计根因 2 / PB-2)
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 此前项目里并存**五套**值相等语义:
|
* 此前项目里并存**五套**值相等语义:
|
||||||
* 1. `where-matcher` 的 `===`
|
* 1. `where-matcher` 的 `===`
|
||||||
@@ -635,7 +635,7 @@ function sqlIn(value, list) {
|
|||||||
* @module query/where-matcher
|
* @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`);
|
* - `matchWhere` —— 布尔版(自己的 `$and/$or/$not` 分支 + `matchField`);
|
||||||
@@ -1072,7 +1072,7 @@ function unquoteIdentifier$1(text) {
|
|||||||
* @module table/validation
|
* @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()`
|
* - 失败被记录为 `lastBackgroundError`,由**下一次** `checkpoint()`
|
||||||
* 显式报告(那是用户主动要求把数据压实到快照的时机,此时失败是真实问题);
|
* 显式报告(那是用户主动要求把数据压实到快照的时机,此时失败是真实问题);
|
||||||
@@ -15081,7 +15081,7 @@ function parseWhereCondition(sql) {
|
|||||||
* @module query/column-value
|
* @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
|
* @module query/expression
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 为什么必须换掉原实现(PLAN-v0.7.5.md 根因 3:字符串化 AST 表达式列)
|
* 为什么必须换掉原实现(v0.8.0 审计根因 3:字符串化 AST 表达式列)
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 原 `parseCaseExpression` 用**正则**在原始 SQL 文本上切分 WHEN/THEN/ELSE:
|
* 原 `parseCaseExpression` 用**正则**在原始 SQL 文本上切分 WHEN/THEN/ELSE:
|
||||||
*
|
*
|
||||||
|
|||||||
Vendored
+7
-7
@@ -47,7 +47,7 @@ class DatabaseError extends Error {
|
|||||||
* @module engine/change-notifier
|
* @module engine/change-notifier
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 为什么需要它(PLAN-v0.7.5.md A9)
|
* 为什么需要它(v0.8.0 审计 A9)
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* `db.subscribe(table, fn)` 此前对**本地写入永不触发**:全库只有一处调用 `emit`,
|
* `db.subscribe(table, fn)` 此前对**本地写入永不触发**:全库只有一处调用 `emit`,
|
||||||
* 而那一处在 BroadcastChannel 收到**其它标签页**消息时才执行。于是:
|
* 而那一处在 BroadcastChannel 收到**其它标签页**消息时才执行。于是:
|
||||||
@@ -396,7 +396,7 @@ function cloneRowFallback(value) {
|
|||||||
* @module query/sql-compare
|
* @module query/sql-compare
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 为什么需要它(PLAN-v0.7.5.md 根因 2 / PB-2)
|
* 为什么需要它(v0.8.0 审计根因 2 / PB-2)
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 此前项目里并存**五套**值相等语义:
|
* 此前项目里并存**五套**值相等语义:
|
||||||
* 1. `where-matcher` 的 `===`
|
* 1. `where-matcher` 的 `===`
|
||||||
@@ -635,7 +635,7 @@ function sqlIn(value, list) {
|
|||||||
* @module query/where-matcher
|
* @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`);
|
* - `matchWhere` —— 布尔版(自己的 `$and/$or/$not` 分支 + `matchField`);
|
||||||
@@ -1072,7 +1072,7 @@ function unquoteIdentifier$1(text) {
|
|||||||
* @module table/validation
|
* @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()`
|
* - 失败被记录为 `lastBackgroundError`,由**下一次** `checkpoint()`
|
||||||
* 显式报告(那是用户主动要求把数据压实到快照的时机,此时失败是真实问题);
|
* 显式报告(那是用户主动要求把数据压实到快照的时机,此时失败是真实问题);
|
||||||
@@ -15081,7 +15081,7 @@ function parseWhereCondition(sql) {
|
|||||||
* @module query/column-value
|
* @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
|
* @module query/expression
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 为什么必须换掉原实现(PLAN-v0.7.5.md 根因 3:字符串化 AST 表达式列)
|
* 为什么必须换掉原实现(v0.8.0 审计根因 3:字符串化 AST 表达式列)
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 原 `parseCaseExpression` 用**正则**在原始 SQL 文本上切分 WHEN/THEN/ELSE:
|
* 原 `parseCaseExpression` 用**正则**在原始 SQL 文本上切分 WHEN/THEN/ELSE:
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* @module engine/change-notifier
|
* @module engine/change-notifier
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 为什么需要它(PLAN-v0.7.5.md A9)
|
* 为什么需要它(v0.8.0 审计 A9)
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* `db.subscribe(table, fn)` 此前对**本地写入永不触发**:全库只有一处调用 `emit`,
|
* `db.subscribe(table, fn)` 此前对**本地写入永不触发**:全库只有一处调用 `emit`,
|
||||||
* 而那一处在 BroadcastChannel 收到**其它标签页**消息时才执行。于是:
|
* 而那一处在 BroadcastChannel 收到**其它标签页**消息时才执行。于是:
|
||||||
|
|||||||
@@ -447,7 +447,7 @@ export class KVStore {
|
|||||||
* 实际数据已经落盘 —— 报错与事实相反,属于最有害的一类不一致
|
* 实际数据已经落盘 —— 报错与事实相反,属于最有害的一类不一致
|
||||||
* (调用方据此重试会写入两次,或据"失败"丢弃业务状态)。
|
* (调用方据此重试会写入两次,或据"失败"丢弃业务状态)。
|
||||||
*
|
*
|
||||||
* 现在的语义(与 PLAN-v0.7.5.md 的 B-6 止血方案一致):
|
* 现在的语义(与 B-6 止血方案一致):
|
||||||
* - 已确认的写入**不得**因为后台失败而报错;
|
* - 已确认的写入**不得**因为后台失败而报错;
|
||||||
* - 失败被记录为 `lastBackgroundError`,由**下一次** `checkpoint()`
|
* - 失败被记录为 `lastBackgroundError`,由**下一次** `checkpoint()`
|
||||||
* 显式报告(那是用户主动要求把数据压实到快照的时机,此时失败是真实问题);
|
* 显式报告(那是用户主动要求把数据压实到快照的时机,此时失败是真实问题);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* @module query/column-value
|
* @module query/column-value
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 为什么必须只有一个实现(PLAN-v0.7.5.md 根因 1)
|
* 为什么必须只有一个实现(v0.8.0 审计根因 1)
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* "从一行里按名字取一列"曾是四处各写一份的实现,规则各不相同:
|
* "从一行里按名字取一列"曾是四处各写一份的实现,规则各不相同:
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* @module query/expression
|
* @module query/expression
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 为什么必须换掉原实现(PLAN-v0.7.5.md 根因 3:字符串化 AST 表达式列)
|
* 为什么必须换掉原实现(v0.8.0 审计根因 3:字符串化 AST 表达式列)
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 原 `parseCaseExpression` 用**正则**在原始 SQL 文本上切分 WHEN/THEN/ELSE:
|
* 原 `parseCaseExpression` 用**正则**在原始 SQL 文本上切分 WHEN/THEN/ELSE:
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* @module query/sql-compare
|
* @module query/sql-compare
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 为什么需要它(PLAN-v0.7.5.md 根因 2 / PB-2)
|
* 为什么需要它(v0.8.0 审计根因 2 / PB-2)
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 此前项目里并存**五套**值相等语义:
|
* 此前项目里并存**五套**值相等语义:
|
||||||
* 1. `where-matcher` 的 `===`
|
* 1. `where-matcher` 的 `===`
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* @module query/where-matcher
|
* @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`);
|
* - `matchWhere` —— 布尔版(自己的 `$and/$or/$not` 分支 + `matchField`);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* @module table/validation
|
* @module table/validation
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 为什么必须合并(PLAN-v0.7.5.md 根因 1:关系语义在引擎间重复实现)
|
* 为什么必须合并(v0.8.0 审计根因 1:关系语义在引擎间重复实现)
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 修复前项目里存在**三份**行校验实现,覆盖范围各不相同:
|
* 修复前项目里存在**三份**行校验实现,覆盖范围各不相同:
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ async function run<T>(page: Page, method: string, args: unknown = null): Promise
|
|||||||
*
|
*
|
||||||
* 修复前这里只是 `win.__ms = null; await page.close()` —— 那是**优雅关闭**:
|
* 修复前这里只是 `win.__ms = null; await page.close()` —— 那是**优雅关闭**:
|
||||||
* 没有未完成的 I/O、不经过任何崩溃窗口,因此"崩溃恢复"的结论实际上是在
|
* 没有未完成的 I/O、不经过任何崩溃窗口,因此"崩溃恢复"的结论实际上是在
|
||||||
* "正常关闭后重开"上得到的(PLAN-v0.7.5.md 根因 5:崩溃语义声称未被验证)。
|
* "正常关闭后重开"上得到的(v0.8.0 审计根因 5:崩溃语义声称未被验证)。
|
||||||
*
|
*
|
||||||
* 现在用 CDP `Page.crash` 直接终止渲染进程:进行中的 OPFS 写入、未 flush 的
|
* 现在用 CDP `Page.crash` 直接终止渲染进程:进行中的 OPFS 写入、未 flush 的
|
||||||
* WAL 缓冲、同步访问句柄全部**立即**消失,与浏览器/标签页被强杀一致。
|
* WAL 缓冲、同步访问句柄全部**立即**消失,与浏览器/标签页被强杀一致。
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
// ===================================================================
|
// ===================================================================
|
||||||
// v0.8.0: 删除本文件内的第三份 OPFS mock —— 改用共享的 storage-harness。
|
// v0.8.0: 删除本文件内的第三份 OPFS mock —— 改用共享的 storage-harness。
|
||||||
//
|
//
|
||||||
// 原实现的问题(见 PLAN-v0.7.5.md 工作流 C-1):
|
// 原实现的问题(见 v0.8.0 迭代工作流 C-1):
|
||||||
// - 与 tests/helpers/opfs-mock.ts 重复(两份都在测同一件事,语义各自漂移);
|
// - 与 tests/helpers/opfs-mock.ts 重复(两份都在测同一件事,语义各自漂移);
|
||||||
// - `close()` 是空函数、写入立即可见 → 无法表达"提交前崩溃";
|
// - `close()` 是空函数、写入立即可见 → 无法表达"提交前崩溃";
|
||||||
// - 定义了 `writeCalls` 记录但**从未被任何断言使用**(死代码);
|
// - 定义了 `writeCalls` 记录但**从未被任何断言使用**(死代码);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* 测试共享 — 故障注入后端包装器(v0.8.0 工作流 C-1)
|
* 测试共享 — 故障注入后端包装器(v0.8.0 工作流 C-1)
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 为什么需要它(PLAN-v0.7.5.md 根因 5)
|
* 为什么需要它(v0.8.0 审计根因 5)
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 项目所有"崩溃恢复"测试用的都是 `await engine.backend.close()`,而 OPFSBackend.close()
|
* 项目所有"崩溃恢复"测试用的都是 `await engine.backend.close()`,而 OPFSBackend.close()
|
||||||
* 会 `await this.writeQueue` 把在途写**全部刷完** —— 那是优雅停机,不是崩溃。
|
* 会 `await this.writeQueue` 把在途写**全部刷完** —— 那是优雅停机,不是崩溃。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* 测试共享 — OPFS 事务性文件存储(v0.8.0 根治版)
|
* 测试共享 — OPFS 事务性文件存储(v0.8.0 根治版)
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 为什么需要重写(审计结论,见 PLAN-v0.7.5.md 工作流 C-1)
|
* 为什么需要重写(审计结论,见 v0.8.0 迭代工作流 C-1)
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 旧 mock(tests/helpers/opfs-mock.ts)有三处与真实 OPFS 语义不符,会掩盖真实缺陷:
|
* 旧 mock(tests/helpers/opfs-mock.ts)有三处与真实 OPFS 语义不符,会掩盖真实缺陷:
|
||||||
* 1. `read()` 返回内部 ArrayBuffer **引用**(真实 OPFS 返回快照副本)
|
* 1. `read()` 返回内部 ArrayBuffer **引用**(真实 OPFS 返回快照副本)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* v0.8.0 强化说明:
|
* v0.8.0 强化说明:
|
||||||
* 1. 此前 12 处断言写着 `expect(ast.where).toBeDefined()` —— 既没有验证结构也没有验证
|
* 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 项)能长期存活的原因。
|
* 断言混合 AND/OR 的**结构**,这正是 AND/OR 无优先级缺陷(总账第 11 项)能长期存活的原因。
|
||||||
* 2. 本文件此前 `parse()` 的返回类型是联合类型 `Statement`,直接访问 `.where` / `.columns`
|
* 2. 本文件此前 `parse()` 的返回类型是联合类型 `Statement`,直接访问 `.where` / `.columns`
|
||||||
* 在类型上不成立(tests 从不做类型检查,所以没人发现)。现在用类型收窄辅助函数显式
|
* 在类型上不成立(tests 从不做类型检查,所以没人发现)。现在用类型收窄辅助函数显式
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* v0.8.0(B-6)回归套件 —— 存储层**单一提交点**(`__aria_manifest`)与 LSM 结构根治
|
* v0.8.0(B-6)回归套件 —— 存储层**单一提交点**(`__aria_manifest`)与 LSM 结构根治
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 本套件覆盖 PLAN-v0.7.5.md 工作流 B 的 B-6 全部条目。每一条都对应一个**实测过**
|
* 本套件覆盖 v0.8.0 迭代工作流 B 的 B-6 全部条目。每一条都对应一个**实测过**
|
||||||
* 的缺陷(或一条被写进 manifest 的持久不变量):
|
* 的缺陷(或一条被写进 manifest 的持久不变量):
|
||||||
*
|
*
|
||||||
* | 编号 | 缺陷 / 不变量 | 本文件对应用例 |
|
* | 编号 | 缺陷 / 不变量 | 本文件对应用例 |
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* v0.8.0 回归套件 —— B-6 存储提交点与崩溃语义(KVStore)
|
* v0.8.0 回归套件 —— B-6 存储提交点与崩溃语义(KVStore)
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 本套件覆盖 PLAN-v0.7.5.md §4「B-6」列出的 KVStore 三处止血,其中两处是
|
* 本套件覆盖 v0.8.0 迭代 §4「B-6」列出的 KVStore 三处止血,其中两处是
|
||||||
* **P0 级数据丢失**:
|
* **P0 级数据丢失**:
|
||||||
*
|
*
|
||||||
* 1. `open()` 遇到损坏日志尾部会**清空整个日志**(`truncateLog()` 写空文件)。
|
* 1. `open()` 遇到损坏日志尾部会**清空整个日志**(`truncateLog()` 写空文件)。
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* v0.8.0 回归套件 —— 查询层缺陷根治(A22/A23/A25/A26/A27/A29/A30/A36)
|
* v0.8.0 回归套件 —— 查询层缺陷根治(A22/A23/A25/A26/A27/A29/A30/A36)
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 本套件锁定 PLAN-v0.7.5.md §5 缺陷总账中查询层的 8 项修复。每一项都先给出
|
* 本套件锁定 v0.8.0 迭代 §5 缺陷总账中查询层的 8 项修复。每一项都先给出
|
||||||
* **修复前的实测错误输出**,再断言正确结果 —— 这样即使将来重构执行器,
|
* **修复前的实测错误输出**,再断言正确结果 —— 这样即使将来重构执行器,
|
||||||
* 失败的断言能直接告诉后来者"当初错在哪里"。
|
* 失败的断言能直接告诉后来者"当初错在哪里"。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* v0.8.0 回归套件:SQL 三值逻辑(NULL 语义)—— PB-2
|
* v0.8.0 回归套件:SQL 三值逻辑(NULL 语义)—— PB-2
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 本套件锁定 PLAN-v0.7.5.md 根因 7("未解析/UNKNOWN 静默变 false")与
|
* 本套件锁定 v0.8.0 审计根因 7("未解析/UNKNOWN 静默变 false")与
|
||||||
* 附录 §5 缺陷 A15 的修复契约,并对**四种存储引擎**逐一验证同一语义。
|
* 附录 §5 缺陷 A15 的修复契约,并对**四种存储引擎**逐一验证同一语义。
|
||||||
*
|
*
|
||||||
* 历史缺陷(本套件即为其反例):
|
* 历史缺陷(本套件即为其反例):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* v0.8.0 回归套件 —— B-1 统一行校验(工作流 B 第 1 项)
|
* v0.8.0 回归套件 —— B-1 统一行校验(工作流 B 第 1 项)
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* 背景(PLAN-v0.7.5.md 根因 1 + 缺陷 A12/A17):
|
* 背景(v0.8.0 审计根因 1 + 缺陷 A12/A17):
|
||||||
* 修复前存在**三份**行校验实现,覆盖面各不相同 —— Memory/KVStore/Hybrid 共用
|
* 修复前存在**三份**行校验实现,覆盖面各不相同 —— Memory/KVStore/Hybrid 共用
|
||||||
* 一份"只查类型"的实现(**没有** maxLength/min/max),Aria 走 checkFieldType
|
* 一份"只查类型"的实现(**没有** maxLength/min/max),Aria 走 checkFieldType
|
||||||
* (有约束),schema.ts 是第三份。于是:
|
* (有约束),schema.ts 是第三份。于是:
|
||||||
|
|||||||
Reference in New Issue
Block a user