#!/usr/bin/env python3 """B-6 变异验证:把每个修复回退到修复前的行为,对应用例必须失败。 用法:python3 scripts/mutation-b6.py 任何一条"回退后测试仍然通过"都会以非零退出码报出来(说明测试只是陪跑)。 """ import hashlib import io import os import re import shlex import signal import subprocess import sys ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) LSM = 'src/engine/aria/index/lsm.ts' MANIFEST = 'src/engine/aria/store/manifest.ts' MERGE = 'src/engine/aria/index/merge_iterator.ts' ENGINE = 'src/engine/aria/index.ts' SEGSTORE = 'src/engine/aria/wal/segmented_store.ts' 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( name='47 MergeIterator 立即补充(提前终止多算 1 条)', file=MERGE, old=""" // 胜出来源的补充推迟到下一次 next()(消费者只取 N 条 → 底层只产出 N 条) this.pendingRefill = first.sourceIndex;""", new=""" // [MUTATION] 立即补充(修复前行为) this.seedFromSource(first.sourceIndex);""", test=B6, pattern='流式扫描提前终止不多算', ), dict( name='49 compacting 单 boolean(跨层触发被丢弃)', file=LSM, old=""" private scheduleCompact(level: number): void { if (level > MAX_LSM_LEVELS - 1 || this.compacting.has(level)) return; this.compacting.add(level);""", new=""" private scheduleCompact(level: number): void { if (level > MAX_LSM_LEVELS - 1 || this.compacting.size > 0) return; // [MUTATION] 单 boolean 语义 this.compacting.add(level);""", test=B6, pattern='按层 compaction 状态', ), dict( name='50 compaction 先 splice 整层(窗口内该层不可见)', file=LSM, old=""" const selected = [...this.levels[level]]; const selectedIds = new Set(selected.map((m) => m.id));""", new=""" const selected = this.levels[level].splice(0, this.levels[level].length); // [MUTATION] 旧行为 const selectedIds = new Set(selected.map((m) => m.id));""", test=B6, pattern='compaction 期间该层对读者始终可见', ), dict( name='51 底部层不回收墓碑', file=LSM, old=""" const merged = isBottomLevel ? mergedRaw.filter(([, value]) => !(value as unknown as Record).__tombstone) : mergedRaw;""", new=""" const merged = mergedRaw; // [MUTATION] 不回收墓碑""", test=B6, pattern='底部层合并回收墓碑', ), dict( name='44 冻结表失败后不重试', file=LSM, old=""" private async retryPendingFlushes(): Promise { let rounds = 0;""", new=""" private async retryPendingFlushes(): Promise { return; // [MUTATION] 无重试路径 let rounds = 0;""", test=B6, pattern='持续失败 → flush 明确报错且数据不丢', ), dict( name='45 后台错误检查放回"入链之前"', file=LSM, old=""" async flush(): Promise { this.enqueuePendingMemtables(); const reported = this.consumeBackgroundError(); await this.drainChain();""", new=""" async flush(): Promise { // [MUTATION] 旧行为:先检查/报告后台错误 → 本次 flush 被整个跳过 const early = this.consumeBackgroundError(); if (early !== null) { throw new DatabaseError('background error (legacy behaviour)', 'ARIA_BACKGROUND_ERROR', early); } this.enqueuePendingMemtables(); const reported = this.consumeBackgroundError(); await this.drainChain();""", test=B6, pattern='后台错误不得让本次 flush 白做', ), dict( name='manifest 全世代损坏 → 返回空状态(静默空库)', file=MANIFEST, old=""" if (generations.length > 0) { // 文件存在但一代都读不出来:这是元数据损坏,不是空库 throw new DatabaseError(""", new=""" if (false && generations.length > 0) { // [MUTATION] 静默当空库 throw new DatabaseError(""", test=B6, pattern='全部世代都损坏', ), dict( name='陈旧实例不设防(静默覆盖新世代)', file=MANIFEST, old=""" if (!opts.allowTakeover && newestForeign > this.generation) {""", new=""" if (false && !opts.allowTakeover && newestForeign > this.generation) { // [MUTATION] 不设防""", test=B6, pattern='陈旧实例提交被拒绝', ), dict( name='旧格式 meta 损坏 → 返回空(静默空库)', file=ENGINE, old=""" } catch (error) { throw new DatabaseError( `Legacy SSTable metadata "${key}" is corrupt and cannot be migrated: ${(error as Error).message}`, 'ARIA_LEGACY_META_CORRUPT', error, ); }""", new=""" } catch (error) { continue; // [MUTATION] 静默跳过损坏的旧 meta }""", test=B6, pattern='旧格式 meta 损坏', ), dict( name='介质读故障被当成"文件不存在"(误删 meta)', file=LSM, old=""" } catch (error) { throw new DatabaseError( `AriaEngine failed to read SSTable id=${meta.id} from storage: ${(error as Error).message}`, 'ARIA_SSTABLE_READ_FAILED', error, ); }""", new=""" } catch (error) { await this.dropInvalidSSTable(meta, 'read failure treated as missing'); // [MUTATION] 旧行为 return null; }""", test=B6, pattern='介质读故障 ≠ 文件缺失', ), dict( name='WAL 分片空洞静默丢弃尾部', file='src/engine/aria/wal/log.ts', old=""" if (gaps.length > 0 && !opts.allowGaps) {""", new=""" if (false && gaps.length > 0 && !opts.allowGaps) { // [MUTATION] 静默丢弃""", test=B6, pattern='活跃区间内缺失分片', ), dict( name='WAL 分片号复用(旧世代排在新记录之后)', file=SEGSTORE, old=""" if (leftover.length === 0) { this.currentSegment = Math.max(maxSeq + 1, keepFrom);""", new=""" if (leftover.length === 0) { this.currentSegment = 0; // [MUTATION] 复用分片号""", test=STRESS, pattern='随机操作压力', ), dict( name='冻结表意图不做"写入是否真的丢了"校验', file=ENGINE, old=""" const summary = intents.map((i) => `${i.ns}#${i.id}(${i.entryCount} 项)`).join(', '); throw new DatabaseError(""", new=""" const summary = intents.map((i) => `${i.ns}#${i.id}(${i.entryCount} 项)`).join(', '); if (summary) return; // [MUTATION] 不校验 throw new DatabaseError(""", test=B6, pattern='ARIA_WRITE_LOST', ), dict( name='提交中的冻结表仍写进意图(回退时误报 WRITE_LOST)', file=LSM, old=""" // 正在提交的表由当前这次 manifest 提交负责(见 FrozenTable.committing) if (frozen.committing) continue;""", new=""" // [MUTATION] 提交中的表也写进意图""", test=B6, pattern='回退上一代并标记 manifestFallback', ), dict( name='checkpoint 等完整 compaction(写路径被拖住)', file=CHECKPOINT, old=""" if (this.flushable && typeof this.flushable.flushMemtables === 'function') {""", new=""" if (false && this.flushable && typeof this.flushable.flushMemtables === 'function') { // [MUTATION] 旧行为""", test=B6, pattern='checkpoint 不再等完整 compaction', ), dict( name='collectReaders 不做结构版本重试(扫描漏掉并发发布的 SSTable)', file=LSM, old=""" if (version === this.structureVersion) return readers; this.readStructureRetries++; }""", new=""" return readers; // [MUTATION] 不校验结构版本 }""", test=B6, pattern='并发 flush 与 compaction 交错时', ), dict( name='点查不做结构版本重试(get 的校验失效)', file=LSM, old=""" if (version !== this.structureVersion) { this.readStructureRetries++; continue; // 期间有结构变化(新产物发布 / 前台冻结)→ 重来 }""", new=""" if (false) { this.readStructureRetries++; continue; }""", test=B6, pattern='并发 flush 与 compaction 交错时', ), dict( name='冻结 memtable 不更新结构版本(点查读到旧值/漏数据)', file=LSM, old=""" // v0.8.0:**前台变化也算结构变化**。读路径的"结构版本一致"必须覆盖""", new=""" this.structureVersion--; // [MUTATION] 抵消下面的自增 // v0.8.0:**前台变化也算结构变化**。读路径的"结构版本一致"必须覆盖""", test=B6, pattern='并发 flush 与 compaction 交错时', ), dict( name='sstable 越界策略不统一(scanAll 另有一套实现:越界即整体放弃)', file=SSTABLE, old=""" scanAll(callback: (key: string, value: Record) => void): void { for (const [key, value] of this.iterEntries(0, this.indexEntries.length - 1)) { callback(key, value); } }""", new=""" scanAll(callback: (key: string, value: Record) => void): void { // [MUTATION] 修复前的状态:这里另有一份解析循环,越界策略与 iterEntries 不同 const lenSize = this.lenFieldSize(); for (let bi = 0; bi < this.indexEntries.length; bi++) { const blockData = this.getBlockData(this.indexEntries[bi]); if (!blockData) continue; const blockView = new DataView(blockData.buffer, blockData.byteOffset, blockData.byteLength); const count = blockView.getUint32(0, false); let offset = 4; for (let i = 0; i < count; i++) { if (offset + lenSize > blockData.byteLength) return; const keyLen = blockView.getUint32(offset, false); offset += lenSize; if (offset + keyLen + lenSize > blockData.byteLength) return; const key = new TextDecoder().decode(blockData.slice(offset, offset + keyLen)); offset += keyLen; const valLen = blockView.getUint32(offset, false); offset += lenSize; if (offset + valLen > blockData.byteLength) return; const valBytes = blockData.slice(offset, offset + valLen); offset += valLen; try { callback(key, JSON.parse(new TextDecoder().decode(valBytes))); } catch { /* skip */ } } } }""", test=B6, pattern='三个读取路径对同一个损坏文件给出一致结论', ), dict( name='vacuum 报告与事实无关的层数(硬编码 6)', file=ENGINE, old=""" const compactedLevels = await this.lsm.vacuumLevels();""", new=""" await this.lsm.vacuumLevels(); const compactedLevels = 6; // [MUTATION] 旧行为:与事实无关的数字""", test=B6, pattern='vacuum 返回', ), dict( name='close() 落盘失败后不释放后端/锁(无 try/finally)', file=ENGINE, old=""" let failure: unknown = null; try { await this.flushAllLsms();""", new=""" let failure: unknown = null; if (true) { // [MUTATION] 旧行为:失败即中断,收尾逻辑不再执行 await this.flushAllLsms(); await this.bufferPool.flushAll(); await this.advanceWalCheckpoint(); await this.backend.close(); if (this.dbLock) { await this.dbLock.release(); this.dbLock = null; } this.schemas.clear(); this.opened = false; return; } try { await this.flushAllLsms();""", test=B6, pattern='close\\(\\) 在落盘失败时', ), dict( name='恢复报告不聚合 LSM 侧丢弃(数据丢失不可见)', file=ENGINE, old=""" for (const lsm of this.allLsms()) { const r = lsm.getRecoveryReport(); for (const d of r.droppedSSTables) { dropped.push({ namespace: r.namespace, ...d }); } if (r.dataLossSuspected) dataLoss = true; }""", 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='旧格式表结构记录的形状校验', ), ] CURRENT = {'path': None, 'content': None} def _restore_current(*_args): """收到信号时也要把源码恢复原状(否则中断会留下变异源码)。""" if CURRENT['path'] and CURRENT['content'] is not None: io.open(CURRENT['path'], 'w', encoding='utf-8').write(CURRENT['content']) print(f"\n[restore] {CURRENT['path']} 已恢复", flush=True) sys.exit(130) for _sig in ('SIGINT', 'SIGTERM', 'SIGHUP'): try: signal.signal(getattr(signal, _sig), _restore_current) except (AttributeError, ValueError): # pragma: no cover - 平台差异 pass 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 io.open(path, 'w', encoding='utf-8').write(mutated) try: # 注意:用 shlex.quote 而不是 repr —— repr 会把正则里的反斜杠再转义一次, # 于是 jest 收到 `\\(` 这种模式、匹配不到任何用例,脚本会把"没跑用例" # 误判成"测试仍然通过"。 cmd = (f"npx jest {shlex.quote(m['test'])} -t {shlex.quote(m['pattern'])} " f"--testPathIgnorePatterns='/node_modules/'") try: res = run(cmd) out = (res.stdout or '') + (res.stderr or '') except subprocess.TimeoutExpired: status = 'BAD(超时)' out = '' else: status = classify(out) results.append((m['name'], status)) 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] != 'OK(变异被拦住)'] for name, status in results: print(f' {status:20s} {name}') if bad: print(f'\n{len(bad)} 条变异没有被测试拦住 —— 那些用例只是陪跑。') return 1 print(f'\n全部 {len(results)} 条变异都被对应用例拦住。') return 0 if __name__ == '__main__': sys.exit(main())