Skip to content

SDSTOR-22729: index/wb_cache: Fix recovery corruption after root-split crash#900

Open
JacksonYao287 wants to merge 1 commit into
eBay:stable/v7.xfrom
JacksonYao287:SDSTOR-22729
Open

SDSTOR-22729: index/wb_cache: Fix recovery corruption after root-split crash#900
JacksonYao287 wants to merge 1 commit into
eBay:stable/v7.xfrom
JacksonYao287:SDSTOR-22729

Conversation

@JacksonYao287

@JacksonYao287 JacksonYao287 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

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) — updates the
    in-memory superblock (SB) and calls transact_bufs(meta_buf, new_root_buf),
    which 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 only written at the very end of CP flush, after all
    node buffers complete.

    Three bugs combined to corrupt the tree when a SIGKILL landed after step 2
    but before the SB write:

    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. As a result,
    old_root_buf linked directly to meta_buf instead of through new_root_buf,
    making new_root_buf and old_root_buf siblings under meta_buf 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 SB write did not complete, the recovery loop saw new_root_buf's
    parent (meta_buf) as uncommitted and threw new_root_buf away. The persisted
    SB still pointed to old_root.

    Bug C — repair_root_node applied an unsafe edge repair
    With old_root as the recovered tree root, 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.

    The same bug sequence applies to the first root split (leaf → level=1) as
    well as any subsequent split.

    Fix 1 — Restore correct flush-DAG ordering (covers Bug A)
    Changed link_buf Condition 1 in wb_cache.cpp to bypass up_buf only when
    BOTH up_buf AND down_buf were created in the current CP. When down_buf is
    an older node (e.g. old_root), the dependency chain through up_buf
    (new_root) is preserved, ensuring old_root cannot be flushed before
    new_root:

     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())
    

    The same fix is mirrored in index_cp.cpp process_txn_record so that journal
    recovery rebuilds an identical DAG. The sanity check is tightened: only a
    buffer that was itself created in the current CP must not point to another
    same-CP-new up_buffer; older downs pointing to new ups are now valid.

    Fix 2 — Detect and promote committed new roots during recovery (covers Bug B)
    After the main recovery loop, newly introduced new_root_candidates tracking
    collects every committed node whose direct parent is an unwritten meta_buf,
    provided the index table already has a persisted SB root
    (has_persisted_root() == true). For each ordinal, the highest-level
    candidate is selected and set_root_from_committed_buf() is called to update
    the in-memory SB (root_node, root_link_version, btree_depth) and the btree's
    own root pointer before the next forced CP writes the corrected SB to disk.
    The first-CP path (SB root still empty) is intentionally excluded so that
    recovery_completed() can build a fresh root as before.

    Fix 3 — Harden repair_root_node fallback (covers Bug C)
    Added two guards before applying the edge repair:
    a. Buffer validity: skip if raw_buffer is null, the node magic is invalid,
    or node_id does not match blkid — the buffer was never written to disk.
    b. Level invariant: read the candidate next_bnode from disk; if its level is
    ≥ old_root's level (partial root-split state), skip the repair rather than
    corrupting the edge.
    A third early return handles the case where next_bnode is already empty,
    meaning new_root was already promoted and there is nothing to repair.

    Fix 4 — Complete node_id validation in set_root_from_committed_buf
    repair_root_node and repair_node both perform a three-way check
    (raw_buffer != nullptr, is_valid_node, node_id == blkid). The initial
    implementation of set_root_from_committed_buf checked only the first two.
    Added the node_id != blkid guard to match, preventing a disk block whose
    node magic is valid but whose node_id disagrees with its blkid from being
    promoted as the new tree root.

    • prune_up_buffers: guard against cascading into committed ancestors; only
      schedule durable (valid magic + matching node_id) buffers for repair.
    • update_up_buffer_counters: skip detaching committed, non-freed buffers from
      their parent so they remain available for new-root promotion.
    • recover_buf: return early for meta buffers (tracked separately via
      new_root_candidates; loading them as vdev nodes corrupts m_dirtied_cp_id).
    • read_buf: reject invalid or mismatched blocks before init_node to surface
      disk corruption early with a clear error instead of a debug assertion.
    • IndexBuffer::to_string: replace bare {...} delimiters with [...] to prevent
      fmt from misinterpreting them as format placeholders when the string is later
      re-parsed by HS_*_ASSERT_CMP macros.
    • btree_common.ipp: include next_bnode value in "Broken child linkage" message;
      use enum_name(ret) for node-read failure reasons.
    • Version bump: 7.5.14 → 7.5.15.

    Added two regression tests to IndexCrashTest:

    CrashAtMetaBufOnFirstRootSplit
    Phase 1: insert max_keys/2 entries and flush cleanly so the tree is at a
    stable leaf-only state (btree_depth=0, SB root = old_leaf).
    Phase 2: set crash_flush_on_meta, insert max_keys+1 more entries to force
    the first root split (leaf → level=1), trigger CP. The flip fires
    after new_root and old_leaf are both durable but before meta_buf
    writes, leaving the SB stale.
    Recovery: Fix 2 detects new_root (cand_level=1 > btree_depth=0) and promotes
    it; Fix 3 would catch candidate.level(0) >= old_leaf.level(0) if
    Fix 2 had not applied. Tree integrity verified via reapply_after_crash
    and get_all.

@codecov-commenter

codecov-commenter commented Jul 23, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 28.03738% with 77 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (stable/v7.x@ae52025). Learn more about missing BASE report.

Files with missing lines Patch % Lines
src/include/homestore/index/index_table.hpp 31.11% 16 Missing and 15 partials ⚠️
src/lib/index/wb_cache.cpp 29.54% 6 Missing and 25 partials ⚠️
...rc/include/homestore/btree/detail/btree_common.ipp 0.00% 8 Missing ⚠️
src/lib/index/index_cp.cpp 33.33% 0 Missing and 4 partials ⚠️
src/lib/index/index_service.cpp 25.00% 0 Missing and 3 partials ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files
@@              Coverage Diff               @@
##             stable/v7.x     #900   +/-   ##
==============================================
  Coverage               ?   48.15%           
==============================================
  Files                  ?      110           
  Lines                  ?    13057           
  Branches               ?     6285           
==============================================
  Hits                   ?     6288           
  Misses                 ?     2579           
  Partials               ?     4190           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@JacksonYao287
JacksonYao287 force-pushed the SDSTOR-22729 branch 2 times, most recently from ac1f6d4 to 1f4d59f Compare July 24, 2026 06:06
…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) — updates the
     in-memory superblock (SB) and calls transact_bufs(meta_buf, new_root_buf),
     which 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 only written at the very end of CP flush, after all
     node buffers complete.

Three bugs combined to corrupt the tree when a SIGKILL landed after step 2
but before the SB write:

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.  As a result,
  old_root_buf linked directly to meta_buf instead of through new_root_buf,
  making new_root_buf and old_root_buf siblings under meta_buf 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 SB write did not complete, the recovery loop saw new_root_buf's
  parent (meta_buf) as uncommitted and threw new_root_buf away.  The persisted
  SB still pointed to old_root.

Bug C — repair_root_node applied an unsafe edge repair
  With old_root as the recovered tree root, 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.

The same bug sequence applies to the first root split (leaf → level=1) as
well as any subsequent split.

Fix 1 — Restore correct flush-DAG ordering (covers Bug A)
  Changed link_buf Condition 1 in wb_cache.cpp to bypass up_buf only when
  BOTH up_buf AND down_buf were created in the current CP.  When down_buf is
  an older node (e.g. old_root), the dependency chain through up_buf
  (new_root) is preserved, ensuring old_root cannot be flushed before
  new_root:

    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())

  The same fix is mirrored in index_cp.cpp process_txn_record so that journal
  recovery rebuilds an identical DAG.  The sanity check is tightened: only a
  buffer that was itself created in the current CP must not point to another
  same-CP-new up_buffer; older downs pointing to new ups are now valid.

Fix 2 — Detect and promote committed new roots during recovery (covers Bug B)
  After the main recovery loop, newly introduced new_root_candidates tracking
  collects every committed node whose direct parent is an unwritten meta_buf,
  provided the index table already has a persisted SB root
  (has_persisted_root() == true).  For each ordinal, the highest-level
  candidate is selected and set_root_from_committed_buf() is called to update
  the in-memory SB (root_node, root_link_version, btree_depth) and the btree's
  own root pointer before the next forced CP writes the corrected SB to disk.
  The first-CP path (SB root still empty) is intentionally excluded so that
  recovery_completed() can build a fresh root as before.

Fix 3 — Harden repair_root_node fallback (covers Bug C)
  Added two guards before applying the edge repair:
  a. Buffer validity: skip if raw_buffer is null, the node magic is invalid,
     or node_id does not match blkid — the buffer was never written to disk.
  b. Level invariant: read the candidate next_bnode from disk; if its level is
     ≥ old_root's level (partial root-split state), skip the repair rather than
     corrupting the edge.
  A third early return handles the case where next_bnode is already empty,
  meaning new_root was already promoted and there is nothing to repair.

Fix 4 — Complete node_id validation in set_root_from_committed_buf
  repair_root_node and repair_node both perform a three-way check
  (raw_buffer != nullptr, is_valid_node, node_id == blkid). The initial
  implementation of set_root_from_committed_buf checked only the first two.
  Added the node_id != blkid guard to match, preventing a disk block whose
  node magic is valid but whose node_id disagrees with its blkid from being
  promoted as the new tree root.

- prune_up_buffers: guard against cascading into committed ancestors; only
  schedule durable (valid magic + matching node_id) buffers for repair.
- update_up_buffer_counters: skip detaching committed, non-freed buffers from
  their parent so they remain available for new-root promotion.
- recover_buf: return early for meta buffers (tracked separately via
  new_root_candidates; loading them as vdev nodes corrupts m_dirtied_cp_id).
- read_buf: reject invalid or mismatched blocks before init_node to surface
  disk corruption early with a clear error instead of a debug assertion.
- IndexBuffer::to_string: replace bare {...} delimiters with [...] to prevent
  fmt from misinterpreting them as format placeholders when the string is later
  re-parsed by HS_*_ASSERT_CMP macros.
- btree_common.ipp: include next_bnode value in "Broken child linkage" message;
  use enum_name(ret) for node-read failure reasons.
- Version bump: 7.5.14 → 7.5.15.

Added two regression tests to IndexCrashTest:

CrashAtMetaBufOnFirstRootSplit
  Phase 1: insert max_keys/2 entries and flush cleanly so the tree is at a
           stable leaf-only state (btree_depth=0, SB root = old_leaf).
  Phase 2: set crash_flush_on_meta, insert max_keys+1 more entries to force
           the first root split (leaf → level=1), trigger CP. The flip fires
           after new_root and old_leaf are both durable but before meta_buf
           writes, leaving the SB stale.
  Recovery: Fix 2 detects new_root (cand_level=1 > btree_depth=0) and promotes
            it; Fix 3 would catch candidate.level(0) >= old_leaf.level(0) if
            Fix 2 had not applied. Tree integrity verified via reapply_after_crash
            and get_all.

CrashAtMetaBufOnSecondRootSplit
  Phase 1: insert max_keys+1 entries to trigger the first root split cleanly,
           then flush (tree now at level=1, SB up to date).
  Phase 2: set crash_flush_on_meta, insert max_keys² more entries to force the
           second root split (level=1 → level=2), trigger CP. Same crash
           mechanics as above.
  Recovery: same verification.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants