Skip to main content

net/adapter/net/redex/
replication_coordinator.rs

1//! `ReplicationCoordinator` core — Phase C slice of
2//! `docs/internal/plans/REDEX_DISTRIBUTED_PLAN.md` §3.
3//!
4//! One coordinator per replicated channel per replica. Holds the
5//! validated [`ReplicaRole`] state, the channel's chain identity,
6//! the per-channel [`ChannelMetricsAtomic`] handle, and a
7//! `ChainTagSink` abstraction over [`MeshNode::announce_chain`] /
8//! [`MeshNode::withdraw_chain`] so the coordinator's tag-lifecycle
9//! discipline is unit-testable without spinning a real mesh.
10//!
11//! This slice covers the **state-machine + tag-lifecycle + metrics**
12//! seam. The heartbeat loop, FSM-driven `elect()` triggering, and
13//! `Redex::open_file` spawn integration land in subsequent slices
14//! per the plan §3 / §6 / §7.
15//!
16//! State-machine transitions route through
17//! [`StateTransition::apply`] (`replication_state.rs`) so the
18//! coordinator can't accidentally advance a `(from, to, signal)`
19//! triple the plan §3 doesn't enumerate. Capability-tag
20//! emission / withdrawal is keyed to specific transitions per the
21//! plan §3 Responsibilities:
22//!
23//! | Transition          | Tag side-effect                          |
24//! |---------------------|------------------------------------------|
25//! | `Idle → Replica`    | `announce_chain(tail_seq)` advertises hold |
26//! | `Replica → Leader`  | re-`announce_chain(tail_seq)` (new role)  |
27//! | `Candidate → Leader`| re-`announce_chain(tail_seq)` (new role)  |
28//! | `* → Idle`          | `withdraw_chain` retracts the holder      |
29//!
30//! Metrics increment on every transition per `replication_metrics.rs`:
31//! `leader_changes_total` on any transition INTO `Leader`,
32//! `election_thrash_total` on `MissedHeartbeats` transitions within
33//! the 30 s window (window enforcement in the heartbeat-loop slice).
34
35use std::sync::atomic::{AtomicU64, Ordering};
36use std::sync::Arc;
37use std::time::Instant;
38
39use parking_lot::{Mutex, RwLock};
40
41use super::replication::ReplicaRole;
42use super::replication_config::ReplicationConfig;
43use super::replication_metrics::{ChannelMetricsAtomic, ReplicationMetricsRegistry};
44use super::replication_state::{StateTransition, StateTransitionError, TransitionSignal};
45use crate::adapter::net::MeshNode;
46use crate::error::AdapterError;
47
48/// Mesh-side surface the coordinator depends on for chain-tag
49/// advertisement + withdrawal. Implemented by [`MeshNode`] in the
50/// substrate and by a mock in unit tests.
51///
52/// Async / Send-bound so the coordinator can be driven from a
53/// tokio task without forcing every implementor into a specific
54/// runtime.
55#[async_trait::async_trait]
56pub trait ChainTagSink: Send + Sync {
57    /// Advertise this node holds `origin_hash` up to `tip_seq`.
58    /// Idempotent — repeated calls with the same `origin_hash`
59    /// replace the prior advertisement.
60    async fn announce_chain(&self, origin_hash: u64, tip_seq: u64) -> Result<(), AdapterError>;
61
62    /// Withdraw every advertisement for `origin_hash`.
63    /// Idempotent.
64    async fn withdraw_chain(&self, origin_hash: u64) -> Result<(), AdapterError>;
65}
66
67/// Substrate impl: route through [`MeshNode::announce_chain`] /
68/// [`MeshNode::withdraw_chain`]. This is the production sink the
69/// [`ReplicationCoordinator`] uses when a real [`MeshNode`] is
70/// wired in.
71#[async_trait::async_trait]
72impl ChainTagSink for MeshNode {
73    async fn announce_chain(&self, origin_hash: u64, tip_seq: u64) -> Result<(), AdapterError> {
74        MeshNode::announce_chain(self, origin_hash, tip_seq).await
75    }
76
77    async fn withdraw_chain(&self, origin_hash: u64) -> Result<(), AdapterError> {
78        MeshNode::withdraw_chain(self, origin_hash).await
79    }
80}
81
82/// Lifecycle event a [`ReplicaTransitionObserver`] receives
83/// when this coordinator transitions through its state
84/// machine. Carries `origin_hash` so observers managing many
85/// coordinators (one per channel) can route the event.
86///
87/// `at` is the monotonic timestamp at the transition. Plain data so observers
88/// can buffer / async-forward without lifetime issues.
89#[derive(Clone, Debug, Eq, PartialEq)]
90#[non_exhaustive]
91pub enum ReplicaTransitionEvent {
92    /// Coordinator entered `Replica` or `Leader` from `Idle` —
93    /// this node is now a holder of the chain.
94    BecameHolder {
95        /// Substrate-level chain identifier.
96        origin_hash: u64,
97        /// Monotonic timestamp of the transition.
98        at: Instant,
99    },
100    /// Coordinator entered `Idle` from any non-Idle state —
101    /// this node is no longer a holder.
102    Idled {
103        /// Substrate-level chain identifier.
104        origin_hash: u64,
105        /// Monotonic timestamp of the transition.
106        at: Instant,
107    },
108    /// Leader changed for this channel — the coordinator
109    /// transitioned through a Leader entry (`Replica → Leader`
110    /// or `Candidate → Leader`). MeshOS uses this to update
111    /// `MeshOsState::replica_leader`.
112    LeaderChanged {
113        /// Substrate-level chain identifier.
114        origin_hash: u64,
115        /// Monotonic timestamp of the transition.
116        at: Instant,
117    },
118    /// This coordinator stepped down from `Leader` to `Replica`
119    /// — the node remains a holder but is no longer leader.
120    /// MeshOS clears its mirror of
121    /// `MeshOsState::replica_leader[origin_hash]` when the
122    /// observer sees this — otherwise the loop would carry a
123    /// stale leader pointer until a different node's
124    /// `LeaderChanged` overwrites it.
125    LeaderLost {
126        /// Substrate-level chain identifier.
127        origin_hash: u64,
128        /// Monotonic timestamp of the transition.
129        at: Instant,
130    },
131    /// This coordinator stepped down from `Leader` straight to
132    /// `Idle` — the node is no longer leader AND no longer a
133    /// holder. The two transitions are bundled into one event so
134    /// downstream sinks publish a single atomic update; firing
135    /// `Idled` and `LeaderLost` separately would let the events
136    /// channel drop one half under backpressure, leaving the
137    /// snapshot with either a phantom leader on a non-holder, or
138    /// a leader-less holder set.
139    LeaderLostAndIdled {
140        /// Substrate-level chain identifier.
141        origin_hash: u64,
142        /// Monotonic timestamp of the transition.
143        at: Instant,
144    },
145    /// Symmetric to [`Self::LeaderLostAndIdled`] for the
146    /// promotion side: this coordinator entered `Leader` directly
147    /// from `Idle`, so the node BOTH became a holder AND became
148    /// leader in one transition. Bundled into one event so a
149    /// downstream sink can't drop half of the (holder add,
150    /// leader set) pair under backpressure — pre-bundle the
151    /// observer fired `BecameHolder` then `LeaderChanged` as two
152    /// `try_publish`es, and a `QueueFull` between them left the
153    /// snapshot with a holder set but no leader (or vice versa).
154    /// `Replica → Leader` / `Candidate → Leader` still fire only
155    /// `LeaderChanged` because the node was already a holder.
156    BecameHolderAndLeader {
157        /// Substrate-level chain identifier.
158        origin_hash: u64,
159        /// Monotonic timestamp of the transition.
160        at: Instant,
161    },
162}
163
164/// Observer hook for replication-coordinator state changes.
165/// Implementations fan events out to whichever consumer wants
166/// them — the MeshOS event loop being the canonical
167/// near-term consumer. Methods are sync + non-blocking.
168pub trait ReplicaTransitionObserver: Send + Sync + 'static {
169    /// Receive one transition event. Must not block.
170    fn observe(&self, event: ReplicaTransitionEvent);
171}
172
173/// Errors the coordinator surfaces from its state-machine + tag-
174/// lifecycle path.
175#[derive(Debug, thiserror::Error)]
176pub enum CoordinatorError {
177    /// State-machine validator rejected a transition.
178    #[error("invalid state transition: {0}")]
179    Transition(#[from] StateTransitionError),
180    /// `MeshNode::announce_chain` / `withdraw_chain` surfaced an
181    /// error. The state mutation already happened — the operator
182    /// observes a divergence between local state and advertised
183    /// state until the next successful announce.
184    #[error("chain-tag side-effect failed: {0}")]
185    TagSink(#[source] AdapterError),
186}
187
188/// Stable identity for a replicated channel. The
189/// [`ReplicationCoordinator`] holds one of these per channel +
190/// replica role; the metrics registry keys per-channel counters on
191/// the `channel_name` string. The `origin_hash` is the substrate-
192/// level identifier the capability layer's `causal:<hex>` tag
193/// carries.
194#[derive(Debug, Clone)]
195pub struct ChannelIdentity {
196    /// Human-readable name of the replicated channel (used as the
197    /// metrics label).
198    pub channel_name: String,
199    /// Substrate-level chain identifier — passed to
200    /// [`ChainTagSink::announce_chain`] / [`ChainTagSink::withdraw_chain`].
201    pub origin_hash: u64,
202}
203
204/// One replication coordinator. Phase C scope:
205///
206/// - Holds the [`ReplicaRole`] cell under a parking_lot mutex
207///   (microsecond critical sections; no async needed inside).
208/// - Holds the local `tail_seq` (`AtomicU64`).
209/// - Holds the channel-identity + config + sink + metrics handle.
210/// - Transitions go through [`Self::transition_to`] which validates
211///   via [`StateTransition::apply`], emits / withdraws capability
212///   tags via the sink, and increments metrics.
213///
214/// Heartbeat loop, election triggering, and spawn lifecycle land
215/// in the next slices.
216pub struct ReplicationCoordinator {
217    channel: ChannelIdentity,
218    config: ReplicationConfig,
219    sink: Arc<dyn ChainTagSink>,
220    metrics: Arc<ChannelMetricsAtomic>,
221    state: Mutex<ReplicaRole>,
222    tail_seq: AtomicU64,
223    /// Serializes the entire `transition_to` body — state update +
224    /// metric bumps + chain-tag side effect — so two racing
225    /// transitions can't interleave announce/withdraw against the
226    /// capability layer. Plan §3 pins the announce/withdraw key to
227    /// specific transitions; without this lock T1 could set
228    /// `Replica` + queue `announce_chain` while T2 sets `Idle` +
229    /// completes `withdraw_chain` first, leaving the mesh
230    /// advertising a chain we've already withdrawn locally.
231    transition_lock: tokio::sync::Mutex<()>,
232    /// Optional observer hook. When set, every successful state
233    /// transition that crosses the Idle ↔ {Replica, Leader}
234    /// boundary fires through it so consumers (MeshOS, audit,
235    /// dashboard) see a coherent replica-update stream.
236    ///
237    /// `parking_lot::RwLock` over `Option<Arc<dyn ...>>` mirrors
238    /// the hot-path router pattern used elsewhere (e.g.
239    /// `DaemonRegistry::observer`): uncontended read on the
240    /// firing path, rare write when an observer is installed.
241    observer: RwLock<Option<Arc<dyn ReplicaTransitionObserver>>>,
242}
243
244impl ReplicationCoordinator {
245    /// Construct a coordinator in [`ReplicaRole::Idle`]. The
246    /// caller transitions it to `Replica` once the placement
247    /// filter has selected this node — `transition_to(Replica,
248    /// CapabilitySelected)`. Validate the [`ReplicationConfig`]
249    /// before calling; an invalid config produces undefined
250    /// transition behavior (the coordinator doesn't re-validate).
251    pub fn new(
252        channel: ChannelIdentity,
253        config: ReplicationConfig,
254        sink: Arc<dyn ChainTagSink>,
255        registry: &ReplicationMetricsRegistry,
256    ) -> Self {
257        let metrics = registry.for_channel(&channel.channel_name);
258        Self {
259            channel,
260            config,
261            sink,
262            metrics,
263            state: Mutex::new(ReplicaRole::Idle),
264            tail_seq: AtomicU64::new(0),
265            transition_lock: tokio::sync::Mutex::new(()),
266            observer: RwLock::new(None),
267        }
268    }
269
270    /// Install a replica-transition observer. Replaces any prior
271    /// observer; returns the prior one if any. Pass `None` to
272    /// detach. Lock-free on the firing path; only the install
273    /// path takes the write lock.
274    pub fn set_transition_observer(
275        &self,
276        observer: Option<Arc<dyn ReplicaTransitionObserver>>,
277    ) -> Option<Arc<dyn ReplicaTransitionObserver>> {
278        let mut guard = self.observer.write();
279        std::mem::replace(&mut *guard, observer)
280    }
281
282    /// `true` when an observer is installed. Cheap (one RwLock
283    /// read).
284    pub fn has_transition_observer(&self) -> bool {
285        self.observer.read().is_some()
286    }
287
288    fn fire_transition(&self, event: ReplicaTransitionEvent) {
289        if let Some(observer) = self.observer.read().clone() {
290            observer.observe(event);
291        }
292    }
293
294    /// Read the coordinator's current state. Snapshot — the value
295    /// may change immediately after the lock releases.
296    pub fn role(&self) -> ReplicaRole {
297        *self.state.lock()
298    }
299
300    /// Read the local `tail_seq`. The coordinator advances this
301    /// via [`Self::record_tail_seq`] as appends land.
302    pub fn tail_seq(&self) -> u64 {
303        self.tail_seq.load(Ordering::Relaxed)
304    }
305
306    /// Record the local `tail_seq`. Monotonic — calls with a value
307    /// `<=` the current tail are dropped. The heartbeat-loop slice
308    /// uses this to keep the gauge fresh; Phase D pull-based
309    /// catch-up advances it per applied `SYNC_RESPONSE` chunk.
310    pub fn record_tail_seq(&self, seq: u64) {
311        let mut current = self.tail_seq.load(Ordering::Relaxed);
312        while seq > current {
313            match self.tail_seq.compare_exchange_weak(
314                current,
315                seq,
316                Ordering::Relaxed,
317                Ordering::Relaxed,
318            ) {
319                Ok(_) => return,
320                Err(now) => current = now,
321            }
322        }
323    }
324
325    /// Channel identity (read-only — fixed at construction time).
326    pub fn channel(&self) -> &ChannelIdentity {
327        &self.channel
328    }
329
330    /// Replication config (read-only).
331    pub fn config(&self) -> &ReplicationConfig {
332        &self.config
333    }
334
335    /// Per-channel metrics handle. Exposed so the heartbeat loop
336    /// and sync path can increment counters without re-resolving
337    /// through the registry on every event.
338    pub fn metrics(&self) -> &ChannelMetricsAtomic {
339        &self.metrics
340    }
341
342    /// Attempt to transition to `target` driven by `signal`.
343    /// Validates the `(from, to, signal)` triple through
344    /// [`StateTransition::apply`]; on success:
345    ///
346    /// 1. Updates the state cell to `target`.
347    /// 2. Performs the documented capability-tag side-effect:
348    ///    - `Idle → Replica`, `Replica → Leader`, `Candidate →
349    ///      Leader`: `announce_chain(tail_seq)` so peers see this
350    ///      node as a holder (or new leader).
351    ///    - `* → Idle`: `withdraw_chain` retracts the
352    ///      advertisement.
353    ///    - All other valid transitions (e.g. `Candidate → Replica`)
354    ///      are state-only; the holder advertisement already
355    ///      reflects "replica."
356    /// 3. Increments the appropriate metric.
357    ///
358    /// Returns:
359    /// - `Ok(Some(StateTransition))` — transition applied; the
360    ///   `StateTransition` is the validated triple, useful for
361    ///   logging.
362    /// - `Ok(None)` — `target == current_state` AND `signal ==
363    ///   ChannelClose` (the idempotent shutdown shape); state
364    ///   unchanged, no side-effect, no metric bump.
365    /// - `Err(CoordinatorError::Transition)` — the triple is
366    ///   invalid (state unchanged).
367    /// - `Err(CoordinatorError::TagSink)` — state IS updated; the
368    ///   tag-sink call failed. Caller logs + retries on the next
369    ///   heartbeat tick.
370    pub async fn transition_to(
371        &self,
372        target: ReplicaRole,
373        signal: TransitionSignal,
374    ) -> Result<Option<StateTransition>, CoordinatorError> {
375        // R-3: hold a single async mutex across the whole
376        // transition (state update + metric bumps + chain-tag
377        // side effect) so two concurrent callers can't interleave
378        // an `announce_chain` from a stale role over a
379        // `withdraw_chain` from a fresher one. The inner state
380        // mutex still serializes the validation + cell flip; the
381        // outer transition_lock serializes the side-effect chain.
382        let _guard = self.transition_lock.lock().await;
383        // Acquire the state lock for the validation + cell update.
384        // Drop it before the await — the sink call is async and
385        // we don't want to hold a sync mutex across an await
386        // point.
387        let transition = {
388            let mut state = self.state.lock();
389            let from = *state;
390            // `ChannelClose` to Idle from an already-Idle state is
391            // a no-op idempotent shutdown — short-circuit without
392            // touching the sink or metrics.
393            if from == ReplicaRole::Idle
394                && target == ReplicaRole::Idle
395                && signal == TransitionSignal::ChannelClose
396            {
397                return Ok(None);
398            }
399            let t = StateTransition::apply(from, target, signal)?;
400            *state = target;
401            t
402        };
403
404        // Metric bumps. Done eagerly so even if the sink call
405        // fails, the operator-facing counter reflects the state
406        // change that actually happened.
407        if transition.to == ReplicaRole::Leader {
408            self.metrics.incr_leader_change();
409        }
410        if matches!(transition.signal, TransitionSignal::MissedHeartbeats) {
411            // The election-thrash 30-s window is enforced by the
412            // heartbeat loop; this counter just records every
413            // MissedHeartbeats-driven transition. The aggregator
414            // upstream collapses thrash via the timestamp series.
415            self.metrics.incr_election_thrash();
416        }
417
418        // Side-effect on the chain-tag layer. Plan §3 pins
419        // emission to exactly two transitions:
420        //
421        //   - `Idle → Replica`          (capability filter selected)
422        //   - `Candidate → Leader`      (won the election)
423        //
424        // Other valid transitions stay in the "already advertising"
425        // window — `Replica → Candidate` and `Candidate → Replica`
426        // don't change the holder advertisement (the tag layer
427        // doesn't distinguish leader-from-replica; that's a wire-
428        // protocol role byte on the heartbeat). Withdrawal happens
429        // on every `* → Idle`.
430        let origin = self.channel.origin_hash;
431        let is_withdraw = transition.to == ReplicaRole::Idle;
432        let result = match (transition.from, transition.to) {
433            (ReplicaRole::Idle, ReplicaRole::Replica)
434            | (ReplicaRole::Candidate, ReplicaRole::Leader) => {
435                let tip = self.tail_seq.load(Ordering::Relaxed);
436                self.sink.announce_chain(origin, tip).await
437            }
438            (_, ReplicaRole::Idle) => self.sink.withdraw_chain(origin).await,
439            _ => Ok(()),
440        };
441        if let Err(e) = result {
442            if is_withdraw {
443                // Local state already flipped to Idle but the
444                // mesh-side withdraw failed — the mesh may still
445                // advertise this node as a chain holder until
446                // something else trips a re-announce. Bump the
447                // divergence counter so operators can spot the
448                // gap; recovery is opportunistic on the next
449                // transition_to call.
450                self.metrics.incr_announce_divergence();
451                tracing::warn!(
452                    origin = format!("{:#x}", origin),
453                    from = ?transition.from,
454                    error = %e,
455                    "replication coordinator: state advanced to Idle but sink withdraw failed; \
456                     advertised-vs-local divergence until next transition_to or cancel()",
457                );
458            }
459            return Err(CoordinatorError::TagSink(e));
460        }
461
462        // Fire the observer AFTER the sink call succeeds. If the
463        // sink call fails we don't fire — the state mutation
464        // already happened, but the operator-visible advertisement
465        // didn't; the next heartbeat cycle will retry and the
466        // observer fires then. (The transition_lock serializes
467        // both, so a retried `transition_to` runs the full chain
468        // again including the observer.)
469        let at = Instant::now();
470        match (transition.from, transition.to) {
471            (ReplicaRole::Idle, ReplicaRole::Replica) => {
472                self.fire_transition(ReplicaTransitionEvent::BecameHolder {
473                    origin_hash: origin,
474                    at,
475                });
476            }
477            // Idle → Leader: bundle the (holder add, leader set)
478            // pair so a backpressured sink can't drop one half and
479            // leave the snapshot with a phantom holder or leader.
480            (ReplicaRole::Idle, ReplicaRole::Leader) => {
481                self.fire_transition(ReplicaTransitionEvent::BecameHolderAndLeader {
482                    origin_hash: origin,
483                    at,
484                });
485            }
486            // Leader → Idle: bundle the (holder removal, leader
487            // clear) pair, symmetric to BecameHolderAndLeader above.
488            (ReplicaRole::Leader, ReplicaRole::Idle) => {
489                self.fire_transition(ReplicaTransitionEvent::LeaderLostAndIdled {
490                    origin_hash: origin,
491                    at,
492                });
493            }
494            (_, ReplicaRole::Idle) => {
495                self.fire_transition(ReplicaTransitionEvent::Idled {
496                    origin_hash: origin,
497                    at,
498                });
499            }
500            _ => {}
501        }
502        // Replica → Leader / Candidate → Leader: already a holder,
503        // so only the leader bit changes. Idle → Leader is handled
504        // atomically above by BecameHolderAndLeader.
505        if matches!(
506            (transition.from, transition.to),
507            (ReplicaRole::Replica, ReplicaRole::Leader)
508                | (ReplicaRole::Candidate, ReplicaRole::Leader)
509        ) {
510            self.fire_transition(ReplicaTransitionEvent::LeaderChanged {
511                origin_hash: origin,
512                at,
513            });
514        }
515        // Leader → Replica step-down — node remains a holder but
516        // is no longer leader. The Leader → Idle case is handled
517        // above by `LeaderLostAndIdled`.
518        if matches!(
519            (transition.from, transition.to),
520            (ReplicaRole::Leader, ReplicaRole::Replica)
521        ) {
522            self.fire_transition(ReplicaTransitionEvent::LeaderLost {
523                origin_hash: origin,
524                at,
525            });
526        }
527
528        Ok(Some(transition))
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use parking_lot::Mutex as ParkingMutex;
536
537    /// Recorder mock — captures every `announce_chain` /
538    /// `withdraw_chain` call and lets the test assert on the
539    /// observed sequence.
540    #[derive(Default)]
541    struct RecorderSink {
542        calls: ParkingMutex<Vec<SinkCall>>,
543        /// When set, every announce/withdraw returns this error
544        /// instead of `Ok(())`. Lets tests pin the "state mutated
545        /// but tag-sink failed" path.
546        fail_next: ParkingMutex<Option<AdapterError>>,
547    }
548
549    #[derive(Debug, Clone, PartialEq, Eq)]
550    enum SinkCall {
551        Announce { origin_hash: u64, tip_seq: u64 },
552        Withdraw { origin_hash: u64 },
553    }
554
555    impl RecorderSink {
556        fn calls(&self) -> Vec<SinkCall> {
557            self.calls.lock().clone()
558        }
559
560        fn arm_failure(&self, err: AdapterError) {
561            *self.fail_next.lock() = Some(err);
562        }
563    }
564
565    #[async_trait::async_trait]
566    impl ChainTagSink for RecorderSink {
567        async fn announce_chain(&self, origin_hash: u64, tip_seq: u64) -> Result<(), AdapterError> {
568            if let Some(err) = self.fail_next.lock().take() {
569                return Err(err);
570            }
571            self.calls.lock().push(SinkCall::Announce {
572                origin_hash,
573                tip_seq,
574            });
575            Ok(())
576        }
577
578        async fn withdraw_chain(&self, origin_hash: u64) -> Result<(), AdapterError> {
579            if let Some(err) = self.fail_next.lock().take() {
580                return Err(err);
581            }
582            self.calls.lock().push(SinkCall::Withdraw { origin_hash });
583            Ok(())
584        }
585    }
586
587    fn build_coordinator() -> (
588        Arc<RecorderSink>,
589        ReplicationMetricsRegistry,
590        ReplicationCoordinator,
591    ) {
592        let sink = Arc::new(RecorderSink::default());
593        let registry = ReplicationMetricsRegistry::new();
594        let coordinator = ReplicationCoordinator::new(
595            ChannelIdentity {
596                channel_name: "payments/settlements".to_string(),
597                origin_hash: 0xCAFE_BABE_DEAD_BEEF,
598            },
599            ReplicationConfig::new(),
600            sink.clone() as Arc<dyn ChainTagSink>,
601            &registry,
602        );
603        (sink, registry, coordinator)
604    }
605
606    #[tokio::test]
607    async fn starts_in_idle_with_zero_tail() {
608        let (_, _, c) = build_coordinator();
609        assert_eq!(c.role(), ReplicaRole::Idle);
610        assert_eq!(c.tail_seq(), 0);
611    }
612
613    #[tokio::test]
614    async fn idle_to_replica_announces_chain() {
615        let (sink, _, c) = build_coordinator();
616        c.record_tail_seq(42);
617        c.transition_to(ReplicaRole::Replica, TransitionSignal::CapabilitySelected)
618            .await
619            .expect("valid transition");
620        assert_eq!(c.role(), ReplicaRole::Replica);
621        assert_eq!(
622            sink.calls(),
623            vec![SinkCall::Announce {
624                origin_hash: 0xCAFE_BABE_DEAD_BEEF,
625                tip_seq: 42,
626            }],
627        );
628    }
629
630    #[tokio::test]
631    async fn candidate_to_leader_announces_chain() {
632        let (sink, _, c) = build_coordinator();
633        // Idle → Replica → Candidate → Leader
634        c.transition_to(ReplicaRole::Replica, TransitionSignal::CapabilitySelected)
635            .await
636            .unwrap();
637        c.transition_to(ReplicaRole::Candidate, TransitionSignal::MissedHeartbeats)
638            .await
639            .unwrap();
640        c.record_tail_seq(999);
641        c.transition_to(ReplicaRole::Leader, TransitionSignal::ElectionWon)
642            .await
643            .unwrap();
644        assert_eq!(c.role(), ReplicaRole::Leader);
645        // Two announces total: one for Replica entry, one for
646        // Leader entry. Candidate is transient — no announce.
647        let calls = sink.calls();
648        assert_eq!(calls.len(), 2);
649        assert!(matches!(calls[0], SinkCall::Announce { tip_seq: 0, .. }));
650        assert!(matches!(calls[1], SinkCall::Announce { tip_seq: 999, .. }));
651    }
652
653    #[tokio::test]
654    async fn candidate_does_not_emit_tag_side_effect() {
655        let (sink, _, c) = build_coordinator();
656        c.transition_to(ReplicaRole::Replica, TransitionSignal::CapabilitySelected)
657            .await
658            .unwrap();
659        // Replica → Candidate: state-only; no tag emission.
660        let baseline = sink.calls().len();
661        c.transition_to(ReplicaRole::Candidate, TransitionSignal::MissedHeartbeats)
662            .await
663            .unwrap();
664        assert_eq!(sink.calls().len(), baseline, "Candidate must not emit tags");
665    }
666
667    #[tokio::test]
668    async fn candidate_to_replica_no_tag_side_effect() {
669        let (sink, _, c) = build_coordinator();
670        c.transition_to(ReplicaRole::Replica, TransitionSignal::CapabilitySelected)
671            .await
672            .unwrap();
673        c.transition_to(ReplicaRole::Candidate, TransitionSignal::MissedHeartbeats)
674            .await
675            .unwrap();
676        let baseline = sink.calls().len();
677        // Losing election: Candidate → Replica. No new tag emission
678        // — already advertising "replica" via the prior announce.
679        c.transition_to(ReplicaRole::Replica, TransitionSignal::ElectionLost)
680            .await
681            .unwrap();
682        assert_eq!(
683            sink.calls().len(),
684            baseline,
685            "Candidate→Replica should not double-announce"
686        );
687    }
688
689    #[tokio::test]
690    async fn leader_to_idle_withdraws_chain() {
691        let (sink, _, c) = build_coordinator();
692        c.transition_to(ReplicaRole::Replica, TransitionSignal::CapabilitySelected)
693            .await
694            .unwrap();
695        c.transition_to(ReplicaRole::Candidate, TransitionSignal::MissedHeartbeats)
696            .await
697            .unwrap();
698        c.transition_to(ReplicaRole::Leader, TransitionSignal::ElectionWon)
699            .await
700            .unwrap();
701        c.transition_to(ReplicaRole::Idle, TransitionSignal::GracefulRelinquish)
702            .await
703            .unwrap();
704        let calls = sink.calls();
705        let last = calls.last().expect("at least one call");
706        assert_eq!(
707            *last,
708            SinkCall::Withdraw {
709                origin_hash: 0xCAFE_BABE_DEAD_BEEF,
710            },
711            "graceful relinquish must withdraw the chain tag",
712        );
713    }
714
715    #[tokio::test]
716    async fn replica_to_idle_disk_pressure_withdraws() {
717        let (sink, _, c) = build_coordinator();
718        c.transition_to(ReplicaRole::Replica, TransitionSignal::CapabilitySelected)
719            .await
720            .unwrap();
721        c.transition_to(ReplicaRole::Idle, TransitionSignal::DiskPressureWithdraw)
722            .await
723            .unwrap();
724        let calls = sink.calls();
725        assert_eq!(
726            *calls.last().unwrap(),
727            SinkCall::Withdraw {
728                origin_hash: 0xCAFE_BABE_DEAD_BEEF,
729            },
730        );
731    }
732
733    #[tokio::test]
734    async fn channel_close_from_idle_is_idempotent_noop() {
735        let (sink, registry, c) = build_coordinator();
736        let result = c
737            .transition_to(ReplicaRole::Idle, TransitionSignal::ChannelClose)
738            .await
739            .unwrap();
740        assert!(result.is_none(), "idempotent close must return None");
741        // No tag side-effect, no metric bump.
742        assert!(sink.calls().is_empty());
743        let snapshot = registry.snapshot();
744        assert_eq!(snapshot.channels.len(), 1);
745        let c_metrics = &snapshot.channels[0];
746        assert_eq!(c_metrics.leader_changes_total, 0);
747        assert_eq!(c_metrics.election_thrash_total, 0);
748    }
749
750    #[tokio::test]
751    async fn channel_close_from_active_state_withdraws() {
752        let (sink, _, c) = build_coordinator();
753        c.transition_to(ReplicaRole::Replica, TransitionSignal::CapabilitySelected)
754            .await
755            .unwrap();
756        c.transition_to(ReplicaRole::Idle, TransitionSignal::ChannelClose)
757            .await
758            .unwrap();
759        let calls = sink.calls();
760        assert!(matches!(*calls.last().unwrap(), SinkCall::Withdraw { .. }));
761    }
762
763    #[tokio::test]
764    async fn invalid_transition_does_not_mutate_state() {
765        let (sink, _, c) = build_coordinator();
766        // Idle → Leader is not in the matrix.
767        let err = c
768            .transition_to(ReplicaRole::Leader, TransitionSignal::ElectionWon)
769            .await
770            .expect_err("Idle→Leader must reject");
771        assert!(matches!(err, CoordinatorError::Transition(_)));
772        assert_eq!(c.role(), ReplicaRole::Idle, "state must not advance");
773        assert!(sink.calls().is_empty(), "no side-effect on rejection");
774    }
775
776    #[tokio::test]
777    async fn record_tail_seq_is_monotonic() {
778        let (_, _, c) = build_coordinator();
779        c.record_tail_seq(100);
780        assert_eq!(c.tail_seq(), 100);
781        c.record_tail_seq(50); // monotonic; drop
782        assert_eq!(c.tail_seq(), 100);
783        c.record_tail_seq(200);
784        assert_eq!(c.tail_seq(), 200);
785    }
786
787    #[tokio::test]
788    async fn metric_increments_on_leader_entry() {
789        let (_, registry, c) = build_coordinator();
790        c.transition_to(ReplicaRole::Replica, TransitionSignal::CapabilitySelected)
791            .await
792            .unwrap();
793        c.transition_to(ReplicaRole::Candidate, TransitionSignal::MissedHeartbeats)
794            .await
795            .unwrap();
796        c.transition_to(ReplicaRole::Leader, TransitionSignal::ElectionWon)
797            .await
798            .unwrap();
799        let snap = registry.snapshot();
800        let row = snap.channel("payments/settlements").unwrap();
801        assert_eq!(row.leader_changes_total, 1);
802        assert_eq!(row.election_thrash_total, 1, "MissedHeartbeats triggered");
803    }
804
805    #[tokio::test]
806    async fn metric_increments_on_repeat_leader_entries() {
807        // Simulate leader bounce: Replica → Candidate → Leader →
808        // Idle (channel close from leader: actually GracefulRelinquish)
809        // → Replica → Candidate → Leader. Counter must be 2.
810        let (_, registry, c) = build_coordinator();
811        c.transition_to(ReplicaRole::Replica, TransitionSignal::CapabilitySelected)
812            .await
813            .unwrap();
814        c.transition_to(ReplicaRole::Candidate, TransitionSignal::MissedHeartbeats)
815            .await
816            .unwrap();
817        c.transition_to(ReplicaRole::Leader, TransitionSignal::ElectionWon)
818            .await
819            .unwrap();
820        c.transition_to(ReplicaRole::Idle, TransitionSignal::GracefulRelinquish)
821            .await
822            .unwrap();
823        c.transition_to(ReplicaRole::Replica, TransitionSignal::CapabilitySelected)
824            .await
825            .unwrap();
826        c.transition_to(ReplicaRole::Candidate, TransitionSignal::MissedHeartbeats)
827            .await
828            .unwrap();
829        c.transition_to(ReplicaRole::Leader, TransitionSignal::ElectionWon)
830            .await
831            .unwrap();
832        let snap = registry.snapshot();
833        let row = snap.channel("payments/settlements").unwrap();
834        assert_eq!(row.leader_changes_total, 2);
835    }
836
837    /// R-3 regression: concurrent `transition_to` calls must
838    /// serialize their chain-tag side effects. A delaying sink
839    /// lets us pin that T1's announce_chain and T2's
840    /// withdraw_chain don't interleave — the observed call
841    /// sequence is exactly one of the two complete orderings
842    /// (announce-then-withdraw or withdraw-then-announce), never
843    /// a torn one.
844    #[tokio::test]
845    async fn concurrent_transitions_serialize_chain_tag_side_effects() {
846        use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
847
848        /// Sink that holds a barrier — every announce/withdraw
849        /// awaits the barrier (so if the coordinator didn't
850        /// serialize calls, both would block at the barrier
851        /// concurrently). Without the transition_lock the test
852        /// would observe `in_flight > 1` at some point; the
853        /// regression assertion catches that.
854        struct BarrierSink {
855            calls: tokio::sync::Mutex<Vec<SinkCall>>,
856            in_flight: AtomicUsize,
857            max_in_flight: AtomicUsize,
858        }
859
860        #[async_trait::async_trait]
861        impl ChainTagSink for BarrierSink {
862            async fn announce_chain(
863                &self,
864                origin_hash: u64,
865                tip_seq: u64,
866            ) -> Result<(), AdapterError> {
867                let n = self.in_flight.fetch_add(1, AtomicOrdering::SeqCst) + 1;
868                self.max_in_flight.fetch_max(n, AtomicOrdering::SeqCst);
869                // Give other tasks a chance to interleave if the
870                // transition_lock isn't holding them off.
871                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
872                self.calls.lock().await.push(SinkCall::Announce {
873                    origin_hash,
874                    tip_seq,
875                });
876                self.in_flight.fetch_sub(1, AtomicOrdering::SeqCst);
877                Ok(())
878            }
879            async fn withdraw_chain(&self, origin_hash: u64) -> Result<(), AdapterError> {
880                let n = self.in_flight.fetch_add(1, AtomicOrdering::SeqCst) + 1;
881                self.max_in_flight.fetch_max(n, AtomicOrdering::SeqCst);
882                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
883                self.calls
884                    .lock()
885                    .await
886                    .push(SinkCall::Withdraw { origin_hash });
887                self.in_flight.fetch_sub(1, AtomicOrdering::SeqCst);
888                Ok(())
889            }
890        }
891
892        let sink = Arc::new(BarrierSink {
893            calls: tokio::sync::Mutex::new(Vec::new()),
894            in_flight: AtomicUsize::new(0),
895            max_in_flight: AtomicUsize::new(0),
896        });
897        let registry = ReplicationMetricsRegistry::new();
898        let coord = Arc::new(ReplicationCoordinator::new(
899            ChannelIdentity {
900                channel_name: "concurrent/serialize".to_string(),
901                origin_hash: 0xC0FFEE,
902            },
903            ReplicationConfig::new(),
904            sink.clone() as Arc<dyn ChainTagSink>,
905            &registry,
906        ));
907
908        // Drive concurrent transitions: T1 wants Idle→Replica
909        // (announce); T2 racing on the same coordinator. Since
910        // only one transition is valid at a time, T2 races by
911        // doing Replica→Idle right after T1 announces. The
912        // transition_lock ensures T2's withdraw can't START
913        // before T1's announce COMPLETES.
914        let c1 = coord.clone();
915        let t1 = tokio::spawn(async move {
916            c1.transition_to(ReplicaRole::Replica, TransitionSignal::CapabilitySelected)
917                .await
918        });
919        let c2 = coord.clone();
920        let t2 = tokio::spawn(async move {
921            // Yield then drive the withdraw transition. The lock
922            // serializes: if T1 holds the transition_lock, T2's
923            // state-mutex acquire only happens after T1's full
924            // body (sink call included) finishes.
925            tokio::time::sleep(std::time::Duration::from_millis(2)).await;
926            c2.transition_to(ReplicaRole::Idle, TransitionSignal::DiskPressureWithdraw)
927                .await
928        });
929        let (r1, r2) = tokio::join!(t1, t2);
930        r1.unwrap().expect("T1 transition succeeds");
931        r2.unwrap().expect("T2 transition succeeds");
932
933        let max_concurrent = sink.max_in_flight.load(AtomicOrdering::SeqCst);
934        assert_eq!(
935            max_concurrent, 1,
936            "transition_lock must serialize sink calls (observed max in-flight = {max_concurrent})"
937        );
938
939        // Sanity: both side-effects landed in announce-then-
940        // withdraw order (T1's announce came first because the
941        // lock made T2 wait).
942        let calls = sink.calls.lock().await.clone();
943        assert_eq!(calls.len(), 2);
944        assert!(matches!(calls[0], SinkCall::Announce { .. }));
945        assert!(matches!(calls[1], SinkCall::Withdraw { .. }));
946    }
947
948    #[tokio::test]
949    async fn tag_sink_failure_surfaces_but_state_mutated() {
950        // Plan §3 pin: "On graceful shutdown, transition to Idle
951        // and withdraw the replica's `causal:` tag." If the
952        // withdraw fails, the state still advances to Idle (the
953        // coordinator can't undo the role change just because the
954        // network blip happened). Operator observes a divergence
955        // between local state and advertised state until the next
956        // tick retries.
957        let (sink, _, c) = build_coordinator();
958        c.transition_to(ReplicaRole::Replica, TransitionSignal::CapabilitySelected)
959            .await
960            .unwrap();
961        sink.arm_failure(AdapterError::Transient(
962            "simulated network blip".to_string(),
963        ));
964        let err = c
965            .transition_to(ReplicaRole::Idle, TransitionSignal::DiskPressureWithdraw)
966            .await
967            .expect_err("must surface sink failure");
968        assert!(matches!(err, CoordinatorError::TagSink(_)));
969        // State HAS advanced — withdraw "happened locally" even
970        // though the wire side missed.
971        assert_eq!(c.role(), ReplicaRole::Idle);
972    }
973
974    /// Pin that a `* → Idle` sink failure bumps
975    /// `announce_divergence_total` on the channel's metrics so
976    /// operators see the gap between local state and advertised
977    /// holder set. Recovery is opportunistic on the next
978    /// `transition_to` call; the counter is the observability
979    /// surface for the window in between.
980    #[tokio::test]
981    async fn tag_sink_failure_bumps_divergence_counter() {
982        use std::sync::atomic::Ordering as AtomicOrdering;
983
984        let (sink, _, c) = build_coordinator();
985        c.transition_to(ReplicaRole::Replica, TransitionSignal::CapabilitySelected)
986            .await
987            .unwrap();
988        let before = c
989            .metrics()
990            .announce_divergence_total
991            .load(AtomicOrdering::Relaxed);
992
993        sink.arm_failure(AdapterError::Transient(
994            "simulated network blip".to_string(),
995        ));
996        let _ = c
997            .transition_to(ReplicaRole::Idle, TransitionSignal::DiskPressureWithdraw)
998            .await
999            .expect_err("must surface sink failure");
1000
1001        let after = c
1002            .metrics()
1003            .announce_divergence_total
1004            .load(AtomicOrdering::Relaxed);
1005        assert_eq!(
1006            after,
1007            before + 1,
1008            "announce_divergence_total must bump by exactly 1 on the failed withdraw"
1009        );
1010    }
1011}