方法:四个对抗性子代理分头审查(数据正确性 / 文档宣称 vs 实现 / 公共 API 契约 / 测试质量),每条结论要求可复现证据;逐条复核 + 探针确认 + 变异验证(40 项全部 被对应用例拦住)。 P0:事务活跃期间 repair()/close()/周期 checkpoint 推进 WAL 水位 → 已 COMMIT 的 事务整批消失且恢复报告"干净"。根因 hasPendingFlushData()/computeDurableLsn() 不看 txnSnapshot;守卫此前只在 CheckpointManager 两个回调里。修复:守卫下沉到 computeDurableLsn() 与 advanceWalCheckpoint() 入口(唯一实现)。 P1: - WAL 前缀缺失丢弃整段活分片(回退上一代 manifest 时 kept 为空)→ 前缀缺失单独 记录,后缀照常重放;仅 fromLsn === 0 时才算真异常 - 孤儿回收门槛只看引擎层 dataLossSuspected,漏掉 LSM 层被丢的 SSTable → 统一 describeRecoveryDamage() 聚合判定(损坏时绝不删"引用不到"的文件) - vacuum() 逐层压缩绕过维护链 → vacuumLevels() 每层作为维护链任务执行 - reclaimRetiredNow() 无视在途读者(读者把"已退休"读成"文件损坏")→ 有读者时 退化为延迟回收 P2:WAL 记录级 CRC 损坏不计数不上报;旧格式表结构记录形状损坏静默当空库; bloomFilterBitsPerKey 配置被接受却完全不生效(构建器写死默认值,实现缺陷); 幽灵 meta;介质读故障等于文件损坏的语义无用例;manifest 回读校验两条守卫无用例; 文件名≠载荷世代判定无用例;pageIdWatermark 单调性无用例;分片号两条真实不变量 无用例。 覆盖率口径(第二处漏洞):interface.ts 混着三个运行时函数(cloneRow 等)却被 描述为"纯类型、不纳入统计" → 实现搬到 src/engine/row_clone.ts;搬完门禁真的 失败(functions 93.84% < 94%),补测退化路径后通过。 测试质量:3 条空壳用例改值级断言;1 条"全损坏"用例实际只走缓存 → 拆成两条真 用例;5 秒墙钟 race 改门控 + 失败上限;setTimeout 改 whenIdle();<= 收紧为 <。 变异脚本加固:正控(干净基线必须全绿)、编译失败/0 用例单独归类、300s 超时、 逐字节 sha256 恢复校验、O_EXCL 进程锁、锚点唯一性;变异 22 → 40 项。 文档两轮订正(16 + 11 条不成立宣称):MVCC 快照隔离、backup 一致性快照、 "空洞检测截断"、体积(251,109 B / gzip 63,145 B)、测试与覆盖率数字、 "5 种存储引擎"、Tree-shakable、错误码表补 16 个码、恢复报告字段、已知限制 (回退单向 / 多实例依赖 Web Locks / manifest 体积 / 尾部 WAL 分片不可识别)。 验证:常规套件 92 套件 / 1980 用例全绿;覆盖率 90.59 / 82.59 / 94.14 / 93.50 (阈值 90/82/94/93);e2e 14/14(真实 Chromium + OPFS + CDP 崩溃); 重型套件 4 套件 / 27 用例;变异 40/40;lint + 两份 tsc 干净;dist 已重建。
1056 lines
95 KiB
Markdown
1056 lines
95 KiB
Markdown
# MetonaSqlark v0.7.5 根治性迭代方案
|
||
|
||
> 编制日期:2026-08-15 · 对象版本:v0.7.4(commit `2844a06`)
|
||
> 方法:全量源码逐行通读(src 16,079 行 / tests 20,223 行 / 文档 4,349 行,共 7 个并行深度审计)+ 独立可执行探针复现
|
||
> 全文证据分为三级:**【实测】**= 本次真实运行复现;**【读码】**= 逐行代码路径确认;**【推断】**= 推理未复现
|
||
|
||
---
|
||
|
||
## 一、结论先行
|
||
|
||
**v0.7.4 不是"接近完成",而是"已经积累了系统性语义债务"。**
|
||
|
||
1304 个测试全绿、90.1% 覆盖率、lint/typecheck 干净——这些指标全部真实,但它们保护的是**已被测试钉死的旧行为**,而不是**用户实际依赖的 SQL 语义**。本次审计在每一个子系统都发现了可稳定复现的静默错误结果、静默数据丢失或崩溃(共 43 项确认缺陷,含 11 项事故级),且它们有一个共同的成因结构。
|
||
|
||
**一句话总结根因**:这个项目把"四套存储引擎 + 三条 SQL 入口"当作四个独立实现来维护,于是**任何一条关系语义都没有唯一实现**。每次审计都发现同一批 bug 出现在 3~4 个引擎里;每次修复只打在审计报告点到的那条路径上,下一版换个入口再犯一次。v0.7.2 修 UPDATE 原子性、v0.7.3 修 INSERT 原子性、v0.7.4 修写语句子查询——三个连续版本修的是同一个结构缺口的不同侧面。
|
||
|
||
**因此 v0.7.5 的正确形态不是"再来一轮修复",而是"先止血,再砍掉让 bug 必然复发的结构"。** 方案分三条并行工作流 + 一道硬门禁。
|
||
|
||
### 关键基线(本次实测,非引用文档)
|
||
|
||
| 指标 | 文档宣称 | 本次实测 | 备注 |
|
||
|---|---|---|---|
|
||
| Jest 测试 | 1304 / 76 套件 | **1304 通过 / 76 套件 / 395s** | 数字真实 |
|
||
| 行覆盖率 | "90.1% 行覆盖率" | 语句 **90.08%**;行 **92.91%**;分支 **82.92%** | 口径写错;分支率从未公布 |
|
||
| 覆盖率分母 | — | `jest.config.cjs:18` 的 `!src/**/index.ts` 排除 15 个文件 | 含 AriaEngine 主实现 2,282 行 |
|
||
| 全量覆盖率(不排除任何文件) | — | 语句 **90.66%** / 行 **93.43%** / 分支 **82.94%** | 被排除文件覆盖率并不低(Aria 96.79%) |
|
||
| typecheck / lint | 干净 | **均 exit 0** | 但 CI 里 lint `continue-on-error: true` |
|
||
| 覆盖率门禁 | — | **不存在**(无 `coverageThreshold`,CI 不跑 `--coverage`) | 覆盖率掉到 0% 也全绿 |
|
||
| 崩溃注入 | "崩溃恢复验证" | **不存在**:单测全是 `backend.close()`(优雅停机);e2e 的 `crashPage()` 只 `page.close()` | 见 §3.3 根因 6 |
|
||
|
||
---
|
||
|
||
## 二、范围与方法
|
||
|
||
### 已完整读取的内容(无截断)
|
||
|
||
| 类别 | 内容 |
|
||
|---|---|
|
||
| 源码 | `src/` 全部 59 个文件、16,079 行 |
|
||
| 测试 | `tests/` 全部 77 个文件、20,223 行(含 e2e harness 与 mock) |
|
||
| 文档 | `README.md` 479、`CHANGELOG.md` 1090(全部 81 个提交历史)、`CONTRIBUTING.md` 219、`site/*.html` 2,561 |
|
||
| 工程配置 | `package.json`、`jest.config.cjs`、`jest.setup.js`、`rollup.config.js`、`tsconfig.json`、`.eslintrc.json`、`babel.config.cjs`、`playwright.config.ts`、`.gitea/workflows/ci.yml`、`.npmrc`、`.gitignore`、`coverage/lcov.info` |
|
||
| Git | 全部 81 个提交的元数据与关键 commit 的 diff |
|
||
|
||
### 复现强度
|
||
|
||
自行编写并运行了 10 轮可执行探针(覆盖 core / SQL / query / 四引擎 / Aria 存储 / 并发语义),**每一轮跑完即删除,仓库最终无残留**。§4 缺陷总账中标注 **【实测】** 的条目均来自这些探针的真实输出。
|
||
|
||
### 方法边界(玥玥的坦诚声明)
|
||
|
||
- 子代理的报告存在**可验证性分级**:标注【实测】的我逐一交叉复跑过关键项;标注【读码】的我没有全部独立复现,已在总账中如实标注来源。
|
||
- 崩溃注入类缺陷(WAL 半写、介质读故障、多实例 seq 撞号)依赖**故障注入实验**,部分由子代理完成后我做了代码路径核对,**未全部独立复跑**。
|
||
- 性能数字(如 `compressLZ4` 64KB→4167ms、主键变更 1.8MB 单条日志)来自子代理实测,我核对过代码路径但未重跑基准。
|
||
- **子代理的结论我做过修正**:KVStore 审计中有两条的**机制描述**与我的独立复现不一致(详见附录 C 的"附带修正")。凡本方案与子代理报告冲突处,以本方案为准。
|
||
- `src/engine/aria/index/lsm.ts` 的深度审计在本方案定稿时**已完成**,结论已并入 §3 根因 4 与 §5 总账(见附录 E)。
|
||
|
||
---
|
||
|
||
## 三、根因分析:为什么同一个 bug 会修四次
|
||
|
||
八个根因,按"砍掉它能消除多少缺陷"排序。每个根因都给出证据链——**这不是推测,是 CHANGELOG 自己记录的历史**。
|
||
|
||
### 根因 1 ★ 关系语义没有唯一实现(最高杠杆)
|
||
|
||
同一套关系语义被独立实现了 4~5 份:
|
||
|
||
| 语义 | 实现位置 | 已发生的漂移(实测) |
|
||
|---|---|---|
|
||
| 行校验 `validateRow/checkType` | `memory.ts:691-722`(私有)· `aria/index.ts:1647-1673`(私有)· `table/schema.ts:87-202`(导出) | **memory/disk/hybrid 完全不校验 `maxLength`/`min`/`max`,仅 Aria 校验**【实测】 |
|
||
| 值相等/比较 | `where-matcher.ts` 的 `===` · 哈希连接 `String(v ?? '\0')` · 分组键 `encodeGroupKey` · `COUNT(DISTINCT)` 的 `String(v)` · Aria 索引键 `String(v)` | 5 套并存 → NULL 三值逻辑完全缺失【实测】 |
|
||
| "受影响表" | Memory 单层且跳过自引用 · KVStore `affectedTables` 传递闭包 · Aria 自有 | ON UPDATE CASCADE 在 memory 只级联一层;自引用外键 CASCADE/RESTRICT **全部静默失效**【实测】 |
|
||
| 主键取值 | `getPrimaryKey` 三份拷贝 | — |
|
||
| 事务内 DDL 语义 | aria 拒绝 CREATE/DROP;memory/disk/hybrid 允许 | dev(memory) 通过,prod(aria) 抛 `NOT_SUPPORTED`【实测】 |
|
||
| 索引维护 | **8 处调用点,纯人工纪律** | v0.3.3 / v0.6.3 / v0.7.3 **连续三个版本**都在修"某条路径忘了清/建索引" |
|
||
|
||
**这就是为什么 v0.7.2→v0.7.4 每个版本都要在 3~4 个引擎里重复修同一个 bug。**
|
||
|
||
### 根因 2 ★ SQL 语义有三条互不校验的入口
|
||
|
||
| 入口 | 实现 | 后果(实测) |
|
||
|---|---|---|
|
||
| `db.query()` | `core.ts:207-242` → executor 完整管线 | 基准 |
|
||
| `db.queryStream()` 快路径 | `core.ts:311-325` 自己推导列投影/limit/offset | `SELECT id AS x` 返回**全列原始名**;`OFFSET 1 LIMIT 2` 返回 2 行而非 1 行;`LIMIT 0` 返回 1 行;`SELECT t.id` 返回 `{}`;不触发 `beforeQuery`/`afterQuery`;不尊重 `maxRowsPerQuery` |
|
||
| `QueryBuilder` | `builder.ts:105` 无 JOIN 直通 `engine.find` | 链式 `.where({a:1}).where({a:2})` **覆盖**而非 AND(返回 a=2 的行);`$subquery` 静默 0 行;事务内 JOIN **静默忽略** |
|
||
|
||
**同一个语义三条实现,没有任何机制保证三者一致。** 132 个 queryStream 测试没有一条断言"流式结果必须等于物化结果"。
|
||
|
||
### 根因 3 ★ AST 把表达式当字符串,导致文法被迫写两遍
|
||
|
||
`SelectStatement.columns: string[]`(`ast.ts:20`)把列引用、`col AS alias`、`COUNT(*)`、常量、**CASE 原文**全部混为字符串。于是 executor 用 6+ 处正则再解析一遍(`executor.ts:60/66/79/93/1027/1033/1051/1071/1079`)。
|
||
|
||
直接后果【全部实测】:
|
||
- `SELECT 1 AS one FROM t` → `[{}]`(数字常量投影丢失)
|
||
- `SELECT COUNT(t.v) FROM t` → `0`(带表前缀的聚合参数取不到键)
|
||
- `CASE WHEN a=1 THEN 'a ELSE b' ELSE 'other' END` → `"'a"`(不感知字符串字面量的正则切分)
|
||
- 嵌套 CASE → 解析器吞掉 `FROM` 之后全部文本,返回 1 行空对象
|
||
- `SELECT dept AS d, COUNT(*) GROUP BY dept` → 别名丢失;`SELECT v AS s ... GROUP BY dept` → **整列静默消失**
|
||
|
||
### 根因 4 ★ 存储层没有单一提交点,"什么已持久"靠猜
|
||
|
||
WAL `lsn`/`currentSegment`、`FileManager.nextPageId`、KVStore `seq`、LSM `levels+meta` 各自是独立的内存计数器,恢复时与介质的一致性全靠推理。已发生的后果:
|
||
|
||
- FileManager 页面 id 崩溃回退 → 复用页面 → compaction 误删 → **丢 75%**(v0.6.1 修)
|
||
- LSM.flush 与 compaction 并发写 meta → 产物变孤儿 → **丢 75%**(v0.6.1 修)
|
||
- KVStore 快照损坏水位 bug → **静默丢数据**(v0.6.1 修)
|
||
|
||
**本次逐一独立复现的三个 KVStore 事故级缺陷**(探针输出见附录 C;其中前两处的**确切机制**比子代理报告的更精确,已在 B-6 中修正):
|
||
|
||
- **损坏日志恢复后崩溃 → 已确认数据永久丢失**:`open` 遇损坏记录会 `truncateLog()` 把日志**写成 0 字节**(`kvstore/index.ts:394-396`),而有效前缀(实测 r1、r2)**只存在于内存 index 里**,既不在日志也不在快照。实测首次 open 后 `logBytes=0 / snapshotBytes=0`;不 checkpoint 直接崩溃重开 → r1/r2 全部消失。
|
||
> 附带修正一条审计结论:恢复的实现是"遇首个坏记录即停止重放"(`log.ts:287-298` 的 `break`,**并非跳过后续记录**),这个方向本身正确;问题只在于"截断成空"而不是"截断到有效前缀"。
|
||
- **checkpoint 失败 → 调用方收到 rejection,但数据已提交且持久**:`appendRecord` 把 `medium.append` 与"内存更新 + 自动 checkpoint"分在两段(`kvstore/index.ts:334-362` vs `:385-390`),后者在 try/catch 之外。实测注入快照写失败后 `put` 抛错、内存里值仍在、重启后**值依然存在**——调用方以为写失败而执行回滚,数据却已落地(事务回滚无效)。
|
||
- **陈旧实例 checkpoint 吞掉对方已提交数据**:`seq` 是**每实例独立**计数器。实测 A、B 同时 open(seq 均为 0)→ A.put(A.seq=1,写入共享日志)→ B.put(B.seq=**也是 1**)触发自动 checkpoint → B 用自己的空快照覆盖共享快照并把日志截断为 0 → 重启后 **fromA 丢失、fromB 存在**。根因是 KVStore 默认独占介质,而引擎层**没有给它加锁**(Web Lock 只加在 AriaEngine 一侧)。
|
||
> 附带确认一条**持久化契约缺口**:`KVStore.close()` **不做 checkpoint**(只 `opQueue` 排空 + `medium.close()`)。数据的持久化完全依赖上层引擎的优雅关闭路径 —— 这是根因 5"崩溃语义靠声称"的又一处实例。
|
||
|
||
- SAVEPOINT 回滚不写补偿 WAL → 崩溃后已回滚的行**复活**【实测】
|
||
|
||
**LSM 层新增一个同类事故级缺陷**(子代理完成审计 + 我代码路径核对):
|
||
|
||
- **后台 flush 失败后,冻结数据既不入 levels 也无重试路径 → 内存成唯一副本,崩溃即丢**:`flushImmutableAsync`(`lsm.ts:220-261`)在 `save`/`saveMeta` 抛错时,`levels[0].unshift(meta)`(253)与 `frozenMemtables` 清理(255)都不会执行 —— 数据留在 `frozenMemtables` 里"读得到",但**只有 `flush()` 里那一次入链机会**(`lsm.ts:605/612` 是唯一两处入链点)。此后每次 `flush()` 又被 `lastBackgroundError` 检查(`lsm.ts:580-590`)在**所有入链动作之前**直接抛出并清空标志 → 该次 flush 完全没执行。**结果:调用方早就收到 `insert()` 成功,数据却只存在于内存,进程崩溃即永久丢失。**
|
||
> 这是我独立验证过并确认的:`frozenMemtables` 只在 `flush()/freezeMemtable` 内被写入(`lsm.ts:188`),而 `flush()` 从不遍历它重新入链(618 行的 filter 只删空表)—— 重试路径**不存在**。
|
||
> 与 KVStore 的三个缺陷同源:**"待落盘意图"没有持久化记录**(根因 4)。
|
||
|
||
- LSM 的具体成因:`sstable.ts` 的解析循环被抄成**三份**(`80-100`/`145-166`/`182-201`)且越界策略不一致(点查与扫描对同一损坏文件行为不同);5 条隐式不变量(层间新鲜度、层内不重叠、索引键=块尾 key、minKey-maxKey 覆盖、bloom 参数一致)**无任何断言或属性测试**;后台任务是 fire-and-forget + 单 `boolean` 互斥(`compacting`,`lsm.ts:78,269-283`,跨层触发被静默丢弃);`LSM.get` 缓存未命中返回 `null` 与"确实不存在"**不可区分**(`lsm.ts:734-753`),整条读路径靠"每次读前必须 prefetch"维持 —— 而 `compactLevelAsync:508-515` 的同类问题**已经修了**,`get` 没修(同类问题只修一半)。
|
||
|
||
### 根因 5 ★ 崩溃语义是"声称"而非"已验证"
|
||
|
||
- 单测的"崩溃"统一是 `await engine.backend.close()`(`aria-matrix-audit.test.ts:93`、`aria-prod-load.test.ts:71`、`aria-wal-segment.test.ts:173` 等十余处),而 `opfs_backend.ts:39-46` 的 `close()` 明确 `await this.writeQueue` ——**这是优雅停机,把所有在途写刷完**。
|
||
- `tests/helpers/opfs-mock.ts` 是立即生效、原子、**绝不撕裂**的内存 Map,无法表达半写/部分删除失败/读故障。
|
||
- e2e 的 `crashPage()`(`opfs.spec.ts:32-42`)只做 `window.__ms=null; page.close()`,注释自承"Playwright 无 Page.crash 公共 API"(实际 CDP 可用)。
|
||
- 结论:**这套测试在结构上不可能发现根因 4 的任何一条。**
|
||
|
||
### 根因 6 生命周期只有布尔,没有状态机
|
||
|
||
`ready`/`opened`/`txActive`/`snapshot` 分散在 core、四个引擎、连接池、React、Vue 六处各自维护,`close/init/reload` 都非幂等且非异常安全【全部实测】:
|
||
|
||
- `MemoryEngine.close()` 不清事务快照 → 重开后 `beginTransaction` 永久 `TX_ACTIVE`,`rollbackTransaction` 用**关闭前的陈旧快照覆盖新会话**(数据复活)
|
||
- `close()` 后再 `init()` 不重建 BroadcastChannel → `multiTabSync` 静默失效
|
||
- `close()` 无 try/finally → engine.close 抛错时 `isReady()` 仍为 true(半关闭态),钩子已全丢
|
||
- 连接池:用户直接 `db.close()` 后同名 `connect()` 新建实例并把 refCount 置 1,**任意一次 disconnect 就关闭它**
|
||
- 多标签页 reload 不检查活跃事务 → 内存快照被销毁、`txActive` 残留 → 提交时内存≠磁盘
|
||
|
||
### 根因 7 "未解析/未知"一律静默降级为 false
|
||
|
||
这是**复发型缺陷模式**,CHANGELOG 连续三个版本都在修它的不同实例:
|
||
|
||
- v0.7.3:`queryStream` 遇 `$subquery` → `matchWhere` 恒 false → 静默空结果
|
||
- v0.7.4:`UPDATE/DELETE` 遇 `$subquery` → **静默影响 0 行**
|
||
- 本次仍存在:`WHERE t.x = t.y`(唯一可解析的列对列写法)恒 0 行;`IN (SELECT ... WHERE o.user_id = u.id)` 关联引用静默空
|
||
|
||
修法是**逐路径补丁 + 两个手写重复的递归探测器**(`core.ts:284-306`、`where-matcher.ts:40-62`),而不是让 matcher 对无法表达的操作数直接抛错。
|
||
|
||
同类:未知列 `SELECT bogus FROM t` → `[{},{},{},{}]`;`GROUP BY bogus` → 全表并一组;`ORDER BY bogus` → 随机顺序;未知 WHERE 操作符 → 静默全匹配。
|
||
|
||
### 根因 8 错误分类与所有权边界缺失
|
||
|
||
- 错误码散落字符串,`onError` 逐方法手写调用(`core.ts` 里 20+ 处 try/catch),Table API 路径、export/backup、migrateTo、repair、clearAll **都不触发 onError**【实测】
|
||
- **行所有权无边界**:`find/findStream` 返回引擎内部行对象引用 → `rows[0].tag='HACKED'` 直接改库,memory/disk/hybrid 下**索引与行失配,该行变得不可检索**【实测】
|
||
- 而 ALTER DROP COLUMN 又**依赖**"find 返回引用"这一副作用来删列数据 —— 一个 bug 被另一个 bug 依赖
|
||
|
||
---
|
||
|
||
## 四、v0.7.5 迭代方案
|
||
|
||
### 总览:三条并行工作流 + 一道硬门禁
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────────────────────┐
|
||
│ 工作流 A · 止血(阻断发布) 11 项事故级 + 18 项高优 P1 │
|
||
│ 目标:让 v0.7.5 不再产生错误结果 / 静默丢数据 / 崩溃 │
|
||
├──────────────────────────────────────────────────────────────────┤
|
||
│ 工作流 B · 根治(砍掉复发结构) 6 个结构性改造 │
|
||
│ 目标:让"同一 bug 修四次"在结构上不可能发生 │
|
||
├──────────────────────────────────────────────────────────────────┤
|
||
│ 工作流 C · 验证(让门禁真的能拦) 故障注入 + 覆盖率口径 + CI 门禁 │
|
||
│ 目标:让 A 和 B 的成果可证明、可回归 │
|
||
└──────────────────────────────────────────────────────────────────┘
|
||
▼ 硬门禁 G1~G6(§7)全部满足才允许发版
|
||
```
|
||
|
||
**排期建议**:A 与 C 可并行开始(C 先做,因为它能验证 A);B 必须在 A 完成后开始,否则会在移动的地基上重构。
|
||
|
||
---
|
||
|
||
### 工作流 A · 止血(11 项事故级,必须全修)
|
||
|
||
按"用户能感知的伤害"排序。每项给出:现象 → 根因位置 → 修复方向 → 必须新增的回归测试。
|
||
|
||
#### A1 ★ `UPDATE` 多行撞同一新主键 → 静默丢行 【实测】
|
||
|
||
- **现象**:`UPDATE t SET id='X'`(匹配 2 行)返回 affected=2,表中只剩 1 行
|
||
- **根因**:`memory.ts:274` 阶段 1 只与"语句执行前的表"比对,**批内新主键互查缺失**;阶段 2 逐行 `set` 互相覆盖。`primaryKey:true` 不隐含 `unique:true`,`checkUpdateUniqueness` 只遍历 `unique` 列,兜不住
|
||
- **对照**:INSERT 路径 v0.7.3 已做批内 PK Set 互查 —— **UPDATE 漏了**
|
||
- **修复**:UPDATE 两阶段预检增加批内新主键 Set 互查(与 INSERT 同一模式);四引擎统一
|
||
- **测试**:2 行改同一新主键 → 必须抛 `DUPLICATE_KEY` 且**表内容不变**(值级断言,非仅行数)
|
||
|
||
#### A2 ★ `ALTER ADD COLUMN ... UNIQUE` 唯一约束永久失效 → 重启静默丢行 【实测】
|
||
|
||
- **现象**:`ALTER TABLE t ADD COLUMN email STRING UNIQUE` → 插入两条相同 email 都不报错 → close/reopen → **只剩 1 行,无任何错误或警告**
|
||
- **根因链**:`memory.ts:122-127` ALTER ADD 只写 `schema.columns` **不建索引桶** → `memory.ts:316-339` 唯一预检依赖索引桶,桶缺失即整段跳过 → `kvstore_engine.ts:103-108` 重启回灌时第 2 行触发 `UNIQUE_VIOLATION`,**异常被 `catch {}` 吞掉**
|
||
- **修复**:① ALTER ADD 同步建索引桶;② 重启回灌的静默 catch 必须记录并上报(`KV_REBUILD_ERROR`)
|
||
- **测试**:ALTER ADD UNIQUE → 重复插入必须报错;已存在重复数据的表重启后必须**报错而非静默丢行**
|
||
|
||
#### A3 ★ 崩溃后已回滚的行复活(SAVEPOINT)【实测】
|
||
|
||
- **现象**:`BEGIN; INSERT a; SAVEPOINT s; INSERT b; ROLLBACK TO s; COMMIT` → 实时只剩 a,**崩溃重开变成 a+b**
|
||
- **根因**:`aria/index.ts:1550-1579` 只改内存 `txnSnapshot`,**WAL 无补偿记录**;恢复按 `committedTxns` 应用该事务**全部**记录
|
||
- **修复**:`ROLLBACK TO` 写入补偿 WAL 记录(或在恢复时按 savepoint 边界过滤)
|
||
- **测试**:上述序列后丢弃引擎实例重开,断言实时态 == 恢复态
|
||
|
||
#### A4 ★ SAVEPOINT 跨事务泄漏 → 当前事务写入被上一事务快照替换 【实测】
|
||
|
||
- **现象**:`begin; update; savepoint sp; commit` 后 `savepoints` 仍含 `sp`;新事务 `update` 后 `ROLLBACK TO sp` + `COMMIT` → **新写入静默消失**(本方案验证时实测 v=2 被回退为 v=1)
|
||
- **根因**:`aria/index.ts:1467-1534` 的 commit/rollback **都不清理 `this.savepoints`**;`index.ts:1563` 用 `sp.snapshot` 整体替换当前快照
|
||
- **修复**:commit/rollback 清空 savepoint 表;`rollbackToSavepoint` 校验 `sp.txnId === currentTxnId`(存了 txnId 却从不校验)
|
||
- **测试**:跨事务复用 savepoint 名 → 必须拒绝;当前事务写入不得丢失
|
||
|
||
#### A5 ★ `MIN`/`MAX` 20 万行栈溢出 【实测】
|
||
|
||
- **现象**:`SELECT MAX(v) FROM big`(20 万行同组)→ `RangeError: Maximum call stack size exceeded`
|
||
- **根因**:`executor.ts:677-678` 用 `Math.min(...distinctNums)` 展开实参
|
||
- **修复**:改为单次遍历归约
|
||
- **测试**:20 万行 `MIN`/`MAX`/`SUM`/`AVG` 全部正常返回
|
||
|
||
#### A6 ★ `LIMIT/OFFSET` 被应用两次 → 丢行 【实测】
|
||
|
||
- **现象**:id=1..4 上 `SELECT id FROM t ORDER BY id LIMIT 2 OFFSET 1` → `["3"]`(SQL 应为 `2,3`);`LIMIT 10 OFFSET 3` → `[]`
|
||
- **根因**:`compiler.ts:40-41` 已把 limit/offset 放进 plan → 引擎 `memory.ts:203`/`aria/index.ts:696` 已切一次 → `executor.ts:391-393` **再切一次**
|
||
- **注**:JOIN 路径与派生表路径**正确**,同一 executor 内自相矛盾
|
||
- **修复**:limit/offset 只在**一处**执行(见工作流 B-3 单管线)
|
||
- **测试**:`LIMIT n OFFSET m`(m>0)在 JOIN / 非 JOIN / 派生表 / UNION 四条路径结果一致
|
||
|
||
#### A7 ★ `queryStream` 与 `query` 结果不一致【实测】
|
||
|
||
| SQL | `query()` | `queryStream()` |
|
||
|---|---|---|
|
||
| `SELECT id AS x FROM t` | `[{x:'a'}]` | `[{id:'a',v:1}]` → **全列+原列名** |
|
||
| `SELECT t.id FROM t` | `[{id:'a'}]` | `[{}]` → **空对象** |
|
||
| `LIMIT 2 OFFSET 1` | 1 行 | 2 行 |
|
||
| `LIMIT 0` | `[]` | 1 行(Aria 却为 `[]`) |
|
||
|
||
- **根因**:`core.ts:316-319` 用正则自行推导投影,未复用 executor 的 `projectRow`/别名规则
|
||
- **修复**:流式路径必须复用同一套投影与 limit/offset 语义(B-3)
|
||
- **测试**:**参数化契约测试**——同一 SQL 集合,断言 `queryStream` 收集到的行严格等于 `query` 结果(这是防复发的关键测试)
|
||
|
||
#### A8 ★ 行引用泄漏 → 调用方可直接改库并破坏索引 【实测】
|
||
|
||
- **现象**:`rows = await db.query('SELECT * FROM t'); rows[0].tag='HACKED'` → 存储被改,且 **`tag='HACKED'` 与 `tag='x'` 两条索引查询都返回 0 行**(行不可检索)
|
||
- **引擎差异**:memory/disk/hybrid 全部泄漏;**Aria 不泄漏**(走反序列化)
|
||
- **根因**:`memory.ts:209/228/765` 直接返回内部行对象
|
||
- **修复**:在引擎接口定义"读返回副本"(`structuredClone` 或按 schema 深拷贝),并把依赖引用的 ALTER DROP COLUMN 改为显式 `deleteRowColumn` API
|
||
- **测试**:修改查询结果后重读必须不变;索引查询必须仍能命中
|
||
|
||
#### A9 ★ `subscribe()` 对本地写入永不触发 【实测】
|
||
|
||
- **现象**:`db.subscribe('t', fn)` 后本地 `INSERT`/`UPDATE`/`DELETE`(SQL 与 Table API 两条路径)**fn 调用 0 次**
|
||
- **根因**:全库仅 `core.ts:84`(BroadcastChannel 外部消息)调用 `emit`;写路径只调 `broadcastChange`
|
||
- **文档冲突**:`site/docs.html:667-679` 明确示例 `event.type: 'insert' | 'update' | 'delete'`;README:232 宣称"订阅表变更"
|
||
- **修复**:写路径接入 `emit`(含 row 与 type),或**明确降级文档**并移除误导示例
|
||
- **测试**:两条入口的三种写操作都必须通知订阅者
|
||
|
||
#### A10 ★ `t.x = t.y` 与关联子查询静默空结果 【实测】
|
||
|
||
- **现象**:`SELECT id FROM t WHERE t.x = t.y` → `[]`(应 3 行);`WHERE id IN (SELECT user_id FROM o WHERE o.user_id = u.id)` → `[]`(应 2 行),而同结构 EXISTS 正确
|
||
- **根因**:`executor.ts:344` 把含 `$col` 的条件交给引擎,而引擎 `matchWhere` 无 `options.$col` 上下文 → 行全被滤光,`filterCorrelated` 拿到空数组;`executor.ts:1417` 执行子查询时**不传外层行**
|
||
- **注**:`tests/v073-fixes.test.ts:267-278` 是这条语义的"护栏",但它只比较 `query()` 与 `queryStream()` 的**行数**(两者都是 0)→ **空断言掩盖错误**
|
||
- **修复**:统一表达式求值器,取消"是否相关"预判(B-4)
|
||
- **测试**:值级断言 + 修正那条空断言护栏
|
||
|
||
#### A11 ★ `AND`/`OR` 无优先级(影响面最大,改动最小)【实测】
|
||
|
||
- **现象**:`WHERE a=1 OR a=2 AND b=3` → 解析为 `(a=1 OR a=2) AND b=3`,返回 `["2","4"]`;加括号的正确写法返回 `["1","2","4"]`
|
||
- **根因**:`parser.ts:780-797` 见 AND/OR 就左结合包一层,无优先级分层
|
||
- **影响**:任何"权限条件 OR 业务条件 AND 软删标记"的写法都会静默错行
|
||
- **修复**:`parseCondition` 拆 `parseOr → parseAnd → parseUnary` 三层
|
||
- **测试**:断言 AST 结构与值级结果,覆盖 `A OR B AND C`、`A AND B OR C`、嵌套括号
|
||
|
||
### 工作流 A 的第二梯队(18 项高优 P1,逐项列入 §5 总账)
|
||
|
||
其中**必须在 v0.7.5 内完成**的:
|
||
|
||
| # | 缺陷 | 实测现象 | 一句话修法 |
|
||
|---|---|---|---|
|
||
| A12 | `maxLength`/`min`/`max` 三引擎不校验 | memory/disk/hybrid 接受 `score:999`(max 100) | 统一 `validateRow`(B-1) |
|
||
| A13 | 自引用外键全失效 | 同表 CASCADE 不删、RESTRICT 不拦 | 用 `visited` 集合安全处理(`memory.ts:393/432/498/822` 四处跳过) |
|
||
| A14 | ON UPDATE CASCADE 只一层 | A→B→C 链改 A 主键后 C 悬空 | 递归 + 传递闭包 |
|
||
| A15 | `= NULL` / `NOT BETWEEN` 三值逻辑错 | `v = NULL` 命中 null 行;`NOT BETWEEN 1 AND 2` **排除** NULL 行 | 引入 `sqlEquals/sqlCompare` 返回 TRUE/FALSE/UNKNOWN(B-2) |
|
||
| A16 | INSERT arity 不校验 | `INSERT INTO t(id) VALUES('1','LOST')` 静默丢值 | 解析期校验 arity |
|
||
| A17 | INSERT 未知列静默丢弃 | `INSERT INTO t(id, nmae)` 静默丢 typo 列 | 与 UPDATE 对齐,`COLUMN_NOT_FOUND` |
|
||
| A18 | 未知列投影返回 `{}` | `SELECT bogus FROM t` → `[{},{},...]` | 投影期 `COLUMN_NOT_FOUND` |
|
||
| A19 | 双引号当字符串 | `SELECT "name" FROM t` → 常量列 `{"'name'":"name"}` | 双引号 = 标识符(或明确拒绝) |
|
||
| A20 | 未闭合块注释静默接受 | `db.query("DELETE FROM t WHERE id='4' /*")` **真的删了 1 行** | 抛 `PARSE_ERROR` |
|
||
| A21 | 连续注释递归爆栈 | `' /*c*/'.repeat(20000)` → `RangeError` | 改循环跳过 |
|
||
| A22 | GROUP BY 别名列静默消失 | `SELECT v AS s ... GROUP BY dept` → `s` 整列消失 | 列绑定(B-5) |
|
||
| A23 | HAVING 不能引用未在 SELECT 的聚合 | `HAVING COUNT(*)>1` → `[]` | HAVING 独立求值顺序 |
|
||
| A24 | 空集聚合返回 0 而非 NULL | `SUM(v) FROM t WHERE 1=0` → `{s:0}` | SQL 语义修正 |
|
||
| A25 | 带表前缀的聚合参数恒 0 | `COUNT(t.v)` → 0(应 4) | 参数归一 |
|
||
| A26 | UNION 尾部 ORDER BY/LIMIT 丢失 | `UNION ... ORDER BY v LIMIT 2` → 4 行乱序 | 归属 UNION 而非右侧 SELECT |
|
||
| A27 | DISTINCT 作用在投影前 | `DISTINCT dept AS d` → 4 行(应 2) | 子句顺序编码(B-3) |
|
||
| A28 | `UPDATE t SET __proto__=...` 静默吞掉 | 返回 1 但无变化 | 全链路 `Object.create(null)`(v0.7.1 只修了建表) |
|
||
| A29 | `INSERT ... SELECT` 被 `maxRowsPerQuery` 静默截断 | maxRows=2 时 4 行源只插 2 行 | 上限只作用于**用户可见结果** |
|
||
|
||
---
|
||
|
||
### 工作流 B · 根治(砍掉复发结构)
|
||
|
||
**B 的每一项都是"一次性消除一整类缺陷",而不是再修一个实例。** 建议按 B-1 → B-2 → B-3 → B-4 → B-5 → B-6 顺序推进,每项独立可交付。
|
||
|
||
#### B-1 唯一校验与规范化咽喉
|
||
|
||
- **做什么**:把 `validateRow` 收敛为 `src/table/schema.ts` 的**唯一实现**,引擎边界只接受"已校验 + 已规范化"的行;统一 JSON 安全编码(`NaN`/`Infinity` 显式拒绝或归一为 NULL)
|
||
- **消除**:A12(三引擎约束失效)、NaN 落盘变 null 的引擎差异、INSERT 未知列(A17)、schema 外列静默丢弃
|
||
- **验收**:跨引擎参数化契约测试——**同一份 schema + 同一批非法数据,四个引擎必须抛同样的错误码**
|
||
|
||
#### B-2 唯一值比较与编码(SQL 三值逻辑)
|
||
|
||
- **做什么**:提供唯一的 `sqlEquals(a,b) / sqlCompare(a,b) → TRUE|FALSE|UNKNOWN` 与唯一的 `encodeValueKey(v)`;比较、`IN`/`NOT IN`、LIKE、JOIN 键、分组键、DISTINCT 键、UNION 去重**全部**走它
|
||
- **消除**:A15(NULL 三值)、JOIN 的 NULL 键"取决于右表有没有索引"、`COUNT(DISTINCT)` 把 `1` 与 `'1'` 合并、`encodeGroupKey` 用 `\x1f` 连接可被伪造、混合类型排序回退 `localeCompare`
|
||
- **验收**:NULL 语义矩阵测试(`= NULL`/`!= NULL`/`IN (1,NULL)`/`NOT IN`/`NOT LIKE`/`NOT(...)` 各 6 种数据形态);`1` vs `'1'` 在各引擎行为一致
|
||
|
||
#### B-3 单管线:SQL 只有一条执行路径
|
||
|
||
- **做什么**:
|
||
1. `SelectQueryBuilder`/`UpdateQueryBuilder`/`DeleteQueryBuilder` **只产出 AST**,统一交给 executor(删除 `builder.ts:105` 的引擎直通)
|
||
2. `queryStream` 改为对**同一管线**的惰性消费者,禁止自建投影/limit 逻辑
|
||
3. 把 SQL 标准子句顺序编码为显式 stage 列表:`FROM → JOIN → WHERE → GROUP BY → HAVING → 聚合 → SELECT 投影 → DISTINCT → ORDER BY → LIMIT/OFFSET`
|
||
4. limit/offset/投影/排序**只在管线的一处执行**,引擎退化为"带索引的行迭代器 + WHERE 匹配"
|
||
- **消除**:A6(双重 LIMIT)、A7(流式不一致)、A22(GROUP BY 别名)、A27(DISTINCT 顺序)、A29、事务内 JOIN 静默忽略、`maxRowsPerQuery` 只在一条路径生效、链式 `where` 覆盖
|
||
- **验收**:
|
||
- **等价性契约测试**:同一 SQL 经 `query` / `queryStream` / `QueryBuilder` / 事务内**四条入口**,结果必须逐值相等(这是本方案最重要的防复发测试)
|
||
- 子句顺序 golden 测试(LIMIT/DISTINCT/GROUP BY/HAVING 组合矩阵)
|
||
|
||
#### B-4 统一表达式求值器(取消"关联性"预判)
|
||
|
||
- **做什么**:表达式求值统一携带 `outerRow` 上下文;删除 `hasCorrelatedRefs` / `stripCorrelatedExists` / `filterCorrelated` 这条特殊通道;**matcher 遇到无法表达的操作数(未解析 `$subquery`、未知操作符)必须抛 `QUERY_ERROR`,禁止返回 false**
|
||
- **消除**:A10(`t.x=t.y` 与关联子查询静默空)、根因 7 整类(v0.7.3/v0.7.4 修过两次的同一模式)、`WHERE EXISTS(...)` 不相关查询被误判为相关而抛 `NOT_SUPPORTED`
|
||
- **验收**:所有"未解析标记"路径的参数化测试必须断言**抛错**而非空结果
|
||
|
||
#### B-5 AST 携带绑定后的列身份
|
||
|
||
- **做什么**:`columns: string[]` 升级为结构化表达式节点(列引用 / 别名 / 聚合 / CASE / 常量),并为每个输出列分配**稳定序号**;GROUP BY / ORDER BY / HAVING / 别名统一按序号对齐;executor 删除 6+ 处正则再解析
|
||
- **消除**:A22/A23/A24/A25/A26、`SELECT 1 AS one` 返回 `{}`、CASE 正则切分(`'a ELSE b'` → `"'a"`)、嵌套 CASE 吞掉 FROM、`COUNT(DISTINCT *)` 忽略 DISTINCT、`GROUP BY` 返回每组第一行的任意值
|
||
- **验收**:表达式矩阵 golden 测试(每类表达式 × 投影/别名/分组/排序/HAVING/流式)
|
||
|
||
#### B-6 存储层单一提交点(manifest)
|
||
|
||
- **做什么**:引入 `__aria_manifest`(`magic + formatVersion + generation + CRC`,OPFS 单文件 COW 原子写),内容为 `{各 LSM 命名空间 meta 快照, WAL 起始分片与起始 LSN, pageId 水位, **待落盘冻结表意图**}`;写入顺序固定为 **数据落盘 → manifest 提交 → 才允许截断 WAL/删除旧分片**;恢复只认"最后一份 CRC 通过的 manifest";**任何引用不到的东西一律保留而非删除**;所有计数器在 manifest 中单调推进、永不复用
|
||
- **消除**:根因 4 整类 —— 页面 id 回退、flush/compaction meta 竞态、KVStore 截断清零、介质读故障误判、陈旧实例 checkpoint 覆盖、DDL 非原子、SSTable meta 损坏静默空库 + repair 删活页、WAL 中段损坏导致尾部全丢、分片 truncate 失败导致旧世代记录排在最新记录之后、**后台 flush 失败后冻结数据无重试路径(总账第 44 项)**
|
||
- **同时要做**(LSM 单项改造,可与 manifest 并行):
|
||
- `LSM.get/rangeScan` 改为**自洽异步读**(未命中 `await store.load` + 用类型区分 MISS 与不存在),可一次删掉引擎层全部 7 处 `drainChain()+prefetch*`
|
||
- `flushChain` 换成**带意图记录、可重试**的串行工作队列;`compacting` 由单 boolean 改 `Set<level>`
|
||
- `flush()` 的 `lastBackgroundError` 检查移到**入链之后**(错误要报告,但不能因此跳过本次 flush)
|
||
- `sstable.ts` 三份解析循环合并为一份并统一越界策略
|
||
- **前置**:必须先有工作流 C 的故障注入后端,否则无法证明修好了
|
||
- **验收**:故障注入矩阵(撕裂写 × 崩溃点 × 后端)下"已确认写入零丢失 + 无幻影行"不变量恒成立;另外新增两条针对性断言:**注入一次 `save()` 失败后,第二次 `flush()` 必须真的落盘**(当前失败);**`limit=5` 的流式扫描生成器产出必须恰好 5 条**(当前 6 条)
|
||
|
||
**B-6 的降级选项**(若 v0.7.5 周期不够):至少完成五处止血 ——
|
||
① KVStore open 损坏分支改为"截断到有效前缀"(复用 `repair()` 的 `log.subarray(0,validBytes)` 写法),而不是写空文件;
|
||
② `appendRecord` 的"内存更新 + 自动 checkpoint"必须纳入失败语义:要么 checkpoint 失败**不得**让已提交的写报错(改为记 `lastBackgroundError` 并让下一次 `checkpoint()` 抛出),要么在报错时**同时回滚内存** —— 当前两者都不做,是最坏组合;
|
||
③ 给 KVStoreEngine 补 Web Lock,或把实例 id / 启动世代编进 `seq` 与快照水位,杜绝陈旧实例覆盖;
|
||
④ `commitTransaction`/`rollbackTransaction` 清空 `savepoints`,并校验 `sp.txnId === currentTxnId`;
|
||
⑤ `AriaEngine.close()` 增加活跃事务守卫(当前会截断 WAL 并把未提交的二级索引条目落盘 → 重开后幽灵 `UNIQUE_VIOLATION`)。
|
||
|
||
---
|
||
|
||
### 工作流 C · 验证基础设施(让门禁真的能拦)
|
||
|
||
**C 应该最先做**,因为它能验证 A 和 B 的成果。
|
||
|
||
#### C-1 故障注入后端(约 60 行,最高性价比)
|
||
|
||
实现 `IStorageBackend` 包装器,支持:
|
||
|
||
```
|
||
failNextWrite / failNextAppend / truncateTail(n) / tearAt(awaitPoint) / dropNextDelete
|
||
```
|
||
|
||
并把 `tests/helpers/opfs-mock.ts` 升级为**可按字节粒度半提交**(当前是立即生效、原子、绝不撕裂的内存 Map)。
|
||
**没有它,本层所有崩溃相关声称都只是"重开测试"。**
|
||
|
||
#### C-2 真崩溃注入(e2e)
|
||
|
||
`tests/e2e/opfs.spec.ts:32-42` 的 `crashPage()` 改用 CDP:
|
||
|
||
```ts
|
||
const cdp = await context.newCDPSession(page);
|
||
await cdp.send('Page.crash');
|
||
```
|
||
|
||
至少覆盖三种窗口:**写入不 await 即崩溃** / **checkpoint 中途崩溃** / **WAL 半写**。
|
||
|
||
#### C-3 覆盖率口径修正与门禁
|
||
|
||
1. `jest.config.cjs:18` 的 `!src/**/index.ts` 改为**显式列白名单**(只排除真正的 barrel 与纯类型文件),并删除残留的 `!src/engine/opfs.ts`(文件已不存在)
|
||
2. README/CHANGELOG/site 的"90.1% 行覆盖率"改为准确表述:**语句 / 行 / 分支三个数字 + 明确采集范围**
|
||
3. 新增 `coverageThreshold`:先按当前实测(行 93%、分支 83%)**各减 2pp** 设为下限,并对 `src/engine/**`、`src/query/**` 设 per-path 阈值
|
||
4. CI 常规 job 加 `--coverage`
|
||
5. CI 的 lint 去掉 `continue-on-error: true`
|
||
6. 新增 `tsconfig.test.json` 对 `tests/` 做类型检查(当前 babel 剥离类型,测试里的类型错误完全不可见)
|
||
7. CI 加 `dist` 同步校验(build 后 `git diff --exit-code dist`)
|
||
8. `coverage/` 生成前清理(当前残留 9 个已删除源文件的幽灵 HTML)
|
||
|
||
#### C-4 契约测试取代行为固化
|
||
|
||
现有测试有三类"虚假信心"必须清理:
|
||
|
||
| 类型 | 实例 | 处理 |
|
||
|---|---|---|
|
||
| 标题与断言不符 | `v042-fixes.test.ts:308-322` 题为"抛 ARIA_OPEN_ERROR"实际断言 `resolves`;`v044-hardening.test.ts:137-142` 断言 `not.toBe('pending')`(成功/失败都过) | 改写为断言具体错误码 |
|
||
| 空断言护栏 | `v073-fixes.test.ts:267-278` 比较两条**同样错**的路径(都是 0 行) | 改为值级断言 |
|
||
| 只比数量不比内容 | `aria-prod-load.test.ts:117-128` 2000 次随机 update/delete 只比 count | 改为结果值比对 |
|
||
| 恒真/无断言 | `aria-batch.test.ts:109-110`(无 expect);`maintenance-sql.test.ts:105`(`>=0`);`v074-fixes.test.ts:452-456`(断言测试自己写的字面量) | 补齐或删除 |
|
||
| 错误码吞掉 | `v073-fixes.test.ts` 等 12 处 `catch { threw = true }` 不绑定异常 | 统一用仓库已有的 `expectCode()` |
|
||
|
||
**新增必测矩阵**(这些正是历史 P0/P1 的形态):
|
||
- 跨引擎参数化契约(同一断言跑 memory/disk/hybrid/aria:约束、钩子、事务、流式、错误码)
|
||
- 入口等价性契约(`query` == `queryStream` == `QueryBuilder` == 事务内)
|
||
- 崩溃不变量(故障注入下"已确认写入零丢失 + 索引与主表一致 + 唯一约束仍生效")
|
||
- NULL 三值逻辑矩阵
|
||
- 错误码契约表(当前 **36 个错误码中 14 个从未被任何测试断言**)
|
||
- 性能护栏改为**相对基线**(当前 `aria-prod-load.test.ts` 的 240s/300s 阈值只能拦住"353s 级"总崩,拦不住 2~5 倍回退)
|
||
|
||
#### C-5 属性测试(property-based)—— 最高价值的长期投资
|
||
|
||
随机操作序列(insert/update/delete/事务/savepoint/checkpoint/崩溃点)后断言四引擎不变量:
|
||
|
||
- 行集合 == 已确认操作集合
|
||
- 每个二级索引与主表一致
|
||
- 唯一约束仍生效
|
||
- `count()` 与 `find()` 一致
|
||
|
||
**CHANGELOG 里横跨 7 个版本的 P0/P1 全部属于此类不变量违反**。随机化能把它们一次网住,而不是每版抓一只。
|
||
|
||
---
|
||
|
||
## 五、缺陷总账(55 项,按优先级)
|
||
|
||
**级别定义**:**P0** = 崩溃 / 静默丢数据 / 静默错误结果;**P1** = 约束或事务语义破坏;**P2** = 边界与健壮性;**P3** = 工程与文档。
|
||
|
||
### 已实测复现(本次或子代理实跑)
|
||
|
||
| # | 级别 | 缺陷 | 位置 | 工作流 |
|
||
|---|---|---|---|---|
|
||
| 1 | P0 | UPDATE 多行撞同一新主键 → 静默丢行 | `memory.ts:274,289-300` | A1 |
|
||
| 2 | P0 | ALTER ADD UNIQUE 失效 → 重启静默丢行 | `memory.ts:122-127,316-339,562` + `kvstore_engine.ts:103-108` | A2 |
|
||
| 3 | P0 | SAVEPOINT 回滚不写补偿 WAL → 崩溃后行复活 | `aria/index.ts:1550-1579` | A3 |
|
||
| 4 | P0 | SAVEPOINT 跨事务泄漏 → 当前事务写入被替换 | `aria/index.ts:1467-1534,1563` | A4 |
|
||
| 5 | P0 | `MIN`/`MAX` 20 万行栈溢出 | `executor.ts:677-678` | A5 |
|
||
| 6 | P0 | `LIMIT/OFFSET` 双重应用 → 丢行 | `executor.ts:391-393` vs 引擎 | A6 |
|
||
| 7 | P0 | `queryStream` 与 `query` 结果不一致(别名/限定列/OFFSET/LIMIT 0) | `core.ts:316-319` | A7 |
|
||
| 8 | P0 | 行引用泄漏 → 改库 + 索引失配不可检索 | `memory.ts:209,228,765` | A8 |
|
||
| 9 | P0 | `subscribe()` 本地写入永不触发 | `core.ts:423-437` | A9 |
|
||
| 10 | P0 | `t.x=t.y` 与关联 IN 子查询静默空结果 | `executor.ts:344,1417` | A10 |
|
||
| 11 | P0 | `AND`/`OR` 无优先级 → 静默错行 | `parser.ts:780-797` | A11 |
|
||
| 12 | P0 | `KVStore.open` 损坏日志 → 有效前缀只留在内存,崩溃即永久丢失 | `kvstore/index.ts:135-138,394-399` | B-6 |
|
||
| 13 | P0 | 自动 checkpoint 失败 → 报错但数据已提交(调用方回滚无效) | `kvstore/index.ts:334-362,385-390` | B-6 |
|
||
| 14 | P0 | KVStore 无锁 → 陈旧实例 checkpoint 覆盖快照并截断日志吞掉对方数据 | `kvstore/index.ts:261-280,385-390` | B-6 |
|
||
| 15 | P1 | `maxLength`/`min`/`max` 三引擎不校验 | `memory.ts:713-722` vs `schema.ts:146-167` | A12/B-1 |
|
||
| 16 | P1 | 自引用外键 CASCADE/RESTRICT/ON UPDATE 全失效 | `memory.ts:393,432,498,822` | A13 |
|
||
| 17 | P1 | ON UPDATE CASCADE 只级联一层 | `memory.ts:430-448` | A14 |
|
||
| 18 | P1 | `= NULL` 命中 null 行;`NOT BETWEEN` 排除 NULL 行 | `parser.ts:845,967` + `where-matcher.ts:163` | A15/B-2 |
|
||
| 19 | P1 | INSERT arity 不校验 → 静默写半行/丢值 | `parser.ts:515-560,1166-1174` | A16 |
|
||
| 20 | P1 | INSERT 未知列静默丢弃 | `schema.ts:87-126` | A17 |
|
||
| 21 | P1 | 未知列投影返回 `{}`;标识符大小写敏感 | `where-matcher.ts:214-228` | A18 |
|
||
| 22 | P1 | 双引号当字符串 → `SELECT "name"` 返回常量列 | `lexer.ts:81-84,194-235` | A19 |
|
||
| 23 | P1 | 未闭合块注释静默接受 → DELETE 照常执行 | `lexer.ts:157-167` | A20 |
|
||
| 24 | P1 | 连续注释递归爆栈 | `lexer.ts:92,97` | A21 |
|
||
| 25 | P1 | GROUP BY 别名列静默消失 | `executor.ts:631-648` | A22/B-5 |
|
||
| 26 | P1 | HAVING 不能引用未在 SELECT 的聚合 | `executor.ts:628-651` | A23/B-5 |
|
||
| 27 | P1 | 空集聚合返回 0 而非 NULL | `executor.ts:660-680` | A24 |
|
||
| 28 | P1 | 带表前缀的聚合参数恒 0 | `executor.ts:328-336,662` | A25 |
|
||
| 29 | P1 | UNION 尾部 ORDER BY/LIMIT 丢失 | `parser.ts:366-388` + `executor.ts:153-200` | A26 |
|
||
| 30 | P1 | DISTINCT 作用在投影前 | `executor.ts:352,364,383` | A27/B-3 |
|
||
| 31 | P1 | `UPDATE SET __proto__` 静默吞掉 | `parser.ts:572-578` | A28 |
|
||
| 32 | P1 | `maxRowsPerQuery` 截断 `INSERT...SELECT` 源 | `executor.ts:707,396-398` | A29 |
|
||
| 33 | P1 | 嵌套 CASE 吞掉 FROM 之后全部文本 | `parser.ts:1088-1098` | B-5 |
|
||
| 34 | P1 | CASE 值含 ` ELSE `/`WHEN`/`''` → 静默错值 | `executor.ts:60,66,79,86-99` | B-5 |
|
||
| 35 | P1 | JOIN NULL 键结果取决于右表有无索引 | `where-matcher.ts:139-150` vs `executor.ts:531,543,553` | B-2 |
|
||
| 36 | P1 | 派生表别名限定失效 | `executor.ts:297-307` | B-3 |
|
||
| 37 | P1 | JOIN 内未限定列名静默失效 | `executor.ts:317-336,447-451` | B-3/B-5 |
|
||
| 38 | P1 | `compression:true` 在 pageStorage 开启时被静默忽略 | `aria/index.ts:1744-1749` | B-6 |
|
||
| 39 | P1 | `compressLZ4` 最坏 O(n²)(64KB→4167ms) | `lz4.ts:41-46` | B-6 |
|
||
| 40 | P1 | `close()` 不检查活跃事务 → 幽灵唯一冲突 | `aria/index.ts:281-296` | B-6 |
|
||
| 41 | P1 | `dropTable` DDL 非原子 → 表和数据复活 | `aria/index.ts:483-507` | B-6 |
|
||
| 42 | P1 | WAL 中段损坏 → 其后完好记录全丢 | `log.ts:264,270,276` | B-6 |
|
||
| 43 | P1 | WAL 单条 CRC 失配跳过 → 已提交事务部分应用 | `log.ts:287-298` | B-6 |
|
||
| 44 | **P0** | 后台 flush 失败后冻结数据无重试路径 → 崩溃即丢(`insert` 早已返回成功) | `lsm.ts:220-261,605,612,618` | B-6 |
|
||
| 45 | P1 | `flush()` 记过后台错误即跳过本次 flush(`lastBackgroundError` 检查在入链之前) | `lsm.ts:580-590` | B-6 |
|
||
| 46 | P1 | `LSM.get` 缓存未命中当"不存在"(同类问题 `compactLevelAsync` 已修、`get` 未修) | `lsm.ts:734-753,417-422` | B-6 |
|
||
| 47 | P1 | `rangeScanLazy` 早停多算 1 条(生成器已推进到下一项才收到 stop) | `lsm.ts:440-479` + `index.ts:1220-1225` | B-6 |
|
||
| 48 | P2 | `MemTable.delete` 大小记账无条件下调 → 可致负数与 flush 阈值失真 | `memtable.ts:441-447` | — |
|
||
| 49 | P2 | `compacting` 单 boolean 跨层丢触发 → 深层维护饥饿 | `lsm.ts:78,269-283` | B-6 |
|
||
| 50 | P2 | compaction 预先 `splice` 整层 → 长 await 窗口内该层对读者不可见 | `lsm.ts:502,569` | B-6 |
|
||
| 51 | P2 | compaction 不回收墓碑与历史版本 → 空间/写放大无界(90% 删除场景不回收) | `lsm.ts:537-552` | B-6 |
|
||
| 52 | P2 | `vacuum()` 硬编码返回 `compactedLevels: 6`,而 `compactLevel` 对单文件层是静默 no-op | `lsm.ts:486-500` + `index.ts:2247-2261` | — |
|
||
| 53 | P2 | SSTable 解析循环三份拷贝、越界策略不一致(点查 vs 扫描行为不同) | `sstable.ts:80-100,145-166,182-201` | B-6 |
|
||
| 54 | P2 | 孤儿 `sst_*` blob 无清理路径(`cleanupOrphanPages` 只管 `pg_*`) | `index.ts:352-388` | B-6 |
|
||
| 55 | P2 | `checkpointManager.tick()` → `lsm.flush()` → `drainChain()` 等完整 compaction(v0.6.1 "8~11s 悬崖"的另一半来源) | `index.ts:664` + `lsm.ts:592` | B-6 |
|
||
|
||
### 已读码确认但未独立复现(清单存档,动工前需补复现)
|
||
|
||
生命周期与 API 面:MemoryEngine.close 泄漏快照(`memory.ts:43-45`);`init()` 无重入保护;`close()` 无 try/finally;连接池 refCount 重置与失败泄漏(`connection-manager.ts:36-77`);`migrateTo` 非原子(`core.ts:527-542`);`addMigration(v)` 中 `v <= config.version` **永久静默跳过**(默认 version=1 使迁移 1 永不执行,无任何警告)【实测】;`subscribe`/`emit` 语义;插件 `priority` 完全无效【实测】;`unregister` 不摘钩子【实测】;`onError` 覆盖不一致。
|
||
|
||
事务面:SQL `COMMIT` 可提前提交 `db.transaction` 外层事务(随后抛 `TX_NONE`)【实测】;事务结束后 `trx` 仍可写入且脱离事务【实测】;事务内 JOIN 静默忽略【实测】;事务内写入不广播、不触发钩子;`setMeta` 不受回滚影响。
|
||
|
||
集成面:React `useDatabase` StrictMode 下呈现"已关闭实例 + ready:true";Vue `watch([()=>sql])` 永不触发且无 `onUnmounted`;`package.json` 的 `./react`/`./vue` 指向裸 TS 源码且 d.ts 无导出;`./migration` 子路径未在 `exports` 声明(README 示例直接不可用);`DatabaseError`/`MetonaPlugin` 未从根入口导出。
|
||
|
||
存储面:`compression` 与 pageStorage 分支(A38);SSTable meta 无校验且 repair 删活页;介质 read 故障折叠为 null;`flushAll` 与 delete 竞争复活已删页;页面头 checksum 恒 0;加密无 AAD/无改密路径;PBKDF2 迭代数 100000(低于当前 OWASP 建议);Web Locks 不支持即无保护;MVCC 的 `snapshotLsn`/`prevVersion` 是死字段(README:358 "版本链 + 快照隔离"与实现不符)。
|
||
|
||
---
|
||
|
||
## 六、非目标(v0.7.5 明确不做)
|
||
|
||
避免范围蔓延,以下**明确排除**:
|
||
|
||
- ❌ 复合主键(v0.8 路线图,已由 `SCHEMA_ERROR` 显式拒绝)
|
||
- ❌ 算术/字符串表达式与完整表达式求值器(`a + 1`、`||`)——B-5 只做**结构化列身份**,不做运算符体系
|
||
- ❌ 真正的多版本 MVCC 并发(当前是单事务 + 全量快照)
|
||
- ❌ 标准 LZ4 兼容(当前是自定义编解码)
|
||
- ❌ 密钥轮换 / KDF 升级
|
||
- ❌ 表级约束、多列索引、`CREATE TABLE AS SELECT`
|
||
- ❌ 新存储引擎或新前端特性
|
||
|
||
**这些都是好功能,但把它们加到一个语义已经漂移的底座上,只会扩大缺陷面。**
|
||
|
||
---
|
||
|
||
## 七、验收标准(硬门禁)
|
||
|
||
**G1~G6 全部满足才允许发 v0.7.5。** 每条都是可机器验证的。
|
||
|
||
| 门禁 | 内容 | 验证方式 |
|
||
|---|---|---|
|
||
| **G1** | 工作流 A 的 11 项事故级全部修复,且每项有**值级**回归测试(非行数断言) | 新增测试套件全绿 |
|
||
| **G2** | 入口等价性成立:同一 SQL 经 `query`/`queryStream`/`QueryBuilder`/事务内四条入口结果**逐值相等** | 参数化契约测试 |
|
||
| **G3** | 跨引擎一致性:同一 schema + 同一操作在 memory/disk/hybrid/aria 四引擎**行为一致**(含错误码) | 参数化契约测试 |
|
||
| **G4** | 故障注入下崩溃不变量恒成立:已确认写入零丢失 + 索引与主表一致 + 唯一约束仍生效 | 故障注入矩阵(撕裂写 × 崩溃点 × 后端) |
|
||
| **G5** | 覆盖率真实且不倒退:`collectCoverageFrom` 无实现文件被排除;CI 有 `coverageThreshold`;README 数字与 lcov 一致 | CI 产出比对 |
|
||
| **G6** | 文档与实现一致:README/CHANGELOG/site 中**每一条能力宣称**都有对应测试或已删除 | 逐条核对清单(见 §8) |
|
||
|
||
**额外要求**:CI 的 lint 必须真正阻断(去掉 `continue-on-error`),并新增 `tests/` 类型检查。
|
||
|
||
---
|
||
|
||
## 八、宣称与实现不符清单(G6 核对表)
|
||
|
||
文档里目前有 **12 条**能力宣称缺少实现或测试支撑。每条给出二选一处理:
|
||
|
||
| 宣称 | 位置 | 实际 | 处理 |
|
||
|---|---|---|---|
|
||
| "在线一致性快照 `backup()`" | README:230 | Aria 逐表 `getAllRows`,**无跨表快照/无隔离**,且并入本事务未提交数据;公开 API 覆盖率 0 | 改为"逐表导出"或实现真快照 |
|
||
| "订阅表变更 `subscribe()`" | README:232 + `site/docs.html:667-679` | 本地写入**永不触发**(仅跨标签页 `external`) | 接线 或 修正文档与示例 |
|
||
| "插件 priority 越大越先执行" | README:62、`constants.ts:189`、`CONTRIBUTING:166` | priority 完全无效,顺序 = config 数组顺序 | 实现 或 删除该字段 |
|
||
| "14 种钩子 allow intercepting" | `CONTRIBUTING:137` | 只有**对象就地修改**有效;返回值被丢弃;SQL 路径 `beforeInsert` 改的是副本 | 明确钩子契约 + 测试 |
|
||
| "MVCC 版本链 + 快照隔离,事务读写不互斥" | README:358 | `snapshotLsn`/`prevVersion` 是死字段;并发被 `TX_ACTIVE` 拒绝 | 改写为"单事务 + 快照回滚" |
|
||
| "LZ4 页面压缩" | README:313/354 | pageStorage 开启时**压缩分支永不执行** | 修复(A38)或改称"可选压缩(整 value 模式)" |
|
||
| "`diskEngine`(disk 模式生效)" | README:181 | `core.ts:621-623` 传参但 KVStoreEngine **忽略**;hybrid 的 diskEngine 也无效 | 实现 或 删除配置项 |
|
||
| "5 种存储引擎" | README:420 | 4 种 mode + 3 种后端,不是 5 个引擎 | 修正表述 |
|
||
| "`@metona-team/metona-sqlark/migration`" | README:253 | `exports` **无 `./migration`** → `ERR_PACKAGE_PATH_NOT_EXPORTED` | 补 exports |
|
||
| React/Vue 集成 | README:386-393 + `package.json:19-27` | 指向**裸 TS 源码**;d.ts 无导出;无 peerDependencies | 构建 `dist/react.js`/`vue.js` + 补 d.ts + peerDeps |
|
||
| "连接池 `db.disconnect()`" | README:166-168,269-274 | `any` 注入,**d.ts 无声明**,tsc 不通过 | 进类型系统 |
|
||
| "行覆盖率 90.1%" | README:6,418 + CHANGELOG + site | 实为**语句 90.08%**;行 92.91%;分支 82.92% 未公布 | 修正口径(C-3) |
|
||
|
||
---
|
||
|
||
## 九、风险与破坏性变更
|
||
|
||
| 风险 | 说明 | 缓解 |
|
||
|---|---|---|
|
||
| **B-2/B-3 改变既有结果** | 修复 NULL 三值逻辑、LIMIT 语义、AND/OR 优先级后,**此前返回错误结果的查询结果会变** | 视为 **minor 版本**(0.7.5 → 建议改称 0.8.0),CHANGELOG 单列"语义修正(行为变更)"章节,逐条给出 before/after |
|
||
| **被测试钉死的错误行为** | `v062-fixes.test.ts:169-176` 把非标准字符串排序写成期望;`lexer.test.ts:47-51` 把双引号=字符串钉死;`hooks-crud.test.ts:127-138` 把"事务不触发钩子"钉死;`vue.test.ts:126-138` 把 watch 失效钉死;`kvstore.test.ts:176-210` 把"截断丢弃"钉死;`aria-wal-crc.test.ts:118` 把"记录跳过后继续"钉死 | 修复时**同步改断言并在 CHANGELOG 说明**;这些"护栏"本身就是缺陷的一部分 |
|
||
| **`forceExit: true` 掩盖泄漏** | 所有句柄/定时器泄漏都不会让测试失败 | C-3 增加一轮不带 `forceExit` 的 `--detectOpenHandles` 运行 |
|
||
| **性能护栏过宽** | 240s/300s 阈值只能拦总崩 | 改为相对基线 ±30% |
|
||
| **`src/engine/aria/index/lsm.ts` 审计未完成** | 本方案未覆盖 LSM 内部(红黑树不变量、compaction 级别、key 编码一致性、tombstone 复活) | **动工前必须补完**,其结论可能新增 P0 |
|
||
| **`dist/` 已提交入库** | 42 个提交都在改 `dist/`,仓库膨胀且无同步校验 | 建议移出版本控制(`files` 已在 package.json,发布时构建);若保留则加 CI 校验 |
|
||
|
||
---
|
||
|
||
## 十、建议里程碑
|
||
|
||
| 阶段 | 内容 | 出口条件 |
|
||
|---|---|---|
|
||
| **M0 · 准备** | 建立缺陷跟踪表(§5 的 55 项);实现 C-1 故障注入后端 + C-2 真崩溃注入 | 故障注入能稳定复现至少 3 个已知崩溃缺陷 |
|
||
| **M1 · 止血** | 工作流 A 全部 11 项事故级 + 18 项高优 P1 | G1 通过 |
|
||
| **M2 · 口径** | C-3 覆盖率修正与门禁 + C-4 契约测试清理 | G5 通过;CI lint 阻断 |
|
||
| **M3 · 根治** | B-1 → B-2 → B-3 → B-4(B-5/B-6 可视周期裁剪,但 B-6 至少完成三处止血) | G2/G3 通过 |
|
||
| **M4 · 收口** | B-5/B-6 收尾 + C-5 属性测试 + G6 文档核对 | G1~G6 全绿 |
|
||
|
||
---
|
||
|
||
## 十一、玥玥的判断(需要爸爸拍板的三件事)
|
||
|
||
**1. 版本号**:我倾向于**把它当作 0.8.0 而不是 0.7.5**。
|
||
理由:工作流 A/B 会**改变既有查询的返回结果**(NULL 语义、LIMIT、AND/OR 优先级),按语义化版本这是破坏性变更。把它藏在 patch 号里会让下游用户在无声中拿到不同的数据。**但爸爸更了解发布节奏与依赖方,最终请爸爸定。**
|
||
|
||
**2. 范围裁剪**:B-5(AST 结构升级)与 B-6(manifest)是**工作量最大的两项**,也是收益最大的两项。
|
||
- 若周期充足 → 全做,一次性还清语义债
|
||
- 若周期紧张 → 我的建议是 **B-3 + B-2 优先**(单管线 + 三值逻辑),因为它们消除的缺陷最多(A6/A7/A15/A19/A22/A27/A35/A37 共 8 项事故级的一半),而 B-5 可以推后到 0.9
|
||
- B-6 无论如何都建议至少完成三处止血(见 §B-6 降级选项),否则崩溃恢复的宣称始终是空头支票
|
||
|
||
**3. `dist/` 与 `.npmrc`**:
|
||
- `dist/` 已提交 42 个提交,我建议移出版本控制(发布时构建 + CI 校验)
|
||
- 我注意到本地 `.npmrc` 含一行 base64 的 registry 认证凭据(已解码可见用户名与口令)。**它未被 git 跟踪(`.gitignore` 已覆盖),这一点是好的**;但既然它存在于工作区,仍建议轮换一次该口令——凭据一旦以明文形式落过盘,就没有"确定没泄漏"这一说。
|
||
|
||
---
|
||
|
||
## 附录 A · 报告关系说明
|
||
|
||
本方案是**总纲**:§3 根因、§4 工作流、§5 总账、§7 验收标准均以本文件为准。
|
||
四份子系统明细报告(见附录 E)提供逐条 `file:line` 证据与验证方法,供动工时按图索骥。
|
||
凡明细报告与本方案冲突处,**以本方案为准**(本方案对冲突项做过独立复现与修正,见附录 C)。
|
||
|
||
## 附录 B · 复现命令速查
|
||
|
||
```bash
|
||
# 基线
|
||
npm test # 1304 通过 / 76 套件 / ~395s
|
||
npm run typecheck # exit 0
|
||
npm run lint # exit 0
|
||
|
||
# 真实覆盖率(不排除任何文件)
|
||
npx jest --coverage --collectCoverageFrom='src/**/*.ts' --coverageReporters=text-summary
|
||
|
||
# 不带 forceExit 检查句柄泄漏(当前被 jest.config.cjs:30 掩盖)
|
||
npx jest --detectOpenHandles --forceExit=false # 需临时改配置
|
||
|
||
# e2e(需先 build)
|
||
npm run build && npm run test:e2e
|
||
```
|
||
|
||
## 附录 C · KVStore 三个事故级缺陷的探针原始输出
|
||
|
||
以下是本方案定稿前,用真实 `KVStore` + `SharedMemoryBackend` 跑出的原始输出(探针跑完已删除)。
|
||
这三个缺陷是 B-6 的直接证据,也是"崩溃语义靠声称"的最清晰样本。
|
||
|
||
```
|
||
# ① 损坏日志恢复后崩溃 → 已确认前缀永久丢失
|
||
KV corruption setup: records=3 logBytes=84
|
||
KV 1st open: inMemory=[true,true,false] logBytes=0 snapshotBytes=0
|
||
KV after crash+restart: [false,false,false] <-- confirmed rows lost = BUG
|
||
|
||
# ② checkpoint 失败 → 报错但数据已提交
|
||
KV checkpoint-failure: put rejected='INJECTED snapshot write failure' inMemory=true logBytes=27
|
||
KV checkpoint-failure: durable after restart=true (caller saw REJECTION)
|
||
|
||
# ③ 陈旧实例 checkpoint 吞掉对方已提交数据
|
||
MULTI2: A.seq=0 B.seq=0 (both start from same snapshot/log)
|
||
MULTI2: after A.put A.seq=1 B.seq=0
|
||
MULTI2: after B.put+autoCheckpoint snapshotBytes=30 logBytes=0
|
||
MULTI2: restart -> fromA=LOST fromB=OK <-- cross-instance data loss
|
||
|
||
# ④ 附带:close() 不做 checkpoint(持久化契约缺口)
|
||
KV close-without-checkpoint: value after reopen = present # 仅在优雅关闭路径下成立
|
||
```
|
||
|
||
## 附录 D · LSM 层审计结论摘要(已完成)
|
||
|
||
`src/engine/aria/index/lsm.ts` 的深度审计已完成,**结论与其它子系统明显不同,值得单独说明**:
|
||
|
||
**常规路径是对的。** 8 组差分 fuzz(engine vs 参照 Map,含 `compactLevel(0..2)`)得到 **0 处不一致**;
|
||
红黑树 4000 步随机增删后中序与参照集完全一致;SSTable 13 组边界(块尾/跨前缀/空表/末块)全绿。
|
||
**所以不要期望 LSM 存在"普遍算错"类缺陷。**
|
||
|
||
审计同时**主动排除了三个很像 P0 的假警报**(这一步的价值不低于找到真 bug):
|
||
- **Bloom Filter 不会产生 false negative**:`Math.abs((h1+i*h2) % bits)` 看着像经典的负余数 bug,
|
||
但 `h1 + i*h2` 在双精度下永不溢出、`% bits` 恒非负 —— `Math.abs` 是冗余而非错误。
|
||
30k 随机 + 5k 密集 + 5 种真实 key 形态各 2000 key,FN **全为 0**。
|
||
- **崩溃中断 compaction 不会让旧文件遮蔽新数据**(逐时点验证层间新鲜度)。
|
||
- **PK 墓碑前缀不会误删 `k10`**(字符串比较下 `t:k1 < t:k10`,实测只删 1 行)。
|
||
|
||
**真正的问题在"未被执行的不变量"与"后台任务模型"**,已并入 §5 总账第 44~55 项。
|
||
|
||
**最高杠杆的单点改动**(已并入 B-6):把 `LSM.get/rangeScan` 从"同步 + 调用方负责预取"改为
|
||
**自洽的异步读**(未命中就 `await store.load`,并用类型区分 MISS 与"不存在"),`rangeScanLazy` 改为
|
||
async generator 按块读取。这一改动可**一次性删掉引擎层全部 7 处 `drainChain()+prefetch*`**
|
||
(`index.ts:574,1219,1605,2024,2077,2106,2113`),同时消掉性能悬崖与第 46 项;
|
||
配套把 `flushChain` 换成**带意图记录、可重试的串行工作队列**(`compacting` 改 `Set<level>`),
|
||
即同时解决第 44、45、49 项。
|
||
|
||
---
|
||
|
||
## 附录 E · 审计证据索引
|
||
|
||
完整报告(均为中文,含逐条 file:line 与验证方法):
|
||
|
||
| 报告 | 行数 | 覆盖范围 |
|
||
|---|---|---|
|
||
| `AUDIT-query-layer-v0.7.4.md` | 346 | query 层(ast/builder/compiler/executor/where-matcher) |
|
||
| `AUDIT-storage-engines-v0.7.4.md` | 243 | MemoryEngine / KVStore / KVStoreEngine / Hybrid |
|
||
| `AUDIT-aria-lsm-v0.7.4.md` | 558 | AriaEngine 门面 + LSM / MemTable / SSTable / Bloom / MergeIterator |
|
||
| `PLAN-v0.7.5.md`(本文件) | — | 根因分析 + 迭代方案 + 缺陷总账 + 验收标准 |
|
||
|
||
另有 SQL 层(tokens/lexer/parser/params)与 core/支撑层(core/table/transaction/plugin/
|
||
connection-manager/integrations/migration)两份完整报告,以及测试质量与 CI 审计报告,
|
||
内容已全部并入本方案的 §3 根因与 §5 总账。
|
||
|
||
---
|
||
|
||
## 附录 F · v0.8.0 实施进度日志
|
||
|
||
> 本附录记录**实际落地情况**(与 §4 的方案对照)。按提交顺序,每条都可 `git show` 复核。
|
||
|
||
### 已完成
|
||
|
||
| 提交 | 范围 | 关键结果 |
|
||
|---|---|---|
|
||
| `0dba1ab` | **工作流 C 基座 + 工程门禁** | 故障注入基座(TransactionalFileStore / FaultyBackend / 忠实 OPFS mock);覆盖率口径修正(移除吞掉 AriaEngine 的 `!src/**/index.ts`)+ coverageThreshold;`tsconfig.test.json` 并修复 **103 个测试类型错误**;lint 清零;CI 增加 tests 类型检查 / 覆盖率门禁 / lint 阻断 / dist 同步校验;版本 0.8.0 三处一致 + 版本契约测试 |
|
||
| `83c5aa0` | **A11** AND/OR 优先级 | parser 三层分层 `or → and → simple`;`a=1 OR a=2 AND b=3` 从返回 1 行修正为 3 行(与显式括号一致) |
|
||
| `7526951` | **A19/A20/A21/A2(词法)** | 双引号 → `QUOTED_IDENTIFIER`(可引用保留字列名);未闭合块注释报错(此前 `DELETE ... /*` 照常执行);注释跳过去递归(消除栈溢出);移除 MySQL 方言反斜杠转义(与绑定器字符串边界统一) |
|
||
| `752bdea` | **A5/A6/A7/A16/A18/A24/A28** | LIMIT/OFFSET 从"应用两次"修正为下推安全判定 + 只应用一次(10 种查询形态验证);**queryStream 与 query 在 16 形态 × 4 引擎下逐值相等**;MIN/MAX 改单次归约(消除 20 万行栈溢出);空集聚合返回 NULL;未知列抛 `COLUMN_NOT_FOUND`;INSERT arity 校验;`__proto__` 列名防护统一 |
|
||
| `074afd3` | **A8 + LSM 读自洽** | 行所有权写进 `IStorageEngine` 契约(读返副本/写收副本)+ `cloneRow`;Memory/KVStore/Hybrid/Aria 全部生效(改返回值不再改库、索引不再失配);**LSM 读取自洽**:未命中即回源 + CRC 校验,消除"缓存未命中 = 静默丢数据"(实测小缓存下 300 行只回 59 行);缓存上限语义修正(超大 SSTable 常驻) |
|
||
| `89243ef` | **A3/A4 SAVEPOINT** | 新增 `SAVEPOINT_ROLLBACK` WAL 记录(复用 `data` 字段,格式不变);恢复按**事务内**下标窗口重放 → 已回滚的行不再复活;事务结束清空 savepoint 并校验归属 → 陈旧 savepoint 不再静默吞写入 |
|
||
| `765df80` | **A1/A2 约束** | UPDATE 阶段 1 增加批内新主键互查(四引擎)→ 不再静默丢行;ALTER ADD UNIQUE 真正建索引并做存量校验(复用 `createIndex`),Memory 侧"重启静默丢行"随之消失 |
|
||
| `674da6b` | **A9/A10** | `ChangeNotifierEngine` 引擎装饰器:INSERT/UPDATE/DELETE/CLEAR/DDL 统一派发变更事件,`db.subscribe` 对本地写入生效(此前只在跨标签页广播时触发);关联子查询的 `$col` 绑定补上外层别名剥离与**递归进入子查询**(`WHERE id IN (SELECT ... WHERE o.user_id = u.id)` 此前静默空结果) |
|
||
| `4ab04df` | **A15 三值逻辑(PB-2)** | 见下节"三值逻辑根治" |
|
||
| `2a109ef` | **B-1 统一校验 choke point** | 新增 `src/table/validation.ts`(`compileValidator`)作为**唯一**行校验定义,消除三份分叉实现(memory 缺 maxLength/min/max);四个引擎新增 `validatePayload` 契约,Executor 在任何副作用前校验;未知列由静默丢弃改为 `COLUMN_NOT_FOUND`(A17);外键级联写入也过校验;NaN/±Infinity 显式拒绝(JSON 无法表示,落盘会变 null) |
|
||
| `841db2e` | **A22/A23/A25/A26/A27/A29/A30/A36** | 查询层 8 项:GROUP BY 别名、HAVING 未选中聚合、带前缀聚合参数、UNION 尾部子句归属、DISTINCT 作用于输出列、`maxRowsPerQuery` 不再静默截断写入、INSERT 值多于列、派生表别名引用。连带根治"同步抛错穿过 async 边界"与"缺列的行形状不一致" |
|
||
| `b20d47b` | **B-3 单管线** | QueryBuilder 只产出 AST、执行一律经 Executor(删除无 JOIN 时直通 `engine.find` 的快路);钩子改为由 `Table` 注入、顺序与传参不变;删除引擎层 4 处"未解析标记 → NOT_SUPPORTED"的过时防御 |
|
||
| `edd9f1d` | **B-6 ①②(两处 P0)** | ① `open()` 遇损坏日志尾部不再**清空整库**(新增 `truncateLogTo(keepBytes)`,与 `repair()` 同口径);② 自动 checkpoint 失败不再让**已确认写入**报错(WAL 已持久化),改为记 `lastBackgroundError` 并由下一次显式 `checkpoint()` 报告 |
|
||
| `68e4731` | **B-6 ③ + 测试介质** | ③ meta 增加 `owner`,陈旧实例 `checkpoint()` 抛 `STALE_INSTANCE` 拒绝覆盖新实例的写入(实测修复前会**静默**抹掉);同时修复 `SharedMemoryBackend` 的跨实例可见性缺陷(私有拼接缓存使介质退化为"每实例一份快照",此前所有多实例用例都跑在错误介质上) |
|
||
| `6a0cfeb` | **PC-2 真崩溃注入** | e2e 的 `crashPage()` 原本只是 `win.__ms = null; page.close()` —— **优雅关闭**,所有"崩溃恢复"用例测的其实是"正常关闭后重开"(CI 注释却声称覆盖 CDP `Page.crash`)。现改用 CDP `Page.crash` 终止渲染进程,并新增两个真实窗口:`createWritable().write()` 中途、`close()` 原子替换前(copy-on-write 的核心不变量)。实测要点:崩溃后 `page.isClosed()` 仍为 false;OPFS 数据在同 context 内跨崩溃保留 |
|
||
| `567d150` | **A37 列引用** | 未限定列可作比较操作数(`WHERE x = y` / `ON k = k` 此前 PARSE_ERROR,列对列比较被迫写限定形态);新增 WHERE / JOIN ON 的列存在性校验 —— `$col` 引用不存在的列此前**静默返回空集**(`WHERE id = oops` → `[]`),现报 `COLUMN_NOT_FOUND`;顺带解除 `schema ↔ validation` 循环依赖 |
|
||
| `85f0f17` | **A13 自引用外键** | 删除 6 处 `refTableName === tableName) continue`(memory 3 + aria 3):自引用外键的删除级联/置空/更新级联/预检四条路径全部失效,`DELETE root` 只删根、子树永久悬挂。A14(传递链)经实测**审计结论有误**——`ON UPDATE CASCADE` 链正常,已固化为护栏 |
|
||
| `97b9fa4` | **A38/A39 压缩** | A38 `compression` 在页面化路径(默认)被静默忽略 → 改为切页前整体压缩,并修正 `SSTableMeta.totalSize` 必须写压缩长度(否则会被 0 填充撑大);A39 `compressLZ4` 逐位置扫描 O(n²)(60KB 伪随机 2345ms)→ 4 字节哈希链(6ms,390×),输出格式不变并保留线性参考实现作对照。连带修正 OPFS mock 忽略库名导致的**跨库共享文件树** |
|
||
|
||
### 三值逻辑根治(A15 / PB-2,提交 `4ab04df`)
|
||
|
||
**根因**:`matchWhere`(布尔版,自带 `matchField`)与三值求值器**并存**,同一条 SQL
|
||
的语义取决于走哪个函数;更严重的是字段级 `$or` 递归进了"where 子句级"求值器。
|
||
|
||
**三类实测静默错值**(修复前的实际返回):
|
||
|
||
| SQL | 修复前 | 修复后 | 机制 |
|
||
|---|---|---|---|
|
||
| `WHERE n = NULL`(n 列含 NULL 行) | 命中 NULL 行 | 空集 | `===` 比较 `null === null` 为真 |
|
||
| `WHERE n != NULL` | 所有非 NULL 行 | 空集 | 同上取反 |
|
||
| `WHERE s NOT LIKE 'x'`(含 NULL 行) | NULL 行被判真 | 仅真正不匹配的行 | 布尔取反未传播 UNKNOWN |
|
||
| `WHERE n NOT BETWEEN 1 AND 2` | **恒空集** | 真正的越界行 | 字段级 `$or` 子项 `{ $lt: 1 }` 被当成"查询列 `$lt`" |
|
||
|
||
**根治方式(取消第二套实现,而非打补丁)**:
|
||
|
||
1. `query/where-matcher.ts` 重写为**唯一一个递归求值器**,同时理解 where 子句级
|
||
(键是列名 / 逻辑连接词)与操作符级(键是 `$gt` 之类)。区别由**位置**承载,
|
||
不再由另一个函数承载;行上下文随求值上下文下传,`$col` 在任意嵌套深度都能解析。
|
||
`matchWhere` 退化为"三值结果是否恰为 TRUE",并新增 `matchWhereThreeValued`。
|
||
2. `sql/parser.ts`:`IS NULL` / `IS NOT NULL` 生成 `$isNull` / `$isNotNull` **谓词**
|
||
(此前与 `= NULL` / `!= NULL` 共用 `$eq: null` / `$ne: null`,调用方无法表达区别);
|
||
`BETWEEN` 生成真正的范围条件(此前把同一对象同时当操作符对象与操作数);
|
||
`NOT BETWEEN` 展开为 `$or: [{ $lt }, { $gt }]`。
|
||
3. `query/executor.ts` 新增 `enginePreFilter`:逐行求值谓词(`$col` / `$exists` /
|
||
CASE 键)必须整体移出引擎层 —— 引擎无外层行上下文,会把它们判 UNKNOWN 并把
|
||
**所有行**过滤掉,逐行求值再正确也无行可算(这正是 `WHERE t.x = t.y` 静默空结果的
|
||
第二层原因)。粒度按连接词决定:`$and` 成员可单独移除;`$or` / `$not` 成员一旦
|
||
移除就改变结果集(漏行 / 多行),故整条放弃下推。
|
||
|
||
**契约变更**(旧测试编码了错误语义,已按 SQL 标准改正并在测试内注明理由):
|
||
|
||
- `{ $eq: null }` 不再命中 NULL 行(`= NULL` 恒 UNKNOWN)→ 需 NULL 匹配时用 `$isNull`;
|
||
- `IN` 列表含 NULL:`x IN (NULL, 'a')` 只命中 `'a'`(`null = NULL` 为 UNKNOWN),
|
||
未命中的非 NULL 行因 UNKNOWN 同样不保留;
|
||
- 引擎层(`MemoryEngine.find` / `AriaEngine.find`)与 Query Builder 路径同步适用。
|
||
|
||
**验证**:新增 `tests/v080-sql-three-valued.test.ts` —— 26 条 SQL 语义矩阵 ×
|
||
4 引擎(memory/disk/hybrid/aria)+ UPDATE/DELETE 写路径,共 **104 断言**。
|
||
|
||
**测试规模**:1304 → **1872 通过 / 90 套件**(另 4 个重型套件与 e2e 14 项)。新增:
|
||
`tests/v080-savepoint.test.ts`、`v080-atomicity.test.ts`、`v080-subscribe.test.ts`、
|
||
`v080-correlated.test.ts`、`v080-sql-three-valued.test.ts`、
|
||
`v080-unified-validation.test.ts`、`v080-query-layer.test.ts`、
|
||
`v080-single-pipeline.test.ts`、`v080-kvstore-commit-point.test.ts`、
|
||
`v080-column-resolution.test.ts`、`v080-foreign-key.test.ts`、
|
||
`tests/engine/aria-compression.test.ts`、`version-contract.test.ts`、
|
||
`helpers/storage-harness.test.ts`
|
||
|
||
**关键修复均做过变异验证**:把修复回退到修复前的行为,对应用例必须失败
|
||
(例:KVStore ①2 项、②3 项)。这是"测试真的能拦住回归"与"测试只是陪跑"的
|
||
分界线,后续新增回归套件都按此执行。
|
||
|
||
**验证状态**:`typecheck`(src + tests)、`lint` 全部为零错误;Aria 生产负载
|
||
(10 万行 / OPFS / KV 后端崩溃恢复)全绿。
|
||
|
||
### 实施过程中的额外发现(方案未列出,已一并修复)
|
||
|
||
1. **`SELECT 1 AS one FROM t` 返回 `[{}]`** —— parser 数字常量分支提前 return 吞掉别名;裸 `SELECT 1` 也未处理匿名常量列。现按 SQLite 语义以表达式原文为键。
|
||
2. **`SELECT 0` 类查询的常量列投影**、`TRUE/FALSE/NULL AS alias` 同样修正。
|
||
3. **`async` 回调判定** —— 原 `constructor.name === 'AsyncFunction'` 对"普通函数返回 Promise"完全失效;现双条件识别并走物化 + await。
|
||
4. **`queryStream` 不受 `maxRowsPerQuery` 约束、不触发钩子** —— 现与物化路径一致。
|
||
5. **缓存上限契约不可满足** —— 原测试断言"缓存字节数 ≤ 上限",但单文件大于上限时驱逐它等价于丢数据;已改为断言"数据完整"这一真正不变量,并明确上限 = `cacheLimit` + 单个最大 SSTable。
|
||
6. **测试直接读私有字段**(`lsm.cacheSize` / `cacheLimitBytes`)—— 那是 TS 错误,只因测试不做类型检查才没暴露;已补公开访问器。
|
||
|
||
### 待完成(下一轮继续)
|
||
|
||
| 优先级 | 项目 | 状态 |
|
||
|---|---|---|
|
||
| ~~P0~~ | ~~A9 `subscribe()` 对本地写入不触发~~ | ✅ `674da6b` |
|
||
| ~~P0~~ | ~~A10 `t.x = t.y` 与关联 `IN (SELECT ...)` 静默空结果~~ | ✅ `674da6b` + `4ab04df`(enginePreFilter) |
|
||
| ~~P1~~ | ~~A15 三值逻辑(PB-2 统一 `sqlCompare`)~~ | ✅ `4ab04df` |
|
||
| ~~P1~~ | ~~A12 `maxLength`/`min`/`max` 在 memory/disk/hybrid 失效(PB-1 统一校验)~~ | ✅ `2a109ef` |
|
||
| ~~P1~~ | ~~A17 INSERT 未知列静默丢弃~~ | ✅ `2a109ef` |
|
||
| ~~P1~~ | ~~A22/A23/A25/A26/A27/A29/A30/A36 查询层~~ | ✅ `841db2e` |
|
||
| ~~P0~~ | ~~**B-3 单管线**(builder 只产出 AST)~~ | ✅ `b20d47b` |
|
||
| ~~P0~~ | ~~**B-6 KVStore 三处止血**~~ | ✅ `edd9f1d` + `68e4731` |
|
||
| ~~P1~~ | ~~A13 自引用外键失效~~ | ✅ `85f0f17`(A14 传递链实测正常,非缺陷) |
|
||
| ~~P1~~ | ~~A35 JOIN NULL 键~~ | ✅ `841db2e`(JOIN 的 NULL 键不成立,且与右表有无索引无关) |
|
||
| ~~P1~~ | ~~A37 未限定列名 / WHERE 列存在性~~ | ✅ `567d150` |
|
||
| ~~P1~~ | ~~A38/A39 压缩被遮蔽 / O(n²)~~ | ✅ `97b9fa4` |
|
||
| ~~P1~~ | ~~A41 DDL 原子性~~ | ✅ `bf93ec5`(A40 经探针未复现,已固化为护栏) |
|
||
| ~~P0~~ | ~~**B-6 存储单一提交点(manifest)**~~ | ✅ **完整实施**(非降级选项):`__aria_manifest` 单一提交点 + LSM 44/45/47/49/50/51/55 全部改造 + 引擎层 prefetch 依赖删除 —— 见**附录 H** |
|
||
| ~~P1~~ | ~~**B-4 统一表达式求值器**~~ | ✅ `3983aae`(CASE 结构化解析 + 列引用/聚合统一) |
|
||
| ~~P1~~ | ~~**B-5 输出列序号 + 分隔标识符**~~ | ✅ `2c945ee` |
|
||
| ~~—~~ | ~~PC-2 e2e 真崩溃注入(CDP `Page.crash`)~~ | ✅ `6a0cfeb`(14 项 e2e,含两个 OPFS 写入窗口) |
|
||
| — | PC-4/PC-5 契约测试 + 属性测试 | 部分(跨引擎参数化已用于 A1/A2/A15/B-1/B-3) |
|
||
| — | PG 文档与站点全量同步 | 待做 |
|
||
|
||
**下一步建议**:v0.8.0 的三条工作流(A 全部缺陷 / B-1~B-6 结构根治 / C 验证
|
||
基础设施)与 G1~G6 门禁均已完成(见附录 G、附录 H)。后续版本方向:
|
||
|
||
1. **真跨表快照** — `backup()` 目前是逐表读取(已如实写进已知限制)。真快照需要
|
||
行所有权改为 COW(引擎内所有写入路径统一"新对象替换"而非就地修改),
|
||
改动面较大但路径清晰;
|
||
2. **MVCC 真快照隔离** — `snapshotLsn`/`prevVersion` 目前只写不读,事务串行。
|
||
若确有多事务并发需求,需让读路径走版本链而不是 `txnSnapshot`;
|
||
3. **简单 CASE 形式** — `CASE <表达式> WHEN <值> THEN ...`(当前仅搜索式);
|
||
4. **复合主键** — 存储布局与外键引用目前假设单主键。
|
||
|
||
**关于原"B-1 遗留(compaction/merge 未过 validatePayload)"的结论**:该项**不是缺陷,
|
||
也无需补校验**,理由是数据来源而非实现惰性 —— LSM 的 compaction/merge 输入只可能
|
||
是本引擎**已提交的 SSTable**(`levels` 中的 meta 由本进程写入),其中每一行都曾在
|
||
唯一写入 choke point(`Executor` → `validatePayload` → `compileValidator`)上校验过;
|
||
compaction 做的是"逐字节搬运 + 按 key 去重",不解析、不构造新的行语义。
|
||
在合并路径上再跑一遍校验反而有害:`ALTER TABLE` 之前写入的旧行形状与当前 schema
|
||
可以合法地不同(这正是 ALTER 的语义),重校验会把合法历史数据判成非法而**中断
|
||
compaction**。触发条件明确写在这里:**一旦 LSM 接受外部输入(例如导入第三方
|
||
SSTable / 跨库搬迁),必须在 `save()` 之前补校验**。
|
||
|
||
**已完成的验证基础设施(本轮重点)**:
|
||
- PC-2 真崩溃注入(CDP `Page.crash`)+ OPFS `createWritable` 的两个写入窗口;
|
||
- `FaultyBackend` 字节级故障注入(撕裂/bit-flip/掉电/写失败);
|
||
- **变异验证**成为回归套件的标准做法:把修复回退到修复前的行为,对应用例
|
||
必须失败。本轮 A15/A13/A38/A39 与 **B-6 全量 + 审查轮共 40 项**均通过该检查(可执行脚本
|
||
`scripts/mutation-b6.py`,逐项打印"变异是否被拦住")——
|
||
这是"测试真能拦住回归"与"测试只是陪跑"的分界线。
|
||
- 测试介质的两处**忠实性**缺陷已被修正(`SharedMemoryBackend` 跨实例可见性、
|
||
OPFS mock 的目录语义)—— 它们此前让"多实例/多库"的验证跑在错误语义上。
|
||
|
||
---
|
||
|
||
## 附录 G · G1~G6 硬门禁验收记录(v0.8.0)
|
||
|
||
> 每条门禁给出**可机器验证的命令与实测结果**。发版前必须全绿。
|
||
|
||
| 门禁 | 内容 | 验证方式与实测结果 | 状态 |
|
||
|---|---|---|---|
|
||
| **G1** | 事故级缺陷全部修复,且每项有**值级**回归测试(非行数断言) | 11 项事故级(A1~A11,与 §2 总账一致)+ 全部 P1 已修;每项均有独立回归套件,断言值/结构而非行数:`v080-sql-three-valued`(26 条 SQL 语义矩阵 × 4 引擎)、`v080-query-layer`(8 项 × 4 引擎)、`v080-column-resolution`、`v080-foreign-key`、`v080-aria-ddl-atomicity`、`v080-unified-validation`、`v080-case-expression`(54 项)、`v080-output-ordinals`(68 项)、`v080-kvstore-commit-point`、`v080-single-pipeline`、`v080-savepoint`、`v080-atomicity`、`v080-subscribe`、`v080-correlated` | ✅ |
|
||
| **G2** | 入口等价性:同一 SQL 经 `query`/`queryStream`/`QueryBuilder`/事务内四条入口**逐值相等** | `v080-single-pipeline`(TABLE API 与 SQL API 逐值等价,4 引擎 × 12 项 + 跨引擎 1 项,共 57 断言);`queryStream` 与 `query` 在 16 形态 × 4 引擎逐值相等(`752bdea`);事务内入口经同一 Executor(`b20d47b`) | ✅ |
|
||
| **G3** | 跨引擎一致性:同 schema + 同操作在 memory/disk/hybrid/aria 行为一致(含错误码) | 全部新增套件均按 `describe.each(ENGINES)` 参数化到四引擎:`v080-unified-validation`(41)、`v080-sql-three-valued`(104)、`v080-query-layer`(111)、`v080-single-pipeline`(57)、`v080-column-resolution`(32)、`v080-foreign-key`(32)、`v080-output-ordinals`(64)、`v080-case-expression` 等;错误码断言用 `rejects.toMatchObject({ code })` | ✅ |
|
||
| **G4** | 故障注入下崩溃不变量恒成立(**限定于矩阵覆盖的窗口**)+ 索引与主表一致 + 唯一约束仍生效 | 单元层:`FaultyBackend`(撕裂写 / bit-flip / 掉电丢弃 / 写失败)+ `TransactionalFileStore`(pending/commit/crash 语义)+ `CrashableStoreBackend`;`v080-kvstore-commit-point`(19 项,含三处 P0 的变异验证);`v080-atomicity`;`v080-savepoint`。e2e 层:CDP `Page.crash` 真崩溃 + OPFS `createWritable` 的 write/commit 两个窗口(`tests/e2e/opfs.spec.ts`,14 项)。<br>**⚠️ 限定说明(v0.8.0 审查修正)**:矩阵覆盖的是"介质/进程在写入窗口内崩溃"这一类故障;它**不能**证明"任何情况下零丢失"。全量回归审查在矩阵覆盖范围之外发现 1 处**已确认写入丢失**(事务活跃期间 `repair()`/checkpoint 推进 WAL 水位 → 已 COMMIT 的事务整批消失,见附录 I),已修复并补上回归测试与变异验证。因此本门禁的结论按"矩阵范围内成立"表述,不宣称普遍零丢失 | ✅(有限定) |
|
||
| **G5** | 覆盖率真实且不倒退:`collectCoverageFrom` 无实现文件被排除;CI 有 `coverageThreshold`;README 数字与产出一致 | `jest.config.cjs` 仅排除两个**纯类型**文件(`engine/interface.ts`、`query/ast.ts`);阈值 statements 90 / branches 82 / functions 94 / lines 93;CI 常规 job 带 `--coverage`;README 写明产出命令与四个实测数字(最终值见 README「项目指标」表),且本轮经历"阈值真的失败 → 补测/删死代码"的闭环(见 `d14663e`) | ✅ |
|
||
| **G6** | 文档与实现一致:README/CHANGELOG/site 中每一条能力宣称都有对应测试或已删除 | **第一轮**:§8 的 **12 条**收口:订阅(已接线)、LZ4 页面压缩(A38 已修)、`./migration` 导出、react/vue 产物+d.ts+peerDeps、`disconnect` 进类型、priority(改为实现符合文档)、MVCC 快照隔离(改为如实描述)、backup 一致性快照(改为如实描述并列入已知限制)、"5 种引擎"(改为 4 模式 + 3 后端)、`diskEngine` 生效范围(改为仅 aria)、"在线一致性快照"(改为全库导出)、钩子契约(CONTRIBUTING 写明返回值语义)。<br>**第二轮(v0.8.0 全量回归审查)**:文档宣称 vs 实现逐条核对又发现 **16 条不成立**(MVCC 快照隔离、backup 一致性快照、`bloomFilterBitsPerKey` 配置项无效、gzip 体积、测试/覆盖率/套件数陈旧、错误码表缺 16 个码、WAL 空洞语义、mutation 数量、"分片号只增不减"等),全部在附录 I 中逐条订正;其中 `bloomFilterBitsPerKey` 是**实现缺陷**(配置被接受却不生效),已修实现而非改文档 | ✅(两轮) |
|
||
|
||
**额外要求**:CI lint 已去掉 `continue-on-error`(真正阻断);`tsconfig.test.json` 对 `tests/` 做类型检查并修复了 **103 个**被 babel 剥离类型掩盖的类型错误。 ✅
|
||
|
||
---
|
||
|
||
## 附录 H · B-6「存储层单一提交点」完整实施记录(v0.8.0 收敛)
|
||
|
||
> **为什么还需要这一节**:附录 F 的"已完成"表里 B-6 只列了 ①②③(KVStore 三处止血)+
|
||
> Aria DDL WAL 意图,而 §B-6 的规格要求的是 **`__aria_manifest` 单一提交点 + LSM 单项改造**;
|
||
> §"B-6 的降级选项"也白纸黑字写着那五处止血是"若周期不够"的替代品。
|
||
> 本次(周期不受限、且明确禁止降级方案)把 B-6 **按规格完整实现**,本节记录落地细节、
|
||
> 实施中新发现的 10 个缺陷,以及可复现的验证方式。
|
||
|
||
### H.1 交付物
|
||
|
||
| 文件 | 作用 |
|
||
|---|---|
|
||
| `src/engine/aria/store/manifest.ts`(新增) | `__aria_manifest_<generation>`:magic + formatVersion + generation + 头部 CRC + 载荷 CRC;`ManifestStore.load/claimOwnership/commit`;先写后验;保留两代;损坏世代**显式失败** |
|
||
| `src/engine/aria/index/lsm.ts` | 冻结表一等状态(id/LSN/重试/提交窗口);读路径 epoch + 结构版本重试;compaction 不再摘整层、底部层原地合并回收墓碑、按层 `compacting` 集合;flush/maintenance 双链;恢复报告 |
|
||
| `src/engine/aria/index.ts` | manifest 装载 → 旧格式迁移 → 认领所有权;`commitManifest()`(唯一提交入口);`advanceWalCheckpoint()`(先算边界 → 提交 → 再删除);表结构随 manifest 提交;恢复报告 `getRecoveryReport()`;删除引擎层全部 `prefetch*` 依赖 |
|
||
| `src/engine/aria/wal/log.ts` / `segmented_store.ts` | LSN 全库单调(manifest 记高水位);`planKeepFrom` / `truncateBefore`;**分片号绝不回退、也绝不低于 manifest 水位**(整体清空后允许复用最后用过的号 —— 见附录 I 的语义澄清);内部空洞与记录级 CRC 损坏如实上报(`gaps` / `corruptRecords`),水位从未推进时的前缀缺失同样按空洞上报;按水位清理旧格式键 |
|
||
| `src/engine/aria/store/page_sstable_store.ts` | 活跃 + 退休两张页面映射(在途读者按 id 读取不再被 compaction 摘除影响);`registerPageIds` |
|
||
| `src/engine/aria/store/file_manager.ts` | 页面水位取 manifest / 旧 meta / 现存最大 id 三者最大(单调、永不复用);`__aria_meta` 降级为兼容提示 |
|
||
| `src/engine/aria/index/sstable.ts` | 三份解析循环合并为 `iterEntries()` 一份,越界策略统一 |
|
||
| `src/engine/aria/index/merge_iterator.ts` | 胜出来源的补充推迟到下一次 `next()`(提前终止不多算) |
|
||
| `src/engine/aria/wal/checkpoint.ts` | checkpoint 只落 memtable(`flushMemtables?`),不等 compaction |
|
||
| `tests/v080-b6-single-commit-point.test.ts` | 上述每一条的回归 + manifest 严格校验表驱动 **24** 例;v0.8.0 审查后扩到 **96 项**(新增事务水位 P0、前缀空洞语义、在途读者与退休文件、孤儿回收门槛、稀疏/损坏分支、形状校验等)|
|
||
| `scripts/mutation-b6.py` | **40 项**变异验证(B-6 的 22 项 + 审查轮的 18 项),全部被对应用例拦住;脚本自带正控、编译失败区分、超时、逐字节恢复校验与进程锁(见 CONTRIBUTING)|
|
||
|
||
### H.2 提交顺序(唯一不变量)
|
||
|
||
```
|
||
数据落盘(SSTable 页面 / 整 value 已 await 完成)
|
||
↓
|
||
__aria_manifest_<gen> 提交(单文件原子写 + 回读校验)
|
||
↓
|
||
才允许截断 WAL / 删除旧分片 / 删除旧 SSTable
|
||
```
|
||
|
||
- manifest 载荷 = `{pageIdWatermark, namespaces{SSTable meta + nextSstableId}, schemas,
|
||
wal{startSegment,startLsn,nextLsn}, frozen[冻结表意图], owner{instanceId,epoch}, committedAt}`
|
||
- 恢复只认**最后一份 CRC 通过**的世代;存在 manifest 文件但全部世代无效 → `ARIA_MANIFEST_CORRUPT`
|
||
(修复前:裸 JSON meta 解析失败 → `[]` → **静默空库**,随后 repair 还会把"没人引用"的活页删光)
|
||
- `frozen` 意图的作用:`wal.startLsn` 不得越过最早冻结表的 `lsnAtFreeze`;
|
||
恢复时若"manifest 声称有未落盘数据 + 一条 WAL 记录都没重放到" → `ARIA_WRITE_LOST`
|
||
(把"已确认写入是否真的丢了"从不可观测变成可判定)
|
||
|
||
### H.3 实施中新发现的缺陷(方案未列出,已一并修复)
|
||
|
||
| # | 现象(实测) | 根因 | 修复 |
|
||
|---|---|---|---|
|
||
| 1 | **删掉的行复活 / 已确认写入丢失**(随机压力套件:76≠68、53≠55 两个方向都出现) | WAL 全量截断后 `currentSegment` 重置为 0,而 manifest 里的 `startSegment` 是删除前算好的(= 旧最大号+1)→ 新记录写进被跳过的分片号;分片号复用还让同一序号对应两代记录,恢复按序号排序时旧 INSERT 排在 DELETE 之后 | 分片号**只增不减**(`truncateBefore`/`truncate` 都只往上走;只有整库清空 `clearAll` 才 `reset()`);正则放宽到 `\d{6,}` |
|
||
| 2 | 300 行全部改名后,紧接着的全表扫描有 25 行仍是旧值(几毫秒后又变新值) | 读路径"同步取 meta 快照 → await 加载",而并发 flush 会在这个窗口里把新 SSTable 发布到 `levels[0]` **并同时**从 `frozenMemtables` 摘掉 → 该批数据既不在快照里也不在前台 | `structureVersion` + 乐观重试(加载后版本变了就重来;极端情况退回"等两条链静默") |
|
||
| 3 | `close()` 之后 `__wal_000000.bin` 仍在(每次打开都要重读一遍) | `planKeepFrom` 只在"下一条记录也在水位之内"时才敢删边界分片,而**最后一个分片**没有"下一片"可比 → 永不删除 | `truncateBefore` 接受 `latestLsn`:末片以"当前 LSN ≤ 水位"判定整片可删 |
|
||
| 4 | 第二个实例打开同一库时 `open()` 直接失败(两个实例互相判 STALE_INSTANCE) | 认领所有权只 +1 个世代号,旧实例**在途**的提交正好落在同一个号上并覆盖认领 | 认领时一次跨过 `MANIFEST_TAKEOVER_STRIDE=1000` 个世代号 |
|
||
| 5 | 从上一代 manifest 回退时报 `ARIA_WRITE_LOST`(数据其实完好) | `saveMeta` 正在提交这张冻结表,而 `frozen` 意图仍把它列为"未落盘" | `FrozenTable.committing` 窗口:提交中的表由本次提交负责,不进意图;失败时标志复位、可重试 |
|
||
| 6 | 底部层"整层只剩墓碑"时 compaction 抛 `TypeError`(读 `merged[0][0]`)→ 该层永远无法合并、墓碑永远回收不掉 | 空产物没有专门的退休分支 | 新增"全部是墓碑 → 直接退休旧文件"分支(`retireMetas`) |
|
||
| 7 | item 55 实际未生效:checkpoint 仍然等整条 compaction | `CheckpointManager.checkpoint()` 里还有一句 `await this.lsm.flush()`(会 drain 维护链) | `Flushable.flushMemtables?()` + 引擎提供 `flushAllLsms(true)`;旧调用方保留原语义 |
|
||
| 8 | 一个损坏的更高世代把库**永久锁死**(之后每次提交都 STALE_INSTANCE) | 陈旧检查拿"磁盘上最大世代号"与自己的世代比,损坏世代不是"别人的提交" | 陈旧判定跳过**已知损坏**的世代;但新世代号仍严格大于任何已存在文件(绝不覆盖) |
|
||
| 9 | 旧格式迁移遇到坏 JSON 静默当空库 | `catch { return [] }`(meta 与 schema 各一处) | `ARIA_LEGACY_META_CORRUPT` 显式失败(迁移全有或全无) |
|
||
| 10 | 介质读故障被折叠成"文件不存在",于是 `dropInvalidSSTable` 把 meta 删掉(不可逆) | `load()` 的 catch 一律 `return null`,调用方无法区分"读失败"与"确实没有" | 读失败**向上抛** `ARIA_SSTABLE_READ_FAILED`;只有"确定不存在且仍被 manifest 引用"才自愈清理,并进恢复报告 |
|
||
|
||
### H.4 验收(可复现)
|
||
|
||
```bash
|
||
# 1) B-6 回归(96 项)
|
||
npx jest tests/v080-b6-single-commit-point.test.ts
|
||
|
||
# 2) 变异验证:把每个修复回退到修复前行为,对应用例必须失败(40 项)
|
||
python3 scripts/mutation-b6.py
|
||
|
||
# 3) 随机压力 / 检查点崩溃恢复(发现缺陷 1 的套件)
|
||
npx jest tests/engine/aria-repair-hardening.test.ts tests/engine/aria-kv-backend.test.ts
|
||
|
||
# 4) 全量常规套件(不含重型与 e2e)
|
||
npx jest --testPathIgnorePatterns='/node_modules/|/tests/e2e/|aria-prod-load|kvstore-stress|aria-matrix-audit|aria-idx-flush-race'
|
||
```
|
||
|
||
**实测数字**(审查轮收尾复测,详见附录 I):常规套件 **1980 通过 / 92 套件**;
|
||
覆盖率 语句 90.59% / 分支 82.59% / 函数 94.14% / 行 93.50%(阈值 90/82/94/93,全部通过);
|
||
e2e 14/14;变异验证 **40/40** 被拦住。
|
||
|
||
**恢复报告**:`engine.getRecoveryReport()` 返回
|
||
`{droppedSSTables, dataLossSuspected, walGaps, droppedWALRecords, legacyImported, manifestFallback}` ——
|
||
"损坏被自愈了多少、有没有真正丢数据"从此是**可读的返回值**,而不是只能翻日志。
|
||
|
||
**新增错误码**:`ARIA_MANIFEST_CORRUPT`、`ARIA_MANIFEST_WRITE_FAILED`、
|
||
`ARIA_MANIFEST_NOT_LOADED`、`ARIA_LEGACY_META_CORRUPT`、`ARIA_SSTABLE_READ_FAILED`、
|
||
`ARIA_SSTABLE_SAVE_CONTRACT`、`ARIA_WAL_GAP`、`ARIA_WRITE_LOST`、`STALE_INSTANCE`。
|
||
|
||
---
|
||
|
||
## 附录 I · v0.8.0 全量回归审查(发版前最后一轮)
|
||
|
||
> 触发:用户要求"做完整全量的回归 review"。方法:**四个对抗性子代理**分头审查
|
||
> (① 数据正确性与崩溃语义 ② 文档宣称 vs 实现 ③ 公共 API 契约 ④ 测试质量与
|
||
> 覆盖盲区),每一条结论都要求**可复现证据**;我再逐条复核、写探针确认、并在
|
||
> `src/` 上做**变异验证**(把修复回退 → 对应用例必须失败)。
|
||
> 详细缺陷叙述见 `CHANGELOG.md` 的 v0.8.0「全量回归审查」一节;本节只列结论与证据。
|
||
|
||
### I.1 确认缺陷(14 项:1×P0 / 4×P1 / 9×P2)
|
||
|
||
| 级别 | 缺陷 | 证据(可复现) | 变异 |
|
||
| --- | --- | --- | --- |
|
||
| **P0** | 事务活跃期间 `repair()` / `close()` / 周期 checkpoint 推进 WAL 水位 → 已 `COMMIT` 的事务整批消失,且恢复报告"干净" | `tests/v080-b6-single-commit-point.test.ts` ›「BEGIN → INSERT → repair() → COMMIT → 崩溃」;根因 `hasPendingFlushData()` 不看 `txnSnapshot` | R1 |
|
||
| P1 | WAL 前缀缺失把**整段活分片**丢掉(回退上一代 manifest 时 `kept` 为空) | 同套件「fromLsn > 0 时前缀缺失只记诊断,后缀分片照常重放」 | R4 |
|
||
| P1 | 孤儿回收门槛只看引擎层 `dataLossSuspected` → LSM 层丢过 SSTable 时仍会删"引用不到"的活页(不可逆) | 「有损坏迹象时:引用不到的东西一律保留」;统一判定 `describeRecoveryDamage()` | R3 |
|
||
| P1 | `vacuum()` 逐层压缩绕过维护链(与后台 compaction 并发写同一目标层) | 「vacuumLevels 的逐层压缩必须挂在维护链上」;**注意**:引擎层 `vacuum()` 的 `flush()` 已会 drain 维护链,故该用例锁定的是 LSM 层不变量 | — |
|
||
| P1 | `reclaimRetiredNow()` 无视在途读者(读者把"已退休"读成"文件损坏") | 「有在途读者时强制回收必须退化为延迟回收」 | R2 |
|
||
| P2 | WAL 记录级 CRC 损坏只打日志(恢复报告无感) | 「一条记录 CRC 失败 → corruptRecords > 0 且标记丢数据」 | R9 |
|
||
| P2 | 旧格式表结构记录**形状**损坏(数组 / null / 非对象条目)静默当空库 | 「旧格式表结构记录的形状校验」5 条 | R16 |
|
||
| P2 | **`bloomFilterBitsPerKey` 配置被接受却完全不生效**(构建器写死默认位数) | 「bloomFilterBitsPerKey 必须真的生效」2 条(段大小 + 无 false negative) | R17 / R18 |
|
||
| P2 | 丢弃损坏文件后不同步内存层数组(幽灵 meta) | 「compaction 丢弃损坏文件后 levels 不留幽灵 meta」 | R5 |
|
||
| P2 | 介质读故障被折叠成"文件损坏"的路径无用例 | 「介质读故障 ≠ 文件损坏:compaction 必须失败且一个 meta 都不许丢」 | R11 |
|
||
| P2 | manifest 回读校验两条守卫无用例(吞写 / 内容坏) | 「写成功但读不回来」「写下去的内容读回来是坏的」 | R8 |
|
||
| P2 | "文件名世代 ≠ 载荷世代"的世代无效判定无用例 | 「文件名世代与载荷世代不一致 → 该世代无效」 | R6 |
|
||
| P2 | `pageIdWatermark` 单调性无用例(水位可回退 → 页面 id 复用) | 「水位是单调下限:计数器回退也不许把 manifest 水位带回低值」 | R7 |
|
||
| P2 | 分片号两条真实不变量无用例(清空后不回退到 0 / 新写入不低于 manifest 水位) | 「整体清空后分片号绝不回退」「写入永不落到 manifest 水位下限之下」 | R13 / R14 |
|
||
|
||
**语义澄清(不是缺陷,但此前文档与实现不一致)**:
|
||
- **"分片号只增不减"** 的准确语义是"**绝不回退、绝不低于 manifest 水位**",
|
||
整体清空(`truncate()`)后允许复用**最后用过的那个号** —— 记录自带 LSN,
|
||
旧世代记录按水位跳过,因此复用安全;真正致命的是"回退到 0"(新记录落在
|
||
`startSegment` 之下 → 恢复时被过滤掉)。已同步 README / H.1 / CHANGELOG。
|
||
- **尾部 WAL 分片丢失**无法从介质自身识别(没有"它本该存在"的证据),
|
||
`ARIA_WRITE_LOST` 只兜住"有未落盘冻结表却重放不到任何记录"的情况 —— 已写入已知限制。
|
||
- **`vacuumLevels()` 里 `drainMaintenance()` 那一行未被变异验证**:它在实现上只是
|
||
优化(抢在链前重算层内文件数),去掉后仍然串行,只是会多排几个空任务。
|
||
|
||
### I.2 测试质量修正("绿"≠"有保护")
|
||
|
||
- 3 条空壳用例(孤儿回收门槛 / 强制回收 / 页面映射退休)改为**值级断言**;
|
||
- 1 条"整层文件全损坏"用例实际**走的是缓存**(从未读介质)→ 拆成两条真用例
|
||
(介质读故障 / 文件真残缺);
|
||
- 5 秒墙钟 `Promise.race` → 门控信号 + **失败上限**(超时只让测试失败,
|
||
不会让它在实现错误时"碰巧通过");`setTimeout` 睡眠 → `whenIdle()` 确定性等待;
|
||
- `<=` 收紧为严格 `<`(冻结意图水位必须**严格低于**意图起点);
|
||
- 删除死代码(`const inner` / `void manifest`)与误名的"非 JSON"用例。
|
||
|
||
### I.2b 覆盖率口径的第二处漏洞(已根因修复)
|
||
|
||
审查发现 README / `jest.config.cjs` / 本附录 G5 都声称"只排除两个纯类型文件、
|
||
可执行语句为 0",但 `engine/interface.ts` 内含三个运行时函数
|
||
(`cloneRow` / `cloneRowFallback` / `cloneRows`)并被 Memory 引擎与 AriaEngine 调用
|
||
—— 即真实实现代码一直在覆盖率统计之外(与 G5 声称根治的问题同类)。
|
||
处理:实现搬到 `src/engine/row_clone.ts`,`interface.ts` 变回纯类型;
|
||
搬完后门禁**真的失败了**(functions 93.84% < 94%),被迫补测退化路径
|
||
(`tests/engine/row-clone.test.ts`,8 项),随后全绿。这是"口径修正 → 门禁真的咬人"的闭环。
|
||
|
||
### I.3 文档订正(第二轮 G6:16 条不成立的宣称)
|
||
|
||
MVCC 快照隔离(docs.html ×3 + demo.html + `mvcc.ts` 文件头)、`backup()`
|
||
"一致性快照"(`interface.ts` 注释)、"空洞检测截断"、"~105KB / gzip ~27KB"
|
||
(实测 251,109 字节 / gzip 63,145 字节)、测试与覆盖率数字(README badge/正文、site 徽标与数字卡、
|
||
CHANGELOG)、"5 种存储引擎"(→ 4 模式 + 3 后端)、"Tree-shakable"(→ 多格式输出)、
|
||
错误码表缺 **16 个**码、CONTRIBUTING 变异数量(17 → 40)、PLAN 附录 G 的
|
||
"12 项事故级"(→ 11 项,A1~A11)、G4"零丢失"(限定为矩阵覆盖范围内)、
|
||
H.1"25 例"(→ 24 例)、"分片号只增不减"(→ 准确语义)。
|
||
|
||
**第二轮核对(独立子代理,只读审计)**:修正后再做一次"宣称 vs 实现"逐条核对,
|
||
又发现 11 条不一致(CONTRIBUTING 的旧体积/旧测试数、README 的"空洞检测截断"残留、
|
||
`getRecoveryReport()` 字段枚举漏 `droppedWALRecords`、PLAN 的 94-vs-96、
|
||
CHANGELOG 历史段落的"快照隔离"表述、docs.html 缺记录级 CRC 上报、
|
||
以及上面 I.2b 的覆盖率口径问题)——**全部已订正**,其中覆盖率口径是改实现而非改文档。
|
||
|
||
### I.4 验证(v0.8.0 收尾复测,全部可复现)
|
||
|
||
```bash
|
||
# 1) B-6 + 分片 WAL 套件(96 + 15 项)
|
||
npx jest tests/v080-b6-single-commit-point.test.ts tests/engine/aria-wal-segment.test.ts
|
||
|
||
# 2) 全量常规套件(不含重型与 e2e)
|
||
npx jest --testPathIgnorePatterns='/node_modules/|/tests/e2e/|aria-prod-load|kvstore-stress|aria-matrix-audit|aria-idx-flush-race'
|
||
# → 92 套件 / 1980 用例全绿
|
||
|
||
# 3) 覆盖率(与 CI 常规 job 完全相同的命令)
|
||
npx jest --coverage --coverageReporters=text-summary --testPathIgnorePatterns='/node_modules/|/tests/e2e/|aria-prod-load|kvstore-stress|aria-matrix-audit|aria-idx-flush-race'
|
||
# → Statements 90.59% / Branches 82.59% / Functions 94.14% / Lines 93.50%(阈值 90/82/94/93)
|
||
|
||
# 4) e2e(真实 Chromium + 真实 OPFS + CDP Page.crash)
|
||
npx playwright test # → 14/14
|
||
|
||
# 5) 变异验证(40 项;脚本自带正控、编译失败区分、超时、逐字节恢复校验、进程锁)
|
||
python3 scripts/mutation-b6.py # → 40/40 全部被拦住
|
||
|
||
# 6) 静态门禁
|
||
npm run lint && npx tsc -p tsconfig.json --noEmit && npx tsc -p tsconfig.test.json --noEmit
|
||
```
|
||
|
||
### I.5 未修复 / 保留项(诚实清单)
|
||
|
||
| 项 | 现状 | 为什么保留 |
|
||
| --- | --- | --- |
|
||
| e2e 未覆盖"manifest 写入窗口"崩溃 | 现有 e2e 覆盖 OPFS 的 `write()`/`commit()` 两个窗口(数据文件),未按**文件名**门控 manifest 的写入窗口 | 需要 harness 支持按 key 名门控 OPFS 写(`getFileHandle` 名追踪 + `armCrashOnOpfsWrite({file})`);收益有限(manifest 的先写后验 + 两代保留已在单测层用字节级损坏覆盖) |
|
||
| `clearAll()` 重置 `manifest.namespaces` 时 `nextSstableId` 归零 | 页面水位保留(媒体可能仍有页面),SSTable 编号在介质已清空的前提下重新开始 | 介质被整体抹掉,编号复用无冲突对象;真要严格只需把 `nextSstableId` 也纳入单调水位 —— 当前无收益 |
|
||
| 无 Web Locks 环境的多实例并发写 | 降级为提交点冲突检测(`STALE_INSTANCE`),**不支持**并发写 | 客户端没有跨标签页互斥原语时无法保证;已写入已知限制 |
|
||
| manifest 体积随 SSTable 数增长 | 单文件整体重写 + 保留两代,无增量/分层 | 属设计取舍(单一提交点的代价),已写入已知限制;收敛手段是 `vacuum()` 合并层级 |
|