Skip to content

Drop flushed WAL data from the secondary's active memtable - #15066

Open
andpred wants to merge 7 commits into
facebook:mainfrom
andpred:secondary-drop-flushed-wal-memtable
Open

Drop flushed WAL data from the secondary's active memtable#15066
andpred wants to merge 7 commits into
facebook:mainfrom
andpred:secondary-drop-flushed-wal-memtable

Conversation

@andpred

@andpred andpred commented Aug 6, 2026

Copy link
Copy Markdown

Summary

Fix #15051: a secondary serves stale reads because entries replayed from a WAL the primary has since flushed are never evicted from the active memtable. During TryCatchUpWithPrimary(), seal the active memtable once the installed Version provably covers everything it holds, so the following RemoveOldMemTables() call drops it. Every memtable-dropping decision takes that same watermark, so a file set the secondary cannot read never turns the stale read into a vanished key.

Why and How

A secondary replays the primary's WALs into its active memtable and stops replaying a WAL once the primary flushes it. Entries already replayed stayed behind, and since reads consult the memtable first, they shadowed the newer flushed values indefinitely. Iterators go stale too once bottommost compaction rewrites the flushed entry's sequence number to 0; until then Get() and an iterator disagree.

Seal the active memtable during catch-up, gated on the installed Version: each point-in-time Version now carries the column family's log number it reflects, exposed via the new
ReactiveVersionSet::GetInstalledVersionLogNumber(). Sealing requires cf_id_to_current_log_[cfd->GetID()] < that number, which guarantees the primary flushed everything the memtable holds (unflushed WAL data is kept) and that the flushed data is readable through the installed Version. The gate asks the Version directly because proxies fail: the log number alone advances even when no Version was installed, and "no missing files" misses atomic_flush groups parked by a sibling's missing file and corrupt-but-present files with
verify_sst_unique_id_in_manifest=false. In those cases the memtable holds the only readable copy, so dropping it would turn a stale read into a vanished key; the gate fails closed and keeps it.

Reconcile every initialized column family, not just cfds_changed: a round that failed after advancing the log number leaves a stale memtable that later rounds see no new MANIFEST record for. It can also leave a collectible immutable memtable, so the loop consults the new O(1) MemTableList::HasOldMemTablesToRemove() before skipping a column family. The predicate keeps the skip precise: reconciling any column family with a non-empty immutable list would be correct but wasteful, installing a fresh SuperVersion every catch-up call for memtables legitimately waiting on a primary flush and costing readers a mutex acquisition each time. With it, no extra super versions are installed in the steady state. That predicate and the RemoveOldMemTables() cutoff take the installed Version's log number as well, not cfd->GetLogNumber(): that advances as soon as the flush record is read, so an immutable memtable sealed on a WAL switch could otherwise be freed while the flushed file superseding it is unreadable. A sealed memtable is stamped with cf_id_to_current_log_[cfd->GetID()] + 1, the log number following its contents.

cf_id_to_current_log_ is keyed by column family id rather than ColumnFamilyData*, because ids are never reused while a dropped column family's pointer can be recycled, and it is recorded before WriteBatchInternal::InsertInto() rather than after: a batch stops at its first failure with earlier entries already inserted, so recording afterwards could leave the map naming an older WAL than the memtable holds. While the watermark lags and uncollectible memtables accumulate, the secondary warns, since it has no write path to enforce max_write_buffer_number.

The seal sequence, shared with RecoverLogFiles() and the follower's TryCatchUpWithLeader(), is extracted into
DBImplSecondary::SealActiveMemtable(). The follower is refactored onto it with no behaviour change: it keeps cfd->GetLogNumber() as its cutoff because that catch-up path never replays WALs, so its memtables hold nothing that only a not-yet-installed Version could make readable.

Testing

New tests in db_secondary_test cover: the stale read via both Get() and an iterator, before and after bottommost compaction; unflushed WAL-only data surviving sealing, including two column families sharing a WAL; no data loss with a missing, atomic_flush-sibling-missing, or corrupt flushed file; an immutable memtable sealed on a WAL switch surviving an unreadable flushed file; a batch rejected partway through replay leaving the recorded WAL no older than the memtable's contents; and reconciliation after a failed round (injected via FaultInjectionTestFS at a sync point). Each mechanism was individually neutralized and confirmed to fail its test.

Verified with build_tools/rocksptest.sh over db_secondary_test, db_follower_test, memtable_list_test, version_set_test and version_edit_test (all pass, also under ASSERT_STATUS_CHECKED=1), plus db_basic_test, corruption_test, repair_test and db_flush_test; the secondary tests also ran 20x under COERCE_CONTEXT_SWITCH=1 with no flakes. make format-auto and make check-sources are clean.

## Summary

Fix facebook#15051: a secondary
serves stale reads because entries replayed from a WAL the primary has
since flushed are never evicted from the active memtable. During
TryCatchUpWithPrimary(), seal the active memtable once the installed
Version provably covers everything it holds, so the following
RemoveOldMemTables() call drops it. Applies to the follower as well.

## Why and How

A secondary replays the primary's WALs into its active memtable and
stops replaying a WAL once the primary flushes it. Entries already
replayed stayed behind, and since reads consult the memtable first,
they shadowed the newer flushed values indefinitely. Iterators go
stale too once bottommost compaction rewrites the flushed entry's
sequence number to 0; until then Get() and an iterator disagree.

Seal the active memtable during catch-up, gated on the installed
Version: each point-in-time Version now carries the column family's
log number it reflects, exposed via the new
ReactiveVersionSet::GetInstalledVersionLogNumber(). Sealing requires
cfd_to_current_log_[cfd] < that number, which guarantees the primary
flushed everything the memtable holds (unflushed WAL data is kept) and
that the flushed data is readable through the installed Version. The
gate asks the Version directly because proxies fail: the log number
alone advances even when no Version was installed, and "no missing
files" misses atomic_flush groups parked by a sibling's missing file
and corrupt-but-present files with
verify_sst_unique_id_in_manifest=false. In those cases the memtable
holds the only readable copy, so dropping it would turn a stale read
into a vanished key; the gate fails closed and keeps it.

Reconcile every initialized column family, not just cfds_changed: a
round that failed after advancing the log number leaves a stale
memtable that later rounds see no new MANIFEST record for. It can also
leave a collectible immutable memtable, so the loop consults the new
O(1) MemTableList::HasOldMemTablesToRemove() before skipping a column
family. The predicate keeps the skip precise: reconciling any column
family with a non-empty immutable list would be correct but wasteful,
installing a fresh SuperVersion every catch-up call for memtables
legitimately waiting on a primary flush and costing readers a mutex
acquisition each time. With it, no extra super versions are installed
in the steady state.

The seal sequence, shared with RecoverLogFiles() and the follower's
TryCatchUpWithLeader(), is extracted into
DBImplSecondary::SealActiveMemtable().

## Testing

New tests in db_secondary_test cover: the stale read via both Get()
and an iterator, before and after bottommost compaction; unflushed
WAL-only data surviving sealing, including two column families sharing
a WAL; no data loss with a missing, atomic_flush-sibling-missing, or
corrupt flushed file; and reconciliation after a failed round
(injected via FaultInjectionTestFS at a sync point). Each mechanism
was individually neutralized and confirmed to fail its test.

Verified with build_tools/rocksptest.sh over db_secondary_test,
db_follower_test, memtable_list_test, version_set_test and
version_edit_test (all pass, also under ASSERT_STATUS_CHECKED=1), plus
db_basic_test, corruption_test, repair_test and db_flush_test; the
secondary tests also ran 15x under COERCE_CONTEXT_SWITCH=1 with no
flakes. make format-auto and make check-sources are clean.
@meta-cla meta-cla Bot added the CLA Signed label Aug 6, 2026
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

⚠️ clang-tidy: 1 warning(s) on changed lines

Completed in 827.0s.

Summary by check

Check Count
cppcoreguidelines-special-member-functions 1
Total 1

Details

db/db_secondary_test.cc (1 warning(s))
db/db_secondary_test.cc:1100:7: warning: class 'DBSecondaryCatchUpFaultTest' defines a non-default destructor but does not define a move constructor or a move assignment operator [cppcoreguidelines-special-member-functions]

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

Claude Code Review - OBSOLETE

Superseded by a newer AI review. Expand to see the original review.

✅ Claude Code Review

Auto-triggered after CI passed — reviewing commit f3731c1


Summary

This is a well-designed, carefully gated fix for a real correctness bug (stale reads on secondary instances). The two-level gate (cheap log-number check + authoritative installed-Version check) is the right approach, and the test coverage is thorough. The PointInTimeVersion struct cleanly tracks the log number alongside the Version without changing any serialization format.

High-severity findings (0):

No high-severity findings.

Full review (click to expand)

Findings

🔴 HIGH

None.

🟡 MEDIUM

M1. Follower path not updated to use MaybeSealFullyFlushedActiveMemtable -- db_impl_follower.cc:131
  • Issue: The follower's TryCatchUpWithLeader() still iterates only over cfds_changed (not all column families) and still uses the pre-existing memtable-seal logic gated on GetEarliestSequenceNumber() < LastSequence() rather than the new MaybeSealFullyFlushedActiveMemtable(). The follower doesn't replay WALs so cfd_to_current_log_ is never populated, which means MaybeSealFullyFlushedActiveMemtable() would always return false. This is correct behavior -- the follower has no WAL-replayed data to seal -- but the "reconcile after a failed round" improvement (iterating all CFs, checking HasOldMemTablesToRemove) is also missing from the follower path.
  • Root cause: The follower's catch-up flow has a different control flow (no WAL replay) so it doesn't suffer the same stale-read bug, but the failed-round reconciliation gap could theoretically leave an orphaned immutable memtable behind if the follower's TryCatchUpWithLeader fails after sealing but before RemoveOldMemTables. This is a pre-existing gap, not introduced by this PR.
  • Suggested fix: Consider, in a follow-up, whether the follower should iterate all CFs for the RemoveOldMemTables + HasOldMemTablesToRemove cleanup, matching the secondary's approach. Low urgency since the follower's seal is driven by cfds_changed, which should be correct for the non-WAL case.
M2. installed_version_log_numbers_ not updated via AtomicUpdateVersionsApply -- version_edit_handler.cc:1026
  • Issue: When an atomic group completes, AtomicUpdateVersionsApply() moves the buffered PointInTimeVersions into versions_, but does NOT update installed_version_log_numbers_. The log numbers are only recorded in CheckIterationResult() when versions are installed via AppendVersion(). Since CheckIterationResult iterates versions_ and installs them at the end of Iterate(), this works: the atomic group's versions land in versions_ first, then get installed with their log numbers in CheckIterationResult. If CheckIterationResult fails (non-ok status), the versions are deleted and log numbers are never recorded.
  • Root cause: This is by design -- a failed CheckIterationResult means the Version wasn't installed, so installed_version_log_numbers_ shouldn't advance. The gate in MaybeSealFullyFlushedActiveMemtable would correctly NOT seal the memtable because the installed log number hasn't advanced. The gate fails closed.
  • Suggested fix: None needed; the behavior is correct. Noting for documentation that installed_version_log_numbers_ is deliberately only updated on successful installation.

🟢 LOW / NIT

L1. Comment accuracy: earliest_seqno_ described as "only an upper bound" -- db_impl_secondary.cc:~415
  • Issue: The replacement memtable's earliest_seqno_ is set to versions_->LastSequence(), which is documented in the comment as "only an upper bound on what will be inserted next, not the lower bound MemTable::earliest_seqno_ is documented to be." No read path currently relies on that bound being a true lower bound (DBIter uses GetFirstSequenceNumber() instead), but this is worth tracking.
  • Suggested fix: Consider adding a TODO to track if any future read path starts depending on earliest_seqno_ being a true lower bound.
L2. Test helper GetNewestTableFilePath uses >= -- db_secondary_test.cc:~100
  • Issue: file_number >= newest_file_number is used where > would be semantically cleaner. Since file numbers are unique by design, this is functionally identical.
  • Suggested fix: Cosmetic only.
L3. No new test for the follower path -- db_impl_follower.cc
  • Issue: The follower's mechanical refactoring (using SealActiveMemtable instead of inline code) is not specifically tested by new tests. The PR description states db_follower_test passes.
  • Suggested fix: Low risk since the refactoring is mechanical.

Cross-Component Analysis

Context Does code execute? Assumptions hold? Action needed?
DBImplFollower YES (SealActiveMemtable only) YES None (no WAL replay)
Atomic flush YES YES (tested) None
Missing files YES YES (tested, gate fails closed) None
Corrupt files YES YES (tested, gate fails closed) None
Multiple CFs sharing WAL YES YES (tested) None
CF dropped during catch-up YES Skipped (IsDropped() check) None
Failed round then recovery YES YES (tested with FaultInjectionTestFS) None
WritePreparedTxnDB NO (secondary doesn't support txns) N/A N/A
User-defined timestamps Possible Change doesn't affect key comparison Safe

Assumption stress-test results:

  1. "installed Version covers everything the memtable holds" -- installed_log_number is set at AppendVersion time from the log number captured at Version build time. Cannot be ahead of what's actually installed. Holds.

  2. "dropping it would turn a stale read into a vanished key" -- When the installed Version doesn't cover the flush, the memtable holds the only readable copy (WAL is obsolete, SST not in installed Version). Holds.

  3. assert(installed_log_number <= cfd->GetLogNumber()) -- The installed log number is set from a past value of GetLogNumber(), which only increases monotonically. Safe.

Positive Observations

  • Two-level gate design is elegant: the cheap check filters most CFs in O(1), the authoritative check only runs for the rare flush case.
  • Fail-closed design: When in doubt, the gate keeps the memtable -- correct default for data safety.
  • Thorough test coverage: Missing file, corrupt file, atomic flush sibling missing, failed round -- each individually tested with both Get() and iterator verification.
  • Clean refactoring: SealActiveMemtable deduplicates code from three call sites.
  • HasOldMemTablesToRemove O(1) optimization avoids unnecessary SuperVersion installations.
  • Release note is appropriately concise.

ℹ️ About this response

Generated by Claude Code.
Review methodology: claude_md/code_review.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

@meta-codesync

meta-codesync Bot commented Aug 8, 2026

Copy link
Copy Markdown

@xingbowang has imported this pull request. If you are a Meta employee, you can view this in D115317523.

@xingbowang

Copy link
Copy Markdown
Contributor

Fix looks good. Thanks for the contribution. We would love to hear about how you use this in production.

Comment thread db/db_impl/db_impl_secondary.cc Outdated
// switch can also leave a collectible immutable memtable behind, so
// check for one instead of relying on this round having sealed.
if (!sealed &&
!cfd->imm()->HasOldMemTablesToRemove(cfd->GetLogNumber()) &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We want to use GetInstalledVersionLogNumber() for the cut off, otherwise Immutable memtables can be removed using manifest-advanced cfd->GetLogNumber(), even when the installed Version watermark remains lower.

Here's am example scenario

WAL 100:
    X = 1
    Y = 1

  WAL 101:
    X = 2

  1. Secondary replays WAL 100:

  active M100: {X=1, Y=1}

  2. Secondary starts replaying WAL 101. It seals M100 and creates M101:

  immutable M100: {X=1, Y=1}, next_log=101
  active M101:    {X=2}

  3. Primary flushes WAL 100 into SST S1 and advances the MANIFEST log number to 101. S1 is unavailable before the secondary opens it (due to a file system issue, or being compacted away by another compaction, etc.):

  cfd->GetLogNumber()            = 101
  installed Version log number   = 100

  4. A catch-up failure skips cleanup. On the next retry, the new reconciliation code:

  - Correctly keeps active M101.
  - Incorrectly removes immutable M100 using cfd->GetLogNumber() == 101.

  The resulting secondary view is:

  X = 2  // still available from M101
  Y = missing  // M100 was its only readable copy

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed. HasOldMemTablesToRemove() and RemoveOldMemTables() now take GetInstalledVersionLogNumber() like the seal gate does, and the sealed memtable is stamped with cf_id_to_current_log_[cfd->GetID()] + 1 so it is still collected in the same round. KeepsImmutableMemtableWhenFlushedFileIsMissing builds your scenario and fails with NotFound when the cutoff is reverted.

Chasing it turned up a second way that gate could free a memtable the installed Version does not cover, so the branch is now five commits:

  • 5c115fb4d the fix above and its test. This addresses your comment.
  • 5a614575b the gate reads cf_id_to_current_log_, which was keyed by a ColumnFamilyData* that can dangle and be recycled, and was recorded only after a successful InsertInto(), so a batch failing partway left it naming an older WAL than the memtable held. Now keyed by id and recorded before the insert. KeepsMemtableAfterPartialReplayFailure covers it.
  • 0660ee78d stale comments, plus three tests that leaked a moved-aside SST when an earlier assertion failed.
  • a52f0a9b4 warns while the watermark lags and uncollectible memtables accumulate. Droppable if you would rather not carry it.
  • 6f4dd0365 merge with main. Only conflict was two tests appended at the same line of memtable_list_test.cc; kept both.

Review feedback on facebook#15066: TryCatchUpWithPrimary() gated sealing the
active memtable on the installed Version's log number, but still freed
immutable memtables against cfd->GetLogNumber(), through
HasOldMemTablesToRemove() and RemoveOldMemTables(). That number advances
as soon as the primary's flush record is read, even when the flushed
files cannot be opened, so a memtable holding the only readable copy of
what was flushed can be freed, turning a stale read into a vanished key.
A memtable sealed on a WAL switch is stamped with the new WAL's number,
so the cutoff reaches it immediately.

Compute GetInstalledVersionLogNumber() once per column family and use it
for the seal gate, the removal predicate and the removal cutoff, and
seal with one past the WAL recorded for the column family, the log
number following the memtable's contents. The installed number differs
from cfd->GetLogNumber() only when a Version fails to install, so a
secondary whose files are all readable behaves exactly as before.

Keeping a memtable costs memory that nothing on a secondary reclaims
until the primary's files become readable or the instance is reopened,
so document that with TryCatchUpWithPrimary(). Correct the comments the
fix falsifies or leaves imprecise: RemoveOldMemTables() collects a
prefix, stopping at the first memtable that does not qualify, and the
cutoff is no longer cfd->GetLogNumber(). Extend the release note, since
the vanished key predates facebook#15066.

New test KeepsImmutableMemtableWhenFlushedFileIsMissing covers the
reported scenario and fails when the cutoff is reverted.
MaybeSealFullyFlushedActiveMemtable() now decides from the map of WALs
replayed per column family whether a memtable can be freed, so all of
its weaknesses matter.

Key it by column family id rather than by ColumnFamilyData*, and rename
it to cf_id_to_current_log_ to say so. A dropped column family's
ColumnFamilyData is destroyed while its entry remains, and the allocator
can hand the same address to a new column family, which would then find
the old entry and a log number that has nothing to do with it. Ids come
from ++max_column_family_ and are never reused. Erase the entry when the
reconciliation loop sees the column family dropped, which is the only
removal path it has ever had; that leaves behind an entry for a column
family destroyed before the loop next runs, which with id keys wastes a
few bytes and cannot be misread.

Record the WAL before inserting from it, not after. The gate needs the
recorded value to be no older than the newest WAL the active memtable
holds, and a batch stops at its first failure with earlier entries
already inserted, so recording afterwards left the map behind the
memtable. Recording first can only overstate, which fails the gate for
longer and merely retains the memtable. The header called this
unreachable because the failure poisons LogReaderContainer::status_,
but FindNewLogNumbers() discards that reader once the WAL goes
obsolete, so a later round can reach the gate with the map still stale.
Record in the loop that already walks the batch's column families rather
than in a second one: a column family's entry only feeds its own gate,
so recording just before the seal keeps the ordering the gate needs
while dropping a repeated column family lookup per batch.

New test KeepsMemtableAfterPartialReplayFailure drives the partial
failure by rejecting memtable writes to a second column family on the
secondary only, and fails with NotFound when the update is moved back
after the insert.
Correct comments that overstated an invariant, named the wrong thing or
leaked a caller's rationale: l0_files.back() is the oldest L0 file;
ExtractInfoFromVersionEdit(), not ApplyVersionEdit(), applies an edit's
log number; AtomicUpdateVersionsPut() and AtomicUpdateVersionsApply() no
longer describe `Version*` updates; HasOldMemTablesToRemove() no longer
describes its caller's bookkeeping; and
VersionEditHandlerPointInTime::GetInstalledVersionLogNumber() says what
the installed Version covers without naming a primary, because that
handler also serves best-effort recovery of an ordinary DB, while the
ReactiveVersionSet declaration a secondary calls keeps that framing.

Document that it is a follower's catch-up path, rather than the class,
that makes cfd->GetLogNumber() a safe watermark there: DBImplFollower
inherits DBImplSecondary::TryCatchUpWithPrimary(), which does replay
WALs.

Restore the moved-aside table files with a Defer instead of a trailing
rename that a failing assertion skips, which would leave a *.sst.aside
file behind in dbname_ for every later test in the fixture. Take the
newest table file with a strict comparison, assert the retained memtable
in KeepsMemtableWhenAtomicFlushSiblingFileIsMissing, and rename an
Options for consistency.
While the installed Version's log number lags behind the column
family's, every WAL switch adds an immutable memtable that
RemoveOldMemTables() cannot collect, and nothing on a secondary flushes
it, so their number grows until the primary's flushed files become
readable or the instance is reopened. Report it, naming both log numbers
and the retained count.

ColumnFamilyData::RecalculateWriteStallConditions() already counts this
as a memtable limit stop, but attributes it to a flush that is not
coming and to a max_write_buffer_number that no writer of this
instance's own can hit, so the warning names the actual cause instead.

Warn only when what would be reported has changed. The condition lasts
until the primary's files become readable and TryCatchUpWithPrimary() is
called as often as the application chooses, so warning per round would
bury everything else in the log; both reported values move only as the
condition worsens, making this one line per newly retained memtable.
Conflict in db/memtable_list_test.cc: upstream added
FlushRequestPersistsAfterPartialPick and this branch added
RemoveOldMemTablesTest at the same place. Kept both.
@github-actions

Copy link
Copy Markdown

✅ Claude Code Review

Auto-triggered after CI passed — reviewing commit 6f4dd03


Summary

Well-designed fix for a real data-correctness bug (stale reads on secondary instances). The approach of gating memtable sealing on the installed Version's log number rather than cfd->GetLogNumber() is the right one, and the fail-closed design (keep memtable when uncertain) prevents the worse outcome of vanished keys. The code is thoroughly tested with 10 new test cases covering the main scenarios.

High-severity findings (0):

No high-severity findings.

Full review (click to expand)

Findings

🔴 HIGH

None.

🟡 MEDIUM

M1. L0 file ordering comment may mislead future readers -- db_impl_secondary.cc

The diff changes the comment from "the last sequence number of the largest sequence persisted" to "l0_files is ordered newest first, so seq is the largest sequence number in the oldest L0 file."

While L0 files are indeed sorted newest-first (by NewestFirstByEpochNumber or NewestFirstBySeqNo in version_builder.cc:80-108), the comment is subtly misleading: l0_files.back()->fd.largest_seqno is the largest_seqno of the oldest L0 file, not the "largest sequence number in the oldest L0 file" which could be confused with the largest key. The existing comment was arguably clearer about what this represents semantically.

More importantly, the correctness of the skip logic itself depends on a non-obvious invariant: that WAL replay visits batches in sequence-number order and that any batch at or below the oldest L0 file's largest_seqno has already been flushed. This is true for the common case but could be fragile if L0 file creation order diverges from sequence-number order (e.g., with ingested external files that have unusual sequence numbers).

  • Suggested fix: Consider adding a brief note that external file ingestion could in theory break this assumption, or document why it does not apply here.
M2. cf_id_to_current_log_ erased on seal may drop tracking for future WALs -- db_impl_secondary.cc:399

In MaybeSealFullyFlushedActiveMemtable(), after sealing, the entry is erased:

cf_id_to_current_log_.erase(log_iter);

If a subsequent TryCatchUpWithPrimary() call replays a new WAL for this column family, RecoverLogFiles() will re-insert the entry. But between the erase and the next replay, if MaybeSealFullyFlushedActiveMemtable() is called again (e.g., in the next catch-up round), it will find no entry and return false, which is the correct fail-closed behavior since a missing entry means "we don't know what the memtable holds."

However, this creates a subtle interaction: once the entry is erased, the seal gate can never fire again until new WAL entries are replayed. If the primary writes and flushes without the secondary replaying any WALs (e.g., because the WALs were already deleted), the stale active memtable from before the erase would persist. This is the correct behavior (the memtable IS empty after sealing), but worth documenting.

  • Issue: Not a correctness bug, but the interaction between erasing and re-inserting could benefit from a comment explaining why erasing is safe.
  • Suggested fix: Add a brief comment near the erase explaining that a fresh memtable has nothing to seal, so the missing entry is correct.
M3. ROCKS_LOG_INFO on every seal may be noisy -- db_impl_secondary.cc:383

MaybeSealFullyFlushedActiveMemtable() logs at INFO level every time it seals. During rapid catch-up (e.g., after a long secondary outage), this could produce many log messages if the primary flushed many times.

  • Suggested fix: Consider ROCKS_LOG_DEBUG or rate-limiting. The MaybeWarnAboutRetainedMemtables() already has good deduplication logic; the seal path does not.

🟢 LOW / NIT

L1. SealActiveMemtable does not assert !cfd->mem()->IsEmpty() -- db_impl_secondary.cc:336

All current callers check IsEmpty() before calling, but the function itself has no guard. Sealing an empty memtable is wasteful (creates an unnecessary immutable memtable) but not incorrect. Adding an assert would catch future misuse.

  • Suggested fix: Add assert(!cfd->mem()->IsEmpty()); or document that callers must check.
L2. Test KeepsMemtableAfterPartialReplayFailure uses disallow_memtable_writes -- db_secondary_test.cc

This test uses disallow_memtable_writes = true on one CF to force a partial replay failure. While clever, this option is not commonly used and the scenario it creates (one CF rejecting memtable writes while another accepts) is artificial. The test is still valuable as it exercises the code path, but a comment noting the artificiality would help future readers understand this is a testing mechanism, not a realistic failure mode.

L3. PointInTimeVersion struct uses aggregate initialization -- version_edit_handler.h

The struct relies on default member initializers (version = nullptr, log_number = 0) and is constructed with brace initialization PointInTimeVersion{version, log_number}. This is valid C++14+ aggregate initialization with non-static data member initializers. RocksDB uses C++17, so this is fine.

L4. Missing db_follower_test coverage for refactored SealActiveMemtable -- follower code

The PR description mentions running db_follower_test, but no new follower-specific tests were added. The refactoring is behavior-preserving (verified by inspection: same parameters, same operations), and existing follower tests provide coverage.

L5. Minor: HasOldMemTablesToRemove accesses current_->memlist_ directly -- memtable_list.h

The method accesses the internal current_->memlist_ directly rather than going through an accessor. This is consistent with other methods in MemTableList that access current_ directly, and the method requires the db mutex, so it's safe.

Cross-Component Analysis

Call-Order Verification

The critical claim that cfd->GetLogNumber() is the pre-edit value when captured in MaybeCreateVersionBeforeApplyEdit() was verified by tracing the call chain:

  1. ApplyVersionEdit() (version_edit_handler.cc:222) calls OnNonCfOperation() (line 234)
  2. OnNonCfOperation() calls MaybeCreateVersionBeforeApplyEdit() (version_edit_handler.cc:343)
  3. ApplyVersionEdit() then calls ExtractInfoFromVersionEdit() (version_edit_handler.cc:238) which is where SetLogNumber() happens (version_edit_handler.cc:597)

So yes, the log number is captured BEFORE the edit is applied. Correct.

Atomic Group Handling

When an atomic group begins:

  • atomic_update_versions_[cfid] = PointInTimeVersion() (log_number = 0, version = nullptr)
  • The counting logic checks second.version == nullptr to determine "missing" status

When versions are built during the group:

  • AtomicUpdateVersionsPut(PointInTimeVersion{version, log_number}) stores the version with its log number

When the group completes:

  • AtomicUpdateVersionsApply() moves entries to versions_
  • CheckIterationResult() later installs them and sets installed_version_log_numbers_

If an atomic group is incomplete (e.g., one CF's file is missing), no Version is installed for ANY CF in the group, and installed_version_log_numbers_ correctly remains at the old value. Correct.

GetInstalledVersionLogNumber Returning 0

When no Version has been installed (returns 0), MaybeSealFullyFlushedActiveMemtable() evaluates log_iter->second >= 0 which is always true for uint64_t, so the gate always fails and the memtable is always kept. Correct fail-closed behavior.

Alternative Execution Contexts

Context Affected? Safe?
DBImplFollower YES - refactored YES - same behavior, uses cfd->GetLogNumber() which is safe because follower never replays WALs
WritePreparedTxnDB NO - secondary doesn't support transactions N/A
ReadOnly DB NO - no TryCatchUpWithPrimary N/A
Atomic flush YES - tested YES - incomplete group keeps memtables
CompactionService MAYBE - uses OpenAsSecondaryImpl YES - sets recover_wal=false, no WAL replay
User-defined timestamps NO - no interaction N/A

Assumption Stress Test

Claim: "The installed Version covers everything the memtable holds"

  • Precondition: cf_id_to_current_log_[id] < installed_log_number
  • Counterexample: WAL replayed into memtable but not recorded in cf_id_to_current_log_ → impossible, recording happens before insertion
  • Counterexample: installed_log_number advances past the WAL without a Version being installed → impossible by construction, installed_version_log_numbers_ is only set when AppendVersion() succeeds
  • Conclusion: Claim holds.

Claim: "Recording WAL before InsertInto is the safe direction"

  • Precondition: On partial failure, recorded WAL >= newest WAL in memtable
  • Counterexample: Batch inserts entries from WAL N, fails partway, recorded WAL is N → correct, gate keeps memtable until Version covers WAL N
  • Conclusion: Claim holds.

Positive Observations

  1. Fail-closed design: Every uncertain case keeps the memtable rather than dropping it. This is exactly right for a data-integrity fix.

  2. Thorough testing: 10 new test cases covering the main bug, safety properties (missing/corrupt files, atomic flush, partial replay), and the unflushed-WAL preservation. Each test documents which mechanism it exercises.

  3. Good separation of concerns: SealActiveMemtable() extracts a reusable operation. MaybeSealFullyFlushedActiveMemtable() encapsulates the gate logic. MaybeWarnAboutRetainedMemtables() handles monitoring with good deduplication.

  4. Key change to uint32_t map key: Moving from ColumnFamilyData* to CF ID is correct -- ColumnFamilyData pointers can be recycled, but IDs are never reused.

  5. Recording WAL before InsertInto: On partial failure, the recorded WAL is at least as new as what the memtable holds, which is the safe direction for the seal gate.

  6. HasOldMemTablesToRemove() O(1) predicate: Avoids unnecessary super version installations in the common case, keeping the cost of iterating all CFs low.

  7. Exceptional documentation: The PR description and code comments explain not just what the code does but why each decision was made and what would go wrong with alternatives.


ℹ️ 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

@meta-codesync

meta-codesync Bot commented Aug 12, 2026

Copy link
Copy Markdown

@xingbowang has imported this pull request. If you are a Meta employee, you can view this in D115317523.

Comment thread db/db_impl/db_impl_secondary.cc Outdated
// merely retains it. Recording before the seal below is equivalent,
// because a column family's entry only feeds its own gate.
const auto [log_iter, inserted] =
cf_id_to_current_log_.insert({id, log_number});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This cf to wal tracking only considers cf ids physically present in the current record. With write-after-commit 2PC, a commit-only record has no ids, but

Status MarkCommit(const Slice& name) override {
replays the prepared writes into memtables; the stale prepare-WAL watermark can then make reconciliation immediately discard this unflushed committed data. Commit recovery should report its touched CFs and advance them to the commit WAL.

WAL 10: Prepare transaction T. T contains Put(X, 2), but X is not inserted yet.
WAL 20: Put(Y, 1) - Primary flushes Y and installs a Version with log watermark 30.
WAL 40: Commit transaction T. This record contains only Commit(T), not Put(X, 2).

On the secondary:

1. Replaying WAL 10 records tracked_log = 10, but only buffers Put(X, 2) as a prepared transaction.
2. WAL 20 may advance tracked_log to 20. Since Y was flushed, the secondary installs the SST Version with: installed_log = 30
3. Replaying WAL 40 calls MarkCommit(T). That internally inserts X=2 into the active memtable.
4. However, the commit record contains no column-family IDs, so tracked_log does not update:

tracked_log remains 20   // should become 40

5. Reconciliation evaluates: tracked_log < installed_log because 20 < 30

It concludes the active memtable is covered by the installed Version and removes it. But X=2 was committed in WAL 40 and has not been flushed, so X disappears from the secondary.

This is just a theory and haven't verified this. Could you add a 2PC regression test to see if the theory here is valid?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, you were right. Was able to reproduce it (KeepsMemtableAfterTwoPhaseCommitReplay), the latest commit has the fix.

A 2PC commit record names no column family, yet MarkCommit() replays its
prepared batch into the memtables, so cf_id_to_current_log_ stays behind
the committed data and the seal gate drops what no file covers yet. Have
InsertInto() report the column families it selected for writing,
collected in MemTableInserter::SeekToColumnFamily(), and advance the map
and cfds_changed from that. New test
KeepsMemtableAfterTwoPhaseCommitReplay fails with NotFound without it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Secondary serves stale reads: entries replayed from a WAL the primary has since flushed are never evicted from the active memtable

3 participants