Skip to content

Commit dbe01c9

Browse files
committed
feat(cluster): start health monitor and harden topology convergence
Start the HealthMonitor task from start_raft so that periodic pings run for the lifetime of the node. Without it, topology updates relied solely on the fire-and-forget broadcast during the join flow; when that broadcast was lost (peer QUIC server not yet accepting), the peer never converged to the full topology. On each successful pong, push the current topology to peers whose reported version is behind ours. This closes the convergence gap for peers that missed the initial broadcast. Bound QUIC handshake attempts by rpc_timeout so a hung connection does not block for the full 30 s idle timeout when a peer is slow to accept. Expose the cluster catalog via ClusterHandle so the HealthMonitor can persist topology changes detected during failure/recovery. Increase the pgwire retry budget from 3 attempts (350 ms) to 5 attempts (750 ms) to tolerate longer Raft leader drains under load.
1 parent 45c33d1 commit dbe01c9

6 files changed

Lines changed: 112 additions & 14 deletions

File tree

nodedb-cluster/src/health.rs

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,14 +151,38 @@ impl HealthMonitor {
151151
}
152152
}
153153

154-
/// Handle a successful pong — reset failure count, mark node Active if needed.
155-
fn handle_pong(&self, peer_id: u64, _pong: &PongResponse) -> bool {
154+
/// Handle a successful pong — reset failure count, mark node Active
155+
/// if needed, and push topology if the peer is behind.
156+
fn handle_pong(&self, peer_id: u64, pong: &PongResponse) -> bool {
156157
// Reset failure count.
157158
{
158159
let mut failures = self.ping_failures.lock().unwrap_or_else(|p| p.into_inner());
159160
failures.remove(&peer_id);
160161
}
161162

163+
// Push topology to peers with a stale version. This closes
164+
// the convergence gap when the fire-and-forget broadcast
165+
// during the join flow is lost (e.g. peer QUIC server not
166+
// yet accepting at that instant).
167+
let our_version = {
168+
let topo = self.topology.read().unwrap_or_else(|p| p.into_inner());
169+
topo.version()
170+
};
171+
if pong.topology_version < our_version {
172+
debug!(
173+
peer_id,
174+
peer_version = pong.topology_version,
175+
our_version,
176+
"peer has stale topology, pushing update"
177+
);
178+
let transport = self.transport.clone();
179+
let topology = self.topology.clone();
180+
let self_id = self.node_id;
181+
tokio::spawn(async move {
182+
broadcast_topology_to_peer(self_id, peer_id, &topology, &transport).await;
183+
});
184+
}
185+
162186
// If node was not Active, mark it Active.
163187
let mut topo = self.topology.write().unwrap_or_else(|p| p.into_inner());
164188
if let Some(node) = topo.get_node(peer_id)
@@ -264,6 +288,34 @@ pub fn broadcast_topology(
264288
}
265289
}
266290

291+
/// Send a topology update to a single peer that has a stale version.
292+
async fn broadcast_topology_to_peer(
293+
_self_node_id: u64,
294+
peer_id: u64,
295+
topology: &RwLock<ClusterTopology>,
296+
transport: &NexarTransport,
297+
) {
298+
let update = {
299+
let topo = topology.read().unwrap_or_else(|p| p.into_inner());
300+
RaftRpc::TopologyUpdate(TopologyUpdate {
301+
version: topo.version(),
302+
nodes: topo
303+
.all_nodes()
304+
.map(|n| JoinNodeInfo {
305+
node_id: n.node_id,
306+
addr: n.addr.clone(),
307+
state: n.state.as_u8(),
308+
raft_groups: n.raft_groups.clone(),
309+
wire_version: n.wire_version,
310+
})
311+
.collect(),
312+
})
313+
};
314+
if let Err(e) = transport.send_rpc(peer_id, update).await {
315+
debug!(peer_id, error = %e, "targeted topology push failed");
316+
}
317+
}
318+
267319
/// Handle an incoming Ping RPC — return a Pong with our topology version.
268320
pub fn handle_ping(node_id: u64, topology_version: u64, _req: &PingRequest) -> RaftRpc {
269321
RaftRpc::Pong(PongResponse {

nodedb-cluster/src/transport/client.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -245,15 +245,23 @@ impl NexarTransport {
245245
.ok_or(ClusterError::NodeUnreachable { node_id: target })?
246246
};
247247

248-
// Connect.
249-
let conn = self
248+
// Connect — bounded by rpc_timeout so a hung QUIC handshake
249+
// (peer not yet serving) doesn't block for the full 30s idle timeout.
250+
let connecting = self
250251
.listener
251252
.endpoint()
252253
.connect_with(self.client_config.clone(), addr, SNI_HOSTNAME)
253254
.map_err(|e| ClusterError::Transport {
254255
detail: format!("connect to node {target} at {addr}: {e}"),
255-
})?
256+
})?;
257+
let conn = tokio::time::timeout(self.rpc_timeout, connecting)
256258
.await
259+
.map_err(|_| ClusterError::Transport {
260+
detail: format!(
261+
"handshake timeout ({}ms) with node {target} at {addr}",
262+
self.rpc_timeout.as_millis()
263+
),
264+
})?
257265
.map_err(|e| ClusterError::Transport {
258266
detail: format!("handshake with node {target} at {addr}: {e}"),
259267
})?;

nodedb/src/control/cluster/handle.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,8 @@ pub struct ClusterHandle {
3333
/// stays `Clone` while still guaranteeing single-transfer
3434
/// semantics at runtime.
3535
pub multi_raft: Mutex<Option<nodedb_cluster::MultiRaft>>,
36+
/// Cluster catalog (redb-backed topology + routing persistence).
37+
/// Shared with the `HealthMonitor` for persisting topology changes
38+
/// on failure detection and recovery.
39+
pub catalog: Arc<nodedb_cluster::ClusterCatalog>,
3640
}

nodedb/src/control/cluster/init.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ pub async fn init_cluster(
3838
"cluster QUIC transport bound"
3939
);
4040

41-
init_cluster_with_transport(config, transport, data_dir).await
41+
init_cluster_with_transport(config, transport, data_dir, transport_tuning).await
4242
}
4343

4444
/// Initialize the cluster using a pre-bound QUIC transport.
@@ -56,13 +56,15 @@ pub async fn init_cluster_with_transport(
5656
config: &ClusterSettings,
5757
transport: Arc<nodedb_cluster::NexarTransport>,
5858
data_dir: &std::path::Path,
59+
transport_tuning: &ClusterTransportTuning,
5960
) -> crate::Result<ClusterHandle> {
6061
// 2. Open cluster catalog.
6162
let catalog_path = data_dir.join("cluster.redb");
62-
let catalog =
63+
let catalog = Arc::new(
6364
nodedb_cluster::ClusterCatalog::open(&catalog_path).map_err(|e| crate::Error::Config {
6465
detail: format!("cluster catalog: {e}"),
65-
})?;
66+
})?,
67+
);
6668

6769
// 3. Bootstrap, join, or restart.
6870
let cluster_config = nodedb_cluster::ClusterConfig {
@@ -75,6 +77,12 @@ pub async fn init_cluster_with_transport(
7577
force_bootstrap: config.force_bootstrap,
7678
join_retry: join_retry_policy_from_env(),
7779
swim_udp_addr: None,
80+
election_timeout_min: std::time::Duration::from_secs(
81+
transport_tuning.election_timeout_min_secs,
82+
),
83+
election_timeout_max: std::time::Duration::from_secs(
84+
transport_tuning.election_timeout_max_secs,
85+
),
7886
};
7987

8088
let lifecycle = nodedb_cluster::ClusterLifecycleTracker::new();
@@ -105,6 +113,7 @@ pub async fn init_cluster_with_transport(
105113
applied_index_watcher,
106114
node_id: config.node_id,
107115
multi_raft: Mutex::new(Some(state.multi_raft)),
116+
catalog,
108117
})
109118
}
110119

nodedb/src/control/cluster/start_raft.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ pub fn start_raft(
112112
// Start the RPC server (accepts inbound QUIC connections).
113113
let transport_serve = handle.transport.clone();
114114
let rl_handler = raft_loop.clone();
115-
let sr_serve = shutdown_rx;
115+
let sr_serve = shutdown_rx.clone();
116116
tokio::spawn(async move {
117117
if let Err(e) = transport_serve.serve(rl_handler, sr_serve).await {
118118
tracing::error!(error = %e, "raft RPC server failed");
@@ -138,6 +138,27 @@ pub fn start_raft(
138138
);
139139
}
140140

141+
// Start the health monitor (periodic pings, failure detection,
142+
// topology re-broadcast). Without this, topology updates are
143+
// only propagated via the fire-and-forget broadcast during the
144+
// join flow — if that single broadcast is lost (peer QUIC server
145+
// not yet accepting), the peer never converges.
146+
let health_config = nodedb_cluster::HealthConfig {
147+
ping_interval: Duration::from_secs(transport_tuning.health_ping_interval_secs),
148+
failure_threshold: transport_tuning.health_failure_threshold,
149+
};
150+
let health_monitor = Arc::new(nodedb_cluster::HealthMonitor::new(
151+
handle.node_id,
152+
handle.transport.clone(),
153+
handle.topology.clone(),
154+
handle.catalog.clone(),
155+
health_config,
156+
));
157+
let sr_health = shutdown_rx;
158+
tokio::spawn(async move {
159+
health_monitor.run(sr_health).await;
160+
});
161+
141162
info!(node_id = handle.node_id, "raft loop and RPC server started");
142163

143164
Ok(ready_rx)

nodedb/src/control/server/pgwire/handler/retry.rs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@
1818
//!
1919
//! ## Retry budget
2020
//!
21-
//! Three attempts total with 50ms, 100ms, 200ms backoff between
22-
//! them — roughly 350ms of tolerance for a drain to complete.
21+
//! Five attempts total with 50/100/200/400 ms backoff between
22+
//! them — roughly 750ms of tolerance for a drain to complete.
2323
//! The `DEFAULT_DRAIN_TIMEOUT` from `metadata_proposer` is 35s,
2424
//! so in practice either drain completes within our retry budget
2525
//! (the proposer is actively draining and is probably close to
@@ -31,13 +31,17 @@ use std::time::Duration;
3131
use crate::error::Error;
3232

3333
/// Maximum number of attempts (including the initial call).
34-
const MAX_ATTEMPTS: usize = 3;
34+
const MAX_ATTEMPTS: usize = 5;
3535

3636
/// Backoff durations BETWEEN attempts. `BACKOFFS[i]` is the sleep
3737
/// duration before attempt `i + 1`. Length must be
3838
/// `MAX_ATTEMPTS - 1`.
39-
const BACKOFFS: [Duration; MAX_ATTEMPTS - 1] =
40-
[Duration::from_millis(50), Duration::from_millis(100)];
39+
const BACKOFFS: [Duration; MAX_ATTEMPTS - 1] = [
40+
Duration::from_millis(50),
41+
Duration::from_millis(100),
42+
Duration::from_millis(200),
43+
Duration::from_millis(400),
44+
];
4145

4246
/// Run `op` up to `MAX_ATTEMPTS` times. Retries only on
4347
/// `Error::RetryableSchemaChanged`. Any other error (including

0 commit comments

Comments
 (0)