Skip to content

Commit d96d81e

Browse files
committed
fix(nodedb-cluster): eliminate 30s-per-peer stalls during cluster join
Two independent but compounding issues caused cluster join to hang for tens of seconds on every startup when seeds were not yet bootstrapped: 1. The QUIC RPC timeout only covered the response-read phase. A handshake attempt against an unreachable or not-yet-listening peer blocked for the transport's internal idle timeout (~30 s), not the configured RPC timeout. In a 5-node race where every non-bootstrapper seed redirects to another non-bootstrapper, this multiplied to (N-1) × 30 s of wasted wall time per join attempt. Fixed by wrapping the entire send_rpc_to_addr operation — handshake, stream open, write, and read — in a single tokio::time::timeout bounded by self.rpc_timeout, and extracting the inner work into send_rpc_to_addr_inner so the public interface stays clean. 2. The seed work-list was a Vec used as a stack (pop), so seed order was unspecified. Under the single-elected-bootstrapper rule the lexicographically smallest address is the one peer that can actually answer during the initial race; hitting it last meant exhausting timeouts against every other seed first. Fixed by sorting seeds at the start of the join loop so the designated bootstrapper surfaces first, and switching to VecDeque so leader redirects are pushed to the front (push_front / pop_front) and consumed before unvisited seeds.
1 parent 06d3b07 commit d96d81e

2 files changed

Lines changed: 38 additions & 12 deletions

File tree

nodedb-cluster/src/bootstrap/join.rs

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -143,15 +143,30 @@ async fn try_join_once(
143143
transport: &NexarTransport,
144144
req_template: &JoinRequest,
145145
) -> Result<ClusterState> {
146-
// Work list: start with the configured seeds, prepend leader hints
147-
// as they arrive. `HashSet` deduplicates so a redirect loop can't
148-
// consume all attempts against the same address.
149-
let mut work: Vec<SocketAddr> = config.seed_nodes.clone();
146+
// Work list: try seeds in sorted order so the lexicographically
147+
// smallest address — the designated bootstrapper under the
148+
// single-elected-bootstrapper rule — is contacted first. This is
149+
// critical during the initial 5-node race: every other seed points
150+
// at a node that is itself still joining, so asking them first
151+
// eats the full RPC timeout per non-bootstrapper before we reach
152+
// the one peer that can actually answer. `HashSet` deduplicates
153+
// so a redirect loop can't consume all attempts against the same
154+
// address.
155+
let mut work: std::collections::VecDeque<SocketAddr> =
156+
config.seed_nodes.iter().copied().collect();
157+
{
158+
// Sort so the designated bootstrapper surfaces first. Leader
159+
// redirects get prepended with push_front below, keeping the
160+
// "most likely to answer" candidate at the head.
161+
let mut sorted: Vec<SocketAddr> = work.drain(..).collect();
162+
sorted.sort();
163+
work.extend(sorted);
164+
}
150165
let mut visited: HashSet<SocketAddr> = HashSet::new();
151166
let mut redirects: u32 = 0;
152167
let mut last_err: Option<ClusterError> = None;
153168

154-
while let Some(addr) = work.pop() {
169+
while let Some(addr) = work.pop_front() {
155170
if !visited.insert(addr) {
156171
continue;
157172
}
@@ -172,7 +187,7 @@ async fn try_join_once(
172187
"following leader redirect"
173188
);
174189
redirects += 1;
175-
work.push(leader);
190+
work.push_front(leader);
176191
continue;
177192
}
178193
debug!(

nodedb-cluster/src/transport/client.rs

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,23 @@ impl NexarTransport {
267267
}
268268

269269
/// Send an RPC to an address directly (for bootstrap/join before peer IDs are known).
270+
///
271+
/// The **entire** operation — handshake, stream open, write, read — is
272+
/// bounded by `self.rpc_timeout`. Previously only the response read was
273+
/// timed out, which meant a QUIC handshake against an unreachable peer
274+
/// could block for the transport's internal idle timeout (~30 s per
275+
/// address). That was fatal to cluster join races where every non-
276+
/// bootstrapper seed points at another non-bootstrapper: each attempt
277+
/// would stall 30 s × (N-1) peers, compounding across retry passes.
270278
pub async fn send_rpc_to_addr(&self, addr: SocketAddr, rpc: RaftRpc) -> Result<RaftRpc> {
279+
tokio::time::timeout(self.rpc_timeout, self.send_rpc_to_addr_inner(addr, rpc))
280+
.await
281+
.map_err(|_| ClusterError::Transport {
282+
detail: format!("RPC timeout ({}ms) to {addr}", self.rpc_timeout.as_millis()),
283+
})?
284+
}
285+
286+
async fn send_rpc_to_addr_inner(&self, addr: SocketAddr, rpc: RaftRpc) -> Result<RaftRpc> {
271287
let frame = rpc_codec::encode(&rpc)?;
272288

273289
let conn = self
@@ -295,12 +311,7 @@ impl NexarTransport {
295311
detail: format!("finish send to {addr}: {e}"),
296312
})?;
297313

298-
let response_frame = tokio::time::timeout(self.rpc_timeout, server::read_frame(&mut recv))
299-
.await
300-
.map_err(|_| ClusterError::Transport {
301-
detail: format!("RPC timeout ({}ms) to {addr}", self.rpc_timeout.as_millis()),
302-
})??;
303-
314+
let response_frame = server::read_frame(&mut recv).await?;
304315
rpc_codec::decode(&response_frame)
305316
}
306317

0 commit comments

Comments
 (0)