docs: v0.7.4 全量深度审计 + v0.8.0 根治性迭代方案

- 全源码逐行通读(src 16079 行 / tests 20223 行 / 文档 4349 行),7 个子系统并行深度审计
- PLAN-v0.7.5.md:8 大根因分析 + 3 条并行工作流 + 55 项缺陷总账 + 6 道硬门禁
- AUDIT-aria-lsm / AUDIT-query-layer / AUDIT-storage-engines:逐条 file:line 证据
- 全部关键结论经可执行探针实测复现(探针已删除,仓库无残留)
This commit is contained in:
thzxx
2026-09-14 20:37:44 +08:00
parent 2844a0617c
commit c8b59bd16f
4 changed files with 1841 additions and 0 deletions
+243
View File
@@ -0,0 +1,243 @@
# MetonaSqlark v0.7.4 存储引擎审计报告
> 范围:`MemoryEngine` / `KVStore`(自研事务 KV/ `KVStoreEngine`KV 包装)/ `HybridEngine`write-through
> 方法:逐行精读 8 个源文件 + 10 个测试文件 + CHANGELOG(1-200) + README「存储引擎」;对可疑项编写临时 jest 用例注入故障复现(全部用例已删除,仓库无残留)。文中标注 **[已复现]** = 可执行实验证实;**[读码确认]** = 代码路径确定但未注入实验;**[疑似]** = 推理所得、未完全证实。
---
## 1. 架构概览
### 1.1 MemoryEngine`src/engine/memory.ts`
**数据结构(memory.ts:15-33**
- `tables: Map<table, Map<pkString, row>>` —— 行存储,PK 直接由 `String(validatedRow[pkColumn])` 得到(memory.ts:163/177),**没有独立的主键索引结构**:PK map 就是行存储本身。
- `indexes: Map<table, Map<col, Map<value, Set<pk>>>>` —— 二级哈希索引;只有 `colDef.index || colDef.unique` 的列在 `createTable` 时建桶(memory.ts:81-85)。索引只记录非 null 值(memory.ts:780、793)。
- `schemas: Map<table, TableSchema>`createTable 时深拷贝列定义,memory.ts:75-79,防止 Hybrid 双引擎共享 schema 对象)。
- `uniqueIndexCols: Set<"table:col">` —— 仅标记 `CREATE UNIQUE INDEX` 来源的 uniquememory.ts:26、593),供 `dropIndex` 区分「建表约束」与「索引来源约束」(memory.ts:613-623)。
- `metaStore``snapshot`(事务快照,memory.ts:29-33)。
**查找路径(memory.ts:192-210, 725-772**`find``tryIndexLookup`:递归展开顶层 `$and`memory.ts:734-745)收集等值条件 → 命中某列索引则用 `colIndex.get(value)` 取 pk 集合回表(memory.ts:763-767);**索引未命中直接 `return []`memory.ts:768)——正确性完全依赖索引与行一致**;null/undefined 条件跳过索引回退全表(memory.ts:759)。随后仍用完整 `matchWhere` 过滤(子集语义安全)、`applyOrderBy``slice(offset,limit)``projectColumns``findStream`memory.ts:213-233)单次迭代不物化,但**完全忽略 `query.orderBy`**。
**insertmemory.ts:146-183**:v0.7.3 两阶段。阶段 1 逐行 `validateRow` + 批内 PK `Set` 互查(memory.ts:165+ `checkInsertUniqueness`(批内 Set + 索引查,memory.ts:309-341);阶段 2 落 row + `updateIndexes`
**updatememory.ts:235-302**`stripUndefinedUpdates` → 未知列报错(254-258)→ 未解析子查询报错(245-250)→ 阶段 1 逐行匹配/校验/唯一预检/**新主键撞已有主键检查**(memory.ts:274)→ 阶段 1b `checkUpdateRestrict`RESTRICT 与 SET NULL+required 整体拒绝,memory.ts:391-421)→ 阶段 2 先 `removeIndexEntries` 再(主键变更时)`applyUpdateCascade``table.delete(old)``table.set(new)``updateIndexes`
**deletememory.ts:450-486**:先扫出全部待删行 → 对每行 `checkCascadeRestrict` 递归预检(memory.ts:492-529)→ 通过后才 `removeIndexEntries` + `cascadeDelete`memory.ts:810-878)→ 最后删父行。返回 `toDelete.length + cascadeCount`
**事务(memory.ts:633-653**`beginTransaction` 深拷贝 tables(浅拷贝行对象 `{...iv}`memory.ts:661)、浅拷贝 schemas`new Map(this.schemas)`memory.ts:638)、深拷贝 indexes`rollback` 直接换回三个 Map`commit` 只丢弃快照。**没有写锁、没有快照隔离**:事务期间任何非事务写入直接落到同一份 live Map 上。
**外键级联**`checkCascadeRestrict`(预检)与 `cascadeDelete`(执行)都在表循环里 `if (refTableName === tableName) continue;`memory.ts:498、822),即**自引用外键被整体跳过**;`applyUpdateCascade` 只处理直接引用被改表的列(memory.ts:430-448),不递归。
### 1.2 KVStore`src/engine/kvstore/*`
**磁盘格式**
- 键空间:`__kv_log` / `__kv_snapshot` / `__kv_meta`index.ts:32-34)。
- **日志记录**log.ts:9-18, 47-92):`[recordLen u32][seq u32][entryCount u32] (每条 entry: [op u8][keyLen][key][valueLen][value]) [crc u32]`,大端,`crc32` 覆盖除 CRC 外全部字节;`recordLen` 含自身不含 CRClog.ts:79)。操作码 PUT=1 / DELETE=2 / APPEND=3log.ts:23-28)。**一条记录 = 一个原子事务**putMany/deleteMany/writeBatch 都编码成单条记录,index.ts:209-242)。
- **快照**snapshot.ts:8-13, 29-59):`[magic "KVSN"][seq u32][entryCount] (keyLen/key/valueLen/value)* [crc]``seq` 是内嵌日志水位。
- **介质抽象**`src/engine/aria/store/backend.ts:13-48`):`IStorageBackend`open/close/read/write/append?/writeMany/delete/deleteMany/listKeys/exists/clear)。浏览器走 `OPFSBackend`(每 key 一个文件,`write` 用 createWritable COW`append` 用 keepExistingData+seek 到 `existing.size`opfs_backend.ts:84-113);Node/测试走 `SharedMemoryBackend`(模块级 registry 跨实例共享、close 不清数据,shared_memory_medium.ts:15-43),默认选择逻辑在 index.ts:44-50。
- **内存索引**`index: Map<string, ArrayBuffer>`index.ts:58)是唯一读路径,`get/size/listKeys` 全部 O(1)index.ts:177-195)。
**恢复流程(index.ts:85-142**:读快照 → `decodeSnapshot` 失败则置空索引并从 0 重放 → 读日志 → `parseLogRecords` 顺序解析,`record.seq <= snapshotSeq` 跳过(幂等),否则 `applyRecord` 并推进 `this.seq` → 命中损坏记录(长度越界/CRC 失败/entry 越界)回调返回 true 停止扫描 → **调用 `truncateLog()` 把整条日志写成 0 字节**index.ts:135-138 + 394-399)。`__kv_meta` 在 checkpoint 时写(index.ts:275-276)但**恢复时从不读取**(v0.6.1 起水位只信快照内嵌 seqindex.ts:113-118)。
**写入与 checkpointindex.ts:334-391, 261-280**:所有写经 `enqueue` 串行队列(index.ts:327-331)→ `seq++``medium.append`(真追加,否则 read+拼+write 回退,index.ts:344-356)→ 成功后更新内存索引与 `logBytes`;失败 `seq--`、记 `lastBackgroundError` 并抛 `KV_LOG_ERROR`(内存不更新)。`checkpoint` = 写快照 → 写 meta → 截断日志;`appendRecord` 内还有一条**按字节阈值的自动 checkpoint**index.ts:385-390,默认 16MBindex.ts:37),**这段不在 try/catch 内**。
### 1.3 KVStoreEngine`src/engine/kvstore_engine.ts`
**三层缓存**:介质文件 → `KVStore.index`ArrayBuffer 权威视图)→ `MemoryEngine`(解码后的行 + 二级索引)。**读全部走 MemoryEngine**kvstore_engine.ts:292-300, 419-422),介质只在 open/checkpoint 碰。
**键布局**`__schema`(全部表 schema 的 JSON 一次写全)、`__meta:{key}``t:{table}:{pk}`kvstore_engine.ts:28-29, 65-71)。
**openkvstore_engine.ts:75-123**`kv.open``memory.open` → 解析 `__schema`JSON 失败抛 `KV_SCHEMA_ERROR`)→ 逐 key 解析行名(`key.indexOf(':', 2)` 取第一个冒号,kvstore_engine.ts:99-101)→ 表存在则 `memory.insert(table,[row])`,**异常被 catch 静默吞掉**kvstore_engine.ts:103-108)→ 之后一段「重建二级索引」循环(kvstore_engine.ts:111-120)实际是死代码(`createTable` 已建桶且 `createIndex``colDef.index/unique` 立即 returnmemory.ts:562)。`this.opened = true` 在**最后**才置位(kvstore_engine.ts:122)。
**写入(非事务)**insert = memory 先行 + `putMany` 持久化 **memory 中的 validated 行**kvstore_engine.ts:283-288);update = 先 `collectMatchingPks` 预取受影响主键(kvstore_engine.ts:316)→ `memory.update` → 主键变更走 `affectedTables` 传递闭包 + `collectTableDiff` 整表 diff,普通更新走「单次全表扫描 + 受影响集合过滤 + 剩余主键删除」(kvstore_engine.ts:345-380);delete = 预取主键 + memory.delete + 本级表按主键删 + 级联表整表 diff,合并成一次 `writeBatch`kvstore_engine.ts:406-415)。
**事务**`beginTransaction` = memory 快照 + 复位 4 个 tx 集合(kvstore_engine.ts:476-485);写入路径按 `txActive` 分叉,把变更记入 `txChanges`(行级 put/delete)或降级为 `txFullTables`(主键变更/级联影响表)或 `txClearedTables`(事务内 clear);`commitTransaction` 把这些合并成 **一次** `kv.writeBatch`,随后**单独** `persistSchema`kvstore_engine.ts:545-549),最后 `memory.commitTransaction``rollbackTransaction` 只回滚内存与集合(kvstore_engine.ts:561-571)。`setMeta` 是直写 `kv.put('__meta:*')`kvstore_engine.ts:181-183),与事务无关。
### 1.4 HybridEngine`src/hybrid/index.ts`
`memoryEngine: MemoryEngine` + `diskEngine: KVStoreEngine`hybrid/index.ts:30-35)。读全部走内存(222-225);写 = 内存先行、磁盘 write-throughinsert 211-220 / update 232-241 / delete 243-252 / clear 258-265 / DDL 141-189);磁盘失败回调 `recoverMemoryAfterDiskError``reloadMemoryFromDisk()` 把内存对齐磁盘后重抛原错误(hybrid/index.ts:199-209)。`reloadMemoryFromDisk`hybrid/index.ts:56-85= 关/开内存引擎 → `disk.reload()` → 遍历磁盘表 `createTable` + `find` 全表 + `insert` 回灌,**单表回灌失败只 `console.warn`**79-82)。事务 = 双引擎 begin(293-303,磁盘失败补偿回滚内存)/ commit(磁盘先、内存后,内存失败抛 `TX_COMMIT_ERROR` 且磁盘已提交,305-319)/ rollback(内存先、磁盘后,321-324)。
### 1.5 语义对照(同为「关系语义」的四份实现)
| 语义 | Memory | KVStore/KVStoreEngine | Hybrid | Aria(对照) |
|---|---|---|---|---|
| 行/索引 | PK=行 Mapcol→Set\<pk\> | 复用 Memory + `t:table:pk` 键 | 复用 Memory ×2 | LSM 二级索引 |
| 约束校验 | 自带 `validateRow/checkType`**无 maxLength/min/max**memory.ts:713-722 | 复用 Memory | 复用 Memory | 共享 `checkFieldType`(含 maxLength/min/maxaria/index.ts:1672 |
| 唯一 | 建表标记 + `uniqueIndexCols` | schema 持久化后再由 createTable 建桶 | 双份 | 自有重建 |
| 事务内 DDL | ALTER/CREATE/DROP INDEX 拒绝(memory.ts:114/552/599),create/dropTable 可回滚 | ALTER/CREATE/DROP INDEX 拒绝(kvstore_engine.ts:236/442/459),create/dropTable 随 commit | 委托两侧 | ALTER 拒绝 |
| 事务原子性 | 快照交换 | 内存快照 + 单条日志批 + **schema 另写** | 双引擎 | MVCC + WAL |
| 失败中途 | 语句级两阶段;级联两阶段 | 内存先行,落盘失败回滚不了内存 | 重载内存对齐 | WAL 领先内存 |
| 级联影响面 | 直接引用表(自引用被跳过、ON UPDATE 只一层) | `affectedTables` 传递闭包(整表 diff) | 委托 | 自有实现 |
---
## 2. 缺陷清单
### P0 — 静默丢数据 / 破坏崩溃恢复
**P0-1 单条 UPDATE 把多行改到同一新主键 → 静默覆盖丢行** **[已复现]**
- 位置:`src/engine/memory.ts:274`(阶段 1 只检查 `table.has(newPk)`)、`memory.ts:289-300`(阶段 2 直接 `set`)。
- 现象:`UPDATE t SET id='X'`(匹配 2 行)返回 affected=2,但表中只剩 1 行;SQL 路径与引擎路径一致。
- 根因:阶段 1 只与「语句执行前的表」比对,批内新主键互查缺失;阶段 2 逐行 `table.delete(old); table.set(new, updated)`,后者覆盖前者的成果。`primaryKey: true` 不隐含 `unique: true``checkUpdateUniqueness`memory.ts:348-385)只遍历 `colDef.unique` 列,兜不住。
- 复现:2 行表 → `engine.update('t',{table:'t'},{id:'X'})``find` 返回 1 行。(INSERT 路径 v0.7.3 已做批内 PK `Set` 互查,UPDATE 漏了。)
**P0-2 唯一约束永久失效 → 重启静默丢行(组合链)** **[已复现]**
- 位置:`memory.ts:562``if (colDef.index || colDef.unique) return;`)、`memory.ts:122-127`ALTER ADD 只写 `schema.columns`**不建索引桶**)、`memory.ts:316-339`(唯一预检依赖 `tableIndexes.get(colName)`,桶缺失即 `colIndex===undefined` → 整段跳过)、`kvstore_engine.ts:103-108`open 回灌 `insert` 异常被吞)。
- 现象(端到端实测):`ALTER TABLE t ADD COLUMN email STRING UNIQUE` → 连续插入两条 `email='a@x.com'` **都不报错**rows=2)→ close/reopen → 只剩 1 行,**无任何错误或警告**。
- 根因:唯一性检查完全由二级索引桶承载,而 ALTER ADD 不建桶;重启时 `createTable` 依据 schema 建桶,回灌第 2 行触发 `UNIQUE_VIOLATION`,异常被 `catch {}` 吞掉 → 该行永久消失。同理 `CREATE UNIQUE INDEX` 落在一个已有普通索引的列上会静默 no-op(`memory.ts:562`),约束不生效。
- 复现:见上(`tests` 临时用例已验证 `dupErr='' | before=2 | after=1`)。
**P0-3 `KVStore.open()` 命中损坏日志后把整条日志清零 → 已确认数据只剩内存,再崩溃即永久丢失** **[已复现]**
- 位置:`src/engine/kvstore/index.ts:135-138`(检测到损坏 → `truncateLog()`)、`index.ts:394-399``truncateLog``new ArrayBuffer(0)`**不是** `log.subarray(0, validBytes)`)。
- 现象:日志尾部有一条坏记录时,open 会先把坏记录之前**全部有效记录**应用到内存,然后清空整条日志;此时快照可能是上一次 checkpoint 的旧值甚至不存在。随后若进程崩溃(未 close、未 checkpoint),这些「已确认」的数据没有任何持久副本 → 永久丢失。
- 佐证:`tests/engine/kvstore.test.ts:176-210` 只验证了**同一会话内** `confirmed/confirmed2` 仍在、且新写的 `after` 能跨一次重启(因为 after 是截断后新追加的记录),**从未验证第二次重启后 confirmed 仍在**。实测:`kv2.open()` 后日志字节数 = 0,第二次重启 `get('confirmed') === null`
- 附带影响:`KVStore.reload()`index.ts:148-156)会清空索引再重新 open,若此前发生过错损截断,reload 直接把库变成空库——而 Hybrid 的跨标签页同步(`src/core.ts:86-90`)会周期性调用它。
- 修复方向:open 截断应写回「有效前缀」或先落一次快照再清日志。
**P0-4 自动 checkpoint 失败发生在「日志已追加 + 内存已更新」之后 → 报错为假、事务回滚无效(数据复活)** **[已复现]**
- 位置:`kvstore/index.ts:334-362`try/catch 只包住 `medium.append`,末尾 `logBytes +=` 与自动 checkpoint 在 catch 之外)、`kvstore/index.ts:385-390`(快照/meta/truncate 失败会以异常形式冒泡给 `put/putMany/writeBatch` 调用方)、`kvstore_engine.ts:545-553`commit 里 `writeBatch` 抛错 → 内存 `commitTransaction` 未执行,调用方按惯例回滚)。
- 现象(端到端实测):事务 insert 一行 → 让快照写入失败 → `commitTransaction()` 抛错 → `rollbackTransaction()` 后内存 count=0 → **重启后 count=1**(被回滚的事务复活)。裸 `KVStore.put` 同样:rejects 但 `get` 有值、重启后 durable。
- 根因:原子语义要求「日志追加失败 ⇒ 内存不更新」,但反向窗口(内存/日志已提交 ⇒ 必须报成功)没有保证;checkpoint 失败被算作写失败。`lastBackgroundError` 也只覆盖 append 失败路径。
- 复现:注入 `medium.write('__kv_snapshot')` 抛错 + `checkpointThreshold` 设小。
### P1 — 结果错误 / 约束与事务语义破坏
**P1-1 maxLength / min / max 约束在 Memory/KVStore/Hybrid 上完全失效** **[已复现]**
- 位置:`memory.ts:691-711` 自带 `validateRow` + `memory.ts:713-722` `checkType` **只做类型判断**;而共享实现 `src/table/schema.ts:129-202``checkFieldType` 才校验 `maxLength`(146)、`min`(161)、`max`(167)。AriaEngine 走共享版(`src/engine/aria/index.ts:12,1672`)。
- 现象:`maxLength:3` 的列插入 8 字符、`min:10/max:20` 的列插入 999 均成功(Aria 同 schema 会抛错)。
- 影响面:README:56 明确宣称「输入校验(required / maxLength / min / max)」;三引擎静默接受越界数据 = 数据完整性缺口 + 引擎间行为漂移。
**P1-2 `find` 返回存储行引用(读到即能改库)** **[已复现]**
- 位置:`memory.ts:209`(无投影直接 `return results`)、`memory.ts:765-766`(索引路径回表得到的就是存储行对象)、`memory.ts:228``findStream` 回调传存储行)、`hybrid/index.ts:224``kvstore_engine.ts:294`(纯委托)。
- 现象:`const rows = await db.query('SELECT * FROM t'); rows[0].name='HACKED'` → 再查已是 HACKED(在 Hybrid 下更严重:见 P2-4,调用方的改动会被后续无关 update 持久化到磁盘)。
- 影响:绕过所有校验/钩子/审计、破坏事务快照(见 P2-5)、`find` 结果集共享导致的隐蔽耦合。`projectColumns``src/query/where-matcher.ts:214-228`)只做浅拷贝,嵌套 json 值仍共享。
**P1-3 自引用外键(同表 `references` 本表)的级联/RESTRICT 全部不生效** **[已复现]**
- 位置:`memory.ts:498``checkCascadeRestrict` 跳过同表)、`memory.ts:822``cascadeDelete` 跳过同表)、`memory.ts:432``applyUpdateCascade` 跳过同表)、`memory.ts:393``checkUpdateRestrict` 同样 `if (refTableName === tableName) continue`)。
- 现象(实测三种):`nodes.parent REFERENCES nodes(id) ON DELETE CASCADE` 删父行后子行残留(悬空引用);`ON DELETE RESTRICT` 不抛错直接删掉父行;`ON UPDATE CASCADE` 改主键后子行 `parent` 仍是旧值。
- 根因:三处「避免自引用无限递归」的保守跳过,用 `visited` 集合本可安全处理(`visited` 已经在用,memory.ts:493-495/817-818)。
**P1-4 ON UPDATE CASCADE 只级联一层 → 多层引用链断裂** **[已复现]**
- 位置:`memory.ts:430-448`:只遍历「列 `references` 的第一段 === 被改表」的直接引用表,修改的是引用表的**普通列**(如 `orders.user_id`),不会继续对 `orders.user_id` 的下游引用(如 `shipments.order_user REFERENCES orders.user_id ON UPDATE CASCADE`)做传递处理,也不进入 `planned`/预检体系。
- 现象:`users.id: u1→u2``orders.user_id=u2`(已改),`shipments.order_user` 仍为 `u1` → 悬空引用。对照 `kvstore_engine.ts:599-621``affectedTables` 反而做了传递闭包(两边语义不一致:磁盘会重写孙表但内容仍是错的)。
**P1-5 事务期间的非事务写入被卷入事务并一起回滚** **[已复现]**
- 位置:`memory.ts` 所有写路径都**不检查 `snapshot`**(只有 DDL 检查,memory.ts:114/552/599);`kvstore_engine.ts:261/318/389/427` 用全局 `txActive` 决定写入是否进 tx 变更集。
- 现象:`beginTransaction(); insert('in-tx'); insert('concurrent-no-tx'); rollbackTransaction()` → 两行都没了。任何在事务 await 间隙由「另一个调用方」发起的写,都会被并进当前事务并随回滚消失(或随提交意外生效)。
- 影响:引擎实例级「一个全局事务」的隐含假设;`db.transaction()` 与并发的 `db.table().insert()` 交错即触发。
**P1-6 KVStoreEngine:事务 commit 的 schema 落盘不在原子批内 → 部分提交 + 孤儿行** **[已复现]**
- 位置:`kvstore_engine.ts:545``writeBatch(puts,deletes)` 一条日志记录)与 `kvstore_engine.ts:547-549``persistSchema()` = 另一次 `kv.put('__schema')`)。CHANGELOG v0.6.1 宣称「一条日志记录 = 真原子,多表事务中途崩溃不会部分提交」,但 DDL 事务的这一半在批外。
- 现象(实测):事务内 `createTable('newt') + insert`,让 `__schema` 写入失败 → commit 抛错 → rollback → 重启后表不存在、**但 `t:newt:1` 已永久留在日志/快照里**(孤儿数据,open 时因表不存在被跳过,永不回收)。
- 附带:反向窗口(行批失败、schema 已写)会留下「有表无行」的空表。
**P1-7 KVStoreEngine:非事务写落盘失败 → 内存/磁盘分叉,同一会话可读到幻影行** **[已复现]**
- 位置:`kvstore_engine.ts:258-290``memory.insert` 先提交,`kv.putMany` 后失败无补偿)、同类 `update`(302-382)/`delete`(384-417)/`clear`(424-436)。
- 现象:注入 append 失败 → `insert` rejects,但 `find` 仍返回该行(同一会话可见),磁盘上没有;重启后行消失。Hybrid 有 v0.7.2 的 `recoverMemoryAfterDiskError` 补偿(hybrid/index.ts:199-209,实测有效),**纯 disk 模式没有这层保护**。
**P1-8 KVStoreEngineopen 回灌时的行级失败被静默吞掉** **[已复现]**
- 位置:`kvstore_engine.ts:103-108``try { JSON.parse; memory.insert } catch { /* 单行损坏跳过 */ }`)——注释设想的是「JSON 损坏」,但真实触发点还包括唯一冲突、类型不符、required 缺失等 schema 语义冲突。
- 现象:schema 收紧(`email``unique`)后重启,磁盘 2 行、内存 1 行,**无日志、无计数、`repair()` 也不报**。与 P0-2 组成完整丢数据链。
**P1-9 KVStoreEngine:事务进行中 reload → commit 静默成功但一行未写(事务数据静默丢失)** **[已复现]**
- 位置:`kvstore_engine.ts:148-157``reload()``txActive` 守卫,`memory.close()/open()` 清空内存态但 `txChanges` 仍在)、`memory.ts:43-45``close()` **不清 `snapshot`**,所以后续 `memory.commitTransaction()` 反而成功)、`kvstore_engine.ts:540-542`(残留的 `'put'` 项被丢弃,既不 put 也不 delete)。
- 现象:`beginTransaction(); insert('a'); reload(); commitTransaction()`**不抛错**count=0,数据消失。
- 可达路径:`Hybrid.reloadMemoryFromDisk()` 由 BroadcastChannel 外部变更事件触发(`src/core.ts:79-91`),**任何时刻**(含事务中途)都可能打断正在执行的事务。
**P1-10 `setMeta` 不受事务回滚影响** **[已复现]**
- 位置:`kvstore_engine.ts:181-183`(直写 `kv.put`,无 tx 记录)、`176-179`(读同一层)。
- 现象:`begin; setMeta('version','9'); rollback``getMeta('version') === '9'`
- 影响:迁移版本号(core.ts:107-114 `__metona_version`)一旦在事务内推进就无法回滚 → 迁移重跑判定失效(数据未迁移但版本已升)。
**P1-11 `MemoryEngine.close()` 遗留事务快照 → 重开后永久 `TX_ACTIVE`** **[已复现]**
- 位置:`memory.ts:43-45`(只清 tables/schemas/indexes/metaStore/opened,不动 `this.snapshot`)。
- 现象:`beginTransaction(); close(); open();``beginTransaction()` 永远抛「Transaction already in progress」,`alterTable/createIndex/dropIndex` 永远抛 `NOT_SUPPORTED`memory.ts:114/552/599)。
- 可达路径:`KVStoreEngine.close()` 虽会先 rollback,但 `KVStoreEngine.reload()`/`repair()` 直接 `memory.close()`kvstore_engine.ts:153、163)——这正是 P1-9 的成因之一。
**P1-12 介质层错误语义被折叠 → 不可恢复的「假空库」** **[读码确认]**
- 位置:`src/engine/aria/store/opfs_backend.ts:69-78``read` 把所有异常折叠为 `null`:文件不存在与 I/O 错误无法区分)、`opfs_backend.ts:84-113``dbDir===null`(已 close)时 `write/append` **静默 return**,调用方以为写成功)。
- 链路:`kvstore/index.ts:98-111``read` 返回 null ⇒ 认为「无快照」)→ `index.ts:119-139`(无日志 ⇒ 空索引)→ 下一次 `checkpoint()`index.ts:272-278)把空索引写成新快照并截断日志 ⇒ 介质上原有数据被永久覆盖。同理 close 之后的写入在 KVStore 内存索引里「成功」,重启即丢。
- 建议:介质接口区分「不存在」与「读取失败」,写路径检查 `isOpen()` 并抛错。
### P2 — 健壮性 / 一致性
**P2-1 KVStoreEngine.open 失败后实例永久不可用,且真实错误被掩盖** **[已复现]**
- 位置:`kvstore_engine.ts:122``opened` 末尾才置位)、`75/79/80``kv.open`/`memory.open` 幂等早退)、`84-93`catch 把 `createTable` 抛的「Table "a" already exists」重新包装成 `Corrupted schema in KVStore`)。
- 现象:schema 半损坏 → 第一次 open 抛 KV_SCHEMA_ERROR**第二次 open 仍抛同一个错误**(因为上次已建好的表再次 `createTable` 失败),永远无法打开,除非换实例。错误信息指向「schema 损坏」,真实原因是「表已存在」,可诊断性差。
**P2-2 行键编码用冒号拼接,表名含 `':'` 时重启串表/丢行** **[已复现]**
- 位置:`kvstore_engine.ts:65-71``t:${table}:${pk}`)与 `kvstore_engine.ts:99-101`(按**第一个**冒号切成 table/pk)。
- 现象(实测两种):表 `a:b` 的行键 `t:a:b:1` 在重启时被解析成 table=`a`、pk=`b:1``a` 不存在则整行跳过(`count('a:b')` 1→0,数据蒸发);`a` 存在则**行被塞进表 `a`**(实测 `count('a')=1``rows(a)=[{"id":"1"}]`),即跨表数据污染。`collectTableDiff``startsWith(rowPrefix(table))` 前缀匹配(kvstore_engine.ts:638-661)同类风险(删表 `a` 会连带删掉 `a:1` 表的行)。
**P2-3 KVStore 的「假失败」还会留下重复 seq** **[读码确认 + 部分复现]**
- 位置:`kvstore/index.ts:357-362`append 抛错即 `this.seq--`)。
- 若失败发生在「数据已写但 API 抛错」(OPFS `close()` 阶段报错、配额错误等),日志里已有 seq=N,而内存水位退回 N-1;下一次成功写入又是 seq=N → 日志出现两条同 seq 记录。恢复按顺序应用(后者覆盖前者),最终值不一定等于写成功的那一条;`repair()`/`findValidLogLength` 也不会检测 seq 单调性。
**P2-4 Hybridjson/数组等嵌套值在两个引擎间共享引用,调用方改动会被持久化** **[已复现]**
- 位置:`hybrid/index.ts:75-83``diskEngine.find` 返回的**存储行引用**直接交给 `memoryEngine.insert`)、`memory.ts:691-711``validateRow` 只浅拷贝顶层,嵌套对象按引用传递)。
- 现象:`h.find(...)` 取出 `meta` 对象就地改 `flag`(不是一次 DB 写)→ 之后任意一次无关 `h.update(...,{v:2})` → 磁盘引擎把被污染的 `meta` 落盘,重启后读回 `MUTATED-BY-CALLER`
**P2-5 事务快照是浅拷贝,嵌套值不在回滚范围内** **[读码确认]**
- 位置:`memory.ts:661``{...iv}`)、`memory.ts:638``new Map(this.schemas)`schema/列定义对象共享)。
- 影响:与 P1-2/P2-4 组合——事务内通过读结果就地修改嵌套对象,`rollbackTransaction` 无法还原;`schemas` 的共享对象也是 v0.7.2 必须「事务内禁止 DDL」的根因(memory.ts:111-119 注释自陈)。
**P2-6 Hybrid 双引擎分叉无任何校验/补偿** **[读码确认]**
- 位置:`hybrid/index.ts:232-241``243-252`(只返回内存引擎的 count,忽略磁盘引擎的 count)、`321-324`(rollback 内存先、磁盘后,内存抛错则磁盘不回滚)。
- 影响:一旦两侧状态因任何原因(P1-7、失败补偿、reload)分叉,后续每次读写都在扩大分叉,且没有任何一致性检查点可用(`repair()` 只对齐一侧:hybrid/index.ts:99-104 修磁盘后重载内存)。
**P2-7 SharedMemoryBackend 的 materialized 缓存跨实例失效** **[读码确认]**
- 位置:`shared_memory_medium.ts:49-67``read` 缓存拼接结果)、`74-83``append` 只失效**本实例**缓存)、`15`chunks 存于模块级 registry)。
- 影响:A 实例读过 `__kv_log` 后,B 实例写入 → A 再 `read` 仍拿到旧缓冲;只有 `open()` 会重置缓存(`shared_memory_medium.ts:36`)。KVStore 的 `reload()` 恰好会 `medium.open()` 所以侥幸绕过,但 `repair()``listKeys` 类直接介质调用会有陈旧视图。仅测试/Node 介质,故 P2。
**P2-8 KVStoreEngine 非事务路径与介质层都缺少事务/生命周期守卫** **[读码确认]**
- `kvstore_engine.ts:170-174``clearAll` 在事务中直接 `kv.clear()` + `memory.clearAll()`snapshot 仍在,rollback 会「复活」旧结构)、`kvstore_engine.ts:176-183``getMeta/setMeta` 不加 `ensureOpen`,未 open 也能写)。KVStore 侧 `put/delete/writeBatch` 不检查 `opened`index.ts:202-254),close 后仍更新内存索引。
**P2-9 重启后 `CREATE UNIQUE INDEX` 来源标记丢失 → 无法再 DROP 该索引** **[已复现]**
- 位置:`memory.ts:26``uniqueIndexCols` 纯内存)、`kvstore_engine.ts:84-93`open 只回灌 schema,不回灌来源标记)、`memory.ts:617-623`
- 现象:同一次生命周期内 `createIndex('t','email',true)``dropIndex` 可用;重启后同一个索引 `dropIndex``NOT_SUPPORTED`。CHANGELOG v0.7.4 已把「重启后一律按建表约束保护」写成显式取舍,但这是一处「重启前后行为不同」的功能回退(P3 级体验,P2 级一致性)。
### P3 — 契约/可维护性(仅列有实际语义影响的)
- **P3-1 `findStream` 忽略 `orderBy`**`memory.ts:213-233` 从不读 `query.orderBy`,且 `limit/offset` 在**未排序**的迭代序上生效;接口文档却承诺「有 orderBy 时实现可回退物化」(`interface.ts:46-47`)。当前 SQL 侧不可达(`core.ts:307-309` 把 ORDER BY 判为不可流式),但 `table().stream()` 之外任何未来调用方都会静默拿到错误结果。
- **P3-2 KVStoreEngine.open 的「重建二级索引」是死代码**(`kvstore_engine.ts:111-120`):`createTable` 已建桶,`createIndex``colDef.index/unique` 立即 return`memory.ts:562`),该循环从未起作用——但注释给人以「索引有独立重建步骤」的错误印象(真实重建靠 `createTable` + 回灌 insert)。
- **P3-3 `__kv_meta` 只写不读**`index.ts:275-276` 写、`95-118` 恢复时明确忽略),是纯冗余状态;`logBytes` 记账同样与实际日志长度脱节(`index.ts:132-134``382``398`:截断失败也置 0)。
- **P3-4 CREATE INDEX 幂等静默 / DROP 不存在报错不对称**(`memory.ts:562` vs `memory.ts:610-612`);SQLite 语义下重复 CREATE INDEX 应报错。
- **P3-5 delete 返回「含级联」的行数**(`memory.ts:485``kvstore_engine.ts:416`),与 SQL「affected rows 仅指目标表」不同;Aria 测试已把级联计数钉死(`tests/aria-cascade.test.ts:26,44`),属四引擎共同语义债。
---
## 3. 性能议题
1. **主键变更 = 整表重写(写放大 ~1.8 万倍)** **[已实测]**`kvstore_engine.ts:347-353``affectedTables` 传递闭包 + `collectTableDiff` 全表 put。2 万行表改 1 行主键 → **单条日志记录 1,877,821 字节**;同一表普通单行更新 103 字节。风险不止带宽:`encodeLogRecord``log.ts:47-92`)会一次性物化整条记录的 `Uint8Array`(含逐值 `new Uint8Array(e.value)` 拷贝),大表事务/级联删除会在内存里再造一份全表;10 万行级库(`tests/engine/kvstore-stress.test.ts` 只做 insert/update/delete,未覆盖主键变更与级联)可能直接 OOM 或产生数十 MB 单记录。
2. **每次 UPDATE/DELETE 都是多趟全表扫描**`kvstore_engine.ts:316``collectMatchingPks` → 一次 `find`)→ `memory.update` 的阶段 1 + 阶段 2`memory.ts:267``289`)→ 落盘前再扫一遍(`kvstore_engine.ts:359`)。Hybrid 下每趟再 ×2(内存引擎 + 磁盘引擎各跑一次)。有索引时 `collectMatchingPks` 那趟能走索引,其余趟仍是全表。
3. **`count()` 永不使用索引**`memory.ts:531-538`):`COUNT(*) WHERE indexed_col = ?` 全表扫描,而同样的 `find` 可以 O(1)。
4. **`find` 无 where 时 `Array.from(table.values())` 全量物化**`memory.ts:729/771`),即使 `limit:1``tryIndexLookup` 命中索引时也会新建结果数组(`memory.ts:764`)。10 万行库上「取第一行」是 O(n) 分配。
5. **级联路径是 O(n·m) 双倍扫描**`delete``checkCascadeRestrict` 对每个待删行扫描每一张引用表(`memory.ts:505-508`),执行阶段 `cascadeDelete` 再扫一遍(`memory.ts:834-839`);`applyUpdateCascade`/`checkUpdateRestrict` 同样各扫一遍全表(`memory.ts:400-410``440-445`)。且级联按列嵌套循环(表 × 列),没有基于索引的定位。
6. **`affectedTables` 是 O(T²·C) 的定点迭代**`kvstore_engine.ts:599-621`),且每张表都 `await getTableSchema`(每次一次 Promise 往返);它把「所有可能受影响的表」整表 diff,而实际只有极少数行会变。
7. **每行一次 async/await + 序列化**`KVStoreEngine.open` 恢复循环每行一次 `await memory.hasTable` + `await memory.insert``kvstore_engine.ts:97-109`),10 万行 = 20 万次微任务 + 每行 `JSON.parse`;写路径每行 `JSON.stringify` + `TextEncoder.encode``kvstore_engine.ts:286/363/534/650`)。
8. **checkpoint 是全量 O(库大小) 且阻塞所有写**`encodeSnapshot``snapshot.ts:29-59`)逐值复制整库;它跑在 `opQueue` 里(`index.ts:327-331`),期间任何 `put` 都要排队等待;`close()`/`commit` 前后的大库 checkpoint 直接体现为写延迟尖刺。自动阈值 16MB(`index.ts:37`)意味着单次 checkpoint 可能处理数万行。
9. **大表 diff 的单记录上限风险**`writeBatch` 把所有 put/delete 编进**一条**记录(`index.ts:237-242`)。行数越多,单个 append 越大,OPFS 的 COW 写需要整文件复制语义(`opfs_backend.ts:102-113`),峰值内存 ≈ 原日志 + 新记录;没有分片/批量上限保护。
10. **测试对 10 万级只做了「写入 + 抽样读」**`tests/engine/kvstore-stress.test.ts:23-61` 采样 4 个 key;第 2 例只校验 1001 个值)——没有覆盖主键变更、级联、事务回滚、自动 checkpoint 失败等重路径,性能悬崖(上述 1/8)都在覆盖面之外。
---
## 4. 系统性观察:四份「关系语义」实现的漂移与收敛点
**已发生的行为漂移(可举证的重复实现代价)**
1. **校验规则两份**`memory.ts:691-722` 的私有 `validateRow/checkType``src/table/schema.ts:87-202` 的共享版并存,前者漏掉 `maxLength/min/max` —— 漂移已经真实发生(P1-1),且 README 的宣称只对 Aria 成立。
2. **主键解析三份**`memory.ts:686-689``table/schema.ts:66-71``kvstore_engine.ts:579-584` 三处同样的「找 primaryKey,否则第一列」。
3. **「受影响表」三种定义**:MemoryEngine 的级联只认「列 references 的第一段 === 被删/改表」(`memory.ts:497-503``433-436`,自引用被跳过、ON UPDATE 不传递)↔ KVStoreEngine 的 `affectedTables` 做**传递闭包**`kvstore_engine.ts:599-621`)↔ Aria 自有实现。结果:同一句 `DELETE`/`UPDATE`,内存里的级联范围与磁盘重写范围不同(P1-4 里磁盘重写了孙表、但内容依旧错误)。
4. **事务内 DDL 三种语义**Memory/KVStore 拒绝 ALTER/CREATE INDEX/DROP INDEX 但允许 create/dropTable`memory.ts:114/552/599``kvstore_engine.ts:236/442/459`),Aria 拒绝 ALTER;而 KVStore 的 createTable 在事务里可回滚、schema 却在 commit 的原子批之外(P1-6)。
5. **唯一索引三处状态机**`colDef.unique`schema 标志)+ `indexes` 桶 + `uniqueIndexCols`(来源)+ 重启后的「一律视为建表约束」(P0-2、P2-9)。任一环缺失就出现「约束静默失效」或「重启后不可 DROP」。
6. **索引一致性靠人工纪律**`removeIndexEntries/updateIndexes` 的调用点分散在 update 阶段 2、cascadeDelete、applyUpdateCascade、clear、alterTable DROP、dropIndex 共 8 处(`memory.ts:135/179/291/298/442/444/481/544/628/791/855/868`),CHANGELOG 里 v0.3.3/v0.6.3/v0.7.3 连续三个版本都在修「某条路径忘了清/建索引」——这是典型的、还会继续发生的缺陷类别。
7. **行/键编解码两份**Memory 用 `String(pk)`KVStoreEngine 用 `t:${table}:${pk}` 且按第一个冒号解析(P2-2),Aria 另有布局;`String()` 归一也意味着 number PK 与 string PK 在键空间理论上可碰撞(当前被 `checkType` 挡住)。
**收敛建议(按性价比排序)**
1. **统一校验**:删除 `MemoryEngine.validateRow/checkType`,改用 `table/schema.ts``validateRow`(已含 default/required/PK 非空/maxLength/min/max),一处修复即同时修复 Memory/KVStore/HybridP1-1),并让四引擎的 `VALIDATION_ERROR` 文案一致。
2. **抽出共享「外键规则引擎」**:输入(schemas + 变更集合 + 方向),输出(RESTRICT 违规 / 需 CASCADE 的行 / 需 SET NULL 的行),**递归 + 自引用 + visited 都在同一处实现**,供 Memory 与 Aria 直接调用,供 KVStoreEngine 生成「受影响主键集合」而不是「整表 diff」。这能一次性消灭 P1-3/P1-4、第 3 节的写放大与 O(n·m) 扫描,并让四个引擎的 delete/update 计数语义统一。
3. **让 MemoryEngine 自己产出变更日志(change hook)**insert/update/delete 时回调「(table, pk, 'put'|'delete')」,KVStoreEngine 只负责把这份日志刷成日志记录。这样 `txChanges/txFullTables/txClearedTables/collectMatchingPks/collectTableDiff/affectedTables` 六个启发式结构(以及它们导致的 P1-6/P1-7/P1-9)可以整体删除,「持久化 = 内存变更的重放」成为结构性保证而非逐路径补丁。
4. **统一行键编解码**:零依赖的长度前缀或转义编码(或干脆把 table 名放进独立键段),消灭 P2-2 一类「拼接分隔符」缺陷;`String(pk)` 归一集中一处并显式声明 PK 的键空间规则。
5. **把 schema 纳入同一条原子记录**commit 时把 `__schema` 与行变更放进同一 `writeBatch`(或在快照内统一版本化),P1-6 的孤儿数据与「有表无行」窗口即消失。
6. **内存引擎补写锁/事务互斥**:至少让写路径在 `snapshot !== null` 时拒绝或加入事务(`txActive` 显式化),并在 `close()` 中清空快照;这消除 P1-5、P1-9、P1-11 一类「状态泄漏 → 静默丢数据」问题。
7. **KVStore 恢复路径的原子化契约**:open 截断必须落盘等价状态(先快照或写回有效前缀),写失败不得在内存/日志之间产生「半提交」,介质接口区分「不存在/读失败/已关闭」。这是 P0-3、P0-4、P2-3、P2-6 的共同根因——**恢复路径缺少「内存状态 ↔ 介质状态」的等价性证明**,目前依赖调用方(KVStoreEngine.close)恰好会 checkpoint。
**测试覆盖的结构性缺口**(直接影响上述风险的可回归性):现有用例把「结果」钉死了,但缺**第二次崩溃**(`kvstore.test.ts:176-210` 只重启一次)、缺**注入 checkpoint 失败**、缺 **reload/close 期间的事务**、缺 **ALTER 后的约束行为**、缺 **自引用/多层外键**、缺 **10 万级的主键变更与级联**。这七类恰是本次 P0/P1 的高发区。