use crate::role::{CordonClass, WorkerRole};
pub type PinKey = u64;
pub type UnitId = u64;
pub const MERGE_PIN_KEY: PinKey = 0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SlotState {
Idle,
Leased {
role: WorkerRole,
key: Option<PinKey>,
},
Running {
role: WorkerRole,
key: Option<PinKey>,
},
Pinned { role: WorkerRole, key: PinKey },
Draining { role: WorkerRole },
}
impl SlotState {
#[must_use]
pub const fn role(self) -> Option<WorkerRole> {
match self {
Self::Idle => None,
Self::Leased { role, .. }
| Self::Running { role, .. }
| Self::Pinned { role, .. }
| Self::Draining { role } => Some(role),
}
}
#[must_use]
pub const fn holds_in_flight(self) -> bool {
matches!(
self,
Self::Leased { .. } | Self::Running { .. } | Self::Pinned { .. }
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Transition {
Lease {
role: WorkerRole,
key: Option<PinKey>,
},
Start { unit: UnitId },
CompleteToPinned,
CompleteToIdle,
BeginDraining,
DrainComplete,
ReleasePin,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TransitionContext {
pub at_epoch_boundary: bool,
pub posture_admits_role: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("rejected transition {from:?} --{transition:?}--> ({reason})")]
pub struct RejectedTransition {
pub from: SlotState,
pub transition: Transition,
pub reason: RejectionReason,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum RejectionReason {
#[error("posture blocks new lease intake for this role")]
PostureBlocksIntake,
#[error("pin release requires an epoch boundary (T9)")]
MidCyclePin,
#[error("Solver completion carries no pin key (T3)")]
MissingPinKey,
#[error("pinnable-role lease claim carries no pin key (T1)")]
ClaimMissingKey,
#[error("Merge pin claims must carry MERGE_PIN_KEY (T1/T4: exactly one merge pin)")]
MergeKeyMismatch,
#[error("no legal row (T1-T9) covers this from/transition pair")]
NoLegalRow,
}
#[must_use = "a rejected transition is a conformance event, not a suggestion"]
pub fn transition(
from: SlotState,
t: Transition,
ctx: TransitionContext,
) -> Result<SlotState, RejectedTransition> {
let reject = |reason: RejectionReason| {
Err::<SlotState, RejectedTransition>(RejectedTransition {
from,
transition: t,
reason,
})
};
let posture_guards_role = |role: WorkerRole| {
(role.cordon_class() == CordonClass::Deferrable && !ctx.posture_admits_role)
.then_some(RejectionReason::PostureBlocksIntake)
};
match (from, t) {
(SlotState::Idle, Transition::Lease { role, key }) => {
if let Some(reason) = posture_guards_role(role) {
return reject(reason);
}
if role.is_pinnable() && key.is_none() {
return reject(RejectionReason::ClaimMissingKey);
}
if role == WorkerRole::Merge && key != Some(MERGE_PIN_KEY) {
return reject(RejectionReason::MergeKeyMismatch);
}
Ok(SlotState::Leased { role, key })
}
(SlotState::Leased { role, key }, Transition::Start { .. }) => {
Ok(SlotState::Running { role, key })
}
(SlotState::Pinned { role, key }, Transition::Start { .. }) => Ok(SlotState::Running {
role,
key: Some(key),
}),
(SlotState::Running { role, key }, Transition::CompleteToPinned) => {
match role {
WorkerRole::Solver => match key {
Some(k) => Ok(SlotState::Pinned { role, key: k }),
None => reject(RejectionReason::MissingPinKey),
},
WorkerRole::Merge => Ok(SlotState::Pinned {
role,
key: MERGE_PIN_KEY,
}),
_ => reject(RejectionReason::NoLegalRow),
}
}
(
SlotState::Running {
role: WorkerRole::SimDriver | WorkerRole::Resolve | WorkerRole::PoolStateUpdater,
..
},
Transition::CompleteToIdle,
)
| (SlotState::Draining { .. }, Transition::DrainComplete) => Ok(SlotState::Idle),
(
SlotState::Running { role, .. } | SlotState::Leased { role, .. },
Transition::BeginDraining,
) => Ok(SlotState::Draining { role }),
(SlotState::Pinned { .. }, Transition::ReleasePin) => {
if ctx.at_epoch_boundary {
Ok(SlotState::Idle)
} else {
reject(RejectionReason::MidCyclePin)
}
}
_ => reject(RejectionReason::NoLegalRow),
}
}
#[cfg(test)]
#[expect(clippy::expect_used)]
mod tests {
use super::*;
use crate::role::ALL_ROLES;
const ADMITS: TransitionContext = TransitionContext {
at_epoch_boundary: false,
posture_admits_role: true,
};
const BLOCKS: TransitionContext = TransitionContext {
at_epoch_boundary: false,
posture_admits_role: false,
};
const fn admits(epochs: bool) -> TransitionContext {
TransitionContext {
at_epoch_boundary: epochs,
posture_admits_role: true,
}
}
#[test]
fn t1_idle_leases_a_pooled_role() {
for role in [
WorkerRole::SimDriver,
WorkerRole::Resolve,
WorkerRole::PoolStateUpdater,
] {
let to = transition(
SlotState::Idle,
Transition::Lease { role, key: None },
ADMITS,
)
.expect("T1 pooled lease");
assert_eq!(
to,
SlotState::Leased { role, key: None },
"T1 must lease pooled roles"
);
}
}
#[test]
fn t1_pin_claims_carry_keys() {
let solver = transition(
SlotState::Idle,
Transition::Lease {
role: WorkerRole::Solver,
key: Some(2),
},
ADMITS,
)
.expect("T1 solver pin claim");
assert_eq!(
solver,
SlotState::Leased {
role: WorkerRole::Solver,
key: Some(2)
}
);
let merge = transition(
SlotState::Idle,
Transition::Lease {
role: WorkerRole::Merge,
key: Some(MERGE_PIN_KEY),
},
ADMITS,
)
.expect("T1 merge pin claim");
assert_eq!(
merge,
SlotState::Leased {
role: WorkerRole::Merge,
key: Some(MERGE_PIN_KEY)
}
);
}
#[test]
fn t1_rejects_keyless_pinnable_claims_and_bad_merge_keys() {
let missing = transition(
SlotState::Idle,
Transition::Lease {
role: WorkerRole::Solver,
key: None,
},
ADMITS,
)
.expect_err("pin claim must carry its key");
assert_eq!(missing.reason, RejectionReason::ClaimMissingKey);
let wrong = transition(
SlotState::Idle,
Transition::Lease {
role: WorkerRole::Merge,
key: Some(9),
},
ADMITS,
)
.expect_err("merge claims must carry MERGE_PIN_KEY");
assert_eq!(wrong.reason, RejectionReason::MergeKeyMismatch);
}
#[test]
fn t1_is_blocked_by_a_cordoned_posture_for_deferrable_roles() {
for role in [
WorkerRole::PoolStateUpdater,
WorkerRole::Registrar,
WorkerRole::Verifier,
] {
let rejected = transition(
SlotState::Idle,
Transition::Lease { role, key: None },
BLOCKS,
)
.expect_err("cordon blocks deferrable intake");
assert_eq!(rejected.reason, RejectionReason::PostureBlocksIntake);
}
for (role, key) in [
(WorkerRole::SimDriver, None),
(WorkerRole::Resolve, None),
(WorkerRole::Solver, Some(1)),
(WorkerRole::Merge, Some(MERGE_PIN_KEY)),
(WorkerRole::Submitter, None),
] {
transition(SlotState::Idle, Transition::Lease { role, key }, BLOCKS)
.map(|_| ())
.expect("role leases through cordon");
}
}
#[test]
fn t2_leased_starts_running_preserving_the_claim_key() {
let running = transition(
SlotState::Leased {
role: WorkerRole::SimDriver,
key: None,
},
Transition::Start { unit: 7 },
ADMITS,
)
.expect("T2");
assert_eq!(
running,
SlotState::Running {
role: WorkerRole::SimDriver,
key: None
}
);
let pinned_walk = transition(
SlotState::Leased {
role: WorkerRole::Solver,
key: Some(3),
},
Transition::Start { unit: 11 },
ADMITS,
)
.expect("T2 pin-claim start");
assert_eq!(
pinned_walk,
SlotState::Running {
role: WorkerRole::Solver,
key: Some(3)
},
"T2 must preserve the claimed pin key into Running"
);
}
#[test]
fn t2_starts_a_granted_unit_even_under_cordon() {
let running = transition(
SlotState::Leased {
role: WorkerRole::Registrar,
key: None,
},
Transition::Start { unit: 1 },
BLOCKS,
)
.expect("a granted unit always starts — cordon sheds, never strands");
assert_eq!(
running,
SlotState::Running {
role: WorkerRole::Registrar,
key: None
}
);
}
#[test]
fn t3_solver_walk_pins_to_its_bin_key() {
let pinned = transition(
SlotState::Running {
role: WorkerRole::Solver,
key: Some(5),
},
Transition::CompleteToPinned,
ADMITS,
)
.expect("T3");
assert_eq!(
pinned,
SlotState::Pinned {
role: WorkerRole::Solver,
key: 5
}
);
}
#[test]
fn t3_rejects_a_keyless_solver_completion() {
let rejected = transition(
SlotState::Running {
role: WorkerRole::Solver,
key: None,
},
Transition::CompleteToPinned,
ADMITS,
)
.expect_err("T3 requires a pin key");
assert_eq!(rejected.reason, RejectionReason::MissingPinKey);
}
#[test]
fn t4_merge_pins_to_the_single_merge_key() {
let pinned = transition(
SlotState::Running {
role: WorkerRole::Merge,
key: Some(MERGE_PIN_KEY),
},
Transition::CompleteToPinned,
ADMITS,
)
.expect("T4");
assert_eq!(
pinned,
SlotState::Pinned {
role: WorkerRole::Merge,
key: MERGE_PIN_KEY
}
);
}
#[test]
fn t4_rejects_re_pinning_another_role() {
for role in [
WorkerRole::SimDriver,
WorkerRole::Resolve,
WorkerRole::Registrar,
] {
let rejected = transition(
SlotState::Running { role, key: None },
Transition::CompleteToPinned,
ADMITS,
)
.expect_err("only pinnable roles reach Pinned");
assert_eq!(rejected.reason, RejectionReason::NoLegalRow, "{role:?}");
}
}
#[test]
fn t5_pooled_roles_return_to_idle() {
for role in [
WorkerRole::SimDriver,
WorkerRole::Resolve,
WorkerRole::PoolStateUpdater,
] {
let idle = transition(
SlotState::Running { role, key: None },
Transition::CompleteToIdle,
ADMITS,
)
.expect("T5");
assert_eq!(
idle,
SlotState::Idle,
"{role:?} must return to the pooled set"
);
}
}
#[test]
fn t5_rejects_running_to_idle_for_pinned_roles() {
for (role, key) in [
(WorkerRole::Solver, Some(1)),
(WorkerRole::Merge, Some(MERGE_PIN_KEY)),
] {
let rejected = transition(
SlotState::Running { role, key },
Transition::CompleteToIdle,
ADMITS,
)
.expect_err("pinned roles never drop straight to Idle");
assert_eq!(rejected.reason, RejectionReason::NoLegalRow);
}
}
#[test]
fn t6_pinned_slots_take_the_next_cycle_unit_same_key() {
let running = transition(
SlotState::Pinned {
role: WorkerRole::Solver,
key: 5,
},
Transition::Start { unit: 42 },
ADMITS,
)
.expect("T6");
assert_eq!(
running,
SlotState::Running {
role: WorkerRole::Solver,
key: Some(5)
},
"T6 keeps the pin key: the pin IS the key"
);
}
#[test]
fn t6_is_blocked_by_cordon_for_deferrable_pins() {
let fake_deferrable_pin_ctx = BLOCKS;
let rejected = transition(
SlotState::Pinned {
role: WorkerRole::Solver,
key: 1,
},
Transition::Start { unit: 2 },
fake_deferrable_pin_ctx,
);
assert!(
rejected.is_ok(),
"Solver pin continuation is cordon-invariant"
);
}
#[test]
fn t7_running_units_drain_under_cordon() {
for role in [
WorkerRole::SimDriver,
WorkerRole::Solver,
WorkerRole::Merge,
WorkerRole::PoolStateUpdater,
] {
let draining = transition(
SlotState::Running { role, key: None },
Transition::BeginDraining,
ADMITS,
)
.expect("T7");
assert_eq!(draining, SlotState::Draining { role }, "{role:?}");
}
}
#[test]
fn t8_draining_completes_to_idle() {
let idle = transition(
SlotState::Draining {
role: WorkerRole::SimDriver,
},
Transition::DrainComplete,
ADMITS,
)
.expect("T8");
assert_eq!(idle, SlotState::Idle);
}
#[test]
fn t8_draining_rejects_new_work() {
for t in [
Transition::Start { unit: 1 },
Transition::Lease {
role: WorkerRole::SimDriver,
key: None,
},
] {
let rejected = transition(
SlotState::Draining {
role: WorkerRole::SimDriver,
},
t,
ADMITS,
)
.expect_err("Draining takes nothing new");
assert_eq!(rejected.reason, RejectionReason::NoLegalRow);
}
}
#[test]
fn t9_pin_release_is_epoch_boundary_only() {
let ok = transition(
SlotState::Pinned {
role: WorkerRole::Solver,
key: 5,
},
Transition::ReleasePin,
admits(true),
)
.expect("T9 at epoch boundary");
assert_eq!(ok, SlotState::Idle);
let rejected = transition(
SlotState::Pinned {
role: WorkerRole::Solver,
key: 5,
},
Transition::ReleasePin,
admits(false),
)
.expect_err("T9 mid-cycle is illegal");
assert_eq!(rejected.reason, RejectionReason::MidCyclePin);
}
#[test]
fn t9_never_re_keys_in_place() {
let rejected = transition(
SlotState::Pinned {
role: WorkerRole::Solver,
key: 1,
},
Transition::CompleteToPinned,
ADMITS,
)
.expect_err("re-keying in place is illegal");
assert_eq!(rejected.reason, RejectionReason::NoLegalRow);
}
#[test]
fn illegal_idle_to_running_is_rejected() {
for t in [
Transition::Start { unit: 1 },
Transition::CompleteToPinned,
Transition::CompleteToIdle,
Transition::BeginDraining,
Transition::DrainComplete,
Transition::ReleasePin,
] {
let rejected =
transition(SlotState::Idle, t, admits(true)).expect_err("Idle only moves via T1");
assert_eq!(rejected.reason, RejectionReason::NoLegalRow, "{t:?}");
}
}
#[test]
fn illegal_non_t1_moves_from_idle_and_pinned_and_draining() {
for from in [
SlotState::Idle,
SlotState::Draining {
role: WorkerRole::Resolve,
},
] {
for t in [
Transition::CompleteToPinned,
Transition::CompleteToIdle,
Transition::ReleasePin,
] {
let rejected = transition(from, t, admits(true))
.expect_err("coverage: no row allows {t:?} from {from:?}");
assert_eq!(rejected.reason, RejectionReason::NoLegalRow);
}
}
for t in [
Transition::CompleteToIdle,
Transition::BeginDraining,
Transition::DrainComplete,
] {
let rejected = transition(
SlotState::Pinned {
role: WorkerRole::Merge,
key: MERGE_PIN_KEY,
},
t,
admits(true),
)
.expect_err("pinned merges only Start (T6) or ReleasePin (T9)");
assert_eq!(rejected.reason, RejectionReason::NoLegalRow, "{t:?}");
}
}
#[test]
fn role_and_in_flight_projections_agree() {
assert_eq!(SlotState::Idle.role(), None);
for role in ALL_ROLES {
for state in [
SlotState::Leased { role, key: None },
SlotState::Running { role, key: None },
SlotState::Pinned { role, key: 0 },
SlotState::Draining { role },
] {
assert_eq!(state.role(), Some(role));
}
}
assert!(SlotState::Running {
role: WorkerRole::Solver,
key: None
}
.holds_in_flight());
assert!(!SlotState::Idle.holds_in_flight());
assert!(!SlotState::Draining {
role: WorkerRole::Solver
}
.holds_in_flight());
}
}