use super::replication::ReplicaRole;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransitionSignal {
CapabilitySelected,
MissedHeartbeats,
ElectionWon,
ElectionLost,
GracefulRelinquish,
DiskPressureWithdraw,
LeaderDiskPressureWithdraw,
CandidateDiskPressureWithdraw,
ChannelClose,
PeerLeaderObserved,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum StateTransitionError {
#[error("invalid replica state transition: {from:?} → {to:?}")]
InvalidPair {
from: ReplicaRole,
to: ReplicaRole,
},
#[error("transition {from:?} → {to:?} not permitted under signal {signal:?}")]
SignalMismatch {
from: ReplicaRole,
to: ReplicaRole,
signal: TransitionSignal,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StateTransition {
pub from: ReplicaRole,
pub to: ReplicaRole,
pub signal: TransitionSignal,
}
impl StateTransition {
pub fn apply(
from: ReplicaRole,
to: ReplicaRole,
signal: TransitionSignal,
) -> Result<Self, StateTransitionError> {
if signal == TransitionSignal::ChannelClose && to == ReplicaRole::Idle {
return Ok(Self { from, to, signal });
}
let permitted = matches!(
(from, to, signal),
(
ReplicaRole::Idle,
ReplicaRole::Replica,
TransitionSignal::CapabilitySelected,
) | (
ReplicaRole::Replica,
ReplicaRole::Candidate,
TransitionSignal::MissedHeartbeats,
) | (
ReplicaRole::Candidate,
ReplicaRole::Leader,
TransitionSignal::ElectionWon,
) | (
ReplicaRole::Candidate,
ReplicaRole::Replica,
TransitionSignal::ElectionLost,
) | (
ReplicaRole::Leader,
ReplicaRole::Idle,
TransitionSignal::GracefulRelinquish,
) | (
ReplicaRole::Replica,
ReplicaRole::Idle,
TransitionSignal::DiskPressureWithdraw,
) | (
ReplicaRole::Leader,
ReplicaRole::Idle,
TransitionSignal::LeaderDiskPressureWithdraw,
) | (
ReplicaRole::Candidate,
ReplicaRole::Idle,
TransitionSignal::CandidateDiskPressureWithdraw,
) | (
ReplicaRole::Leader,
ReplicaRole::Replica,
TransitionSignal::PeerLeaderObserved,
)
);
if !permitted {
if pair_is_valid_for_some_signal(from, to) {
return Err(StateTransitionError::SignalMismatch { from, to, signal });
}
return Err(StateTransitionError::InvalidPair { from, to });
}
Ok(Self { from, to, signal })
}
}
fn pair_is_valid_for_some_signal(from: ReplicaRole, to: ReplicaRole) -> bool {
if to == ReplicaRole::Idle {
return true;
}
matches!(
(from, to),
(ReplicaRole::Idle, ReplicaRole::Replica)
| (ReplicaRole::Replica, ReplicaRole::Candidate)
| (ReplicaRole::Candidate, ReplicaRole::Leader)
| (ReplicaRole::Candidate, ReplicaRole::Replica)
| (ReplicaRole::Leader, ReplicaRole::Replica)
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn idle_to_replica_via_capability_selected() {
let t = StateTransition::apply(
ReplicaRole::Idle,
ReplicaRole::Replica,
TransitionSignal::CapabilitySelected,
)
.expect("plan §3 valid pair");
assert_eq!(t.from, ReplicaRole::Idle);
assert_eq!(t.to, ReplicaRole::Replica);
assert_eq!(t.signal, TransitionSignal::CapabilitySelected);
}
#[test]
fn replica_to_candidate_via_missed_heartbeats() {
StateTransition::apply(
ReplicaRole::Replica,
ReplicaRole::Candidate,
TransitionSignal::MissedHeartbeats,
)
.expect("plan §3 valid pair");
}
#[test]
fn candidate_to_leader_via_election_won() {
StateTransition::apply(
ReplicaRole::Candidate,
ReplicaRole::Leader,
TransitionSignal::ElectionWon,
)
.expect("plan §3 valid pair");
}
#[test]
fn candidate_to_replica_via_election_lost() {
StateTransition::apply(
ReplicaRole::Candidate,
ReplicaRole::Replica,
TransitionSignal::ElectionLost,
)
.expect("plan §3 valid pair");
}
#[test]
fn leader_to_idle_via_graceful_relinquish() {
StateTransition::apply(
ReplicaRole::Leader,
ReplicaRole::Idle,
TransitionSignal::GracefulRelinquish,
)
.expect("plan §3 valid pair");
}
#[test]
fn replica_to_idle_via_disk_pressure() {
StateTransition::apply(
ReplicaRole::Replica,
ReplicaRole::Idle,
TransitionSignal::DiskPressureWithdraw,
)
.expect("plan §3 valid pair");
}
#[test]
fn channel_close_terminates_from_any_state() {
for from in [
ReplicaRole::Leader,
ReplicaRole::Replica,
ReplicaRole::Candidate,
ReplicaRole::Idle,
] {
StateTransition::apply(from, ReplicaRole::Idle, TransitionSignal::ChannelClose)
.unwrap_or_else(|_| panic!("ChannelClose must drive {from:?} → Idle"));
}
}
#[test]
fn rejects_invalid_pair_idle_to_leader() {
let err = StateTransition::apply(
ReplicaRole::Idle,
ReplicaRole::Leader,
TransitionSignal::ElectionWon,
)
.expect_err("Idle → Leader is not in the matrix");
assert!(matches!(
err,
StateTransitionError::InvalidPair {
from: ReplicaRole::Idle,
to: ReplicaRole::Leader,
}
));
}
#[test]
fn rejects_invalid_pair_replica_to_leader() {
let err = StateTransition::apply(
ReplicaRole::Replica,
ReplicaRole::Leader,
TransitionSignal::ElectionWon,
)
.expect_err("Replica → Leader bypasses Candidate");
assert!(matches!(err, StateTransitionError::InvalidPair { .. }));
}
#[test]
fn rejects_invalid_pair_leader_to_candidate() {
let err = StateTransition::apply(
ReplicaRole::Leader,
ReplicaRole::Candidate,
TransitionSignal::MissedHeartbeats,
)
.expect_err("Leader → Candidate is not in the matrix");
assert!(matches!(err, StateTransitionError::InvalidPair { .. }));
}
#[test]
fn rejects_pair_valid_but_signal_mismatch() {
let err = StateTransition::apply(
ReplicaRole::Idle,
ReplicaRole::Replica,
TransitionSignal::ElectionWon,
)
.expect_err("wrong signal for Idle → Replica");
assert!(matches!(
err,
StateTransitionError::SignalMismatch {
from: ReplicaRole::Idle,
to: ReplicaRole::Replica,
signal: TransitionSignal::ElectionWon,
}
));
}
#[test]
fn rejects_self_transitions_via_normal_signals() {
for state in [
ReplicaRole::Leader,
ReplicaRole::Replica,
ReplicaRole::Candidate,
ReplicaRole::Idle,
] {
for signal in [
TransitionSignal::CapabilitySelected,
TransitionSignal::MissedHeartbeats,
TransitionSignal::ElectionWon,
TransitionSignal::ElectionLost,
TransitionSignal::GracefulRelinquish,
TransitionSignal::DiskPressureWithdraw,
] {
let result = StateTransition::apply(state, state, signal);
assert!(
result.is_err(),
"self-transition {state:?} → {state:?} via {signal:?} must be rejected",
);
}
}
}
#[test]
fn channel_close_idle_to_idle_is_valid_idempotent_shutdown() {
StateTransition::apply(
ReplicaRole::Idle,
ReplicaRole::Idle,
TransitionSignal::ChannelClose,
)
.expect("ChannelClose on Idle is idempotent");
}
#[test]
fn channel_close_with_wrong_target_rejected() {
let err = StateTransition::apply(
ReplicaRole::Replica,
ReplicaRole::Leader,
TransitionSignal::ChannelClose,
)
.expect_err("ChannelClose target must be Idle");
assert!(matches!(err, StateTransitionError::InvalidPair { .. }));
}
#[test]
fn matrix_exhaustive_negative_coverage() {
const ROLES: [ReplicaRole; 4] = [
ReplicaRole::Leader,
ReplicaRole::Replica,
ReplicaRole::Candidate,
ReplicaRole::Idle,
];
const SIGNALS: [TransitionSignal; 7] = [
TransitionSignal::CapabilitySelected,
TransitionSignal::MissedHeartbeats,
TransitionSignal::ElectionWon,
TransitionSignal::ElectionLost,
TransitionSignal::GracefulRelinquish,
TransitionSignal::DiskPressureWithdraw,
TransitionSignal::ChannelClose,
];
let mut valid_pairs = 0;
for from in ROLES {
for to in ROLES {
let mut signal_hits = 0;
for signal in SIGNALS {
if StateTransition::apply(from, to, signal).is_ok() {
signal_hits += 1;
}
}
if signal_hits > 0 {
valid_pairs += 1;
}
if to == ReplicaRole::Idle {
assert!(
signal_hits <= 2,
"{from:?} → Idle has too many valid signals: {signal_hits}",
);
} else {
assert!(
signal_hits <= 1,
"{from:?} → {to:?} has too many valid signals: {signal_hits}",
);
}
}
}
assert_eq!(valid_pairs, 8, "expected 8 reachable (from, to) pairs");
}
}