db_stress: document expected-state trace/replay contract (#14612)

Summary:
- add a `docs/components/` landing page and a stress-test docs index
- document the `db_stress` expected-state trace/replay lifecycle, file invariants, and prefix-recovery contract in `expected_state_trace.md`
- align `db_stress` comments with the restore semantics: missing trace entries are fatal, while extra tail trace entries are tolerated
- keep the new docs tree trackable and point repo instructions at the new component-docs entrypoint

## Testing
- Not run (documentation and comment updates only)

Pull Request resolved: https://github.com/facebook/rocksdb/pull/14612

Reviewed By: joshkang97

Differential Revision: D100797173

Pulled By: xingbowang

fbshipit-source-id: 25be8c6239b9fdd84580818efe7520c371f9a46b
This commit is contained in:
Xingbo Wang
2026-04-14 09:58:20 -07:00
committed by meta-codesync[bot]
parent 9f474a1034
commit 50852b5c8d
9 changed files with 540 additions and 6 deletions
+3
View File
@@ -107,3 +107,6 @@ third-party/folly/
.claude/settings.local.json .claude/settings.local.json
tools/__pycache__/ tools/__pycache__/
# Keep documentation trackable even if broader ignores match names like "*_test".
!docs/
!docs/**
+9
View File
@@ -0,0 +1,9 @@
# Agent Instructions
This repository's authoritative agent instructions live in `CLAUDE.md`.
Read and follow [`CLAUDE.md`](./CLAUDE.md) in full before making changes or
reviewing code in this checkout.
If there is any ambiguity between this file and `CLAUDE.md`, `CLAUDE.md` takes
precedence.
+8
View File
@@ -266,6 +266,14 @@ The following patterns emerged as frequent sources of review feedback:
### Stress test ### Stress test
* When adding a new feature, make sure stress test covers the new option. * When adding a new feature, make sure stress test covers the new option.
### Component docs
* For component-level design notes and implementation walkthroughs, start with
`docs/components/index.md`.
* Documentation under `docs/components/` is organized by subsystem in
`docs/components/<area>/`.
* Each subsystem directory should have an `index.md` entry point plus focused
chapter files for deeper topics.
### DB bench update ### DB bench update
* When adding a performance related feature, support it in db_bench * When adding a performance related feature, support it in db_bench
+5 -5
View File
@@ -1396,11 +1396,11 @@ void StressTest::OperateDb(ThreadState* thread) {
if (thread->rand.OneInOpt(FLAGS_key_may_exist_one_in)) { if (thread->rand.OneInOpt(FLAGS_key_may_exist_one_in)) {
TestKeyMayExist(thread, read_opts, rand_column_families, rand_keys); TestKeyMayExist(thread, read_opts, rand_column_families, rand_keys);
} }
// Prefix-recoverability relies on tracing successful user writes. // Historical expected-state restore replays exactly
// Currently we trace all user writes regardless of whether it later // `db->GetLatestSequenceNumber() - saved_seqno_` write ops from the
// succeeds or not. To simplify, we disable any fault injection during // trace. Missing trace entries are fatal, while extra suffix entries are
// user write. // tolerated. Keep fault injection disabled during user writes until each
// TODO(hx235): support tracing user writes with fault injection. // injected failure mode is audited against that contract.
bool disable_fault_injection_during_user_write = bool disable_fault_injection_during_user_write =
fault_fs_guard && MightHaveUnsyncedDataLoss(); fault_fs_guard && MightHaveUnsyncedDataLoss();
int prob_op = thread->rand.Uniform(100); int prob_op = thread->rand.Uniform(100);
+4
View File
@@ -397,6 +397,10 @@ Status FileExpectedStateManager::SaveAtAndAfter(DB* db) {
trace_opts.filter |= kTraceFilterMultiGet; trace_opts.filter |= kTraceFilterMultiGet;
trace_opts.filter |= kTraceFilterIteratorSeek; trace_opts.filter |= kTraceFilterIteratorSeek;
trace_opts.filter |= kTraceFilterIteratorSeekForPrev; trace_opts.filter |= kTraceFilterIteratorSeekForPrev;
// Expected-state restore replays by recovered DB sequence count rather than
// by trace-side commit acknowledgement. This trace therefore needs to be an
// ordered superset of writes that could survive recovery: missing trace
// entries are fatal, while extra suffix entries are tolerated.
trace_opts.preserve_write_order = true; trace_opts.preserve_write_order = true;
s = db->StartTrace(trace_opts, std::move(trace_writer)); s = db->StartTrace(trace_opts, std::move(trace_writer));
} }
+4 -1
View File
@@ -323,7 +323,10 @@ class FileExpectedStateManager : public ExpectedStateManager {
// Say `db->GetLatestSequenceNumber()` was `a` last time `SaveAtAndAfter()` // Say `db->GetLatestSequenceNumber()` was `a` last time `SaveAtAndAfter()`
// was called and now it is `b`. Then this function replays `b - a` write // was called and now it is `b`. Then this function replays `b - a` write
// operations from "`a`.trace" onto "`a`.state", and then copies the resulting // operations from "`a`.trace" onto "`a`.state", and then copies the resulting
// file into "LATEST.state". // file into "LATEST.state". This relies on "`a`.trace" being an ordered
// superset of writes that could survive recovery: missing trace entries are
// fatal because replay length comes from DB sequence space, while extra tail
// entries are tolerated.
Status Restore(DB* db) override; Status Restore(DB* db) override;
private: private:
+12
View File
@@ -0,0 +1,12 @@
# RocksDB Components
This directory collects component-specific design notes and implementation
walkthroughs.
## Sections
| Section | Path | Summary |
|---------|------|---------|
| Read Flow | [read_flow/index.md](read_flow/index.md) | Point lookups, MultiGet, iterators, cache integration, range deletions, and read-side tuning. |
| Write Flow | [write_flow/index.md](write_flow/index.md) | Write APIs, write thread, WAL, memtable insertion, sequence numbers, write modes, crash recovery, and performance. |
| Stress Test | [stress_test/index.md](stress_test/index.md) | Stress-test-specific design notes, invariants, and debugging references. |
@@ -0,0 +1,486 @@
# `db_stress` Expected-State Trace Logic
This note documents the trace/replay path used by `db_stress` crash-recovery
verification when it needs to tolerate lost buffered writes.
It is not a guide to RocksDB's generic tracing APIs in general. It is
specifically about the code path centered on:
- `db_stress_tool/db_stress_driver.cc`
- `db_stress_tool/db_stress_test_base.cc`
- `db_stress_tool/expected_state.{h,cc}`
- `trace_replay/trace_replay.{h,cc}`
- `utilities/trace/replayer_impl.cc`
## What problem this solves
`LATEST.state` is the normal `db_stress` oracle: it stores the latest expected
value for each logical key. That is sufficient when recovery must preserve the
latest state exactly.
It is not sufficient when the test intentionally allows loss of buffered writes
such as:
- `--sync_fault_injection`
- `--disable_wal`
- `--manual_wal_flush_one_in > 0`
In those modes, recovery is allowed to return an older prefix of recent writes.
The important property is "no hole":
- recovered writes must form a prefix of the writes that happened before crash
- it must not recover a newer write while losing an older one
The trace logic makes this check possible by snapshotting the oracle at a known
DB sequence number `N`, tracing subsequent writes, then rebuilding the oracle
for the recovered DB sequence number `M` by replaying the first `M - N` traced
write operations.
## When this path is active
History tracking only exists when `db_stress` uses the file-backed expected
state manager, which means `--expected_values_dir` is non-empty.
Tracing is started only when all of the following are true:
- the stress mode tracks expected state (`IsStateTracked()`)
- `--expected_values_dir` is non-empty
- `MightHaveUnsyncedDataLoss()` is true
As of the current code, `MightHaveUnsyncedDataLoss()` means:
- `FLAGS_sync_fault_injection`
- or `FLAGS_disable_wal`
- or `FLAGS_manual_wal_flush_one_in > 0`
This is broader than the older `--expected_values_dir` flag help text, which
still says historical values are tracked only with `--sync_fault_injection`.
## High-level lifecycle
The full flow for one `db_stress` process looks like this:
1. Open the DB.
2. If a historical snapshot/trace exists, restore `LATEST.state` to match the
DB's recovered sequence number before any startup verification runs.
3. Run verification against the reconstructed `LATEST.state`.
4. Save a new historical baseline at the DB's current sequence number and start
tracing new writes.
5. Run stress operations.
6. Crash or reopen without explicitly closing the trace.
The important ordering in `db_stress_driver.cc` is:
- `FinishInitDb()` runs before tracing is started for the new run.
- `TrackExpectedState()` runs after startup verification to avoid verification
reads contending on the DB-wide trace mutex.
- fault-injection settings that simulate data loss are enabled after
`TrackExpectedState()`.
That ordering ensures the sidecar oracle files are set up before the run starts
creating potentially losable DB writes.
## Files and invariants
The file-backed manager (`FileExpectedStateManager`) uses these files inside
`--expected_values_dir`:
| File | Meaning |
| --- | --- |
| `LATEST.state` | Current expected-value oracle used for normal verification |
| `PERSIST.seqno` | Separate persisted-sequence-number oracle metadata |
| `<N>.state` | Historical snapshot of expected values at DB sequence number `N` |
| `<N>.trace` | Trace of writes that happened after sequence number `N` |
| `.<name>.tmp` | Temporary file used for atomic replacement |
Only one historical generation matters at a time:
- `saved_seqno_` is the maximum sequence number found among `*.state` files
other than `LATEST.state`
- older `*.state` and `*.trace` files are treated as stale and cleaned up
`Open()` also repairs one specific partial-save case:
- if `<N>.state` exists but `<N>.trace` does not, it creates an empty
`<N>.trace`
That models the intended semantics of crashing after the baseline snapshot was
created but before tracing actually started.
## Why the oracle files live outside the fault-injected DB path
The expected-state snapshot and trace are written through `Env::Default()`,
not through the DB's fault-injected filesystem wrapper.
That is intentional. These files are part of the test oracle, not part of the
database state being validated. If they were subject to the same simulated data
loss as the DB files, the oracle would become unreliable exactly when it is
needed most.
`SaveAtAndAfter()` also disables `WritableFileWriter` buffering for the trace
file (`writable_file_max_buffer_size = 0`). This removes userspace buffering so
trace data is not stranded in an application buffer when the process is killed.
## Save/start-trace path
`StressTest::TrackExpectedState()` calls `SharedState::SaveAtAndAfter()`, which
dispatches to `FileExpectedStateManager::SaveAtAndAfter(DB*)`.
The save path does this:
1. Read the DB sequence number `N = db->GetLatestSequenceNumber()`.
2. Copy `LATEST.state` to a temp file.
3. Rename the temp file to `<N>.state`.
4. Create `<N>.trace` as an empty file.
5. Start RocksDB tracing on the DB, writing into `<N>.trace`.
6. Delete the previous historical `<old>.state` and `<old>.trace`, if any.
The state snapshot is created atomically via temp-file-plus-rename. The trace
file is created directly because an empty trace already has the desired meaning.
The trace options are important:
- reads are filtered out by setting
`kTraceFilterGet | kTraceFilterMultiGet | kTraceFilterIteratorSeek |
kTraceFilterIteratorSeekForPrev`
- writes are still traced
- `preserve_write_order = true`
The "filter" bits in `TraceOptions` are exclusion bits, so setting those bits
means "do not trace those read operations."
`preserve_write_order = true` is required because restore relies on prefix
semantics. It replays the first `M - N` traced write operations, so the trace
order must match the DB/WAL application order. Without preserved ordering, the
trace could contain the right writes in the wrong order and prefix replay would
be incorrect.
## Trace coverage contract
For expected-state recovery, the trace must satisfy this property:
- every write that can show up in recovered DB sequence/WAL state must already
be present in the trace in the same prefix order
- extra write records are acceptable if they only appear beyond the recovered
prefix
Equivalently:
- missing trace records are fatal
- extra suffix trace records are tolerated
This follows directly from how `Restore()` consumes the trace:
- `Restore()` chooses replay length from `db->GetLatestSequenceNumber()`, not
from trace metadata or explicit commit acknowledgements
- it then replays exactly that many logical write operations from the trace
As a result, a later trace point can be strictly worse than an earlier one. If
a crash happens after WAL/sequence state is recoverable but before the sidecar
trace file gets the record, then `Restore()` will under-replay and validation
will fail.
By contrast, an earlier trace point can leave extra tail records for writes
that do not survive recovery. That is acceptable as long as those records stay
beyond the prefix implied by the recovered DB sequence number.
In short:
- `db_stress` needs a prefix-preserving superset of recoverable writes
- it does not require an exact set of writes known to have fully completed at
the trace site
## Producer and consumer relationship
The semantics for this path are defined jointly by the trace producer and the
expected-state consumer:
1. Generic producer API
The producer uses generic `StartTrace()` / `Tracer` / `Replayer` APIs, but the
active consumer in this path is `FileExpectedStateManager::Restore()`, not
generic query replay.
2. Replay progress from DB sequence space
`Restore()` does not replay "until the trace says commit." It replays
`db->GetLatestSequenceNumber() - saved_seqno_` logical write operations.
3. Sidecar trace file
`<N>.trace` is written through `Env::Default()` and intentionally lives outside
the fault-injected DB path. There is no atomic coupling between WAL durability
and trace durability.
4. Ordered prefix semantics
For this path, `preserve_write_order` means the recovered trace prefix must
match DB/WAL application order. It does not by itself define whether the trace
contains an exact set of completed writes or a superset of recoverable writes;
that requirement comes from how `Restore()` interprets the trace.
## What actually goes into `<N>.trace`
`<N>.trace` is a normal RocksDB query trace file produced by `Tracer`.
In this `db_stress` path it contains:
- one `kTraceBegin` header record with trace magic and version metadata
- zero or more `kTraceWrite` records
- optionally one `kTraceEnd` footer record
Because the read trace types are filtered out, the practical payload is "header
plus write batches." Each `kTraceWrite` record stores:
- a timestamp
- a trace type
- a payload map
- the raw `WriteBatch::Data()` bytes
The timestamp is recorded by the generic tracing library, but the expected-state
restore path does not use timing at all. It uses `Replayer::Prepare()` and
`Replayer::Next()` only as a parser for the trace stream.
## Why truncated or footerless traces are expected
`db_stress` does not explicitly call `DB::EndTrace()` during the normal
crash/reopen loop. That means:
- the trace often has no `kTraceEnd` footer
- the last record may be partially written if the process dies mid-write
This is not an accident. The restore logic is intentionally tolerant of it.
The generic `TraceReader` returns `Status::Incomplete()` at EOF. The generic
replay stack already recognizes this as the kind of condition caused by killing
a process without `EndTrace()`. `FileExpectedStateManager::Restore()` adds the
expected-state-specific rule that EOF or tail corruption is acceptable only
after enough writes have already been recovered:
- if EOF is reached before enough writes were replayed, restore fails
- if EOF is reached after enough writes were replayed, restore succeeds
- if a corruption is encountered on the tail record after enough writes were
replayed, restore also succeeds
This is the core reason the trace only needs to be good up to the recovered DB
sequence number.
## Restore path
On the next run, `FinishInitDb()` checks `shared->HasHistory()`. If history is
present, it calls `shared->Restore(db_)` before normal verification and before
the compaction filter factory is attached to shared state.
`Restore(DB*)` does this:
1. Read the recovered DB sequence number `M = db->GetLatestSequenceNumber()`.
2. Require `M >= saved_seqno_`. Otherwise the DB rolled back further than the
oldest restorable baseline and restore fails.
3. Compute `replay_write_ops = M - saved_seqno_`.
4. Copy `<saved_seqno_>.state` to a temp `LATEST.state`.
5. Open `<saved_seqno_>.trace`.
6. Build a default `Replayer`, call `Prepare()`, and repeatedly call `Next()`
to decode trace records.
7. Feed each decoded `TraceRecord` into a custom handler that updates the temp
expected-state file.
8. Once exactly `replay_write_ops` logical write operations have been applied,
restore has enough information to succeed and becomes tolerant of EOF or tail
corruption.
9. Rename the temp `LATEST.state` into place atomically.
10. Delete `<saved_seqno_>.state`.
11. Delete traces older than `<saved_seqno_>.trace`, but keep the replayed
trace itself for debugging.
12. Clear `saved_seqno_`.
An important detail: the default `Replayer` is not used to execute traced
operations against the DB. It is only used to parse header and record formats.
`Restore()` pulls out `TraceRecord`s with `Next()` and then calls
`record->Accept(custom_handler, &result)` on its own handler.
## How replay updates the oracle
`ExpectedStateTraceRecordHandler` implements both:
- `TraceRecord::Handler`
- `WriteBatch::Handler`
The generic trace layer gives it decoded `TraceRecord`s. For write records, it
constructs a `WriteBatch` from the traced bytes and iterates the batch, letting
the handler process each individual batch entry.
Read trace types are ignored. In practice they should not appear because the
trace options filtered them out, but the handler is still tolerant if they do.
### Key decoding
The handler does not store raw RocksDB keys in the expected-state oracle. It
maps traced user keys back to `db_stress` logical integer keys.
The path is:
1. strip any user timestamp suffix from the traced key
2. parse the remaining user key with `GetIntVal()`
3. use the resulting logical key ID to mutate the expected-state array
This is why the debug logs track:
- parse failures
- raw-key to logical-key roundtrip mismatches
The roundtrip check compares the traced raw key against `Key(parsed_id)`.
### Per-operation semantics
The handler replays only the logical effect needed by the oracle:
- `PutCF` and `TimedPutCF`
- parse the logical key
- read `value_base` from the traced value bytes
- call `ExpectedState::SyncPut()`
- `PutEntityCF`
- deserialize the wide-column entity
- verify column consistency
- use the default wide-column value to obtain `value_base`
- call `SyncPut()`
- `DeleteCF`
- parse the logical key
- call `SyncDelete()`
- `SingleDeleteCF`
- outside prepared transactions, replay as `DeleteCF`
- inside prepared transactions, buffer the original single-delete form until
commit
- `DeleteRangeCF`
- parse begin/end logical keys
- call `SyncDeleteRange(begin, end)`
- count it as one replayed write operation even though it can affect many
logical keys
- `MergeCF`
- replay as `PutCF`
- this matches the `db_stress` merge operator, whose merged value is derived
from the latest operand rather than from a more complex accumulation rule
- `PutBlobIndexCF`
- blob direct-write tracing records the transformed `BlobIndex`, not the
original user value bytes
- the handler therefore treats it as "one more put to this logical key" and
derives the next `value_base` from the existing expected value
### Prepared transactions
Prepared transactions need extra care because the trace may contain prepare and
commit markers instead of immediately applied writes.
The handler buffers prepared writes in memory by transaction ID:
- `MarkBeginPrepare()` starts buffering into a temporary `WriteBatch`
- write entries encountered while buffering are appended to that batch
- `MarkEndPrepare(xid)` stores the buffered batch in a map
- `MarkCommit(xid)` replays the stored batch through the same handler
- `MarkRollback(xid)` drops the stored batch without applying it
That way the expected-state oracle reflects commit semantics rather than
prepare-time visibility.
## Why replay counts write operations, not trace records
The trace stream is made of `kTraceWrite` records, but each one contains a
whole `WriteBatch`, and a batch can contain multiple individual write entries.
Restore therefore counts replay progress using the number of write operations
applied by the handler, not the number of trace records read. The target count
is:
`db->GetLatestSequenceNumber() - saved_seqno_`
Within a traced `WriteBatch`, the handler's `Continue()` method stops batch
iteration once enough write operations have been applied. The outer restore
loop still keeps reading trace records until `Next()` returns EOF, footer, or
corruption, at which point restore decides whether the trace prefix it already
consumed was sufficient.
## Debugging support
Three flags control replay debugging:
- `--expected_state_trace_debug`
- `--expected_state_trace_debug_key`
- `--expected_state_trace_debug_max_logs`
When enabled, restore prints lines prefixed with
`[expected_state_trace_debug]`, including:
- restore begin/end markers
- `Next()` failures such as EOF or corruption
- per-key or per-range replay details
- parse failures and key roundtrip mismatches
- a replay summary with counters
Useful counters in the summary include:
- `replayed_write_ops`
- `key_decode_failures`
- `key_roundtrip_mismatches`
- `focus_key_op_hits`
- `logs_emitted`
- `logs_suppressed`
`--expected_state_trace_debug_key=<k>` narrows logging to a particular logical
key where possible. This is useful when the trace is large and only one key's
history matters.
## Crash-safety rules encoded in file deletion order
Several delete orders in the code are deliberate:
- after successfully saving a new baseline, it is safe to delete the old
historical files in any order because the new pair is already established
- after restore succeeds, the old `<N>.state` is deleted before old traces
because deleting the trace first and then crashing would leave no way to
replay back up to `N`
`Clean()` also removes:
- stale temp files from interrupted `Open()` or `SaveAtAndAfter()`
- stale historical state files older than `saved_seqno_`
- stale trace files older than `saved_seqno_`
## Minimal worked example
Suppose a previous run saved a baseline at sequence number `100`:
- `100.state` contains the oracle snapshot at seqno 100
- `100.trace` contains writes after seqno 100
Then the process crashes after issuing ten more write operations. The recovered
DB comes back with latest sequence number `107`.
On the next startup:
1. `Restore()` copies `100.state` to a temp `LATEST.state`.
2. It reads `100.trace`.
3. It applies the first `107 - 100 = 7` replayed write operations to the temp
oracle.
4. It ignores any tail after those seven operations, even if the trace ends
without a footer or the next record is truncated.
5. It renames the rebuilt temp file into `LATEST.state`.
The rebuilt oracle now matches the recovered DB and startup verification can
check for logical holes.
## Summary
The expected-state trace logic is a prefix-recovery oracle:
- `SaveAtAndAfter()` snapshots the oracle at sequence number `N` and starts a
write-only, write-order-preserving trace
- `Restore()` learns the recovered sequence number `M`, replays the first
`M - N` traced write operations onto the snapshot, and rebuilds
`LATEST.state`
- truncated or footerless traces are acceptable as long as the prefix required
by `M` is intact
- the trace must therefore be an ordered superset of writes that could survive
recovery; exact successful-write filtering is not the right invariant here
That is the mechanism that lets `db_stress` validate "no hole in recovery"
instead of requiring exact preservation of the latest unsynced writes.
+9
View File
@@ -0,0 +1,9 @@
# Stress Test Docs
This directory collects stress-test-specific design notes, invariants, and
debugging references.
## Documents
- [`expected_state_trace.md`](expected_state_trace.md): expected-state
trace/replay contract used by `db_stress` crash-recovery verification.