Skip to main content

datum_cluster/
node.rs

1use std::{
2    collections::BTreeMap,
3    net::{IpAddr, SocketAddr},
4    sync::{Arc, Mutex},
5    time::{Duration, SystemTime},
6};
7
8use datum::{Signal, Subscription, SubscriptionOverflow};
9use tokio::{
10    net::UdpSocket,
11    sync::{mpsc, oneshot},
12    task::JoinHandle,
13};
14
15use crate::{
16    ClusterConfig, ClusterError, ClusterResult, ClusterState, DowningProvider, Member, MemberEvent,
17    MemberEventKind, MemberState, TimeoutDowning,
18    foca_driver::{
19        FocaEvent, FocaIdentity, GossipCommand, WireMemberAnnouncement, incarnation_seed,
20        run_gossip_task,
21    },
22};
23
24pub(crate) enum ActorCommand {
25    Foca(Box<FocaEvent>),
26    Leave {
27        reply: oneshot::Sender<ClusterResult<()>>,
28    },
29    Abort {
30        reply: oneshot::Sender<ClusterResult<()>>,
31    },
32}
33
34/// Running Datum cluster membership node.
35pub struct ClusterNode {
36    config: ClusterConfig,
37    local_addr: SocketAddr,
38    advertise_addr: SocketAddr,
39    state: Signal<ClusterState>,
40    events: Subscription<MemberEvent>,
41    actor: mpsc::UnboundedSender<ActorCommand>,
42    tasks: Arc<Mutex<Vec<JoinHandle<()>>>>,
43}
44
45impl ClusterNode {
46    /// Start a cluster node with [`TimeoutDowning`] using
47    /// [`ClusterConfig::downing_timeout`].
48    pub async fn start(config: ClusterConfig) -> ClusterResult<Self> {
49        let downing = Arc::new(TimeoutDowning::new(config.downing_timeout));
50        Self::start_with_downing(config, downing).await
51    }
52
53    /// Start a cluster node with a custom downing provider.
54    pub async fn start_with_downing(
55        config: ClusterConfig,
56        downing: Arc<dyn DowningProvider>,
57    ) -> ClusterResult<Self> {
58        validate_config(&config)?;
59        let roles = config.normalized_roles();
60        let socket = UdpSocket::bind(config.bind_addr).await?;
61        let local_addr = socket.local_addr()?;
62        let advertise_addr = normalize_advertise_addr(config.advertise_addr, local_addr);
63        let incarnation = incarnation_seed();
64        let identity = FocaIdentity {
65            node_id: config.node_id.trim().to_owned(),
66            address: advertise_addr,
67            agent_addr: config.agent_addr,
68            roles: roles.clone(),
69            incarnation,
70        };
71
72        let now = SystemTime::now();
73        let local_member = Member::new(
74            identity.node_id.clone(),
75            advertise_addr,
76            identity.agent_addr,
77            roles,
78            MemberState::Joining,
79            incarnation,
80            now,
81        );
82        let state = Signal::new(ClusterState::new(local_member.clone()))?;
83        let events = Subscription::new(
84            MemberEvent::new(0, MemberEventKind::Initialized, local_member.clone(), now),
85            config.event_buffer.max(1),
86            SubscriptionOverflow::Backpressure,
87        )?;
88
89        let (actor_tx, actor_rx) = mpsc::unbounded_channel();
90        let (gossip_tx, gossip_rx) = mpsc::channel(128);
91        let (event_tx, mut event_rx) = mpsc::unbounded_channel();
92        let tasks = Arc::new(Mutex::new(Vec::new()));
93
94        // The publisher task calls the potentially-parking `Subscription::set`
95        // from a dedicated blocking thread so a permanently-stuck events
96        // subscriber can never park a core runtime worker.
97        //
98        // The actor ↔ publisher channel is deliberately unbounded: the actor
99        // must never block on event publication.  A permanently-stuck subscriber
100        // costs memory proportional to event volume — that is the accepted cost;
101        // wedging the control plane is not.
102        let events_clone = events.clone();
103        let publisher_task = tokio::task::spawn_blocking(move || {
104            while let Some(event) = event_rx.blocking_recv() {
105                let _ = events_clone.set(event);
106            }
107        });
108
109        let actor_state = MembershipActor {
110            config: config.clone(),
111            state: state.clone(),
112            members: ClusterState::new(local_member.clone()).members,
113            local_node_id: local_member.node_id.clone(),
114            local_incarnation: local_member.incarnation,
115            next_sequence: 1,
116            downing,
117            gossip: gossip_tx,
118            event_tx,
119        };
120
121        let actor_task = tokio::spawn(run_membership_actor(actor_state, actor_rx));
122        let gossip_task = tokio::spawn(run_gossip_task(
123            socket,
124            identity,
125            config.clone(),
126            actor_tx.clone(),
127            gossip_rx,
128        ));
129
130        {
131            let mut locked = tasks.lock().expect("cluster task list poisoned");
132            locked.push(actor_task);
133            locked.push(gossip_task);
134            locked.push(publisher_task);
135        }
136
137        Ok(Self {
138            config,
139            local_addr,
140            advertise_addr,
141            state,
142            events,
143            actor: actor_tx,
144            tasks,
145        })
146    }
147
148    /// Local UDP address bound by the node.
149    #[must_use]
150    pub const fn local_addr(&self) -> SocketAddr {
151        self.local_addr
152    }
153
154    /// Address advertised to peers.
155    #[must_use]
156    pub const fn advertise_addr(&self) -> SocketAddr {
157        self.advertise_addr
158    }
159
160    /// Node id.
161    #[must_use]
162    pub fn node_id(&self) -> &str {
163        &self.config.node_id
164    }
165
166    /// Current-state feed.
167    #[must_use]
168    pub fn state(&self) -> Signal<ClusterState> {
169        self.state.clone()
170    }
171
172    /// Lossless membership event feed.
173    #[must_use]
174    pub fn events(&self) -> Subscription<MemberEvent> {
175        self.events.clone()
176    }
177
178    /// Current immutable membership snapshot.
179    #[must_use]
180    pub fn current_state(&self) -> Arc<ClusterState> {
181        self.state.get()
182    }
183
184    /// Gracefully leave the cluster.
185    pub async fn leave(&self) -> ClusterResult<()> {
186        let (reply, receiver) = oneshot::channel();
187        self.actor
188            .send(ActorCommand::Leave { reply })
189            .map_err(|_| ClusterError::Stopped)?;
190        tokio::time::timeout(self.config.request_timeout, receiver)
191            .await
192            .map_err(|_| ClusterError::Timeout(self.config.request_timeout))?
193            .map_err(|_| ClusterError::Stopped)?
194    }
195
196    /// Stop this node without sending a graceful leave. This is useful for
197    /// crash/failure tests.
198    pub async fn abort(&self) -> ClusterResult<()> {
199        let (reply, receiver) = oneshot::channel();
200        self.actor
201            .send(ActorCommand::Abort { reply })
202            .map_err(|_| ClusterError::Stopped)?;
203        tokio::time::timeout(self.config.request_timeout, receiver)
204            .await
205            .map_err(|_| ClusterError::Timeout(self.config.request_timeout))?
206            .map_err(|_| ClusterError::Stopped)?
207    }
208
209    /// Alias for [`ClusterNode::abort`].
210    pub async fn shutdown(&self) -> ClusterResult<()> {
211        self.abort().await
212    }
213}
214
215impl Drop for ClusterNode {
216    fn drop(&mut self) {
217        if let Ok(mut tasks) = self.tasks.lock() {
218            for task in tasks.drain(..) {
219                task.abort();
220            }
221        }
222    }
223}
224
225struct MembershipActor {
226    config: ClusterConfig,
227    state: Signal<ClusterState>,
228    members: BTreeMap<String, Member>,
229    local_node_id: String,
230    local_incarnation: u64,
231    next_sequence: u64,
232    downing: Arc<dyn DowningProvider>,
233    gossip: mpsc::Sender<GossipCommand>,
234    event_tx: mpsc::UnboundedSender<MemberEvent>,
235}
236
237async fn run_membership_actor(
238    mut actor: MembershipActor,
239    mut receiver: mpsc::UnboundedReceiver<ActorCommand>,
240) {
241    actor.mark_local_up();
242    let mut downing_tick = tokio::time::interval(downing_interval(actor.config.downing_timeout));
243
244    loop {
245        tokio::select! {
246            _ = downing_tick.tick() => {
247                actor.apply_downing();
248            }
249            Some(command) = receiver.recv() => {
250                match command {
251                    ActorCommand::Foca(event) => actor.apply_foca_event(*event),
252                    ActorCommand::Leave { reply } => {
253                        let result = actor.leave().await;
254                        let _ = reply.send(result);
255                    }
256                    ActorCommand::Abort { reply } => {
257                        let result = actor.abort().await;
258                        let _ = reply.send(result);
259                        return;
260                    }
261                }
262            }
263            else => return,
264        }
265    }
266}
267
268impl MembershipActor {
269    fn mark_local_up(&mut self) {
270        self.transition_existing(
271            &self.local_node_id.clone(),
272            MemberState::Up,
273            MemberEventKind::MemberUp,
274        );
275    }
276
277    async fn leave(&mut self) -> ClusterResult<()> {
278        if self
279            .members
280            .get(&self.local_node_id)
281            .map(|member| member.state == MemberState::Removed)
282            .unwrap_or(false)
283        {
284            return Ok(());
285        }
286
287        self.transition_existing(
288            &self.local_node_id.clone(),
289            MemberState::Leaving,
290            MemberEventKind::MemberLeaving,
291        );
292
293        let (reply, receiver) = oneshot::channel();
294        self.gossip
295            .send(GossipCommand::Leave { reply })
296            .await
297            .map_err(|_| ClusterError::Stopped)?;
298        let result = receiver.await.map_err(|_| ClusterError::Stopped)?;
299
300        self.transition_existing(
301            &self.local_node_id.clone(),
302            MemberState::Exiting,
303            MemberEventKind::MemberExiting,
304        );
305        self.transition_existing(
306            &self.local_node_id.clone(),
307            MemberState::Removed,
308            MemberEventKind::MemberRemoved,
309        );
310        result
311    }
312
313    async fn abort(&mut self) -> ClusterResult<()> {
314        let (reply, receiver) = oneshot::channel();
315        self.gossip
316            .send(GossipCommand::Shutdown { reply })
317            .await
318            .map_err(|_| ClusterError::Stopped)?;
319        receiver.await.map_err(|_| ClusterError::Stopped)?
320    }
321
322    fn apply_foca_event(&mut self, event: FocaEvent) {
323        match event {
324            FocaEvent::MemberUp(identity) | FocaEvent::Rejoin(identity) => {
325                self.apply_member_up(identity);
326            }
327            FocaEvent::MemberDown(identity) => self.apply_member_down(identity),
328            FocaEvent::Rename(_before, after) => self.apply_member_up(after),
329            FocaEvent::ProbeFailed(identity) => self.apply_probe_failed(identity),
330            FocaEvent::GracefulState(announcement) => self.apply_graceful_state(announcement),
331            FocaEvent::GossipError(_error) => {}
332            FocaEvent::Active | FocaEvent::Idle | FocaEvent::Defunct => {}
333        }
334    }
335
336    fn apply_member_up(&mut self, identity: FocaIdentity) {
337        if self.is_local_identity(&identity) {
338            return;
339        }
340
341        let now = SystemTime::now();
342        let Some(existing) = self.members.get(&identity.node_id).cloned() else {
343            let joining = member_from_identity(&identity, MemberState::Joining, now);
344            self.publish_member(joining, MemberEventKind::MemberJoining);
345            let up = member_from_identity(&identity, MemberState::Up, now);
346            self.publish_member(up, MemberEventKind::MemberUp);
347            return;
348        };
349
350        if identity.incarnation < existing.incarnation {
351            return;
352        }
353
354        let mut member = member_from_identity(&identity, MemberState::Up, now);
355        let rejoined = identity.incarnation > existing.incarnation
356            && matches!(existing.state, MemberState::Down | MemberState::Removed);
357        if rejoined {
358            self.publish_member(member, MemberEventKind::MemberRejoined);
359            return;
360        }
361
362        member.state = if matches!(existing.state, MemberState::Leaving | MemberState::Exiting) {
363            existing.state
364        } else {
365            MemberState::Up
366        };
367        let changed_metadata = existing.address != member.address
368            || existing.agent_addr != member.agent_addr
369            || existing.roles != member.roles
370            || existing.incarnation != member.incarnation;
371        let became_reachable = existing.unreachable;
372        if became_reachable {
373            member.state = MemberState::Up;
374            self.publish_member(member, MemberEventKind::MemberReachable);
375        } else if changed_metadata {
376            self.publish_member(member, MemberEventKind::MemberUpdated);
377        } else if existing.state != MemberState::Up {
378            self.publish_member(member, MemberEventKind::MemberUp);
379        }
380    }
381
382    fn apply_member_down(&mut self, identity: FocaIdentity) {
383        if self.is_local_identity(&identity) {
384            return;
385        }
386        let Some(existing) = self.members.get(&identity.node_id).cloned() else {
387            return;
388        };
389        if identity.incarnation < existing.incarnation {
390            return;
391        }
392
393        match existing.state {
394            MemberState::Leaving | MemberState::Exiting => {
395                self.transition_existing(
396                    &identity.node_id,
397                    MemberState::Exiting,
398                    MemberEventKind::MemberExiting,
399                );
400                self.transition_existing(
401                    &identity.node_id,
402                    MemberState::Removed,
403                    MemberEventKind::MemberRemoved,
404                );
405            }
406            MemberState::Removed => {}
407            MemberState::Down => {}
408            MemberState::Joining | MemberState::Up => {
409                let mut member = existing;
410                member.state = MemberState::Down;
411                member.unreachable = true;
412                member.unreachable_since = member.unreachable_since.or(Some(SystemTime::now()));
413                member.seen_at = SystemTime::now();
414                self.publish_member(member, MemberEventKind::MemberDown);
415            }
416        }
417    }
418
419    fn apply_probe_failed(&mut self, identity: FocaIdentity) {
420        if self.is_local_identity(&identity) {
421            return;
422        }
423        let Some(mut member) = self.members.get(&identity.node_id).cloned() else {
424            return;
425        };
426        if identity.incarnation < member.incarnation
427            || matches!(member.state, MemberState::Down | MemberState::Removed)
428        {
429            return;
430        }
431        if !member.unreachable {
432            let now = SystemTime::now();
433            member.unreachable = true;
434            member.unreachable_since = Some(now);
435            member.seen_at = now;
436            self.publish_member(member, MemberEventKind::MemberUnreachable);
437        }
438    }
439
440    fn apply_graceful_state(&mut self, announcement: WireMemberAnnouncement) {
441        if announcement.node_id == self.local_node_id
442            && announcement.incarnation == self.local_incarnation
443        {
444            return;
445        }
446        let now = SystemTime::now();
447        let state = announcement.member_state();
448        if let Some(existing) = self.members.get(&announcement.node_id)
449            && announcement.incarnation < existing.incarnation
450        {
451            return;
452        }
453        if let Some(existing) = self.members.get(&announcement.node_id)
454            && announcement.incarnation == existing.incarnation
455            && !matches!(existing.state, MemberState::Joining | MemberState::Up)
456        {
457            return;
458        }
459        let mut member = Member::new(
460            announcement.node_id,
461            announcement.address,
462            announcement.agent_addr,
463            announcement.roles,
464            state,
465            announcement.incarnation,
466            now,
467        );
468        member.unreachable = false;
469        member.unreachable_since = None;
470        self.publish_member(member, MemberEventKind::MemberLeaving);
471    }
472
473    fn apply_downing(&mut self) {
474        let now = SystemTime::now();
475        let to_down = self
476            .members
477            .values()
478            .filter(|member| member.node_id != self.local_node_id)
479            .filter(|member| member.unreachable)
480            .filter(|member| matches!(member.state, MemberState::Joining | MemberState::Up))
481            .filter_map(|member| {
482                let since = member.unreachable_since?;
483                let unreachable_for = now.duration_since(since).unwrap_or_default();
484                self.downing
485                    .should_down(member, unreachable_for, now)
486                    .then(|| member.node_id.clone())
487            })
488            .collect::<Vec<_>>();
489
490        for node_id in to_down {
491            if let Some(mut member) = self.members.get(&node_id).cloned() {
492                member.state = MemberState::Down;
493                member.seen_at = now;
494                self.publish_member(member, MemberEventKind::MemberDown);
495            }
496        }
497    }
498
499    fn transition_existing(&mut self, node_id: &str, state: MemberState, kind: MemberEventKind) {
500        let Some(mut member) = self.members.get(node_id).cloned() else {
501            return;
502        };
503        if member.state == state {
504            return;
505        }
506        member.state = state;
507        member.seen_at = SystemTime::now();
508        if matches!(
509            state,
510            MemberState::Up | MemberState::Leaving | MemberState::Removed
511        ) {
512            member.unreachable = false;
513            member.unreachable_since = None;
514        }
515        self.publish_member(member, kind);
516    }
517
518    fn publish_member(&mut self, member: Member, kind: MemberEventKind) {
519        let now = SystemTime::now();
520        self.members.insert(member.node_id.clone(), member.clone());
521        let state = ClusterState {
522            self_node: self.local_node_id.clone(),
523            members: self.members.clone(),
524        };
525        let event = MemberEvent::new(self.next_sequence, kind, member, now);
526        self.next_sequence = self.next_sequence.wrapping_add(1).max(1);
527        let _ = self.state.set(state);
528        let _ = self.event_tx.send(event);
529    }
530
531    fn is_local_identity(&self, identity: &FocaIdentity) -> bool {
532        identity.node_id == self.local_node_id && identity.incarnation == self.local_incarnation
533    }
534}
535
536fn validate_config(config: &ClusterConfig) -> ClusterResult<()> {
537    if config.node_id.trim().is_empty() {
538        return Err(ClusterError::InvalidConfig(
539            "node_id must not be empty".to_owned(),
540        ));
541    }
542    if config.gossip_interval.is_zero() {
543        return Err(ClusterError::InvalidConfig(
544            "gossip_interval must be non-zero".to_owned(),
545        ));
546    }
547    if config.probe_timeout.is_zero() || config.probe_timeout >= config.gossip_interval {
548        return Err(ClusterError::InvalidConfig(
549            "probe_timeout must be non-zero and smaller than gossip_interval".to_owned(),
550        ));
551    }
552    if config.downing_timeout.is_zero() {
553        return Err(ClusterError::InvalidConfig(
554            "downing_timeout must be non-zero".to_owned(),
555        ));
556    }
557    // These are also validated inside the gossip task's `foca_config`, but a
558    // failure there is reported as a `FocaEvent::GossipError` that the
559    // membership actor drops, leaving a node that started successfully with no
560    // running gossip plane. Validate up front so `start` fails fast instead.
561    if config.suspect_timeout.is_zero() {
562        return Err(ClusterError::InvalidConfig(
563            "suspect_timeout must be non-zero".to_owned(),
564        ));
565    }
566    if config.max_packet_size == 0 {
567        return Err(ClusterError::InvalidConfig(
568            "max_packet_size must be non-zero".to_owned(),
569        ));
570    }
571    Ok(())
572}
573
574fn normalize_advertise_addr(advertise: SocketAddr, local: SocketAddr) -> SocketAddr {
575    let ip = if advertise.ip().is_unspecified() {
576        local.ip()
577    } else {
578        advertise.ip()
579    };
580    let port = if advertise.port() == 0 {
581        local.port()
582    } else {
583        advertise.port()
584    };
585    SocketAddr::new(normalize_unspecified_loopback(ip, local.ip()), port)
586}
587
588fn normalize_unspecified_loopback(ip: IpAddr, local_ip: IpAddr) -> IpAddr {
589    if ip.is_unspecified() { local_ip } else { ip }
590}
591
592fn member_from_identity(identity: &FocaIdentity, state: MemberState, now: SystemTime) -> Member {
593    Member::new(
594        identity.node_id.clone(),
595        identity.address,
596        identity.agent_addr,
597        identity.roles.clone(),
598        state,
599        identity.incarnation,
600        now,
601    )
602}
603
604fn downing_interval(timeout: Duration) -> Duration {
605    (timeout / 4).clamp(Duration::from_millis(10), Duration::from_millis(100))
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use crate::{
612        DowningProvider,
613        foca_driver::{BroadcastKey, WireMemberState},
614    };
615    use std::sync::Arc;
616
617    struct NoopDowning;
618    impl DowningProvider for NoopDowning {
619        fn name(&self) -> &'static str {
620            "noop"
621        }
622
623        fn should_down(
624            &self,
625            _member: &Member,
626            _unreachable_for: Duration,
627            _now: SystemTime,
628        ) -> bool {
629            false
630        }
631    }
632
633    fn make_announcement(node_id: &str, incarnation: u64) -> WireMemberAnnouncement {
634        WireMemberAnnouncement {
635            key: BroadcastKey::new(node_id.to_owned(), incarnation, 1),
636            node_id: node_id.to_owned(),
637            address: "127.0.0.1:0".parse().unwrap(),
638            agent_addr: None,
639            roles: vec![],
640            state: WireMemberState::Leaving,
641            incarnation,
642        }
643    }
644
645    fn make_actor() -> MembershipActor {
646        let config = ClusterConfig::new("local");
647        let local_member = Member::new(
648            "local".to_owned(),
649            "127.0.0.1:0".parse().unwrap(),
650            None,
651            vec![],
652            MemberState::Up,
653            incarnation_seed(),
654            SystemTime::now(),
655        );
656        let state = Signal::new(ClusterState::new(local_member.clone())).unwrap();
657        let (gossip_tx, _) = mpsc::channel(1);
658        let (event_tx, _) = mpsc::unbounded_channel();
659        MembershipActor {
660            config,
661            state,
662            members: ClusterState::new(local_member).members,
663            local_node_id: "local".to_owned(),
664            local_incarnation: 0,
665            next_sequence: 1,
666            downing: Arc::new(NoopDowning),
667            gossip: gossip_tx,
668            event_tx,
669        }
670    }
671
672    #[test]
673    fn graceful_state_ignored_when_member_already_removed() {
674        let mut actor = make_actor();
675        let now = SystemTime::now();
676        let peer = "peer-1";
677
678        actor.members.insert(
679            peer.to_owned(),
680            Member::new(
681                peer.to_owned(),
682                "127.0.0.1:1".parse().unwrap(),
683                None,
684                vec![],
685                MemberState::Removed,
686                1,
687                now,
688            ),
689        );
690        let before = actor.members.len();
691
692        actor.apply_graceful_state(make_announcement(peer, 1));
693        assert_eq!(actor.members.len(), before);
694        assert_eq!(actor.members.get(peer).unwrap().state, MemberState::Removed);
695    }
696
697    #[test]
698    fn graceful_state_accepted_when_member_is_up() {
699        let mut actor = make_actor();
700        let now = SystemTime::now();
701        let peer = "peer-2";
702
703        actor.members.insert(
704            peer.to_owned(),
705            Member::new(
706                peer.to_owned(),
707                "127.0.0.1:2".parse().unwrap(),
708                None,
709                vec![],
710                MemberState::Up,
711                1,
712                now,
713            ),
714        );
715        let before = actor.members.len();
716
717        actor.apply_graceful_state(make_announcement(peer, 1));
718        assert_eq!(actor.members.len(), before);
719        assert!(matches!(
720            actor.members.get(peer).unwrap().state,
721            MemberState::Leaving
722        ));
723    }
724
725    #[test]
726    fn graceful_state_accepted_when_no_existing_member() {
727        let mut actor = make_actor();
728        let peer = "peer-3";
729
730        actor.apply_graceful_state(make_announcement(peer, 1));
731        assert_eq!(actor.members.get(peer).unwrap().state, MemberState::Leaving);
732    }
733
734    #[test]
735    fn graceful_state_rejected_when_member_is_down() {
736        let mut actor = make_actor();
737        let now = SystemTime::now();
738        let peer = "peer-4";
739
740        actor.members.insert(
741            peer.to_owned(),
742            Member::new(
743                peer.to_owned(),
744                "127.0.0.1:4".parse().unwrap(),
745                None,
746                vec![],
747                MemberState::Down,
748                1,
749                now,
750            ),
751        );
752        let before = actor.members.len();
753
754        actor.apply_graceful_state(make_announcement(peer, 1));
755        assert_eq!(actor.members.len(), before);
756        assert_eq!(actor.members.get(peer).unwrap().state, MemberState::Down);
757    }
758}