Repository navigation
Conversation
Summary: Why the change is required: `WriteOptions::disableWAL` returns `Status::InvalidArgument` when `DBOptions::recycle_log_file_num > 0` (T185140603), and the crash test forces `recycle_log_file_num=0` whenever `disable_wal=1`. The root cause also loses data without disableWAL: external file ingestion consumes a sequence number without a WAL record, which is the recovery data loss reproduced by the disabled `DBWALTest.RecycleMultipleWalsCrash` (github PR 12918) and the reason WAL recycling is off in the crash test. Previous behavior: After a crash, a recycled WAL usually ends with records of its previous incarnation. In `kPointInTimeRecovery` the log reader reports such a record through `OldLogRecord()`. Since it cannot be told apart from a corrupted record, recovery stops replaying there and only continues into the next WAL if that WAL's first WriteBatch has exactly the sequence number recovery expects next (github PR 12403, D54544824). Any sequence numbers consumed between the last WriteBatch of one WAL and the first WriteBatch of the next without a WAL record (an unlogged write, an ingested file) fail that check, so recovery silently drops every later WAL, including acknowledged `sync` writes, or fails `DB::Open` with "SST file contains data beyond the point of corruption" when a column family was already flushed past it. PR 12403 therefore rejected `disableWAL` with recycling, PR 12639 exempted WritePrepared's internal split writes, and ingestion was never covered. New behavior: - `disableWAL` writes are accepted with `recycle_log_file_num > 0` for plain DBs and WriteCommitted and optimistic transaction DBs. WritePrepared and WriteUnprepared (`seq_per_batch`) still return `InvalidArgument`, exactly as before (with a message that now names them). - Point-in-time recovery from recycled WALs no longer drops WAL data after sequence numbers consumed outside the WAL at a WAL boundary, whether by `disableWAL` writes, ingestion, or any other feature. - Real corruption in a recycled WAL still stops recovery at the same point as before. Implementation: - `DBImpl` tracks `next_seq_after_last_wal_record_`: the sequence number recovery reaches after replaying the last WriteBatch successfully added to a WAL, i.e. its sequence plus the sequence numbers it consumes. `WriteToWAL()` and `WriteGroupToWAL()` take that count (`seq_inc` in `WriteImpl()` and `ConcurrentWriteGroupToWAL()`, `total_count` in `PipelinedWriteImpl()`, 0 for the `DB::Open` dummy entry). A failed WAL write sets it to 0 (unknown). - Before the first WriteBatch of each WAL (`log::Writer::GetLastSeqnoRecorded() == 0`), `MaybeAddWalSeqnoGapFiller()` writes an empty WriteBatch whose sequence number is the expected one, if recycling is on, `seq_per_batch_` is false, the expected number is known, and the first WriteBatch skips ahead of it. Recovery's continuity check then compares against the filler instead of the first real WriteBatch. This is the same trick `DB::Open` already uses (an empty batch at the recovered sequence number) to keep point-in-time recovery working across WritePrepared gaps. - The fix is entirely on the write side and keyed on what was actually written to the WAL, so it does not depend on auditing every feature that consumes sequence numbers, which was the concern in PR 12918. The recovery code is unchanged. - The `InvalidArgument` check now applies only with `seq_per_batch_`. The crash-test workaround is removed. Safety, compatibility, and limitations: - Corruption detection: if corruption truncates WAL N (a record whose header log number is damaged looks like an old record; other damage fails the checksum), recovery ends WAL N below the filler's sequence number and stops, as it did when the next WAL started with a real WriteBatch. Detection power equals the existing continuity check; losing records that consume no sequence numbers (WriteCommitted prepares, empty batches) was never detected and still is not. - Format: no new record type. The filler is a regular record holding an empty WriteBatch, which every RocksDB version reads, and older versions apply the same continuity check, so they also recover these WALs correctly after a downgrade. Fillers are written only with recycling on and a sequence gap at the start of a WAL, so WALs of existing workloads without such gaps are unchanged byte for byte. - Safety invariant: a filler must never be below the sequence number recovery reaches after the previous WAL, or `CheckSeqnoNotSetBackDuringRecovery()` would fail `DB::Open`. Recovery reproduces sequence plus `seq_inc` for each WriteBatch without `seq_per_batch` (keys outside prepare sections, the prepared batch at a commit marker, reserved WBWI counts). Cases where recovery advances less (a `WalFilter` dropping entries, a commit marker whose prepare is gone) only make the filler larger than needed, which at worst falls back to the old stop-replay behavior. Fillers are skipped when the expected number is unknown, such as after a failed WAL write. - Observable side effects: a `WalFilter` or `GetUpdatesSince()` can see an extra empty WriteBatch, as they already can for the `DB::Open` dummy entry. `GetUpdatesSince()` already reported such gaps as "Gap in sequence numbers". - Performance: per WAL write group, one load and compare of the writer's last recorded sequence number (true once per WAL) plus one store; `disableWAL` writes are unchanged. See the benchmark below. - Not covered: WritePrepared and WriteUnprepared keep the restriction. Their own internal unlogged writes (e.g. the commit publish write with two_write_queues) can already leave gaps at WAL boundaries under recycling; that pre-existing issue is unchanged here and needs follow-up. The crash test still defaults `recycle_log_file_num` to 0; enabling it at random is left to a follow-up after more stress runs. Requested by: Jay Huh (jewoongh) Differential Revision: D129451715
|
@jaykorean has exported this pull request. If you are a Meta employee, you can view the originating Diff in D129451715. |
✅ clang-tidy: No findings on changed linesCompleted in 209.9s. |
✅ Claude Code ReviewAuto-triggered after CI passed — reviewing commit 35db9c1 SummaryWell-designed and carefully reasoned fix for a real data-loss bug in WAL recovery with recycled logs. The gap-filler approach is elegant -- it fixes the problem on the write side without touching recovery code, and it's backwards-compatible since older RocksDB versions process the filler identically. The change correctly limits scope by excluding seq_per_batch (WritePrepared/WriteUnprepared) and is well-tested. High-severity findings (0): No high-severity findings. Full review (click to expand)Findings🔴 HIGHNone. 🟡 MEDIUMM1. Gap filler bytes not counted in
|
| Context | Does code execute? | Assumptions hold? | Action needed? |
|---|---|---|---|
| WritePreparedTxnDB | YES - guarded by seq_per_batch_ |
N/A - correctly excluded | None |
| WriteUnpreparedTxnDB | YES - same as above | N/A - correctly excluded | None |
| ReadOnly DB | NO - no writes | N/A | None |
| User-defined timestamps | YES - UDT size record before filler | GetLastSeqnoRecorded() not affected by UDT record |
Safe |
| External file ingestion | YES - creates the gap the filler bridges | Correctly addressed | Safe |
| unordered_write | YES - WriteImplWALOnly -> ConcurrentWriteGroupToWAL |
seq_inc correct via kDoAssignOrder |
Safe |
| PipelinedWrite | YES - WriteGroupToWAL -> WriteToWAL |
seq_inc = total_count, correct |
Safe |
| two_write_queues (WriteCommitted) | YES - under wal_write_mutex_ |
Synchronization correct | Safe |
| manual_wal_flush_ | YES - filler under same lock | Correct | Safe |
| Other WAL recovery modes | YES - filler written regardless | Benign (empty WriteBatch) | Safe |
| Older RocksDB versions (downgrade) | YES - read as normal empty batch | Continuity check works same way | Safe |
DB Mutex Analysis
The changed code does not execute while DBImpl::mutex_ is held, except for the DB::Open path (db_impl_open.cc:2839-2841) where the mutex is held per impl->mutex_.AssertHeld(). The DB::Open path with seq_inc=0 does minimal additional work (the gap filler early-returns because next_seq_after_last_wal_record_ is 0). No change to mutex-held critical section cost.
The main write path (WriteToWAL) runs outside the DB mutex. The gap filler adds one log_writer->AddRecord() call amortized once per WAL file (not per write group), which is negligible.
Positive Observations
- Elegant design: The fix is entirely on the write side, requiring no changes to recovery code. This means older RocksDB versions can correctly recover WALs produced by the new code.
- Correct scoping: Excluding
seq_per_batch_avoids complex interactions and is clearly documented with a follow-up path. - Good reuse of existing patterns: The gap filler reuses the "empty WriteBatch" trick already used by
DB::Open, making it a natural extension. - Thorough test coverage: Tests cover all four write paths, verify gap filler invocation count, and test that corruption detection still works. The
RecycleWalsWithUnloggedWritesDetectCorruptiontest is particularly well-designed. - Good test refactoring: Common patterns extracted into reusable helpers (
DisableWalTruncationOnClose,VerifyWalFilesToRecycle,PopulateWalsToRecycle). - Correct synchronization:
next_seq_after_last_wal_record_protected by the same synchronization asWriteToWAL. - Re-enabling
DISABLED_RecycleMultipleWalsCrash: Previously disabled test now passing.
ℹ️ About this response
Generated by Claude Code.
Review methodology: claude_md/ci_review_prompt.md
Limitations:
- Claude may miss context from files not in the diff
- Large PRs may be truncated
- Always apply human judgment to AI suggestions
Commands:
/claude-review [context]— Request a code review/claude-query <question>— Ask about the PR or codebase
Summary:
Why the change is required:
WriteOptions::disableWALreturnsStatus::InvalidArgumentwhenDBOptions::recycle_log_file_num > 0(T185140603), and the crash test forcesrecycle_log_file_num=0wheneverdisable_wal=1. The root cause also loses data without disableWAL: external file ingestion consumes a sequence number without a WAL record, which is the recovery data loss reproduced by the disabledDBWALTest.RecycleMultipleWalsCrash(github PR 12918) and the reason WAL recycling is off in the crash test.Previous behavior:
After a crash, a recycled WAL usually ends with records of its previous incarnation. In
kPointInTimeRecoverythe log reader reports such a record throughOldLogRecord(). Since it cannot be told apart from a corrupted record, recovery stops replaying there and only continues into the next WAL if that WAL's first WriteBatch has exactly the sequence number recovery expects next (github PR 12403, D54544824). Any sequence numbers consumed between the last WriteBatch of one WAL and the first WriteBatch of the next without a WAL record (an unlogged write, an ingested file) fail that check, so recovery silently drops every later WAL, including acknowledgedsyncwrites, or failsDB::Openwith "SST file contains data beyond the point of corruption" when a column family was already flushed past it. PR 12403 therefore rejecteddisableWALwith recycling, PR 12639 exempted WritePrepared's internal split writes, and ingestion was never covered.New behavior:
disableWALwrites are accepted withrecycle_log_file_num > 0for plain DBs and WriteCommitted and optimistic transaction DBs. WritePrepared and WriteUnprepared (seq_per_batch) still returnInvalidArgument, exactly as before (with a message that now names them).disableWALwrites, ingestion, or any other feature.Implementation:
DBImpltracksnext_seq_after_last_wal_record_: the sequence number recovery reaches after replaying the last WriteBatch successfully added to a WAL, i.e. its sequence plus the sequence numbers it consumes.WriteToWAL()andWriteGroupToWAL()take that count (seq_incinWriteImpl()andConcurrentWriteGroupToWAL(),total_countinPipelinedWriteImpl(), 0 for theDB::Opendummy entry). A failed WAL write sets it to 0 (unknown).log::Writer::GetLastSeqnoRecorded() == 0),MaybeAddWalSeqnoGapFiller()writes an empty WriteBatch whose sequence number is the expected one, if recycling is on,seq_per_batch_is false, the expected number is known, and the first WriteBatch skips ahead of it. Recovery's continuity check then compares against the filler instead of the first real WriteBatch. This is the same trickDB::Openalready uses (an empty batch at the recovered sequence number) to keep point-in-time recovery working across WritePrepared gaps.InvalidArgumentcheck now applies only withseq_per_batch_. The crash-test workaround is removed.Safety, compatibility, and limitations:
CheckSeqnoNotSetBackDuringRecovery()would failDB::Open. Recovery reproduces sequence plusseq_incfor each WriteBatch withoutseq_per_batch(keys outside prepare sections, the prepared batch at a commit marker, reserved WBWI counts). Cases where recovery advances less (aWalFilterdropping entries, a commit marker whose prepare is gone) only make the filler larger than needed, which at worst falls back to the old stop-replay behavior. Fillers are skipped when the expected number is unknown, such as after a failed WAL write.WalFilterorGetUpdatesSince()can see an extra empty WriteBatch, as they already can for theDB::Opendummy entry.GetUpdatesSince()already reported such gaps as "Gap in sequence numbers".disableWALwrites are unchanged. See the benchmark below.recycle_log_file_numto 0; enabling it at random is left to a follow-up after more stress runs.Requested by: Jay Huh (jewoongh)
Differential Revision: D129451715