1use std::collections::HashSet;
24use std::net::SocketAddr;
25use std::sync::{Arc, Mutex, RwLock};
26
27use tracing::{debug, info, warn};
28
29use crate::calvin::SEQUENCER_GROUP_ID;
30use crate::catalog::{ClusterCatalog, ClusterSettings};
31use crate::error::{ClusterError, Result};
32use crate::lifecycle_state::ClusterLifecycleTracker;
33use crate::multi_raft::MultiRaft;
34use crate::routing::{GroupInfo, RoutingTable};
35use crate::rpc_codec::{JoinRequest, JoinResponse, LEADER_REDIRECT_PREFIX, RaftRpc};
36use crate::topology::{ClusterTopology, NodeInfo, NodeState};
37use crate::transport::NexarTransport;
38
39use super::config::{ClusterConfig, ClusterState};
40
41const MAX_REDIRECTS_PER_ATTEMPT: u32 = 3;
45
46pub(crate) fn parse_leader_hint(error: &str) -> Option<SocketAddr> {
59 error
60 .strip_prefix(LEADER_REDIRECT_PREFIX)
61 .and_then(|s| s.trim().parse().ok())
62}
63
64pub(super) async fn join(
76 config: &ClusterConfig,
77 catalog: &ClusterCatalog,
78 transport: &NexarTransport,
79 lifecycle: &ClusterLifecycleTracker,
80) -> Result<ClusterState> {
81 info!(
82 node_id = config.node_id,
83 seeds = ?config.seed_nodes,
84 "joining existing cluster"
85 );
86
87 if config.seed_nodes.is_empty() {
88 let err = ClusterError::Transport {
89 detail: "no seed nodes configured".into(),
90 };
91 lifecycle.to_failed(err.to_string());
92 return Err(err);
93 }
94
95 let req_template = JoinRequest {
96 node_id: config.node_id,
97 listen_addr: config.listen_addr.to_string(),
98 wire_version: crate::topology::CLUSTER_WIRE_FORMAT_VERSION,
99 spiffe_id: None,
100 spki_pin: transport.local_spki_pin().map(|arr| arr.to_vec()),
101 };
102
103 let policy = config.join_retry;
104 let mut last_err: Option<ClusterError> = None;
105
106 for attempt in 0..policy.max_attempts {
107 lifecycle.to_joining(attempt);
108
109 let delay = policy.backoff_for(attempt);
110 if !delay.is_zero() {
111 debug!(
112 node_id = config.node_id,
113 attempt,
114 delay_ms = delay.as_millis() as u64,
115 "backing off before next join attempt"
116 );
117 tokio::time::sleep(delay).await;
118 }
119
120 match try_join_once(config, catalog, transport, &req_template).await {
121 Ok(state) => return Ok(state),
122 Err(e) => {
123 warn!(
124 node_id = config.node_id,
125 attempt,
126 error = %e,
127 "join attempt failed; will retry"
128 );
129 last_err = Some(e);
130 }
131 }
132 }
133
134 let max_attempts = policy.max_attempts;
135 let err = last_err.unwrap_or_else(|| ClusterError::Transport {
136 detail: format!("join exhausted {max_attempts} attempts with no concrete error"),
137 });
138 lifecycle.to_failed(err.to_string());
139 Err(err)
140}
141
142async fn try_join_once(
147 config: &ClusterConfig,
148 catalog: &ClusterCatalog,
149 transport: &NexarTransport,
150 req_template: &JoinRequest,
151) -> Result<ClusterState> {
152 let mut work: std::collections::VecDeque<SocketAddr> =
162 config.seed_nodes.iter().copied().collect();
163 {
164 let mut sorted: Vec<SocketAddr> = work.drain(..).collect();
168 sorted.sort();
169 work.extend(sorted);
170 }
171 let mut visited: HashSet<SocketAddr> = HashSet::new();
172 let mut redirects: u32 = 0;
173 let mut last_err: Option<ClusterError> = None;
174
175 while let Some(addr) = work.pop_front() {
176 if !visited.insert(addr) {
177 continue;
178 }
179
180 let rpc = RaftRpc::JoinRequest(req_template.clone());
181 match transport.send_rpc_to_addr(addr, rpc).await {
182 Ok(RaftRpc::JoinResponse(resp)) => {
183 if resp.success {
184 return apply_join_response(config, catalog, transport, &resp);
185 }
186 if let Some(leader) = parse_leader_hint(&resp.error) {
188 if redirects < MAX_REDIRECTS_PER_ATTEMPT && !visited.contains(&leader) {
189 info!(
190 node_id = config.node_id,
191 from = %addr,
192 to = %leader,
193 "following leader redirect"
194 );
195 redirects += 1;
196 work.push_front(leader);
197 continue;
198 }
199 debug!(
200 node_id = config.node_id,
201 from = %addr,
202 leader = %leader,
203 redirects,
204 "redirect cap reached or loop detected; falling through"
205 );
206 }
207 last_err = Some(ClusterError::Transport {
208 detail: format!("join rejected by {addr}: {}", resp.error),
209 });
210 }
211 Ok(other) => {
212 last_err = Some(ClusterError::Transport {
213 detail: format!("unexpected response from {addr}: {other:?}"),
214 });
215 }
216 Err(e) => {
217 debug!(%addr, error = %e, "seed unreachable");
218 last_err = Some(e);
219 }
220 }
221 }
222
223 Err(last_err.unwrap_or_else(|| ClusterError::Transport {
224 detail: "no seed nodes produced a response".into(),
225 }))
226}
227
228fn apply_join_response(
244 config: &ClusterConfig,
245 catalog: &ClusterCatalog,
246 transport: &NexarTransport,
247 resp: &JoinResponse,
248) -> Result<ClusterState> {
249 let mut topology = ClusterTopology::new();
251 for node in &resp.nodes {
252 let state = NodeState::from_u8(node.state).unwrap_or(NodeState::Active);
253 let spki_pin: Option<[u8; 32]> = node.spki_pin.as_deref().and_then(|b| {
254 if b.len() == 32 {
255 let mut arr = [0u8; 32];
256 arr.copy_from_slice(b);
257 Some(arr)
258 } else {
259 None
260 }
261 });
262 let mut info = NodeInfo::new(
263 node.node_id,
264 node.addr.parse().unwrap_or_else(|_| {
265 "0.0.0.0:0"
266 .parse()
267 .expect("invariant: \"0.0.0.0:0\" is a valid SocketAddr literal")
268 }),
269 state,
270 )
271 .with_wire_version(node.wire_version)
272 .with_spiffe_id(node.spiffe_id.clone())
273 .with_spki_pin(spki_pin);
274 info.raft_groups = node.raft_groups.clone();
276 if node.node_id == config.node_id {
277 info.state = NodeState::Active;
278 }
279 topology.add_node(info);
280 }
281
282 let mut group_members = std::collections::HashMap::new();
284 for g in &resp.groups {
285 if g.group_id == SEQUENCER_GROUP_ID {
286 continue;
287 }
288 group_members.insert(
289 g.group_id,
290 GroupInfo {
291 leader: g.leader,
292 members: g.members.clone(),
293 learners: g.learners.clone(),
294 placement: None,
298 },
299 );
300 }
301 let routing = Arc::new(RwLock::new(RoutingTable::from_parts(
304 resp.vshard_to_group.clone(),
305 group_members,
306 )));
307
308 catalog.save_cluster_id(resp.cluster_id)?;
317 catalog.save_topology(&topology)?;
318 catalog.save_routing(&routing.read().unwrap_or_else(|p| p.into_inner()))?;
319 catalog.save_cluster_settings(&ClusterSettings::from_config(config))?;
324
325 let mut multi_raft = MultiRaft::new_with_shared_routing(
331 config.node_id,
332 routing.clone(),
333 config.data_dir.clone(),
334 )
335 .with_election_timeout(config.election_timeout_min, config.election_timeout_max)
336 .with_log_compaction_threshold(config.log_compaction_threshold);
337 for g in &resp.groups {
338 let is_voter = g.members.contains(&config.node_id);
339 let is_learner = g.learners.contains(&config.node_id);
340
341 if is_voter {
342 let peers: Vec<u64> = g
343 .members
344 .iter()
345 .copied()
346 .filter(|&id| id != config.node_id)
347 .collect();
348 multi_raft.add_group(g.group_id, peers)?;
349 } else if is_learner {
350 let voters = g.members.clone();
351 let other_learners: Vec<u64> = g
352 .learners
353 .iter()
354 .copied()
355 .filter(|&id| id != config.node_id)
356 .collect();
357 multi_raft.add_group_as_learner(g.group_id, voters, other_learners)?;
358 }
359 }
360
361 for node in &resp.nodes {
363 if node.node_id != config.node_id
364 && let Ok(addr) = node.addr.parse::<SocketAddr>()
365 {
366 transport.register_peer(node.node_id, addr);
367 }
368 }
369
370 info!(
371 node_id = config.node_id,
372 nodes = topology.node_count(),
373 groups = routing
374 .read()
375 .unwrap_or_else(|p| p.into_inner())
376 .num_groups(),
377 "joined cluster"
378 );
379
380 Ok(ClusterState {
381 topology: Arc::new(RwLock::new(topology)),
382 routing,
383 multi_raft: Arc::new(Mutex::new(multi_raft)),
384 })
385}
386
387#[cfg(test)]
388mod tests {
389 use super::super::bootstrap_fn::bootstrap;
390 use super::super::config::JoinRetryPolicy;
391 use super::super::handle_join::handle_join_request;
392 use super::*;
393 use std::sync::Arc;
394 use std::time::Duration;
395
396 fn temp_catalog() -> (tempfile::TempDir, ClusterCatalog) {
397 let dir = tempfile::tempdir().unwrap();
398 let path = dir.path().join("cluster.redb");
399 let catalog = ClusterCatalog::open(&path).unwrap();
400 (dir, catalog)
401 }
402
403 #[test]
406 fn parse_leader_hint_extracts_valid_addr() {
407 assert_eq!(
408 parse_leader_hint("not leader; retry at 10.0.0.1:9400"),
409 Some("10.0.0.1:9400".parse().unwrap())
410 );
411 assert_eq!(
412 parse_leader_hint("not leader; retry at 127.0.0.1:65535"),
413 Some("127.0.0.1:65535".parse().unwrap())
414 );
415 }
416
417 #[test]
418 fn parse_leader_hint_rejects_unrelated_error() {
419 assert_eq!(
420 parse_leader_hint("node_id 2 already registered with different address 10.0.0.2:9400"),
421 None
422 );
423 assert_eq!(parse_leader_hint(""), None);
424 assert_eq!(
425 parse_leader_hint("conf change commit timeout on group 0"),
426 None
427 );
428 }
429
430 #[test]
431 fn parse_leader_hint_rejects_malformed_addr() {
432 assert_eq!(parse_leader_hint("not leader; retry at notanaddress"), None);
433 assert_eq!(parse_leader_hint("not leader; retry at "), None);
434 assert_eq!(parse_leader_hint("not leader; retry at 10.0.0.1"), None);
435 }
436
437 #[test]
438 fn join_retry_policy_default_schedule() {
439 let policy = JoinRetryPolicy::default();
443 assert_eq!(policy.backoff_for(0), Duration::ZERO);
444 assert_eq!(policy.backoff_for(1), Duration::from_millis(250));
445 assert_eq!(policy.backoff_for(2), Duration::from_millis(500));
446 assert_eq!(policy.backoff_for(3), Duration::from_secs(1));
447 assert_eq!(policy.backoff_for(4), Duration::from_secs(2));
448 assert_eq!(policy.backoff_for(5), Duration::from_secs(4));
449 assert_eq!(policy.backoff_for(6), Duration::from_secs(8));
450 assert_eq!(policy.backoff_for(7), Duration::from_secs(16));
451 assert_eq!(policy.backoff_for(8), Duration::from_secs(32));
452 assert_eq!(policy.backoff_for(9), Duration::ZERO);
454 }
455
456 #[test]
457 fn join_retry_policy_test_schedule_is_subsecond() {
458 let policy = JoinRetryPolicy {
461 max_attempts: 8,
462 max_backoff_secs: 2,
463 };
464 let total: Duration = (0..=policy.max_attempts)
467 .map(|a| policy.backoff_for(a))
468 .sum();
469 assert!(
470 total < Duration::from_secs(5),
471 "test schedule too slow: {total:?}"
472 );
473 assert_eq!(policy.backoff_for(8), Duration::from_secs(2));
475 }
476
477 #[tokio::test]
480 async fn full_bootstrap_join_flow() {
481 use crate::transport::credentials::TransportCredentials;
483 let t1 = Arc::new(
484 NexarTransport::new(
485 1,
486 "127.0.0.1:0".parse().unwrap(),
487 TransportCredentials::Insecure,
488 )
489 .unwrap(),
490 );
491 let t2 = Arc::new(
492 NexarTransport::new(
493 2,
494 "127.0.0.1:0".parse().unwrap(),
495 TransportCredentials::Insecure,
496 )
497 .unwrap(),
498 );
499
500 let (_dir1, catalog1) = temp_catalog();
501 let (_dir2, catalog2) = temp_catalog();
502
503 let addr1 = t1.local_addr();
504 let addr2 = t2.local_addr();
505
506 let config1 = ClusterConfig {
507 node_id: 1,
508 listen_addr: addr1,
509 seed_nodes: vec![addr1],
510 num_groups: 2,
511 replication_factor: 1,
512 data_dir: _dir1.path().to_path_buf(),
513 force_bootstrap: false,
514 join_retry: Default::default(),
515 swim_udp_addr: None,
516 election_timeout_min: Duration::from_millis(150),
517 election_timeout_max: Duration::from_millis(300),
518 install_snapshot_chunk_bytes: 4 * 1024 * 1024,
519 orphan_partial_max_age_secs: 300,
520 log_compaction_threshold: None,
521 };
522 let state1 = bootstrap(&config1, &catalog1, None).unwrap();
523
524 let topology1 = state1.topology.clone();
527 let routing1 = state1.routing.clone();
528
529 struct JoinHandler {
530 topology: std::sync::Arc<std::sync::RwLock<ClusterTopology>>,
531 routing: std::sync::Arc<std::sync::RwLock<RoutingTable>>,
532 }
533
534 impl crate::transport::RaftRpcHandler for JoinHandler {
535 async fn handle_rpc(&self, rpc: RaftRpc) -> Result<RaftRpc> {
536 match rpc {
537 RaftRpc::JoinRequest(req) => {
538 let mut topo = self.topology.write().unwrap();
539 let routing = self.routing.read().unwrap();
540 let resp = handle_join_request(&req, &mut topo, &routing, 99);
541 Ok(RaftRpc::JoinResponse(resp))
542 }
543 other => Err(ClusterError::Transport {
544 detail: format!("unexpected: {other:?}"),
545 }),
546 }
547 }
548
549 async fn handle_rpc_streaming(
550 &self,
551 _req: crate::rpc_codec::ExecuteRequest,
552 _sink: impl crate::forward::ChunkSink,
553 ) -> Option<crate::rpc_codec::TypedClusterError> {
554 Some(crate::rpc_codec::TypedClusterError::Internal {
555 code: 0,
556 message: "streaming not supported by JoinHandler".into(),
557 })
558 }
559
560 async fn on_shuffle_request(&self, _req: crate::rpc_codec::ShufflePushRequest) {}
561
562 async fn on_timeout_now(&self, _req: nodedb_raft::TimeoutNowRequest) {}
563
564 async fn on_shuffle_chunk(
565 &self,
566 _shuffle_id: u64,
567 _part: u32,
568 _side: u8,
569 _payload: Vec<u8>,
570 ) -> Result<()> {
571 Ok(())
572 }
573
574 async fn on_shuffle_end(
575 &self,
576 _shuffle_id: u64,
577 _part: u32,
578 _side: u8,
579 _error: Option<crate::rpc_codec::TypedClusterError>,
580 ) {
581 }
582
583 async fn on_shuffle_produce(
584 &self,
585 _req: crate::rpc_codec::ShuffleProduceRequest,
586 ) -> crate::rpc_codec::ShuffleProduceResponse {
587 crate::rpc_codec::ShuffleProduceResponse {
588 error: None,
589 read_version_lsn: 0,
590 }
591 }
592
593 async fn on_shuffle_consume(
594 &self,
595 _req: crate::rpc_codec::ShuffleConsumeRequest,
596 ) -> crate::rpc_codec::ShuffleConsumeResponse {
597 crate::rpc_codec::ShuffleConsumeResponse {
598 rows: Vec::new(),
599 error: None,
600 }
601 }
602
603 async fn on_shuffle_aggregate(
604 &self,
605 _req: crate::rpc_codec::ShuffleAggregateConsumeRequest,
606 ) -> crate::rpc_codec::ShuffleAggregateConsumeResponse {
607 crate::rpc_codec::ShuffleAggregateConsumeResponse {
608 rows: Vec::new(),
609 error: None,
610 }
611 }
612
613 async fn on_assign_surrogate(
614 &self,
615 _req: crate::rpc_codec::AssignSurrogateRequest,
616 ) -> crate::rpc_codec::AssignSurrogateResponse {
617 crate::rpc_codec::AssignSurrogateResponse {
618 surrogate: 0,
619 error: None,
620 }
621 }
622
623 async fn on_submit_calvin_txn(
624 &self,
625 _req: crate::rpc_codec::SubmitCalvinTxnRequest,
626 ) -> crate::rpc_codec::SubmitCalvinTxnResponse {
627 crate::rpc_codec::SubmitCalvinTxnResponse {
628 error: None,
629 payload_bytes: None,
630 }
631 }
632
633 async fn on_submit_calvin_inbox(
634 &self,
635 _req: crate::rpc_codec::SubmitCalvinInboxRequest,
636 ) -> crate::rpc_codec::SubmitCalvinInboxResponse {
637 crate::rpc_codec::SubmitCalvinInboxResponse {
638 inbox_seq: 0,
639 epoch: 0,
640 position: 0,
641 participants: 0,
642 error: None,
643 }
644 }
645
646 async fn on_reserve_read(
647 &self,
648 _req: crate::rpc_codec::ReserveReadRequest,
649 ) -> crate::rpc_codec::ReserveReadResponse {
650 crate::rpc_codec::ReserveReadResponse {
651 owner_bytes: None,
652 error: None,
653 }
654 }
655
656 async fn on_release_reservation(
657 &self,
658 _req: crate::rpc_codec::ReleaseReservationRequest,
659 ) -> crate::rpc_codec::ReleaseReservationResponse {
660 crate::rpc_codec::ReleaseReservationResponse { error: None }
661 }
662 }
663
664 let handler = Arc::new(JoinHandler {
665 topology: topology1.clone(),
666 routing: routing1.clone(),
667 });
668
669 let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
670 let t1c = t1.clone();
671 tokio::spawn(async move {
672 t1c.serve(handler, shutdown_rx).await.unwrap();
673 });
674
675 tokio::time::sleep(Duration::from_millis(30)).await;
676
677 let config2 = ClusterConfig {
678 node_id: 2,
679 listen_addr: addr2,
680 seed_nodes: vec![addr1],
681 num_groups: 2,
682 replication_factor: 1,
683 data_dir: _dir2.path().to_path_buf(),
684 force_bootstrap: false,
685 join_retry: Default::default(),
686 swim_udp_addr: None,
687 election_timeout_min: Duration::from_millis(150),
688 election_timeout_max: Duration::from_millis(300),
689 install_snapshot_chunk_bytes: 4 * 1024 * 1024,
690 orphan_partial_max_age_secs: 300,
691 log_compaction_threshold: None,
692 };
693
694 let lifecycle = ClusterLifecycleTracker::new();
695 let state2 = join(&config2, &catalog2, &t2, &lifecycle).await.unwrap();
696 assert!(matches!(
699 lifecycle.current(),
700 crate::lifecycle_state::ClusterLifecycleState::Joining { .. }
701 ));
702
703 assert_eq!(state2.topology.read().unwrap().node_count(), 2);
704 assert_eq!(state2.routing.read().unwrap().num_groups(), 3);
709
710 assert!(catalog2.load_topology().unwrap().is_some());
713 assert!(catalog2.load_routing().unwrap().is_some());
714
715 let topo1 = topology1.read().unwrap();
717 assert_eq!(topo1.node_count(), 2);
718 assert!(topo1.contains(2));
719
720 shutdown_tx.send(true).unwrap();
721 }
722}