fix(v0.8.0): 全量回归审查 —— 1 处 P0 数据丢失 + 4 处 P1 + 9 处 P2 根因修复

方法:四个对抗性子代理分头审查(数据正确性 / 文档宣称 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 已重建。
This commit is contained in:
thzxx
2026-09-15 16:33:40 +08:00
parent c3757f486c
commit 0b44620721
34 changed files with 4026 additions and 641 deletions
+253 -27
View File
@@ -4,6 +4,7 @@
用法:python3 scripts/mutation-b6.py
任何一条"回退后测试仍然通过"都会以非零退出码报出来(说明测试只是陪跑)。
"""
import hashlib
import io
import os
import re
@@ -22,7 +23,16 @@ CHECKPOINT = 'src/engine/aria/wal/checkpoint.ts'
SSTABLE = 'src/engine/aria/index/sstable.ts'
B6 = 'tests/v080-b6-single-commit-point.test.ts'
WALSEG = 'tests/engine/aria-wal-segment.test.ts'
STRESS = 'tests/engine/aria-repair-hardening.test.ts'
WALLOG = 'src/engine/aria/wal/log.ts'
# 单条变异的 jest 运行上限(秒)。超时视为"变异脚本自身的问题",必须报错而不是
# 悄悄当成"测试通过了"。
JEST_TIMEOUT_S = 300
# 正控(未变异的干净代码)必须跑通的套件:没有正控就无法区分
# "变异被测试拦住"与"这套件本来就是红的"。
CONTROL_SUITES = [B6, WALSEG]
MUTATIONS = [
dict(
@@ -247,12 +257,11 @@ MUTATIONS = [
test=B6, pattern='三个读取路径对同一个损坏文件给出一致结论',
),
dict(
name='vacuum 硬编码返回 6报告与事实无关)',
name='vacuum 报告与事实无关的层数(硬编码 6',
file=ENGINE,
old=""" let compactedLevels = 0;
const isBottom = (level: number): boolean => level === MAX_LSM_LEVELS - 1;""",
new=""" let compactedLevels = 6; // [MUTATION] 旧行为:与事实无关的数字
const isBottom = (level: number): boolean => level === MAX_LSM_LEVELS - 1;""",
old=""" const compactedLevels = await this.lsm.vacuumLevels();""",
new=""" await this.lsm.vacuumLevels();
const compactedLevels = 6; // [MUTATION] 旧行为:与事实无关的数字""",
test=B6, pattern='vacuum 返回',
),
dict(
@@ -289,6 +298,146 @@ MUTATIONS = [
new=""" // [MUTATION] 不聚合 LSM 报告""",
test=B6, pattern='恢复报告聚合',
),
# ------------------------------------------------------------------
# review 轮(v0.8.0 全量回归审查)新增:每条对应一个已证实的缺陷
# ------------------------------------------------------------------
dict(
name='R1 事务进行中仍推进 WAL 水位(P0:已提交事务静默丢失)',
file=ENGINE,
old=""" if (this.currentTxnId !== null) return this.durableLsn;""",
new=""" if (false as boolean) return this.durableLsn; // [MUTATION] 去掉事务守卫""",
test=B6, pattern='事务进行中不得推进 WAL 水位',
),
dict(
name='R2 强制回收不理会途读者(在途读者数据被打断)',
file=LSM,
old=""" if (this.activeReaders.size > 0) {
this.reclaimRetired();
return;
}""",
new=""" if (false as boolean) {
this.reclaimRetired();
return;
}""",
test=B6, pattern='退休 SSTable 与在途读者',
),
dict(
name='R3 孤儿回收只看引擎层损坏(漏掉 LSM 层被丢的 SSTable',
file=ENGINE,
old=""" if (damage.length > 0) {""",
new=""" if (damage.length > 99) { // [MUTATION] 门槛失效""",
test=B6, pattern='回收门槛与孤儿回收',
),
dict(
name='R4 WAL 前缀缺失丢弃后缀分片(整段活 WAL 被丢掉)',
file=SEGSTORE,
old=""" if (fromLsn === 0 && missingPrefix.length > 0) {""",
new=""" if (missingPrefix.length > 0) { // [MUTATION] 前缀缺失一律算空洞""",
test=B6, pattern='WAL 前缀缺失',
),
dict(
name='R5 丢弃 SSTable 后不同步内存层数组(幽灵 meta)',
file=LSM,
old=""" if (this.levels[meta.level]?.some((m) => m.id === meta.id)) {""",
new=""" if (false as boolean) { // [MUTATION] 只从 manifest 摘除""",
test=B6, pattern='内存层数组与 manifest 必须一致',
),
dict(
name='R6 不校验"文件名世代 == 载荷世代"(改名的旧副本被当成新提交点)',
file=MANIFEST,
old=""" if (decoded.manifest.generation !== gen) {""",
new=""" if (false as boolean) { // [MUTATION] 不比对世代号""",
test=B6, pattern='文件名世代与载荷世代不一致',
),
dict(
name='R7 pageIdWatermark 不再取单调 max(水位可回退→页面 id 复用)',
file=ENGINE,
old=""" this.manifest.pageIdWatermark = Math.max(
this.manifest.pageIdWatermark,
this.fileManager.getNextPageId(),
);""",
new=""" this.manifest.pageIdWatermark = this.fileManager.getNextPageId(); // [MUTATION]""",
test=B6, pattern='水位是单调下限',
),
dict(
name='R8 提交不做回读校验(写丢了也报成功)',
file=MANIFEST,
old=""" if (!verified.ok || verified.manifest.generation !== nextGeneration) {""",
new=""" if (false as boolean) { // [MUTATION] 关掉回读校验""",
test=B6, pattern='读回来是坏的',
),
dict(
name='R9 WAL 损坏记录不计数(静默丢记录)',
file=WALLOG,
old=""" corrupt++;""",
new=""" corrupt += 0; // [MUTATION] 不计数""",
test=B6, pattern='WAL 记录级损坏',
),
dict(
name='R10 丢弃残缺文件不进恢复报告(静默丢弃)',
file=LSM,
old=""" this.recoveryReport.droppedSSTables.push({ id: meta.id, level: meta.level, reason });""",
new=""" void reason; // [MUTATION] 不记录丢弃原因""",
test=B6, pattern='文件真的残缺',
),
dict(
name='R11 介质读故障的错误码被改写(读故障≠文件损坏的语义丢失)',
file=LSM,
old=""" 'ARIA_SSTABLE_READ_FAILED',""",
new=""" 'MUTATED_READ_FAILED', // [MUTATION]""",
test=B6, pattern='介质读故障',
),
dict(
name='R12 冻结意图不再压低水位(水位越过只在内存+WAL 的写入)',
file=ENGINE,
old=""" return Math.min(this.durableLsn, Math.min(...intents.map((i) => i.lsnAtFreeze)));""",
new=""" return Math.max(this.durableLsn, Math.max(...intents.map((i) => i.lsnAtFreeze))); // [MUTATION]""",
test=B6, pattern='水位严格低于意图起点',
),
dict(
name='R13 truncateBefore 把分片号重置为 0(新记录落在 manifest 水位之下被过滤)',
file=SEGSTORE,
old=""" this.currentSegment = Math.max(maxSeq + 1, keepFrom);""",
new=""" this.currentSegment = 0; // [MUTATION] 修复前行为""",
test=WALSEG, pattern='写入永不落到 manifest 水位下限之下',
),
dict(
name='R14 truncate 把分片号重置为 0(旧世代与新记录同号)',
file=SEGSTORE,
old=""" this.currentSegment = Math.max(this.currentSegment, maxSeq + 1);""",
new=""" this.currentSegment = 0; // [MUTATION] 修复前行为""",
test=WALSEG, pattern='整体清空后分片号绝不回退',
),
dict(
name='R15 内部空洞不再上报(丢一段已提交事务却毫无痕迹)',
file=SEGSTORE,
old=""" gaps.push(missing);""",
new=""" void missing; // [MUTATION] 不上报空洞""",
test=WALSEG, pattern='内部空洞',
),
dict(
name='R17 bloom 位数被忽略(bloomFilterBitsPerKey 配置无效)',
file='src/engine/aria/index/sstable_builder.ts',
old=""" const bloomFilter = new BloomFilter(this.entries.length, this.bloomBitsPerKey);""",
new=""" const bloomFilter = new BloomFilter(this.entries.length); // [MUTATION] 忽略配置""",
test=B6, pattern='bloomFilterBitsPerKey',
),
dict(
name='R18 引擎层不把 bloom 配置透传给 LSM(配置无效)',
file=LSM,
old=""" this.bloomBitsPerKey = Number.isFinite(config.bloomBitsPerKey) && (config.bloomBitsPerKey as number) > 0
? Math.floor(config.bloomBitsPerKey as number)
: DEFAULT_BLOOM_BITS_PER_KEY;""",
new=""" this.bloomBitsPerKey = DEFAULT_BLOOM_BITS_PER_KEY; // [MUTATION] 忽略配置""",
test=B6, pattern='bloomFilterBitsPerKey',
),
dict(
name='R16 旧格式表结构形状坏掉时静默当空库',
file=ENGINE,
old=""" if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {""",
new=""" if (false as boolean) { // [MUTATION] 不校验形状""",
test=B6, pattern='旧格式表结构记录的形状校验',
),
]
@@ -310,22 +459,97 @@ for _sig in ('SIGINT', 'SIGTERM', 'SIGHUP'):
pass
def run(cmd):
return subprocess.run(cmd, cwd=ROOT, shell=True, capture_output=True, text=True)
def run(cmd, timeout=JEST_TIMEOUT_S):
"""跑一条命令:带超时(超时 = 变异脚本自身的问题,必须显式报出来)。"""
return subprocess.run(
cmd, cwd=ROOT, shell=True, capture_output=True, text=True, timeout=timeout,
)
def sha256(text):
return hashlib.sha256(text.encode('utf-8')).hexdigest()
def acquire_lock():
"""同一时刻只允许一个变异进程改 src/(并发跑会互相踩掉对方的源码)。"""
lock = os.path.join(ROOT, '.mutation-b6.lock')
try:
fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
print(f'[abort] 已有变异进程在跑({lock} 存在)。')
print(' 确认没有残留后删除该文件再重试。')
sys.exit(2)
os.write(fd, str(os.getpid()).encode())
os.close(fd)
return lock
def classify(out):
"""把一次 jest 输出分类。顺序很重要:
编译失败/未匹配**不能**算"测试拦住了变异""""
if re.search(r'Test suite failed to run', out) or re.search(r'error TS\d+', out) \
or 'Cannot find module' in out or 'SyntaxError' in out:
return 'BAD(变异破坏编译)'
if re.search(r'^Tests:\s+0 total', out, re.M) or 'No tests found' in out:
return 'BAD(用例未匹配)'
if re.search(r'^Tests:.*\bfailed\b', out, re.M) or '' in out:
return 'OK(变异被拦住)'
return 'BAD(测试仍然通过)'
def positive_control():
"""正控:未变异的干净代码上,这些套件必须全绿。
没有正控的变异脚本会把"套件本来就红"误读成"变异被拦住了""""
print('[control] 干净代码基线检查 …', flush=True)
for suite in CONTROL_SUITES:
try:
res = run(f"npx jest {shlex.quote(suite)} --testPathIgnorePatterns='/node_modules/'")
except subprocess.TimeoutExpired:
print(f'[control] 超时:{suite}')
return False
out = (res.stdout or '') + (res.stderr or '')
m = re.search(r'^Tests:\s+(.*)$', out, re.M)
line = m.group(1) if m else '无法解析'
if 'failed' in line or not m:
print(f'[control] 基线不干净:{suite} → Tests: {line}')
return False
print(f'[control] ok {suite} → Tests: {line}', flush=True)
return True
def main():
lock = acquire_lock()
try:
return _main()
finally:
try:
os.unlink(lock)
except OSError: # pragma: no cover
pass
def _main():
only = sys.argv[1] if len(sys.argv) > 1 else None
if not positive_control():
print('\n[abort] 正控未通过 —— 先修好基线再谈"变异被拦住"')
return 2
results = []
fingerprints = {}
for m in MUTATIONS:
if only and only not in m['name']:
continue
path = os.path.join(ROOT, m['file'])
original = io.open(path, encoding='utf-8').read()
fingerprints[path] = sha256(original)
if m['old'] not in original:
results.append((m['name'], 'SKIP(锚点未找到)'))
print(f"[skip] {m['name']}: 锚点未找到", flush=True)
continue
if original.count(m['old']) != 1:
results.append((m['name'], f"SKIP(锚点不唯一 x{original.count(m['old'])})"))
print(f"[skip] {m['name']}: 锚点出现 {original.count(m['old'])} 次,拒绝变异", flush=True)
continue
mutated = original.replace(m['old'], m['new'], 1)
CURRENT['path'] = path
CURRENT['content'] = original
@@ -335,37 +559,39 @@ def main():
# 于是 jest 收到 `\\(` 这种模式、匹配不到任何用例,脚本会把"没跑用例"
# 误判成"测试仍然通过"。
cmd = (f"npx jest {shlex.quote(m['test'])} -t {shlex.quote(m['pattern'])} "
f"--testPathIgnorePatterns='/node_modules/' 2>&1 | tail -80")
out = run(cmd).stdout
# 注意:不能只搜 "0 total" —— jest 的 "Snapshots: 0 total" 也会命中,
# 于是把"跑了用例且失败"误判成"用例未匹配"。
no_tests = bool(re.search(r'^Tests:\s+0 total', out, re.M)) or ('No tests found' in out)
failed = ('' in out) or ('Tests:' in out and 'failed' in out) or ('' in out)
if no_tests:
status = 'SKIP(用例未匹配)'
elif failed:
status = 'FAIL(如预期)'
f"--testPathIgnorePatterns='/node_modules/'")
try:
res = run(cmd)
out = (res.stdout or '') + (res.stderr or '')
except subprocess.TimeoutExpired:
status = 'BAD(超时)'
out = ''
else:
status = 'PASS(!!)'
status = classify(out)
results.append((m['name'], status))
msg = {
'FAIL(如预期)': '测试失败(变异被拦住)',
'PASS(!!)': '测试仍然通过!',
'SKIP(用例未匹配)': '用例未匹配(模式写错了)',
}[status]
print(f"[{'ok' if status == 'FAIL(如预期)' else 'BAD'}] {m['name']}{msg}", flush=True)
icon = 'ok ' if status == 'OK(变异被拦住)' else 'BAD'
print(f'[{icon}] {m["name"]}{status}', flush=True)
if status != 'OK(变异被拦住)':
tail = [l for l in out.splitlines() if l.startswith('Tests:')]
if tail:
print(f' {tail[0]}', flush=True)
finally:
io.open(path, 'w', encoding='utf-8').write(original)
# 恢复必须逐字节一致(否则会悄悄改动源码)
if sha256(io.open(path, encoding='utf-8').read()) != fingerprints[path]:
print(f'[FATAL] {m["file"]} 恢复后与原始内容不一致!', flush=True)
return 3
CURRENT['path'] = None
CURRENT['content'] = None
print('\n==== 变异验证汇总 ====')
bad = [r for r in results if r[1] != 'FAIL(如预期)']
bad = [r for r in results if r[1] != 'OK(变异被拦住)']
for name, status in results:
print(f' {status:14s} {name}')
print(f' {status:20s} {name}')
if bad:
print(f'\n{len(bad)} 条变异没有被测试拦住 —— 那些用例只是陪跑。')
return 1
print('\n全部变异都被对应用例拦住。')
print(f'\n全部 {len(results)}变异都被对应用例拦住。')
return 0