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 ®istry,
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 ®istry,
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}