You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
SDSTOR-22729: index/wb_cache: Fix recovery corruption after root-split crash (#900)
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).
0 commit comments