Skip to main content

nodedb_cluster/bootstrap/
join.rs

1// SPDX-License-Identifier: BUSL-1.1
2
3//! Join path: contact seeds, receive full cluster state, apply locally.
4//!
5//! The join loop is deliberately robust against two realistic cluster
6//! startup failure modes:
7//!
8//! 1. **Slow start**: the designated bootstrapper has not yet
9//!    completed its first Raft election when this node first calls
10//!    `join()`. Every seed may return "unreachable" or "not leader"
11//!    for a brief window. We retry the whole loop with exponential
12//!    backoff so the join eventually succeeds without operator
13//!    intervention.
14//!
15//! 2. **Leader redirect**: the seed we contacted is alive but isn't
16//!    the group-0 leader. It returns
17//!    `JoinResponse { success: false, error: "not leader; retry at <addr>" }`
18//!    and we follow the hint up to a small number of hops before
19//!    falling through to the next seed. The string format is the
20//!    contract set by `raft_loop::join::join_flow` — keep this parser
21//!    in lock-step with that producer.
22
23use 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
41/// Maximum number of leader-redirect hops inside a single join
42/// attempt. The redirect chain starts at whichever seed we first
43/// contact; each hop costs a round-trip, so keep this small.
44const MAX_REDIRECTS_PER_ATTEMPT: u32 = 3;
45
46/// Parse a `JoinResponse::error` string as a leader redirect hint.
47///
48/// The prefix is defined as a shared constant in `rpc_codec`
49/// (`LEADER_REDIRECT_PREFIX`) so the producer side
50/// (`raft_loop::join::join_flow`) and this consumer can never
51/// drift. Any other kind of rejection (collision, parse error,
52/// catalog persist failure, commit timeout, etc.) is treated as
53/// a hard failure that bubbles through the normal error path.
54///
55/// Returns `None` for any string that doesn't start with the
56/// expected prefix, or where the address portion does not parse
57/// as a valid `SocketAddr`.
58pub(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
64/// Join an existing cluster by contacting seed nodes.
65///
66/// The loop has two layers:
67///
68/// - **Outer**: retry passes with exponential backoff per
69///   `config.join_retry`. Handles the "bootstrapper not up yet"
70///   startup race.
71/// - **Inner**: walk the seed list plus any leader-redirect hops for
72///   this attempt. A successful `JoinResponse` short-circuits the
73///   whole function; failures on one candidate fall through to the
74///   next.
75pub(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
142/// One pass over the seed list plus up to `MAX_REDIRECTS_PER_ATTEMPT`
143/// leader-redirect hops. Returns `Ok(state)` on the first successful
144/// `JoinResponse` or an error describing the last failure in this
145/// attempt.
146async fn try_join_once(
147    config: &ClusterConfig,
148    catalog: &ClusterCatalog,
149    transport: &NexarTransport,
150    req_template: &JoinRequest,
151) -> Result<ClusterState> {
152    // Work list: try seeds in sorted order so the lexicographically
153    // smallest address — the designated bootstrapper under the
154    // single-elected-bootstrapper rule — is contacted first. This is
155    // critical during the initial 5-node race: every other seed points
156    // at a node that is itself still joining, so asking them first
157    // eats the full RPC timeout per non-bootstrapper before we reach
158    // the one peer that can actually answer. `HashSet` deduplicates
159    // so a redirect loop can't consume all attempts against the same
160    // address.
161    let mut work: std::collections::VecDeque<SocketAddr> =
162        config.seed_nodes.iter().copied().collect();
163    {
164        // Sort so the designated bootstrapper surfaces first. Leader
165        // redirects get prepended with push_front below, keeping the
166        // "most likely to answer" candidate at the head.
167        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                // Rejected — is it a leader redirect we can follow?
187                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
228/// Apply a JoinResponse: reconstruct topology, routing, and MultiRaft
229/// from wire data.
230///
231/// Order of operations is load-bearing for crash safety:
232///
233/// 1. Reconstruct the `ClusterTopology` and `RoutingTable` in memory.
234/// 2. Persist topology + routing to the catalog **first**, before any
235///    on-disk side effects. If we crash after this step, the next
236///    boot sees `catalog.is_bootstrapped() == true` and takes the
237///    `restart()` path, which reconstructs cleanly from the catalog.
238/// 3. Create the `MultiRaft` and add groups. `add_group` opens redb
239///    files on disk per group; these are idempotent per group id, so
240///    a crash mid-way leaves a recoverable state.
241/// 4. Register every peer address in the transport before returning
242///    so the first outgoing AppendEntries has a known destination.
243fn apply_join_response(
244    config: &ClusterConfig,
245    catalog: &ClusterCatalog,
246    transport: &NexarTransport,
247    resp: &JoinResponse,
248) -> Result<ClusterState> {
249    // 1. Reconstruct topology.
250    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        // Override raft_groups from wire data (NodeInfo::new starts empty).
275        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    // 1. Reconstruct routing table.
283    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                // Join response carries no placement; it converges via
295                // metadata-group replication after join (effective_placement
296                // falls back to members until then).
297                placement: None,
298            },
299        );
300    }
301    // ONE shared routing handle: MultiRaft and ClusterState read/write the
302    // same table so committed Raft conf-changes converge the data-plane view.
303    let routing = Arc::new(RwLock::new(RoutingTable::from_parts(
304        resp.vshard_to_group.clone(),
305        group_members,
306    )));
307
308    // 2. Persist to catalog before any on-disk Raft side effects.
309    //    Cluster id is written first so `is_bootstrapped()` returns
310    //    `true` on any subsequent boot — without this, a joined node
311    //    that restarts would re-enter the bootstrap/join path
312    //    instead of taking `restart()`. Zero is a valid marker: the
313    //    joining node's catalog now carries `Some(0)` for
314    //    `load_cluster_id`, which is enough for the restart
315    //    dispatcher.
316    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    // Persist cluster settings on join (mirroring the bootstrap path) so the
320    // node can load its replication factor at start_raft and on every later
321    // restart. Failing to persist settings fails the join: without them the
322    // node cannot start correctly.
323    catalog.save_cluster_settings(&ClusterSettings::from_config(config))?;
324
325    // 3. Create MultiRaft — join any group that includes this node,
326    //    either as a voter (group members) or as a learner (group
327    //    learners). A learner-started group boots in the `Learner`
328    //    role and will not run an election until a subsequent
329    //    `PromoteLearner` conf change is applied.
330    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    // 4. Register peer addresses in the transport.
362    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    // ── Pure-function tests ───────────────────────────────────────
404
405    #[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        // Production default: 8 attempts, ceiling 32 s. Each delay is
440        // `32 s >> (8 - attempt)`, so the schedule halves down from
441        // the ceiling toward the first attempt.
442        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        // Out-of-range attempt → no backoff.
453        assert_eq!(policy.backoff_for(9), Duration::ZERO);
454    }
455
456    #[test]
457    fn join_retry_policy_test_schedule_is_subsecond() {
458        // A typical test config: still 8 attempts, but a 2 s ceiling
459        // produces a sub-5-second total backoff window.
460        let policy = JoinRetryPolicy {
461            max_attempts: 8,
462            max_backoff_secs: 2,
463        };
464        // First few attempts are floored to 1 ms (they round down
465        // below a millisecond in raw shifts).
466        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        // Final attempt sleeps the full ceiling.
474        assert_eq!(policy.backoff_for(8), Duration::from_secs(2));
475    }
476
477    // ── End-to-end bootstrap + join flow over QUIC ────────────────
478
479    #[tokio::test]
480    async fn full_bootstrap_join_flow() {
481        // Node 1 bootstraps, Node 2 joins via QUIC.
482        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        // state1.topology and state1.routing are Arc<RwLock<T>> after the
525        // ClusterState refactor.
526        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        // Lifecycle should have walked Joining{0} → [settled before
697        // `to_ready` which is the caller's responsibility].
698        assert!(matches!(
699            lifecycle.current(),
700            crate::lifecycle_state::ClusterLifecycleState::Joining { .. }
701        ));
702
703        assert_eq!(state2.topology.read().unwrap().node_count(), 2);
704        // 1 data group + metadata group = 2 in old config; with metadata-skip
705        // routing the count includes group 0 → 1 data + 1 metadata = 2 still
706        // when num_groups=1. For num_groups=2 it's 3 total. The test config
707        // bootstraps with 2 data groups (see config above), so total = 3.
708        assert_eq!(state2.routing.read().unwrap().num_groups(), 3);
709
710        // Verify node 2's state was persisted (reorder check: catalog
711        // is saved before MultiRaft files are touched).
712        assert!(catalog2.load_topology().unwrap().is_some());
713        assert!(catalog2.load_routing().unwrap().is_some());
714
715        // Verify node 1's topology was updated.
716        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}