Skip to content

fix #308: snapshot install success driven by apply result not transfer ACK - #360

Merged
JoshuaChi merged 1 commit into
mainfrom
fix/308-snapshot-transfer
Apr 13, 2026
Merged

JoshuaChi merged 1 commit into
mainfrom
fix/308-snapshot-transfer

Conversation

@JoshuaChi

@JoshuaChi JoshuaChi commented Apr 13, 2026 •

Copy link
Copy Markdown
Contributor

What Does This PR Do?

Fixes a bug where follower/learner nodes reported snapshot install success
based on whether all chunks were received, not whether the snapshot was
actually applied — causing the leader to advance match_index prematurely
and stop retrying, leaving the node permanently behind.

Type:

  • Bug Fix (with test)

Why Is This Needed?

Bug: When apply_snapshot_from_file failed after a successful chunk
transfer, the spawned ACK-drain task saw the last per-chunk ACK
(ChunkStatus::Accepted) and sent success:true to the leader. The
leader then called init_peers_next_index_and_match_index, advancing
match_index to its own last_entry_id. With match_index caught up,
the heartbeat loop never retried the snapshot — leaving the follower
permanently behind with no recovery path.

Fix: Remove the spawned ACK-drain task in FollowerState and
LearnerState. Send SnapshotResponse directly after
apply_snapshot_stream_from_leader returns, with
success = snap_result.is_ok(). The leader now only advances
match_index when the snapshot is fully applied (Raft §7). No new
retry timer needed — the existing heartbeat loop handles retries
naturally once match_index stays behind the purge boundary.


Checklist

Required:

  • make test passes
  • Added tests for new code
  • Commits squashed to 1-2 logical units

Testing

How tested:

For bug fixes:

  • Added test that fails without this fix

Does This Follow d-engine's Principles?

  • Solves a real problem for most users (not just my edge case)
  • Keeps implementation simple
  • Doesn't bloat the API surface

Reviewer Notes

Core change is 2 files × ~15 lines each. The spawned task and its
ACK-drain loop are removed entirely; sender.send(...) is now a direct
call in the same execution path as snap_result.

The _ack_rx discard is intentional: ack_tx is still passed into
process_snapshot_stream for per-chunk internal validation; the receiver
side is simply not needed at the call site in push mode.

Estimated review complexity:

  • Quick (< 100 lines)

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Snapshot installation responses now correctly report failures when application fails, even if chunk transfer succeeds, improving reliability in follower and learner nodes.
  • Tests

    • Added comprehensive tests for snapshot application failure scenarios to verify correct error reporting behavior.

…ply completes

Leader advanced match_index based on the last per-chunk ACK (Accepted),
not on whether apply_snapshot_from_file actually succeeded. When apply
failed after a successful transfer, the spawned ACK-drain task still sent
success:true — causing the leader to stop retrying and leaving the node
permanently behind.

Remove the spawned ACK-drain task in FollowerState and LearnerState;
send SnapshotResponse directly from snap_result.is_ok() so the leader
only advances match_index when the snapshot is fully applied (Raft §7).
@coderabbitai

coderabbitai Bot commented Apr 13, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR removes the spawned ACK-draining task from snapshot installation handling in followers and learners. Instead of deriving SnapshotResponse.success from the last chunk's ACK status, the response now reflects the direct result of apply_snapshot_stream_from_leader. A channel allocation replaces the ACK collection mechanism.

Changes

Cohort / File(s) Summary
Follower Snapshot Installation
d-engine-core/src/raft_role/follower_state.rs, d-engine-core/src/raft_role/follower_state_test.rs
Removed ACK-draining task; now derives SnapshotResponse.success from apply_snapshot_stream_from_leader result instead of last chunk ACK status. Added two tests validating success/failure paths when apply succeeds or fails after transfer.
Learner Snapshot Installation
d-engine-core/src/raft_role/learner_state.rs, d-engine-core/src/raft_role/learner_state_test.rs
Removed ACK-draining task and directly responds with result-based success. Conditional log purge now only executes on successful apply. Added test for failure-after-transfer scenario.
Server Node Shutdown
d-engine-server/src/node/mod.rs
Updated shutdown sequence commentary to clarify sm-worker join precedence and RocksDB lock release.

Sequence Diagram

sequenceDiagram
    participant Leader
    participant Follower
    participant ApplyTask as apply_snapshot_stream<br/>from_leader
    participant RocksDB

    rect rgba(0, 100, 150, 0.5)
    Note over Leader,Follower: OLD FLOW: ACK-based success
    Leader->>Follower: InstallSnapshotChunk
    Follower->>ApplyTask: Call with ack_tx
    ApplyTask->>ApplyTask: Send per-chunk SnapshotAck<br/>(ChunkStatus::Accepted)
    Follower->>Follower: Spawn task to drain ACKs<br/>from ack_rx
    ApplyTask->>RocksDB: Apply chunks
    ApplyTask-->>Follower: Return result
    Follower->>Follower: Derive success from<br/>last ACK status
    Follower->>Leader: SnapshotResponse<br/>(success from ACK)
    end

    rect rgba(150, 100, 0, 0.5)
    Note over Leader,Follower: NEW FLOW: Result-based success
    Leader->>Follower: InstallSnapshotChunk
    Follower->>ApplyTask: Call with ack_tx
    ApplyTask->>ApplyTask: Send per-chunk SnapshotAck<br/>(for validation only)
    ApplyTask->>RocksDB: Apply chunks
    ApplyTask-->>Follower: Return Ok()/Err
    Follower->>Follower: Derive success from<br/>result.is_ok()
    Follower->>Leader: SnapshotResponse<br/>(success from result)
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Possibly related PRs

  • Feature/245 refactor server ut #247 — Modifies snapshot handling tests in follower_state_test.rs and learner_state_test.rs with overlapping logic changes.
  • feat #167: workspace structure #175 — Makes similar modifications to snapshot install handling in raft_role/learner_state.rs by removing the ACK-draining task and basing response success on apply results.

Poem

🐰 Snapshots now shine bright and true,
No more ACK-dance, just what we knew—
Results speak louder than echoes of chunks,
The leader's eyes see truth at once! ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title clearly and concisely identifies the main change: switching snapshot install success determination from transfer ACKs to the apply result, directly addressing issue #308.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/308-snapshot-transfer

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
d-engine-core/src/raft_role/learner_state.rs (1)

289-312: ⚠️ Potential issue | 🔴 Critical

The learner path has the same 32-chunk deadlock.

Line 291 creates a bounded ACK channel but never drains ack_rx. Because d-engine-core/src/state_machine_handler/snapshot_stream_processor.rs:1-20 awaits tx.send(ack) for each chunk, a pushed snapshot with more than 32 chunks will block mid-transfer and apply_snapshot_stream_from_leader will never return. This needs the same fix as the follower path: keep draining the channel, or pass a sink that cannot backpressure.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@d-engine-core/src/raft_role/learner_state.rs` around lines 289 - 312, The
bounded ACK channel created with let (ack_tx, _ack_rx) =
mpsc::channel::<SnapshotAck>(32) can block apply_snapshot_stream_from_leader
because _ack_rx is never drained; fix by consuming _ack_rx (or switch to a
non-blocking sink) similarly to the follower path: spawn a background task to
loop and drain/ignore received SnapshotAck values from _ack_rx so ack_tx never
backpressures apply_snapshot_stream_from_leader, leaving the rest of the logic
(use of ack_tx, call to apply_snapshot_stream_from_leader, and sending the
SnapshotResponse via sender.send) unchanged.
d-engine-core/src/raft_role/follower_state.rs (1)

336-359: ⚠️ Potential issue | 🔴 Critical

The ACK channel will block on snapshots larger than 32 chunks.

Line 338 discards _ack_rx, but process_snapshot_stream (line 963 in default_state_machine_handler.rs) sends an ACK for every successfully processed chunk via ack_tx.send(ack).await.map_err(...). With a 32-slot bounded channel and no consumer, the buffer fills after 32 chunks. On chunk 33+, the bounded channel fails to send because the receiver is dropped—causing the entire snapshot install to fail. Tests only use snapshots with ≤4 chunks, so this goes undetected.

Either spawn a drain task for the channel, or refactor apply_snapshot_stream_from_leader/process_snapshot_stream to skip or accept a no-op ACK sink in push mode instead of a bounded channel.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@d-engine-core/src/raft_role/follower_state.rs` around lines 336 - 359, The
current implementation creates a bounded mpsc channel
(mpsc::channel::<SnapshotAck>(32)) and drops the receiver (_ack_rx), which will
block apply_snapshot_stream_from_leader/process_snapshot_stream once more than
32 chunk ACKs are sent; fix by ensuring ACKs are drained: either spawn a
background task that consumes _ack_rx and silently ignores incoming SnapshotAck
values (so ack_tx.send(...).await never blocks), or change the sink passed to
apply_snapshot_stream_from_leader to a no-op/discard sink instead of a bounded
channel (refactor process_snapshot_stream to accept a Box<dyn Sink> or similar).
Ensure the fix targets the channel creation and usage around ack_tx/_ack_rx and
update apply_snapshot_stream_from_leader/process_snapshot_stream wiring
accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@d-engine-core/src/raft_role/follower_state.rs`:
- Around line 336-359: The current implementation creates a bounded mpsc channel
(mpsc::channel::<SnapshotAck>(32)) and drops the receiver (_ack_rx), which will
block apply_snapshot_stream_from_leader/process_snapshot_stream once more than
32 chunk ACKs are sent; fix by ensuring ACKs are drained: either spawn a
background task that consumes _ack_rx and silently ignores incoming SnapshotAck
values (so ack_tx.send(...).await never blocks), or change the sink passed to
apply_snapshot_stream_from_leader to a no-op/discard sink instead of a bounded
channel (refactor process_snapshot_stream to accept a Box<dyn Sink> or similar).
Ensure the fix targets the channel creation and usage around ack_tx/_ack_rx and
update apply_snapshot_stream_from_leader/process_snapshot_stream wiring
accordingly.

In `@d-engine-core/src/raft_role/learner_state.rs`:
- Around line 289-312: The bounded ACK channel created with let (ack_tx,
_ack_rx) = mpsc::channel::<SnapshotAck>(32) can block
apply_snapshot_stream_from_leader because _ack_rx is never drained; fix by
consuming _ack_rx (or switch to a non-blocking sink) similarly to the follower
path: spawn a background task to loop and drain/ignore received SnapshotAck
values from _ack_rx so ack_tx never backpressures
apply_snapshot_stream_from_leader, leaving the rest of the logic (use of ack_tx,
call to apply_snapshot_stream_from_leader, and sending the SnapshotResponse via
sender.send) unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 446b2d0c-dfd2-4917-9838-b919422481d6

📥 Commits

Reviewing files that changed from the base of the PR and between 773cd9c and 4d6d626.

📒 Files selected for processing (5)
  • d-engine-core/src/raft_role/follower_state.rs
  • d-engine-core/src/raft_role/follower_state_test.rs
  • d-engine-core/src/raft_role/learner_state.rs
  • d-engine-core/src/raft_role/learner_state_test.rs
  • d-engine-server/src/node/mod.rs

@codecov

codecov Bot commented Apr 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

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.

1 participant