Skip to main content

atomr_cluster_tools/
cluster_singleton.rs

1//! ClusterSingletonManager / Proxy — one logical actor across the cluster.
2//!
3//! Phase 7.C of `docs/full-port-plan.md`. Handover protocol:
4//!
5//! ```text
6//! Active(here) ── oldest changed ──► HandingOver ── handover ack ──► Inactive
7//! Inactive ────── elected oldest ──► Starting     ── started   ──► Active(here)
8//! Inactive ────── observed remote ──► Active(remote)
9//! ```
10//!
11//! While in `HandingOver` or `Starting`, messages submitted via the
12//! [`ClusterSingletonProxy`] are buffered (up to `buffer_size`) and
13//! flushed once the singleton is `Active` again.
14
15use std::collections::VecDeque;
16use std::sync::atomic::{AtomicU64, Ordering};
17use std::sync::Arc;
18
19use parking_lot::RwLock;
20
21use atomr_core::actor::UntypedActorRef;
22
23/// Monotonic fencing token (FR-7c). Bumped every time this node assumes
24/// the singleton, so stale writers from a prior incarnation can be
25/// rejected by comparing tokens. `Ord` so the highest token wins.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
27pub struct FenceToken(pub u64);
28
29/// Opaque serialized external session carried across a handover.
30///
31/// The bytes are produced by the outgoing singleton's
32/// [`SingletonHandoff::prepare_handoff`] and handed to the incoming
33/// singleton's [`SingletonHandoff::assume`]. The manager never inspects
34/// them.
35pub struct HandoffState(pub Vec<u8>);
36
37/// Hook for migrating *external* state (e.g. a broker session, a lease,
38/// an open file) when the singleton moves between nodes (FR-7c).
39///
40/// `prepare_handoff` runs on the outgoing node when it enters
41/// `HandingOver`; `assume` runs on the incoming node when it becomes
42/// `Active(here)`, receiving the prior state (if any was captured
43/// locally) and the freshly-bumped [`FenceToken`].
44#[async_trait::async_trait]
45pub trait SingletonHandoff: Send + 'static {
46    async fn prepare_handoff(&mut self) -> HandoffState;
47    async fn assume(&mut self, prior: Option<HandoffState>, fence: FenceToken);
48}
49
50/// Singleton lifecycle state.
51#[derive(Debug, Clone, PartialEq, Eq)]
52#[non_exhaustive]
53pub enum SingletonState {
54    /// No singleton known yet.
55    Inactive,
56    /// We are about to become the singleton, but haven't started it.
57    Starting,
58    /// The singleton lives at this ref.
59    Active { ref_: UntypedActorRef, here: bool },
60    /// We were the singleton; new oldest member is taking over.
61    HandingOver,
62}
63
64/// Buffered envelope used during handover. The payload is type-erased
65/// (Box<dyn Any>) because the proxy doesn't know the concrete message
66/// type at the API boundary; recipients downcast on flush. Phase 13
67/// will replace this with typed-per-(M) buffering.
68type BufferedMsg = Box<dyn FnOnce(&UntypedActorRef) + Send + 'static>;
69
70/// Decides which node hosts the singleton based on oldest up-member —
71/// a hook is provided so tests can simulate handover without wiring
72/// the full cluster.
73pub struct ClusterSingletonManager {
74    state: RwLock<SingletonState>,
75    buffer: parking_lot::Mutex<VecDeque<BufferedMsg>>,
76    buffer_size: usize,
77    /// Count of messages dropped because the buffer was full.
78    drops: parking_lot::Mutex<u64>,
79    /// Monotonic fencing token; bumped on every `set_active_here`.
80    fence: AtomicU64,
81    /// Optional external-state migration hook (FR-7c).
82    handoff: tokio::sync::Mutex<Option<Box<dyn SingletonHandoff>>>,
83    /// State captured by the last `prepare_handoff`, consumed by the
84    /// next local `assume`. For a single-node hand-back this carries the
85    /// session across; cross-node carriage is a transport concern.
86    prior: parking_lot::Mutex<Option<HandoffState>>,
87}
88
89impl Default for ClusterSingletonManager {
90    fn default() -> Self {
91        Self {
92            state: RwLock::new(SingletonState::Inactive),
93            buffer: parking_lot::Mutex::new(VecDeque::new()),
94            buffer_size: 1_000,
95            drops: parking_lot::Mutex::new(0),
96            fence: AtomicU64::new(0),
97            handoff: tokio::sync::Mutex::new(None),
98            prior: parking_lot::Mutex::new(None),
99        }
100    }
101}
102
103impl ClusterSingletonManager {
104    pub fn new() -> Arc<Self> {
105        Arc::new(Self::default())
106    }
107
108    /// Construct with a custom proxy buffer size.
109    pub fn with_buffer_size(size: usize) -> Arc<Self> {
110        Arc::new(Self { buffer_size: size, ..Self::default() })
111    }
112
113    /// Construct with an external-state migration hook (FR-7c).
114    pub fn with_handoff(handoff: Box<dyn SingletonHandoff>) -> Arc<Self> {
115        Arc::new(Self { handoff: tokio::sync::Mutex::new(Some(handoff)), ..Self::default() })
116    }
117
118    pub fn state(&self) -> SingletonState {
119        self.state.read().clone()
120    }
121
122    /// Current fencing token (FR-7c). Monotonically increases each time
123    /// this node assumes the singleton via [`Self::set_active_here`].
124    pub fn fence(&self) -> FenceToken {
125        FenceToken(self.fence.load(Ordering::SeqCst))
126    }
127
128    /// Mark `r` as the local singleton (we won the election).
129    ///
130    /// Bumps the fencing token, runs the [`SingletonHandoff::assume`]
131    /// hook (if configured) with any locally-captured prior state, then
132    /// flushes any messages buffered during handover.
133    pub fn set_active_here(&self, r: UntypedActorRef) {
134        // Bump fence first so `assume` sees the incarnation it owns.
135        let token = FenceToken(self.fence.fetch_add(1, Ordering::SeqCst) + 1);
136        self.run_assume(token);
137        *self.state.write() = SingletonState::Active { ref_: r.clone(), here: true };
138        self.flush(&r);
139    }
140
141    /// Drive the `assume` hook (if any), consuming the prior captured
142    /// state. Runs the async hook to completion on the current thread.
143    fn run_assume(&self, token: FenceToken) {
144        // `try_lock` cannot contend: the singleton lifecycle is driven from a
145        // single task. Skip the hook rather than panic if that ever changes.
146        if let Ok(mut guard) = self.handoff.try_lock() {
147            if let Some(h) = guard.as_mut() {
148                let prior = self.prior.lock().take();
149                futures::executor::block_on(h.assume(prior, token));
150            }
151        }
152    }
153
154    /// Mark `r` as the remote singleton (some other node is hosting it).
155    pub fn set_active_remote(&self, r: UntypedActorRef) {
156        *self.state.write() = SingletonState::Active { ref_: r.clone(), here: false };
157        self.flush(&r);
158    }
159
160    /// Begin handover (we used to be Active(here)).
161    ///
162    /// Runs the [`SingletonHandoff::prepare_handoff`] hook (if
163    /// configured) and stashes the captured state locally so a
164    /// subsequent local `assume` can pick it up.
165    pub fn begin_handover(&self) {
166        // See `run_assume`: uncontended by design; skip the hook rather than
167        // panic on the impossible contention path. The state transition below
168        // must still run regardless.
169        if let Ok(mut guard) = self.handoff.try_lock() {
170            if let Some(h) = guard.as_mut() {
171                let captured = futures::executor::block_on(h.prepare_handoff());
172                *self.prior.lock() = Some(captured);
173            }
174        }
175        *self.state.write() = SingletonState::HandingOver;
176    }
177
178    /// Begin starting (we were elected as the new oldest).
179    pub fn begin_starting(&self) {
180        *self.state.write() = SingletonState::Starting;
181    }
182
183    /// Forget the current singleton entirely.
184    pub fn clear(&self) {
185        *self.state.write() = SingletonState::Inactive;
186    }
187
188    pub fn current(&self) -> Option<UntypedActorRef> {
189        match &*self.state.read() {
190            SingletonState::Active { ref_, .. } => Some(ref_.clone()),
191            _ => None,
192        }
193    }
194
195    /// Buffer `deliver` for replay once the singleton becomes
196    /// `Active`. Used by the proxy when the singleton isn't yet
197    /// reachable. Returns `true` if buffered, `false` if the buffer
198    /// was full (in which case the caller can route to DeadLetters).
199    fn buffer_or_deliver<F>(&self, deliver: F) -> bool
200    where
201        F: FnOnce(&UntypedActorRef) + Send + 'static,
202    {
203        if let Some(r) = self.current() {
204            deliver(&r);
205            return true;
206        }
207        let mut q = self.buffer.lock();
208        if q.len() >= self.buffer_size {
209            *self.drops.lock() += 1;
210            return false;
211        }
212        q.push_back(Box::new(deliver));
213        true
214    }
215
216    fn flush(&self, target: &UntypedActorRef) {
217        let mut q = self.buffer.lock();
218        while let Some(deliver) = q.pop_front() {
219            deliver(target);
220        }
221    }
222
223    /// Number of currently-buffered messages (waiting for handover to
224    /// complete).
225    pub fn buffered(&self) -> usize {
226        self.buffer.lock().len()
227    }
228
229    /// Total number of messages dropped due to buffer-full overflow.
230    pub fn drops(&self) -> u64 {
231        *self.drops.lock()
232    }
233}
234
235/// Proxy that routes messages to the current singleton, buffering
236/// during handover.
237pub struct ClusterSingletonProxy {
238    pub manager: Arc<ClusterSingletonManager>,
239}
240
241impl ClusterSingletonProxy {
242    pub fn new(manager: Arc<ClusterSingletonManager>) -> Self {
243        Self { manager }
244    }
245
246    pub fn singleton(&self) -> Option<UntypedActorRef> {
247        self.manager.current()
248    }
249
250    /// Schedule `deliver` against the singleton. If `Active`, runs
251    /// immediately; if `Inactive`/`Starting`/`HandingOver`, buffers
252    /// for replay. Returns `false` if the buffer was full.
253    pub fn send<F>(&self, deliver: F) -> bool
254    where
255        F: FnOnce(&UntypedActorRef) + Send + 'static,
256    {
257        self.manager.buffer_or_deliver(deliver)
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use atomr_core::actor::Inbox;
265    use std::sync::atomic::{AtomicU32, Ordering};
266
267    #[test]
268    fn proxy_routes_to_current_singleton() {
269        let mgr = ClusterSingletonManager::new();
270        let inbox = Inbox::<u32>::new("singleton");
271        mgr.set_active_here(inbox.actor_ref().as_untyped());
272        let proxy = ClusterSingletonProxy::new(mgr);
273        assert!(proxy.singleton().is_some());
274    }
275
276    #[test]
277    fn handover_state_transitions() {
278        let mgr = ClusterSingletonManager::new();
279        assert!(matches!(mgr.state(), SingletonState::Inactive));
280        mgr.begin_starting();
281        assert!(matches!(mgr.state(), SingletonState::Starting));
282        let inbox = Inbox::<u32>::new("s");
283        mgr.set_active_here(inbox.actor_ref().as_untyped());
284        assert!(matches!(mgr.state(), SingletonState::Active { here: true, .. }));
285        mgr.begin_handover();
286        assert!(matches!(mgr.state(), SingletonState::HandingOver));
287    }
288
289    #[tokio::test]
290    async fn proxy_buffers_during_handover_and_flushes_after() {
291        let mgr = ClusterSingletonManager::new();
292        let proxy = ClusterSingletonProxy::new(mgr.clone());
293
294        let calls = Arc::new(AtomicU32::new(0));
295        // Send 3 messages while inactive — all buffered.
296        for _ in 0..3 {
297            let c = calls.clone();
298            assert!(proxy.send(move |_r| {
299                c.fetch_add(1, Ordering::SeqCst);
300            }));
301        }
302        assert_eq!(mgr.buffered(), 3);
303        assert_eq!(calls.load(Ordering::SeqCst), 0);
304
305        // Become active → buffer flushes.
306        let inbox = Inbox::<u32>::new("s");
307        mgr.set_active_here(inbox.actor_ref().as_untyped());
308        assert_eq!(mgr.buffered(), 0);
309        assert_eq!(calls.load(Ordering::SeqCst), 3);
310
311        // After active, send delivers immediately.
312        let c2 = calls.clone();
313        proxy.send(move |_| {
314            c2.fetch_add(1, Ordering::SeqCst);
315        });
316        assert_eq!(calls.load(Ordering::SeqCst), 4);
317    }
318
319    #[test]
320    fn full_buffer_drops_and_counts_overflow() {
321        let mgr = ClusterSingletonManager::with_buffer_size(2);
322        let proxy = ClusterSingletonProxy::new(mgr.clone());
323        assert!(proxy.send(|_| {}));
324        assert!(proxy.send(|_| {}));
325        // Third should overflow.
326        assert!(!proxy.send(|_| {}));
327        assert_eq!(mgr.drops(), 1);
328        assert_eq!(mgr.buffered(), 2);
329    }
330
331    #[test]
332    fn fence_token_is_monotonic() {
333        let mgr = ClusterSingletonManager::new();
334        assert_eq!(mgr.fence(), FenceToken(0));
335        let inbox = Inbox::<u32>::new("s");
336        mgr.set_active_here(inbox.actor_ref().as_untyped());
337        let f1 = mgr.fence();
338        mgr.begin_handover();
339        mgr.set_active_here(inbox.actor_ref().as_untyped());
340        let f2 = mgr.fence();
341        assert!(f2 > f1, "fence must advance: {f1:?} -> {f2:?}");
342        assert_eq!(f1, FenceToken(1));
343        assert_eq!(f2, FenceToken(2));
344    }
345
346    #[test]
347    fn handoff_prepare_then_assume_carries_state_and_fence() {
348        use std::sync::atomic::AtomicU64;
349
350        struct Session {
351            // Records the fence seen by the most recent `assume`.
352            assumed_fence: Arc<AtomicU64>,
353            // Records the prior bytes seen by the most recent `assume`.
354            assumed_prior: Arc<parking_lot::Mutex<Option<Vec<u8>>>>,
355        }
356        #[async_trait::async_trait]
357        impl SingletonHandoff for Session {
358            async fn prepare_handoff(&mut self) -> HandoffState {
359                HandoffState(b"session-42".to_vec())
360            }
361            async fn assume(&mut self, prior: Option<HandoffState>, fence: FenceToken) {
362                self.assumed_fence.store(fence.0, Ordering::SeqCst);
363                *self.assumed_prior.lock() = prior.map(|p| p.0);
364            }
365        }
366
367        let seen_fence = Arc::new(AtomicU64::new(0));
368        let seen_prior = Arc::new(parking_lot::Mutex::new(None));
369        let mgr = ClusterSingletonManager::with_handoff(Box::new(Session {
370            assumed_fence: seen_fence.clone(),
371            assumed_prior: seen_prior.clone(),
372        }));
373
374        let inbox = Inbox::<u32>::new("s");
375        // First activation: no prior state, fence == 1.
376        mgr.set_active_here(inbox.actor_ref().as_untyped());
377        assert_eq!(seen_fence.load(Ordering::SeqCst), 1);
378        assert!(seen_prior.lock().is_none());
379
380        // Hand over (captures session bytes) then re-assume locally.
381        mgr.begin_handover();
382        mgr.set_active_here(inbox.actor_ref().as_untyped());
383        assert_eq!(seen_fence.load(Ordering::SeqCst), 2);
384        assert_eq!(seen_prior.lock().clone(), Some(b"session-42".to_vec()));
385    }
386
387    #[test]
388    fn handoff_buffering_still_works() {
389        // With a handoff configured, buffering during inactive/handover
390        // must be unchanged.
391        struct Noop;
392        #[async_trait::async_trait]
393        impl SingletonHandoff for Noop {
394            async fn prepare_handoff(&mut self) -> HandoffState {
395                HandoffState(vec![])
396            }
397            async fn assume(&mut self, _prior: Option<HandoffState>, _fence: FenceToken) {}
398        }
399        let mgr = ClusterSingletonManager::with_handoff(Box::new(Noop));
400        let proxy = ClusterSingletonProxy::new(mgr.clone());
401        let calls = Arc::new(AtomicU32::new(0));
402        for _ in 0..2 {
403            let c = calls.clone();
404            assert!(proxy.send(move |_| {
405                c.fetch_add(1, Ordering::SeqCst);
406            }));
407        }
408        assert_eq!(mgr.buffered(), 2);
409        let inbox = Inbox::<u32>::new("s");
410        mgr.set_active_here(inbox.actor_ref().as_untyped());
411        assert_eq!(calls.load(Ordering::SeqCst), 2);
412        assert_eq!(mgr.buffered(), 0);
413    }
414
415    #[test]
416    fn set_active_remote_marks_here_false() {
417        let mgr = ClusterSingletonManager::new();
418        let inbox = Inbox::<u32>::new("remote-host");
419        mgr.set_active_remote(inbox.actor_ref().as_untyped());
420        match mgr.state() {
421            SingletonState::Active { here, .. } => assert!(!here),
422            _ => panic!("expected active-remote"),
423        }
424    }
425}