SDSTOR-22729: index/wb_cache: Fix recovery corruption after root-split crash - #900
Conversation
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## stable/v7.x #900 +/- ##
==============================================
Coverage ? 48.24%
==============================================
Files ? 110
Lines ? 13114
Branches ? 6323
==============================================
Hits ? 6327
Misses ? 2558
Partials ? 4229 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
96f4aad to
dd527a7
Compare
3dc0428 to
cf8be37
Compare
| } | ||
| } | ||
|
|
||
| IndexBufferPtrList IndexCPContext::root_change_preflush_bufs() { |
There was a problem hiding this comment.
this collects all the bufs , excluding meta_buf and freed buf, in the cp where a new root if found(root split). we need to flush these bufs for new root recovery if crash happens.
|
|
||
| // Root-change records contain no split/merge side effects. Retaining the last record per ordinal identifies | ||
| // the final intended root even if the same CP grows and then collapses the tree. | ||
| if (rec->is_parent_meta && rec->num_freed_ids == 0) { |
There was a problem hiding this comment.
this part is used to identify what is the final root according to txn_journal
| HS_REL_ASSERT(up_buffer->m_created_cp_id == -1, | ||
| "Sanity check failed: Buffer {} has an up_buffer {} that just created (created_cp_id={})", | ||
| bufferPtr->to_string(), up_buffer->to_string(), up_buffer->m_created_cp_id); | ||
| if (bufferPtr->m_created_cp_id == cp_id) { |
There was a problem hiding this comment.
we have linked the old root to new root(new root is created in the cp , but it is now a valid up_buffer) for root split case, so change this check.
| if (up_buf->m_created_cp_id == icp_ctx->id()) { | ||
| // Condition 1: Flatten only new-to-new links. An existing node modified by a root split must retain the new root | ||
| // as its up-buffer so recovery reconstructs the same root transition deterministically. | ||
| if (up_buf->m_created_cp_id == icp_ctx->id() && down_buf->m_created_cp_id == icp_ctx->id()) { |
There was a problem hiding this comment.
Fix 1(the changes here) tightens the Condition 1 guard in link_buf:
// Before: bypass up_buf whenever it was created in the current CP
if (up_buf->m_created_cp_id == icp_ctx->id())
// After: bypass only when BOTH up and down were created in the current CP
if (up_buf->m_created_cp_id == icp_ctx->id() &&
down_buf->m_created_cp_id == icp_ctx->id())
How the DAG Is Built for a Root Split
transact_bufs makes two link_buf calls:
// parent_buf = new_root_buf, child_buf = old_root_buf, new_node_bufs = {child_node2_buf}
link_buf(new_root_buf, old_root_buf, is_sibling=false); // ← Fix 1 affects this call
link_buf(old_root_buf, child_node2_buf, is_sibling=true); // ← Fix 1 does NOT affect this call
Second call (up=old_root_buf, down=child_node2_buf): old_root is an existing node from a previous CP, so old_root->m_created_cp_id != current. Condition 1 never fires here regardless of Fix 1. child_node2_buf->m_up_buffer is always set to old_root_buf — child_node2 is always a down-buffer of old_root.
First call is where Fix 1 matters. With up=new_root_buf (newly created) and down=old_root_buf (old):
- Without Fix 1: Condition 1 fires (new_root->m_created_cp_id == current), bypassing new_root_buf. old_root_buf->m_up_buffer becomes meta_buf.
- With Fix 1: Condition 1 requires both to be new, but old_root is old — does not fire. old_root_buf->m_up_buffer becomes new_root_buf.
The Two Resulting DAGs
Without Fix 1 (broken):
meta_buf (wait=2)
├── new_root_buf (new, wait=0) ← sibling of old_root, no ordering guarantee
└── old_root_buf (old, wait=1)
└── child_node2_buf (new, wait=0)
With Fix 1 (correct linear chain):
meta_buf (wait=1)
└── new_root_buf (new, wait=1)
└── old_root_buf (old, wait=1)
└── child_node2_buf (new, wait=0)
Fix 1 changes only one edge: old_root's up-buffer moves from meta_buf to new_root_buf. The child_node2 → old_root dependency is present in both cases and is unaffected by Fix 1.
What Goes Wrong Without Fix 1
During normal CP flush
In the broken flat DAG, new_root_buf and old_root_buf are siblings under meta_buf with no ordering between them. old_root can reach disk in its transient split state (edge_info=EMPTY, next_bnode=child_node2) before new_root is written. If a crash lands in that window:
- old_root is on disk in split state
- new_root is not on disk
- The SB still points to old_root
pre-flush compensates for this by writing new_root to disk before the DAG flush starts. But without Fix 1, the recovery DAG is also broken, which causes the second problem below.
During crash recovery (the critical failure)
Fix 1 has a mirror change in process_txn_record so that journal replay reconstructs the identical DAG. Without the mirror:
Reconstructed DAG in recovery (without Fix 1's mirror):
meta_buf (wait=2)
├── new_root_buf (new, up=meta_buf) ← correctly identified as root candidate
└── old_root_buf (old, up=meta_buf) ← Condition 1 fired, bypassed new_root_buf
└── child_node2_buf (new, up=old_root_buf)
The recovery main loop processes child_node2_buf (new, up=old_root_buf, not meta):
// child_node2 path: up_buffer is old_root (not meta), both committed
→ commit_recovered_blk(child_node2)
→ pending_bufs.push_back(old_root_buf)
And new_root_buf (new, up=meta_buf) goes through new_root_candidates → set_root_from_committed_buf(new_root) correctly promotes the root.
But then recover_buf(old_root_buf) is called from pending_bufs:
was_node_committed(old_root_buf) → true (on disk in split state)
buf->m_up_buffer->is_meta_buf() → true (old_root links to meta_buf in the broken DAG)
→ update_root(ordinal, old_root_buf) // ← called AFTER root was already promoted to new_root!
This call to update_root(old_root) is at best a harmless no-op and at worst silently reverts the correctly promoted root back to old_root, undoing the
entire recovery.
With Fix 1's mirror, the reconstructed recovery DAG is the correct linear chain:
meta_buf (wait=1)
└── new_root_buf (new, up=meta_buf, wait=1)
└── old_root_buf (old, up=new_root_buf, wait=1) ← links to new_root, not meta_buf
└── child_node2_buf (new, up=old_root_buf, wait=0)
The recovery loop still pushes old_root_buf into pending_bufs via the child_node2 path. But now recover_buf(old_root_buf) sees old_root->m_up_buffer =
new_root_buf (not meta_buf), so it does not call update_root(old_root). Instead it calls recover_buf(new_root_buf), which finds new_root->m_up_buffer
= meta_buf and calls update_root(new_root) — idempotent, since the root is already new_root.
| } | ||
| } | ||
|
|
||
| for (auto const& [ordinal, candidate] : new_root_candidates) { |
There was a problem hiding this comment.
change the meta_buf , pointing to the identified root buf
91944de to
bd91fa1
Compare
d092edd to
23dd65c
Compare
|
the newly added code is mostly comments , which helps you to better understand what the code does. no big logic changes, pls feel free to keep reviewing. |
23dd65c to
d8bbb05
Compare
…t crash
A B-tree root split (tree height N → N+1) proceeds in three steps:
1. Allocate new_root and call on_root_changed(new_root), which updates
the in-memory superblock (SB) and links meta_buf → new_root_buf in
the CP flush DAG.
2. split_node(new_root, old_root) modifies old_root in memory
(edge_info=EMPTY, next_bnode=child_node2) and calls
transact_nodes({child_node2}, {}, old_root, new_root), which invokes
link_buf(new_root_buf, old_root_buf).
3. The on-disk SB is written at the very end of CP flush, after all node
buffers complete.
A SIGKILL landing after step 2 but before the SB write exposed three
latent bugs that combined to corrupt the tree.
**Bug A — link_buf Condition 1 created a flat flush DAG**
Condition 1 bypassed new_root_buf whenever it was newly created in the
current CP, regardless of whether old_root_buf was new or old. This
caused old_root_buf to link directly to meta_buf, making new_root_buf
and old_root_buf siblings with no ordering between them. old_root could
therefore reach disk in its transient split state (edge_info=EMPTY,
next_bnode=child_node2) before new_root, widening the crash window.
**Bug B — Recovery discarded a committed new_root**
When the crash left old_root on disk in split state and new_root durable
but the SB unwritten, the recovery loop could not reliably identify
new_root as the intended root. Existing logic either discarded the
committed new_root candidate or had no mechanism to promote it,
leaving the persisted SB still pointing to old_root.
**Bug C — repair_root_node applied an unsafe edge repair**
With old_root as the recovered tree root (per the stale SB),
repair_root_node read old_root.next_bnode (= child_node2, level N) and
set it as old_root's edge child (also level N). This violated the
B-tree invariant child.level == parent.level - 1, causing validate_node
to abort with "Child node level mismatch" on the next B-tree access.
**Fix 1 — Restore correct flush-DAG ordering (Bug A)**
Changed link_buf Condition 1 to bypass up_buf only when BOTH up_buf AND
down_buf were created in the current CP:
Before: if (up_buf->m_created_cp_id == icp_ctx->id())
After: if (up_buf->m_created_cp_id == icp_ctx->id() &&
down_buf->m_created_cp_id == icp_ctx->id())
When down_buf is an older node (e.g. old_root), the dependency chain
through up_buf (new_root) is preserved. The key guarantee this
establishes is: if new_root is committed (on disk), then old_root and
child_node2 are also committed, making Fix 2's root promotion safe.
The mirror fix is applied in index_cp.cpp process_txn_record so that
journal recovery rebuilds an identical DAG. The sanity check is
tightened accordingly: only a buffer that was itself created in the
current CP must not point to another same-CP-new up_buffer.
**Fix 2 — Pre-flush new-root nodes and promote on recovery (Bug B)**
Added a pre-flush phase in async_cp_flush: before the normal DAG flush
starts, all newly-created nodes belonging to ordinals that had a root
change are written to disk via async_write. Only after these writes
complete does the normal DAG flush begin. This ensures new_root (and
child_node2) are always durable before old_root can be written in its
transient split state.
During recovery, the journal is parsed to identify the final intended
new-root BlkId per ordinal (m_recovered_root_ids). A candidate is
promoted when:
- The journal identifies it as the final root for its ordinal
- was_node_committed() confirms it is durable on disk
- persisted_root_was_committed() confirms the old (persisted SB) root
was also written in this CP, meaning the tree is in post-split state
This check is stronger than simply testing whether SB root is non-empty:
if old_root was not yet written in split state, the tree is still in a
pre-split consistent state and the new_root candidate is discarded.
set_root_from_committed_buf() updates the in-memory SB (root_node,
root_link_version, btree_depth) and the btree's root pointer before the
forced recovery CP writes the corrected SB to disk.
The first-CP path (SB root still empty) is excluded so that
recovery_completed() can build a fresh root as before.
**Fix 3 — Harden repair_root_node (Bug C)**
Added guards before applying the edge repair:
a. Buffer validity: skip if raw_buffer is null, node magic is invalid,
or node_id does not match blkid — the buffer was never written.
b. next_bnode empty: skip if next_bnode is already empty_bnodeid,
meaning new_root was already promoted and nothing needs repairing.
c. Level invariant: read the candidate next_bnode from disk; if its
level >= old_root's level (partial root-split state), skip the
repair rather than corrupting the edge.
Also changed the raw pointer `n` to BtreeNodePtr `bn` to prevent a
memory leak on the early-return paths added by this fix.
The same validity guard is applied to repair_node to protect against
same-CP new nodes that were never flushed (all-zero on disk).
**Additional hardening**
- skip load_buf for meta_buf in recover(): MetaIndexBuffer has
blkid={0,0,0,0}; calling load_buf on it reads garbage from vdev and
corrupts m_dirtied_cp_id.
- recover_buf() returns early for meta_buf: meta durability is tracked
via the new-root candidate path, not via was_node_committed.
- read_buf() validates node magic and node_id before init_node to catch
corrupt or unwritten blocks early.
- to_string() replaces unescaped inner braces {{{}}} with [{}] to avoid
fmt::format_error when HS_*_ASSERT_CMP re-parses the message string.
Added two regression tests to IndexCrashTest:
CrashAtMetaBufOnSecondRootSplit: Establishes a durable level-1 tree,
then sets crash_flush_on_meta and inserts enough keys to force a second
root split (level-1 → level-2). The crash fires after new_root and
old_root are both durable but before meta_buf writes. Fix 2 detects
new_root and promotes it; Fix 3 would catch the level invariant
violation if Fix 2 had not applied. Tree integrity is verified via
reapply_after_crash and get_all.
CrashAfterOldRootFlushOnSecondRootSplit: Same setup, but uses
crash_flush_on_root to crash specifically when old_root is flushed
(after the pre-flush has already made new_root durable). Also sets
skip_cp_after_index_root_recovery to keep the original journal current,
then performs a second restart to verify that replaying an
already-promoted candidate is idempotent.
d8bbb05 to
9042e67
Compare
shosseinimotlagh
left a comment
There was a problem hiding this comment.
The three-fix approach (DAG condition narrowing, pre-flush barrier + journal-based root promotion, repair_root_node hardening) is architecturally sound, each fix targets a distinct causal layer, and the 6 new crash tests cover all relevant crash windows including double-crash idempotency.
External Code ReviewThe bug analysis is correct and the direction of all three fixes is right. But there are real correctness concerns and one likely regression that should be addressed before merge. CRITICAL1. Silent behavioral regression:
// OLD (when parent is uncommitted):
buf->m_up_buffer->remove_down_buffer(buf);
prune_up_buffers(buf, pruned_bufs_to_repair); // traverses up, adds to repair list
bufs_to_skip_sanity_check.insert(buf);
// NEW:
prune_from_up_buffer(buf); // only disconnects immediate parent
bufs_to_skip_sanity_check.insert(buf);
2.
m_dirty_buf_list.foreach_entry([...](IndexBufferPtr const& buf) {
if (buf->is_meta_buf() || buf->m_node_freed || buf->m_created_cp_id != id() ||
!m_root_changed_ordinals.contains(buf->m_index_ordinal)) {
return;
}
if (selected_blkids.insert(buf->blkid()).second) { bufs.push_back(buf); }
});This collects all new-CP nodes for any ordinal that had a root change, not just 3.
HS_REL_ASSERT(status == BlkAllocStatus::SUCCESS, "Failed to commit recovered index block {}",
buf->m_blkid.to_string());In Case B (SB was already written before the crash), HIGH4. Fragile null-pointer dependency on short-circuit evaluation in
if (ret != btree_status_t::success || edge_node->level() >= root->level()) {When 5.
// caller:
HS_REL_ASSERT(table && table->set_root_from_committed_buf(candidate), ...);The function has multiple guards (null raw_buf, invalid node magic, 6. Journal root-record parsing has undocumented layout coupling
bool const is_root_split = !rec->has_inplace_child && rec->num_new_ids == 1;
bool const is_root_collapse = rec->has_inplace_child && rec->num_new_ids == 0;
auto const root_idx = rec->has_inplace_parent ? 1 : 0;
m_recovered_root_ids[rec->index_ordinal] = rec->blk_id(root_idx);
MEDIUM7. No test for multiple root splits in the same CP The PR description explicitly calls out the multi-root-split scenario: Condition 1 flattening, intermediate roots committed but not promoted, 8. In the root-split branch, LOW / STYLE9. Duplicate error messages for distinct failure conditions Both 10. The comment says "crash after old_root is flushed but before new_root_buf is written." Fix 2's pre-flush has already written 11.
12. Pre-flush crash simulator calls After Summary
Must fix before merge: issues 1, 2, 3, 5, 6. Issues 4 and 7 should be addressed in this PR rather than deferred. |
A B-tree root split (tree height N → N+1) proceeds in three steps:
the in-memory superblock (SB) and links meta_buf → new_root_buf in
the CP flush DAG.
(edge_info=EMPTY, next_bnode=child_node2) and calls
transact_nodes({child_node2}, {}, old_root, new_root), which invokes
link_buf(new_root_buf, old_root_buf).
buffers complete.
A SIGKILL landing after step 2 but before the SB write exposed three
latent bugs that combined to corrupt the tree.
Bug A — link_buf Condition 1 created a flat flush DAG
Condition 1 bypassed new_root_buf whenever it was newly created in the
current CP, regardless of whether old_root_buf was new or old. This
caused old_root_buf to link directly to meta_buf, making new_root_buf
and old_root_buf siblings with no ordering between them. old_root could
therefore reach disk in its transient split state (edge_info=EMPTY,
next_bnode=child_node2) before new_root, widening the crash window.
Bug B — Recovery discarded a committed new_root
When the crash left old_root on disk in split state and new_root durable
but the SB unwritten, the recovery loop could not reliably identify
new_root as the intended root. Existing logic either discarded the
committed new_root candidate or had no mechanism to promote it,
leaving the persisted SB still pointing to old_root.
Bug C — repair_root_node applied an unsafe edge repair
With old_root as the recovered tree root (per the stale SB),
repair_root_node read old_root.next_bnode (= child_node2, level N) and
set it as old_root's edge child (also level N). This violated the
B-tree invariant child.level == parent.level - 1, causing validate_node
to abort with "Child node level mismatch" on the next B-tree access.
Fix 1 — Preserve root-transition DAG topology (Bug A)
Changed link_buf Condition 1 to bypass up_buf only when BOTH up_buf AND
down_buf were created in the current CP:
Before: if (up_buf->m_created_cp_id == icp_ctx->id())
After: if (up_buf->m_created_cp_id == icp_ctx->id() &&
down_buf->m_created_cp_id == icp_ctx->id())
When down_buf is an older node (e.g. old_root), the dependency chain
through up_buf (new_root) is preserved so journal recovery rebuilds the
same root-transition topology deterministically. In the normal DAG,
old_root remains a down-buffer of new_root and still completes before
new_root; this does not by itself prevent old_root from reaching disk
before new_root. Physical durability of new_root and its new-node
dependencies before the modified old_root is written is provided by the
pre-flush barrier in Fix 2.
Because new_root waits for its downs, a committed new_root also implies
that old_root and child_node2 have completed their flush, which makes
Fix 2's root promotion safe once the candidate is durable.
The mirror fix is applied in index_cp.cpp process_txn_record so that
journal recovery rebuilds an identical DAG. The sanity check is
tightened accordingly: only a buffer that was itself created in the
current CP must not point to another same-CP-new up_buffer.
Fix 2 — Pre-flush new-root nodes and promote on recovery (Bug B)
Added a pre-flush phase in async_cp_flush: before the normal DAG flush
starts, all newly-created nodes belonging to ordinals that had a root
change are written to disk via async_write. Only after these writes
complete does the normal DAG flush begin. This ensures new_root (and
child_node2) are always durable before old_root can be written in its
transient split state.
During recovery, the journal is parsed to identify the final intended
new-root BlkId per ordinal (m_recovered_root_ids). A candidate is
promoted when:
was also written in this CP, meaning the tree is in post-split state
This check is stronger than simply testing whether SB root is non-empty:
if old_root was not yet written in split state, the tree is still in a
pre-split consistent state and the new_root candidate is discarded.
set_root_from_committed_buf() updates the in-memory SB (root_node,
root_link_version, btree_depth) and the btree's root pointer before the
forced recovery CP writes the corrected SB to disk.
The first-CP path (SB root still empty) is excluded so that
recovery_completed() can build a fresh root as before.
Fix 3 — Harden repair_root_node (Bug C)
Added guards before applying the edge repair:
a. Buffer validity: skip if raw_buffer is null, node magic is invalid,
or node_id does not match blkid — the buffer was never written.
b. next_bnode empty: skip if next_bnode is already empty_bnodeid,
meaning new_root was already promoted and nothing needs repairing.
c. Level invariant: read the candidate next_bnode from disk; if its
level >= old_root's level (partial root-split state), skip the
repair rather than corrupting the edge.
Also changed the raw pointer
nto BtreeNodePtrbnto prevent amemory leak on the early-return paths added by this fix.
The same validity guard is applied to repair_node to protect against
same-CP new nodes that were never flushed (all-zero on disk).