From 50852b5c8da7d3d93d43fc1d7c2c7aff8df314b6 Mon Sep 17 00:00:00 2001 From: Xingbo Wang Date: Tue, 14 Apr 2026 09:58:20 -0700 Subject: [PATCH] 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 --- .gitignore | 3 + AGENTS.md | 9 + CLAUDE.md | 8 + db_stress_tool/db_stress_test_base.cc | 10 +- db_stress_tool/expected_state.cc | 4 + db_stress_tool/expected_state.h | 5 +- docs/components/index.md | 12 + .../stress_test/expected_state_trace.md | 486 ++++++++++++++++++ docs/components/stress_test/index.md | 9 + 9 files changed, 540 insertions(+), 6 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/components/index.md create mode 100644 docs/components/stress_test/expected_state_trace.md create mode 100644 docs/components/stress_test/index.md diff --git a/.gitignore b/.gitignore index af99939f9b..0d82eb5d0e 100644 --- a/.gitignore +++ b/.gitignore @@ -107,3 +107,6 @@ third-party/folly/ .claude/settings.local.json tools/__pycache__/ +# Keep documentation trackable even if broader ignores match names like "*_test". +!docs/ +!docs/** diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..2119936547 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index c5f872ae09..c49b356dab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -266,6 +266,14 @@ The following patterns emerged as frequent sources of review feedback: ### Stress test * 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//`. +* Each subsystem directory should have an `index.md` entry point plus focused + chapter files for deeper topics. + ### DB bench update * When adding a performance related feature, support it in db_bench diff --git a/db_stress_tool/db_stress_test_base.cc b/db_stress_tool/db_stress_test_base.cc index dbfd4e0d2f..2cfdc20c44 100644 --- a/db_stress_tool/db_stress_test_base.cc +++ b/db_stress_tool/db_stress_test_base.cc @@ -1396,11 +1396,11 @@ void StressTest::OperateDb(ThreadState* thread) { if (thread->rand.OneInOpt(FLAGS_key_may_exist_one_in)) { TestKeyMayExist(thread, read_opts, rand_column_families, rand_keys); } - // Prefix-recoverability relies on tracing successful user writes. - // Currently we trace all user writes regardless of whether it later - // succeeds or not. To simplify, we disable any fault injection during - // user write. - // TODO(hx235): support tracing user writes with fault injection. + // Historical expected-state restore replays exactly + // `db->GetLatestSequenceNumber() - saved_seqno_` write ops from the + // trace. Missing trace entries are fatal, while extra suffix entries are + // tolerated. Keep fault injection disabled during user writes until each + // injected failure mode is audited against that contract. bool disable_fault_injection_during_user_write = fault_fs_guard && MightHaveUnsyncedDataLoss(); int prob_op = thread->rand.Uniform(100); diff --git a/db_stress_tool/expected_state.cc b/db_stress_tool/expected_state.cc index 13e8abe01b..230e503a70 100644 --- a/db_stress_tool/expected_state.cc +++ b/db_stress_tool/expected_state.cc @@ -397,6 +397,10 @@ Status FileExpectedStateManager::SaveAtAndAfter(DB* db) { trace_opts.filter |= kTraceFilterMultiGet; trace_opts.filter |= kTraceFilterIteratorSeek; 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; s = db->StartTrace(trace_opts, std::move(trace_writer)); } diff --git a/db_stress_tool/expected_state.h b/db_stress_tool/expected_state.h index 0b179dcd43..e2704600b4 100644 --- a/db_stress_tool/expected_state.h +++ b/db_stress_tool/expected_state.h @@ -323,7 +323,10 @@ class FileExpectedStateManager : public ExpectedStateManager { // Say `db->GetLatestSequenceNumber()` was `a` last time `SaveAtAndAfter()` // 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 - // 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; private: diff --git a/docs/components/index.md b/docs/components/index.md new file mode 100644 index 0000000000..bc1860ea2d --- /dev/null +++ b/docs/components/index.md @@ -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. | diff --git a/docs/components/stress_test/expected_state_trace.md b/docs/components/stress_test/expected_state_trace.md new file mode 100644 index 0000000000..59bdcd0f0a --- /dev/null +++ b/docs/components/stress_test/expected_state_trace.md @@ -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 | +| `.state` | Historical snapshot of expected values at DB sequence number `N` | +| `.trace` | Trace of writes that happened after sequence number `N` | +| `..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 `.state` exists but `.trace` does not, it creates an empty + `.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 `.state`. +4. Create `.trace` as an empty file. +5. Start RocksDB tracing on the DB, writing into `.trace`. +6. Delete the previous historical `.state` and `.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 +`.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 `.trace` + +`.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 `.state` to a temp `LATEST.state`. +5. Open `.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 `.state`. +11. Delete traces older than `.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=` 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 `.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. diff --git a/docs/components/stress_test/index.md b/docs/components/stress_test/index.md new file mode 100644 index 0000000000..4988e6c317 --- /dev/null +++ b/docs/components/stress_test/index.md @@ -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.