- 全源码逐行通读(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 证据 - 全部关键结论经可执行探针实测复现(探针已删除,仓库无残留)
38 KiB
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 已做批内 PKSet互查,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-722checkType只做类型判断;而共享实现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 失败 →
insertrejects,但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:562vsmemory.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.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 单记录。 - 每次 UPDATE/DELETE 都是多趟全表扫描:
kvstore_engine.ts:316(collectMatchingPks→ 一次find)→memory.update的阶段 1 + 阶段 2(memory.ts:267、289)→ 落盘前再扫一遍(kvstore_engine.ts:359)。Hybrid 下每趟再 ×2(内存引擎 + 磁盘引擎各跑一次)。有索引时collectMatchingPks那趟能走索引,其余趟仍是全表。 count()永不使用索引(memory.ts:531-538):COUNT(*) WHERE indexed_col = ?全表扫描,而同样的find可以 O(1)。find无 where 时Array.from(table.values())全量物化(memory.ts:729/771),即使limit:1;tryIndexLookup命中索引时也会新建结果数组(memory.ts:764)。10 万行库上「取第一行」是 O(n) 分配。- 级联路径是 O(n·m) 双倍扫描:
delete先checkCascadeRestrict对每个待删行扫描每一张引用表(memory.ts:505-508),执行阶段cascadeDelete再扫一遍(memory.ts:834-839);applyUpdateCascade/checkUpdateRestrict同样各扫一遍全表(memory.ts:400-410、440-445)。且级联按列嵌套循环(表 × 列),没有基于索引的定位。 affectedTables是 O(T²·C) 的定点迭代(kvstore_engine.ts:599-621),且每张表都await getTableSchema(每次一次 Promise 往返);它把「所有可能受影响的表」整表 diff,而实际只有极少数行会变。- 每行一次 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)。 - checkpoint 是全量 O(库大小) 且阻塞所有写:
encodeSnapshot(snapshot.ts:29-59)逐值复制整库;它跑在opQueue里(index.ts:327-331),期间任何put都要排队等待;close()/commit前后的大库 checkpoint 直接体现为写延迟尖刺。自动阈值 16MB(index.ts:37)意味着单次 checkpoint 可能处理数万行。 - 大表 diff 的单记录上限风险:
writeBatch把所有 put/delete 编进一条记录(index.ts:237-242)。行数越多,单个 append 越大,OPFS 的 COW 写需要整文件复制语义(opfs_backend.ts:102-113),峰值内存 ≈ 原日志 + 新记录;没有分片/批量上限保护。 - 测试对 10 万级只做了「写入 + 抽样读」(
tests/engine/kvstore-stress.test.ts:23-61采样 4 个 key;第 2 例只校验 1001 个值)——没有覆盖主键变更、级联、事务回滚、自动 checkpoint 失败等重路径,性能悬崖(上述 1/8)都在覆盖面之外。
4. 系统性观察:四份「关系语义」实现的漂移与收敛点
已发生的行为漂移(可举证的重复实现代价)
- 校验规则两份:
memory.ts:691-722的私有validateRow/checkType与src/table/schema.ts:87-202的共享版并存,前者漏掉maxLength/min/max—— 漂移已经真实发生(P1-1),且 README 的宣称只对 Aria 成立。 - 主键解析三份:
memory.ts:686-689、table/schema.ts:66-71、kvstore_engine.ts:579-584三处同样的「找 primaryKey,否则第一列」。 - 「受影响表」三种定义:MemoryEngine 的级联只认「列 references 的第一段 === 被删/改表」(
memory.ts:497-503、433-436,自引用被跳过、ON UPDATE 不传递)↔ KVStoreEngine 的affectedTables做传递闭包(kvstore_engine.ts:599-621)↔ Aria 自有实现。结果:同一句DELETE/UPDATE,内存里的级联范围与磁盘重写范围不同(P1-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)。 - 唯一索引三处状态机:
colDef.unique(schema 标志)+indexes桶 +uniqueIndexCols(来源)+ 重启后的「一律视为建表约束」(P0-2、P2-9)。任一环缺失就出现「约束静默失效」或「重启后不可 DROP」。 - 索引一致性靠人工纪律:
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 连续三个版本都在修「某条路径忘了清/建索引」——这是典型的、还会继续发生的缺陷类别。 - 行/键编解码两份:Memory 用
String(pk),KVStoreEngine 用t:${table}:${pk}且按第一个冒号解析(P2-2),Aria 另有布局;String()归一也意味着 number PK 与 string PK 在键空间理论上可碰撞(当前被checkType挡住)。
收敛建议(按性价比排序)
- 统一校验:删除
MemoryEngine.validateRow/checkType,改用table/schema.ts的validateRow(已含 default/required/PK 非空/maxLength/min/max),一处修复即同时修复 Memory/KVStore/Hybrid(P1-1),并让四引擎的VALIDATION_ERROR文案一致。 - 抽出共享「外键规则引擎」:输入(schemas + 变更集合 + 方向),输出(RESTRICT 违规 / 需 CASCADE 的行 / 需 SET NULL 的行),递归 + 自引用 + visited 都在同一处实现,供 Memory 与 Aria 直接调用,供 KVStoreEngine 生成「受影响主键集合」而不是「整表 diff」。这能一次性消灭 P1-3/P1-4、第 3 节的写放大与 O(n·m) 扫描,并让四个引擎的 delete/update 计数语义统一。
- 让 MemoryEngine 自己产出变更日志(change hook):insert/update/delete 时回调「(table, pk, 'put'|'delete')」,KVStoreEngine 只负责把这份日志刷成日志记录。这样
txChanges/txFullTables/txClearedTables/collectMatchingPks/collectTableDiff/affectedTables六个启发式结构(以及它们导致的 P1-6/P1-7/P1-9)可以整体删除,「持久化 = 内存变更的重放」成为结构性保证而非逐路径补丁。 - 统一行键编解码:零依赖的长度前缀或转义编码(或干脆把 table 名放进独立键段),消灭 P2-2 一类「拼接分隔符」缺陷;
String(pk)归一集中一处并显式声明 PK 的键空间规则。 - 把 schema 纳入同一条原子记录:commit 时把
__schema与行变更放进同一writeBatch(或在快照内统一版本化),P1-6 的孤儿数据与「有表无行」窗口即消失。 - 内存引擎补写锁/事务互斥:至少让写路径在
snapshot !== null时拒绝或加入事务(txActive显式化),并在close()中清空快照;这消除 P1-5、P1-9、P1-11 一类「状态泄漏 → 静默丢数据」问题。 - 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 的高发区。