swactor/crates/core/src/lib.rs

273 lines
11 KiB
Rust

//! Pure, synchronous, deterministic core for the swactor actor framework.
//!
//! This crate contains the decision logic that maps 1:1 to a TLA+ specification.
//! Every public function is a pure function: `fn(inputs) -> output` with no side
//! effects, no IO, no allocation, and no panics.
//!
//! The runtime crate (`swactor`) calls these functions to make decisions; this crate
//! never calls back into the runtime. If you can't trivially transliterate a
//! function's signature into TLA+, it doesn't belong here.
#![no_std]
#![deny(unsafe_code)]
// ─── Actor Lifecycle Phase ───────────────────────────────────────────────────
/// The lifecycle phase of an actor slot.
///
/// Replaces the four boolean fields (`started`, `poisoned`, `stopping`, `suspended`)
/// with a single state machine. Every phase transition goes through
/// [`lifecycle_transition`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActorPhase {
/// Actor has been inserted into the pool but `on_start` has not been called yet.
Unstarted,
/// Actor is running normally — processing messages from its mailbox.
Running,
/// Actor is suspended — messages continue to queue but are not processed.
Suspended,
/// Actor has been requested to stop gracefully. `on_stop` will be called during cleanup.
Stopping,
/// Actor panicked during `on_start` or message handling. No further processing occurs.
Poisoned,
}
// ─── Stop / Exit Reasons ─────────────────────────────────────────────────────
/// Reason an actor was removed from the runtime.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum StopReason {
/// Graceful stop (via `ctx.stop_self()` or `Runtime::stop_actor()`).
Normal,
/// Actor panicked and could not be restarted.
Panicked,
/// Actor stopped with a typed exit value (via `Ctx::stop_with`).
Completed,
}
/// Why an actor exited — delivered to watchers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExitReason {
/// Actor was explicitly stopped or removed from the pool.
Stopped,
/// Actor panicked during message handling.
Panicked,
/// The node hosting the actor left the cluster (SWIM Dead).
NodeDown,
/// Actor stopped with a typed exit value (via `Ctx::stop_with`).
Completed,
}
// ─── Mailbox ─────────────────────────────────────────────────────────────────
/// What to do when a bounded mailbox is full.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MailboxOverflow {
/// Drop the incoming message (newest). The message is silently discarded.
DropNewest,
/// Drop the oldest message in the queue to make room for the new one.
DropOldest,
}
/// Result of the mailbox capacity check.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MailboxDecision {
/// The mailbox has room (or is unbounded). Accept the message.
Accept,
/// The mailbox is full and the policy is DropNewest — reject the incoming message.
RejectNewest,
/// The mailbox is full and the policy is DropOldest — evict the front of the queue.
EvictOldest,
}
// ─── Lifecycle Events ────────────────────────────────────────────────────────
/// Events that drive the actor lifecycle state machine.
///
/// Each variant corresponds to a runtime action that may cause a phase transition.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LifecycleEvent {
/// `on_start` completed successfully.
Started,
/// `on_start` panicked.
StartPanicked,
/// A message handler panicked.
MessagePanicked,
/// A graceful stop was requested (via `ctx.stop_self()` or `StopSignal`).
StopRequested,
/// A stop-with-value was requested (via `ctx.stop_with()`).
StopWithRequested,
/// The actor was suspended (via `ctx.suspend_self()`).
SuspendRequested,
/// A suspended actor was resumed (via `ResumeSignal`).
Resumed,
}
// ─── Deliver Decision ────────────────────────────────────────────────────────
/// What should happen when a message arrives at an actor in a particular phase.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeliverDecision {
/// Enqueue the message in the actor's mailbox (subject to overflow policy).
Enqueue,
/// The actor is dead/stopping — discard the message silently.
Discard,
/// The actor is suspended — enqueue but do not process until resumed.
Queue,
}
// ─── Pure Functions ─────────────────────────────────────────────────────────
// TLA+: LifecycleTransition
/// The actor lifecycle state machine.
///
/// Given the current phase and an event, returns the new phase. Every phase
/// transition in the runtime must go through this function.
///
/// Illegal transitions (e.g., `Resumed` on a non-suspended actor) return the
/// phase unchanged — the runtime is responsible for not issuing nonsensical
/// events, but the core never panics.
#[must_use]
pub fn lifecycle_transition(phase: ActorPhase, event: LifecycleEvent) -> ActorPhase {
match (phase, event) {
// Unstarted: waiting for on_start result
(ActorPhase::Unstarted, LifecycleEvent::Started) => ActorPhase::Running,
(ActorPhase::Unstarted, LifecycleEvent::StartPanicked) => ActorPhase::Poisoned,
(ActorPhase::Unstarted, LifecycleEvent::StopRequested) => ActorPhase::Stopping,
(ActorPhase::Unstarted, LifecycleEvent::StopWithRequested) => ActorPhase::Stopping,
(ActorPhase::Unstarted, LifecycleEvent::SuspendRequested) => ActorPhase::Suspended,
(ActorPhase::Unstarted, LifecycleEvent::MessagePanicked) => phase,
(ActorPhase::Unstarted, LifecycleEvent::Resumed) => phase,
// Running: normal operation
(ActorPhase::Running, LifecycleEvent::MessagePanicked) => ActorPhase::Poisoned,
(ActorPhase::Running, LifecycleEvent::StopRequested) => ActorPhase::Stopping,
(ActorPhase::Running, LifecycleEvent::StopWithRequested) => ActorPhase::Stopping,
(ActorPhase::Running, LifecycleEvent::SuspendRequested) => ActorPhase::Suspended,
(ActorPhase::Running, LifecycleEvent::Started) => phase,
(ActorPhase::Running, LifecycleEvent::StartPanicked) => phase,
(ActorPhase::Running, LifecycleEvent::Resumed) => phase,
// Suspended: waiting for resume or stop
(ActorPhase::Suspended, LifecycleEvent::Resumed) => ActorPhase::Running,
(ActorPhase::Suspended, LifecycleEvent::StopRequested) => ActorPhase::Stopping,
(ActorPhase::Suspended, LifecycleEvent::StopWithRequested) => ActorPhase::Stopping,
(ActorPhase::Suspended, LifecycleEvent::Started) => phase,
(ActorPhase::Suspended, LifecycleEvent::StartPanicked) => phase,
(ActorPhase::Suspended, LifecycleEvent::MessagePanicked) => phase,
(ActorPhase::Suspended, LifecycleEvent::SuspendRequested) => phase,
// Terminal states: Stopping and Poisoned absorb all events
(ActorPhase::Stopping, _) => ActorPhase::Stopping,
(ActorPhase::Poisoned, _) => ActorPhase::Poisoned,
}
}
// TLA+: MailboxAccept
/// Decide what to do with an incoming message given mailbox state and policy.
///
/// `capacity` of 0 means unbounded (always accept). When the mailbox is full,
/// the overflow policy determines whether the newest or oldest message is dropped.
#[must_use]
pub fn mailbox_accept(
mailbox_len: usize,
capacity: usize,
policy: MailboxOverflow,
) -> MailboxDecision {
if capacity == 0 || mailbox_len < capacity {
MailboxDecision::Accept
} else {
match policy {
MailboxOverflow::DropNewest => MailboxDecision::RejectNewest,
MailboxOverflow::DropOldest => MailboxDecision::EvictOldest,
}
}
}
// TLA+: ShouldTickActor
/// Whether an actor in the given phase should be ticked (i.e., have messages processed).
///
/// Returns `false` for `Poisoned`, `Stopping`, and `Suspended` — these actors
/// either cannot process messages or must not.
#[must_use]
pub fn should_tick_actor(phase: ActorPhase) -> bool {
match phase {
ActorPhase::Unstarted | ActorPhase::Running => true,
ActorPhase::Suspended | ActorPhase::Stopping | ActorPhase::Poisoned => false,
}
}
// TLA+: BudgetExhausted
/// Whether the per-actor message budget has been exhausted.
///
/// `budget` of 0 means unlimited — never exhausted.
#[must_use]
pub fn budget_exhausted(processed: usize, budget: usize) -> bool {
budget > 0 && processed >= budget
}
// TLA+: CleanupStopReason
/// Determine the stop reason for a dead actor given its phase and whether
/// it has a typed exit value.
///
/// - `Poisoned` → `StopReason::Panicked`
/// - Has exit value → `StopReason::Completed`
/// - Otherwise → `StopReason::Normal`
#[must_use]
pub fn cleanup_stop_reason(phase: ActorPhase, has_exit_value: bool) -> StopReason {
if phase == ActorPhase::Poisoned {
StopReason::Panicked
} else if has_exit_value {
StopReason::Completed
} else {
StopReason::Normal
}
}
// ─── Invariant Predicates ───────────────────────────────────────────────────
// TLA+: PhaseIsTerminal
/// Returns `true` if the phase is terminal — the actor will not process any
/// further messages and is awaiting cleanup.
#[must_use]
pub fn phase_is_terminal(phase: ActorPhase) -> bool {
match phase {
ActorPhase::Stopping | ActorPhase::Poisoned => true,
ActorPhase::Unstarted | ActorPhase::Running | ActorPhase::Suspended => false,
}
}
// TLA+: ValidPhaseTransition
/// Returns `true` only for transitions that exist in the lifecycle state machine.
///
/// This is an invariant predicate — useful for property-based testing and Kani
/// harnesses to verify that no illegal transition is ever taken.
#[must_use]
pub fn valid_phase_transition(from: ActorPhase, to: ActorPhase) -> bool {
match (from, to) {
// Self-transitions are always valid (no-op events)
(a, b) if a == b => true,
// From Unstarted
(ActorPhase::Unstarted, ActorPhase::Running) => true,
(ActorPhase::Unstarted, ActorPhase::Poisoned) => true,
(ActorPhase::Unstarted, ActorPhase::Stopping) => true,
(ActorPhase::Unstarted, ActorPhase::Suspended) => true,
// From Running
(ActorPhase::Running, ActorPhase::Poisoned) => true,
(ActorPhase::Running, ActorPhase::Stopping) => true,
(ActorPhase::Running, ActorPhase::Suspended) => true,
// From Suspended
(ActorPhase::Suspended, ActorPhase::Running) => true,
(ActorPhase::Suspended, ActorPhase::Stopping) => true,
// Terminal states never transition out
(ActorPhase::Stopping, _) => false,
(ActorPhase::Poisoned, _) => false,
// Everything else is invalid
_ => false,
}
}