fix(replication): redeliver_missed shipped to the wrong shard over gRPC

Partition recovery (heal_region) and await_convergence never worked over real
GrpcTransports: redeliver_missed sent to entry.source_shard (the LEADER's
shard), but a follower's transport only knows its own shard as a peer
(self-loop wiring in tidal-server::cluster), so every recovery ship failed
the peer lookup — and the error was swallowed by 'let _ ='. ChannelTransport
ignores the destination, which is why the in-process suite never caught it.

Found live on k3s: POST /cluster/partition + writes + /cluster/heal left the
region at lag=3 forever, no log line.

Fix: ship to transport.local_shard() (matching the eager path's
send_segment(ShardId(region.0), ...)); keep entry.source_shard in the payload
(receiver advances applied_seqno per SOURCE shard). WARN on ship failure
instead of dropping it. Adds a RecordingTransport regression test that locks
dest == local_shard + payload source == leader — the exact case channels
cannot catch. cargo test: 263 replication + 13 testing + tidal-server all
pass.
This commit is contained in:
jx12n 2026-06-09 18:06:58 -06:00
parent 1092d34c39
commit 0c99709990

View File

@ -140,7 +140,21 @@ pub(super) const fn single_event_payload(
/// would flood the transport with redundant payloads; relying on it alone /// would flood the transport with redundant payloads; relying on it alone
/// (without the receiver's monotonic advance) would double-apply on any race /// (without the receiver's monotonic advance) would double-apply on any race
/// between the `applied_seqno` read here and the receiver thread's advance. /// between the `applied_seqno` read here and the receiver thread's advance.
///
/// 3. **Destination is the follower's own shard.** `send_segment`'s first
/// argument is the DESTINATION peer, and a follower's transport only knows
/// its own shard as a peer (the gRPC self-loop wiring in
/// `tidal-server::cluster`). The eager path ships with
/// `send_segment(ShardId(region.0), …)`; re-delivery must match it via
/// `transport.local_shard()` — the follower this transport belongs to.
/// Shipping to `entry.source_shard` (the LEADER's shard) fails the peer
/// lookup on every `GrpcTransport` and recovery silently never happens.
/// `ChannelTransport` ignores the destination, which is exactly why the
/// in-process suite cannot catch that regression — the unit test below
/// locks it instead. The payload itself still carries `entry.source_shard`:
/// the receiver advances `applied_seqno` PER SOURCE shard.
pub(super) fn redeliver_missed(transport: &dyn Transport, db: &TidalDb, log: &[BatchEntry]) { pub(super) fn redeliver_missed(transport: &dyn Transport, db: &TidalDb, log: &[BatchEntry]) {
let dest = transport.local_shard();
for entry in log { for entry in log {
// Read the follower's high-water-mark per source shard. The // Read the follower's high-water-mark per source shard. The
// `seqno > applied` gate enforces both invariants documented above: // `seqno > applied` gate enforces both invariants documented above:
@ -153,7 +167,18 @@ pub(super) fn redeliver_missed(transport: &dyn Transport, db: &TidalDb, log: &[B
if entry.seqno > applied { if entry.seqno > applied {
let payload = let payload =
single_event_payload(entry.source_shard, entry.seqno, entry.bytes.clone()); single_event_payload(entry.source_shard, entry.seqno, entry.bytes.clone());
let _ = transport.send_segment(entry.source_shard, payload); // Mirror the eager path's observability contract: recovery is
// best-effort, but a swallowed ship error leaves an operator
// staring at a follower that never catches up. WARN, don't drop.
if let Err(e) = transport.send_segment(dest, payload) {
tracing::warn!(
dest_shard = dest.0,
source_shard = entry.source_shard.0,
seqno = entry.seqno,
error = %e,
"re-delivery ship failed; will retry on next heal/convergence pass"
);
}
} }
} }
} }
@ -186,4 +211,87 @@ mod tests {
assert_eq!(payload.id.seqno, seqno); assert_eq!(payload.id.seqno, seqno);
assert_eq!(payload.bytes, bytes); assert_eq!(payload.bytes, bytes);
} }
/// A transport that records every `(dest, payload-source)` pair. Unlike
/// [`ChannelTransport`] (which IGNORES the destination — the reason the
/// in-process suite cannot catch a wrong-destination regression), this
/// mock asserts on it.
struct RecordingTransport {
local: ShardId,
sent: std::sync::Mutex<Vec<(ShardId, ShardId, u64)>>, // (dest, payload source, seqno)
}
impl Transport for RecordingTransport {
fn send_segment(
&self,
to: ShardId,
payload: WalSegmentPayload,
) -> Result<(), TransportError> {
self.sent
.lock()
.unwrap()
.push((to, payload.id.shard_id, payload.id.seqno));
Ok(())
}
fn recv_segment(&self) -> Option<WalSegmentPayload> {
None
}
fn local_shard(&self) -> ShardId {
self.local
}
}
/// Re-delivery must ship to the FOLLOWER'S OWN shard (`local_shard`) —
/// never to `entry.source_shard` (the leader). A follower's gRPC
/// transport only knows itself as a peer (self-loop wiring), so the wrong
/// destination fails the peer lookup and partition recovery silently
/// never happens (the m8p9 heal bug). The payload, by contrast, must keep
/// the LEADER's shard: the receiver advances `applied_seqno` per source.
#[test]
fn redeliver_ships_to_local_shard_with_source_payload() {
let leader_shard = ShardId(0);
let follower_shard = ShardId(2);
let transport = RecordingTransport {
local: follower_shard,
sent: std::sync::Mutex::new(Vec::new()),
};
let db = crate::TidalDb::builder()
.ephemeral()
.open()
.expect("open ephemeral db");
let log = vec![
BatchEntry {
source_shard: leader_shard,
seqno: 1,
bytes: vec![1],
},
BatchEntry {
source_shard: leader_shard,
seqno: 2,
bytes: vec![2],
},
];
redeliver_missed(&transport, &db, &log);
let sent = transport.sent.lock().unwrap();
assert_eq!(sent.len(), 2, "both unapplied entries must re-ship");
for (dest, payload_source, _seqno) in sent.iter() {
assert_eq!(
*dest, follower_shard,
"re-delivery destination must be the follower's own shard \
(transport.local_shard()), not the leader's"
);
assert_eq!(
*payload_source, leader_shard,
"payload must carry the SOURCE shard so the receiver advances \
applied_seqno for the right origin"
);
}
assert_eq!(
(sent[0].2, sent[1].2),
(1, 2),
"per-source FIFO order must be preserved"
);
}
} }