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

244 lines
38 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 的高发区。