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:
+253
-27
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user