Summary: Pull Request resolved: https://github.com/facebook/rocksdb/pull/14940 A few improvements to the CPU corruption injector: - Seed handling: `--seed` is now validated against db_stress's accepted range and no longer silently wraps, so an out-of-range seed fails fast instead of producing misleading NO_INJECTION runs. Omit `--seed` for a fresh random base seed. - Per-run reproducibility: each run's full db_stress command is recorded at the top of its `gdb.log`, so any run can be reproduced by copy-pasting one line. - README: clarified the `--seed` semantics and examples. **Test plan:** All db_stress runs use a debug build: `make DEBUG_LEVEL=0 EXTRA_CXXFLAGS="-g -fno-inline" db_stress`. ## Seed handling: out-of-range seeds fail fast Command: ``` python3 tools/cpu_corruption_injector/runner.py --op write --runs 2 --stress_cmd ./db_stress --report_dir /tmp/tp_badseed --seed 4294967295; echo "exit=$?" python3 tools/cpu_corruption_injector/runner.py --op write --runs 1 --stress_cmd ./db_stress --report_dir /tmp/tp_badseed0 --seed 0; echo "exit=$?" ``` Output: ``` 2026-07-15 01:26:14,408 ERROR --seed range [4294967295, 4294967296] is outside db_stress's accepted [1, 4294967295]; pass a smaller --seed exit=2 2026-07-15 01:26:14,558 ERROR --seed range [0, 0] is outside db_stress's accepted [1, 4294967295]; pass a smaller --seed exit=2 ``` Both are rejected before any run starts (previously the seed silently wrapped and every run came back a misleading NO_INJECTION). ## Per-run command logged (full db_stress command in gdb.log) Command: ``` python3 tools/cpu_corruption_injector/runner.py --op write --runs 2 --stress_cmd ./db_stress --report_dir /tmp/tp_seed --seed 424242 head -6 /tmp/tp_seed/run_00000/gdb.log ``` Output (the first line is the full command; the run's gdb/db_stress output follows it): ``` # /usr/bin/gdb --batch --nx -iex 'py import sys; sys.argv=["injector.py", "--op", "write", "--op_index", "259", "--entry_fn", "rocksdb::MemTable::Add", "--target_fn", "rocksdb::MemTable::Add", "--seed", "424242", "--dir", "/tmp/tp_seed/run_00000"]' -x /data/users/huixiao/fbsource/fbcode/internal_repo_rocksdb/repo/tools/cpu_corruption_injector/injector.py --args /data/users/huixiao/fbsource/fbcode/internal_repo_rocksdb/repo/db_stress --db=/tmp/tp_seed/run_00000/db --expected_values_dir=/tmp/tp_seed/run_00000/exp --seed=424242 --verify_cpu_corruption_dir=/tmp/tp_seed/run_00000 --ops_per_thread=1000 --max_key=1000 --value_size_mult=8 --column_families=1 --nooverwritepercent=0 --writepercent=50 --delpercent=40 --delrangepercent=10 --readpercent=0 --iterpercent=0 --prefixpercent=0 --flush_one_in=20 --compact_range_one_in=10 --compact_files_one_in=10 --target_file_size_base=65536 --reopen=0 --destroy_db_initially=1 --clear_column_family_one_in=0 --disable_wal=0 --threads=1 --num_dbs=1 --disable_auto_compactions=1 --write_buffer_size=1073741824 --remote_compaction_worker_threads=0 --subcompactions=1 --batch_protection_bytes_per_key=8 --memtable_protection_bytes_per_key=8 --block_protection_bytes_per_key=8 --paranoid_file_checks=1 --paranoid_memory_checks=1 --memtable_verify_per_key_checksum_on_seek=1 --detect_filter_construct_corruption=true --verify_checksum=1 --compression_checksum=1 --file_checksum_impl=crc32c --verify_checksum_one_in=1 --verify_file_checksums_one_in=1000 --read_fault_one_in=0 --write_fault_one_in=0 --metadata_read_fault_one_in=0 --metadata_write_fault_one_in=0 --open_read_fault_one_in=0 --open_write_fault_one_in=0 --open_metadata_read_fault_one_in=0 --open_metadata_write_fault_one_in=0 --secondary_cache_fault_one_in=0 --sync_fault_injection=0 This GDB supports auto-downloading debuginfo from the following URLs: <https://debuginfod.centos.org/> Debuginfod has been disabled. ``` The first line of `gdb.log` is the complete `gdb` + `db_stress` command, including `--seed=424242` and `--max_key=1000`; the run's own output follows below it. ## The logged command reflects real flag values Temporarily set `max_key` to `12345` in `runner_build.py`, then: Command: ``` python3 tools/cpu_corruption_injector/runner.py --op write --runs 1 --stress_cmd ./db_stress --report_dir /tmp/tp_maxkey --seed 424242 head -1 /tmp/tp_maxkey/run_00000/gdb.log | tr ' ' ' ' | grep -- --max_key ``` Output: ``` --max_key=12345 ``` The logged command tracked the changed preset value. Reverted the change afterward. Reviewed By: archang19 Differential Revision: D112075800 fbshipit-source-id: 0d2be1741ede806a84a1a0f9ca6b2a32147c4e9c
CPU Corruption Injector
Overview
This tool measures the outcome distribution over many db_stress test runs.
In each db_stress test run, a single CPU-register bit-flip is injected into
exactly one db_stress operation (a write, flush, or compaction) running under
gdb. Aggregating over many such runs answers "when the CPU corrupts one
instruction inside that op, how often does RocksDB catch it, crash, silently
lose/return wrong data, or absorb it with no effect?".
It has three layers:
- Detection (
db_stress --verify_cpu_corruption_dir): right after the injected op,db_stressreads the whole keyspace back and compares against its expected state, writing any finding to adata_corruption.*.jsonfile. - Injection (
injector*.py): runs insidegdb, navigates to a randomly chosen instance of the op, stops at a random "critical" instruction -- one of the two kinds that can carry a fault into data: a move carrying a value between CPU and memory (a general-purpose or vector register), or a branch/jump (theeflagsregister) -- and flips one random bit in the register it uses. Control registers (rsp/rbp/rip/segment) are left alone. - Orchestration (
runner*.py): builds the run set, launchesgdbper run, classifies each outcome, and aggregates them.
Requirements
-
A
db_stressbinary built with debug info and no inlining, sogdbcan set breakpoints on the injection-site functions and read their source lines:make DEBUG_LEVEL=0 EXTRA_CXXFLAGS="-g -fno-inline" db_stress -
gdb. Defaults to/usr/bin/gdb; override with theICC_GDBenv var if the telemetry's source resolves to@ ?:0(agdbtoo old to read this build's debug info). -
Optional per-run timeout via the
ICC_RUN_TIMEOUTenv var (seconds, default600). One hunggdb+db_stresscannot block the rest of the set.
Entry point: runner.py
A campaign is many db_stress test runs of one op type. Each run is a
full single-threaded db_stress test that performs ops_per_thread ops, but the
injector corrupts exactly one op instance of the chosen type in that run
(e.g. the 51st Put of that run); the run is then classified into a single outcome
bucket.
--opis one ofwrite,flush,compaction.--parallel NrunsNruns at once (eachdb_stressrun is itself single-threaded, so raise this to use more cores).--seed Nsets the campaign'sbase_seed: runideterministically uses seedN+i, which drives both the injection choices anddb_stress's own--seed, so the whole set is reproducible frombase_seedalone. Omit--seedto draw one fresh randombase_seedper campaign (chosen once perrunner.pyrun and logged asbase_seed=N); pass thatNback via--seedto replay the set, or--seed=<N+i>with--runs 1to replay just runi.
In the examples below, <db_stress> is the path to the db_stress binary you
built (see Requirements above -- e.g. ./db_stress), and
<dir> is a writable directory for the campaign's output.
# a compaction campaign of 100 runs
python3 tools/cpu_corruption_injector/runner.py --op compaction --runs 100 \
--stress_cmd <db_stress> --report_dir <dir>
# a flush campaign of 200 runs, 8 in parallel
python3 tools/cpu_corruption_injector/runner.py --op flush --runs 200 \
--stress_cmd <db_stress> --report_dir <dir> --parallel 8
# replay an earlier campaign: pass its logged base_seed back as --seed
# (the runner prints "reproduce with --seed=<base_seed>"); base_seed is in [1, 4294967295]
python3 tools/cpu_corruption_injector/runner.py --op write --runs 100 \
--stress_cmd <db_stress> --report_dir <dir> --seed 3141592653
Before doing any work, runner.py preflights the build: it confirms gdb can
set breakpoints on the op's injection-site functions and read their source
lines, and fails fast otherwise.
Outputs
Everything lands under --report_dir: two campaign-wide files at the top level,
plus one run_XXXXX/ subdirectory per run.
<report_dir>/
├── summary.json # campaign-wide: outcome_counts + a record per run
├── runner.log # campaign-wide: runner logging (also echoed to console)
├── run_00000/ # one subdirectory per run
│ ├── inject.json # what the injector did (WHERE / WHAT / HOW)
│ ├── data_corruption.<tid>.json # the finding, only if this run flagged an SDC/corruption
│ ├── gdb.log # this run's gdb output
│ ├── db/ # this run's db_stress DB (working dir)
│ └── exp/ # this run's expected-values (working dir)
├── run_00001/
│ └── ...
└── ...
So summary.json and runner.log are at the top of --report_dir; the
per-run gdb.log / inject.json / data_corruption.*.json live inside each
run_XXXXX/.
Outcome buckets:
- SDC: silent data corruption -- the read-back succeeded but returned the wrong result (a lost key, a resurrected key, or a wrong value).
- CORRUPTION:
db_stressdetected a corruption via an integrity check (Status::Corruption). - CRASH: the injected corruption crashed
db_stress(a fatal signal). - NO_EFFECT: the corruption landed but the run finished with correct data.
- NO_INJECTION: no bit was flipped this run. The op instance, its
target_fn, and the instruction to corrupt are all picked randomly up front, so a run can occasionally target something it never reaches -- a random op instance beyond the ops this run actually performed, or a critical-instruction index that no latertarget_fncall happens to have. Should be rare; a high count means the injection sites (or the random ranges) need revisiting. - ERROR: a harness/framework failure, not a
db_stressdata outcome -- the injector hit a bug,gdbdied before writinginject.json, or the run hit theICC_RUN_TIMEOUTcap. Expected to be 0; any non-zero count is a framework bug worth investigating.
Reading a run's output across the 3 layers
Each run threads through all three layers: the injector records what it corrupted
(inject.json), db_stress records what the post-op read-back found
(data_corruption.<tid>.json), and the runner rolls every run up into
summary.json. The examples below are from a real 40-run compaction campaign:
python3 tools/cpu_corruption_injector/runner.py --op compaction --runs 40 \
--seed 424242 --stress_cmd <db_stress> --report_dir <dir>
Orchestration layer -- summary.json's outcome_counts (how many of the
campaign's runs landed in each bucket), the whole campaign at a glance:
| SDC | CORRUPTION | CRASH | NO_EFFECT | NO_INJECTION | ERROR |
|---|---|---|---|---|---|
| 2 | 10 | 0 | 25 | 3 | 0 |
A silent data corruption (SDC): run_00023
Injection layer -- run_00023/inject.json: an eflags flag-flip on the
compaction iterator's key-copy path; the op did NOT crash
(db_stress_crash_signal: null):
{
"injection_result": "injected",
"db_stress_crash_signal": null,
"op": "compaction",
"op_index": 6,
"entry_fn": "rocksdb::CompactionJob::Run",
"target_fn": "rocksdb::CompactionIterator::NextFromInput",
"critical_instruction_index": 0,
"corruptions": [
{
"instruction": "cmp $0x40,%rdx",
"register": "eflags",
"corruption_type": "flag_flip",
"before": "0x287",
"after": "0x286",
"details": {
"source": "__memmove_avx512_unaligned_erms @ ../sysdeps/x86_64/multiarch/memmove-vec-unaligned-erms.S:299",
"call_chain": [
"__memmove_avx512_unaligned_erms @ ../sysdeps/x86_64/multiarch/memmove-vec-unaligned-erms.S:299",
"rocksdb::IterKey::SetKeyImpl @ ./db/dbformat.h:941",
"rocksdb::IterKey::SetInternalKey @ ./db/dbformat.h:741",
"rocksdb::IterKey::SetInternalKey @ ./db/dbformat.h:749",
"rocksdb::CompactionIterator::NextFromInput @ db/compaction/compaction_iterator.cc:781"
]
}
}
],
"ops_seen": 6,
"critical_instructions_seen": 1
}
Detection layer -- run_00023/data_corruption.<tid>.json: the post-op
read-back found a committed key missing:
{
"kind": "lost",
"cf": 0,
"key": 202,
"value_from_db": "",
"value_from_expected": "010000000504070609080B0A0D0C0F0E",
"op_status": "Get: NotFound"
}
Reading it: the flag flip mis-copied the compaction output key, dropping key 202
from the output SST. The op returned OK and no checksum fired, but reading key
202 back returns NotFound for a committed key, so the runner classifies the run
SDC (kind=lost) -- the bug class this tool exists to surface.
Auxiliary entry point: injector.py
injector.py is not run on its own -- there is no python3 injector.py
usage. It only works inside gdb's embedded Python (it does import gdb), so
runner.py runs it by launching gdb with the injector as gdb's script. Each
run's invocation looks like:
gdb --batch --nx \
-iex 'py import sys; sys.argv=[...]' \
-x tools/cpu_corruption_injector/injector.py \
--args <db_stress> <db_stress flags...>
--batchrunsgdbnon-interactively and exits when the script finishes;--nxskips the user's~/.gdbinitso their config can't change behavior.-iex 'py ... sys.argv=[...]'runs a Python command before the script loads, seedinginjector.py'ssys.argvwith its parameters (--op,--op_index,--entry_fn,--target_fn,--seed,--dir) -- gdb otherwise hands a-xscript an empty argv.-x tools/cpu_corruption_injector/injector.pyis the scriptgdbexecutes in its embedded Python.--args <db_stress> <db_stress flags...>is the programgdblaunches and debugs -- thedb_stressbinary and its flags, whichgdbstarts, stops mid-op, and corrupts.