datum_cluster/model.rs
1use std::{collections::BTreeMap, net::SocketAddr, time::SystemTime};
2
3/// Akka-style member lifecycle state used by Datum membership.
4#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
5pub enum MemberState {
6 /// The member has been discovered but is not yet considered available.
7 Joining,
8 /// The member is available for future control-plane decisions.
9 Up,
10 /// The member requested a graceful leave.
11 Leaving,
12 /// The member is exiting after a graceful leave was acknowledged.
13 Exiting,
14 /// The member was downed after failure detection/downing.
15 Down,
16 /// The member has been removed from the active membership view.
17 Removed,
18}
19
20/// Datum-owned member record. No foca types appear in this model.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct Member {
23 /// Stable logical node id.
24 pub node_id: String,
25 /// Gossip address peers use for this member.
26 pub address: SocketAddr,
27 /// DCP agent address peers use for node-to-node control, if advertised.
28 pub agent_addr: Option<SocketAddr>,
29 /// Member roles advertised by the node.
30 pub roles: Vec<String>,
31 /// Current membership state.
32 pub state: MemberState,
33 /// True after foca suspicion and before reachability is restored or the
34 /// member is downed/removed.
35 pub unreachable: bool,
36 /// Monotonic incarnation used to distinguish restarts with the same node id
37 /// and address.
38 pub incarnation: u64,
39 /// Last state update time.
40 pub seen_at: SystemTime,
41 /// First time this member was marked unreachable, if currently unreachable.
42 pub unreachable_since: Option<SystemTime>,
43}
44
45impl Member {
46 #[must_use]
47 pub(crate) fn new(
48 node_id: String,
49 address: SocketAddr,
50 agent_addr: Option<SocketAddr>,
51 roles: Vec<String>,
52 state: MemberState,
53 incarnation: u64,
54 now: SystemTime,
55 ) -> Self {
56 Self {
57 node_id,
58 address,
59 agent_addr,
60 roles,
61 state,
62 unreachable: false,
63 incarnation,
64 seen_at: now,
65 unreachable_since: None,
66 }
67 }
68
69 /// Return `true` when the member has `role`.
70 #[must_use]
71 pub fn has_role(&self, role: &str) -> bool {
72 self.roles.iter().any(|candidate| candidate == role)
73 }
74}
75
76/// Immutable current membership snapshot.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct ClusterState {
79 /// Local node id for the node that produced this view.
80 pub self_node: String,
81 /// Members keyed by logical node id.
82 pub members: BTreeMap<String, Member>,
83}
84
85impl ClusterState {
86 #[must_use]
87 pub(crate) fn new(self_member: Member) -> Self {
88 let self_node = self_member.node_id.clone();
89 let mut members = BTreeMap::new();
90 members.insert(self_member.node_id.clone(), self_member);
91 Self { self_node, members }
92 }
93
94 /// Return a member by node id.
95 #[must_use]
96 pub fn member(&self, node_id: &str) -> Option<&Member> {
97 self.members.get(node_id)
98 }
99
100 /// Return all members currently in `Up` state and reachable.
101 #[must_use]
102 pub fn up_members(&self) -> Vec<&Member> {
103 self.members
104 .values()
105 .filter(|member| member.state == MemberState::Up && !member.unreachable)
106 .collect()
107 }
108
109 /// Return the deterministic placement coordinator for this membership view.
110 ///
111 /// Datum v0.10 mirrors Akka's "oldest member" coordinator convention without
112 /// adding a consensus protocol. C1 does not assign Akka-style up numbers, so
113 /// the ordering key is the propagated member incarnation, then node id. Every
114 /// node computes the same answer from the same C1 view. Under a partition and
115 /// timeout downing, different views can transiently produce different
116 /// coordinators; quorum or lease fencing is a v0.11+ follow-up.
117 #[must_use]
118 pub fn placement_coordinator(&self) -> Option<&Member> {
119 self.up_members().into_iter().min_by(|left, right| {
120 left.incarnation
121 .cmp(&right.incarnation)
122 .then_with(|| left.node_id.cmp(&right.node_id))
123 })
124 }
125
126 /// Return `true` when `node_id` is the coordinator in this view.
127 #[must_use]
128 pub fn is_placement_coordinator(&self, node_id: &str) -> bool {
129 self.placement_coordinator()
130 .is_some_and(|member| member.node_id == node_id)
131 }
132}
133
134/// Ordered member event.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct MemberEvent {
137 /// Monotonic sequence assigned by the membership actor.
138 pub sequence: u64,
139 /// Event kind.
140 pub kind: MemberEventKind,
141 /// Member snapshot after the event.
142 pub member: Member,
143 /// Event publication time.
144 pub at: SystemTime,
145}
146
147impl MemberEvent {
148 #[must_use]
149 pub(crate) const fn new(
150 sequence: u64,
151 kind: MemberEventKind,
152 member: Member,
153 at: SystemTime,
154 ) -> Self {
155 Self {
156 sequence,
157 kind,
158 member,
159 at,
160 }
161 }
162}
163
164/// Kind of [`MemberEvent`].
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
166pub enum MemberEventKind {
167 /// Initial event held by the lossless subscription before the actor starts.
168 Initialized,
169 /// Member entered `Joining`.
170 MemberJoining,
171 /// Member entered `Up`.
172 MemberUp,
173 /// Member roles or advertised address changed without a state change.
174 MemberUpdated,
175 /// Member was marked unreachable by foca suspicion.
176 MemberUnreachable,
177 /// Member became reachable again.
178 MemberReachable,
179 /// Member entered `Leaving`.
180 MemberLeaving,
181 /// Member entered `Exiting`.
182 MemberExiting,
183 /// Member entered `Down`.
184 MemberDown,
185 /// Member entered `Removed`.
186 MemberRemoved,
187 /// A down/removed logical node returned with a newer incarnation.
188 MemberRejoined,
189}