///|
/// Errors raised while creating or advancing a command.
pub(all) suberror CommandError {
EmptyCommandId
EmptyAction
InvalidExpiry
InvalidTimestamp
InvalidTransition(String)
EmptyResultMessage
InvalidResultState
} derive(Debug, Eq)
///|
/// A stable idempotency key for one command request.
pub(all) struct CommandId {
value : String
} derive(Debug, Eq, ToJson, FromJson)
///|
pub extend CommandId with ToJson::{to_json}
///|
pub extend CommandId with @json.FromJson::{from_json}
///|
/// Creates a non-empty command identifier.
pub fn CommandId::new(value~ : String) -> CommandId raise CommandError {
if value == "" {
raise CommandError::EmptyCommandId
}
{ value, }
}
///|
/// The operation requested by a command.
pub(all) enum CommandAction {
ApplyConfig(ConfigVersion)
ReadTelemetry
Execute(String)
} derive(Debug, Eq, ToJson, FromJson)
///|
pub extend CommandAction with ToJson::{to_json}
///|
pub extend CommandAction with @json.FromJson::{from_json}
///|
/// Lifecycle states for one command request.
pub(all) enum CommandState {
Pending
Accepted
Running
Succeeded
Failed
Expired
Rejected
} derive(Debug, Eq, ToJson, FromJson)
///|
pub extend CommandState with ToJson::{to_json}
///|
pub extend CommandState with @json.FromJson::{from_json}
///|
/// Terminal information attached to a completed command.
pub(all) struct CommandResult {
state : CommandState
message : String
completed_at : Timestamp
} derive(Debug, Eq, ToJson, FromJson)
///|
pub extend CommandResult with ToJson::{to_json}
///|
pub extend CommandResult with @json.FromJson::{from_json}
///|
/// Creates a result for a terminal command state.
pub fn CommandResult::new(
state~ : CommandState,
message~ : String,
completed_at~ : Timestamp,
) -> CommandResult raise CommandError {
if !is_terminal(state) {
raise CommandError::InvalidResultState
}
if message == "" {
raise CommandError::EmptyResultMessage
}
{ state, message, completed_at, }
}
///|
/// A command with an explicit expiry and an auditable result.
pub(all) struct Command {
id : CommandId
node_id : NodeId
action : CommandAction
issued_at : Timestamp
expires_at : Timestamp
updated_at : Timestamp
state : CommandState
result : CommandResult?
} derive(Debug, Eq, ToJson, FromJson)
///|
pub extend Command with ToJson::{to_json}
///|
pub extend Command with @json.FromJson::{from_json}
///|
/// Creates a pending command with a non-decreasing expiry deadline.
pub fn Command::new(
id~ : CommandId,
node_id~ : NodeId,
action~ : CommandAction,
issued_at~ : Timestamp,
expires_at~ : Timestamp,
) -> Command raise CommandError {
if expires_at.unix_seconds < issued_at.unix_seconds {
raise CommandError::InvalidExpiry
}
match action {
CommandAction::Execute(name) if name == "" =>
raise CommandError::EmptyAction
_ => ()
}
{
id,
node_id,
action,
issued_at,
expires_at,
updated_at: issued_at,
state: CommandState::Pending,
result: None,
}
}
///|
/// Reports whether two commands refer to the same idempotent request.
pub fn Command::same_identity(self : Command, other~ : Command) -> Bool {
self.id == other.id
}
///|
/// Moves a pending command into the accepted state.
pub fn Command::accept(
self : Command,
at~ : Timestamp,
) -> Command raise CommandError {
self.advance(target=CommandState::Accepted, at~, result=None)
}
///|
/// Moves an accepted command into the running state.
pub fn Command::start(
self : Command,
at~ : Timestamp,
) -> Command raise CommandError {
self.advance(target=CommandState::Running, at~, result=None)
}
///|
/// Completes a running command successfully.
pub fn Command::succeed(
self : Command,
at~ : Timestamp,
message~ : String,
) -> Command raise CommandError {
let result = CommandResult::new(
state=CommandState::Succeeded,
message~,
completed_at=at,
)
self.advance(target=CommandState::Succeeded, at~, result=Some(result))
}
///|
/// Completes a running command with a failure result.
pub fn Command::fail(
self : Command,
at~ : Timestamp,
message~ : String,
) -> Command raise CommandError {
let result = CommandResult::new(
state=CommandState::Failed,
message~,
completed_at=at,
)
self.advance(target=CommandState::Failed, at~, result=Some(result))
}
///|
/// Rejects a pending command before execution begins.
pub fn Command::reject(
self : Command,
at~ : Timestamp,
reason~ : String,
) -> Command raise CommandError {
let result = CommandResult::new(
state=CommandState::Rejected,
message=reason,
completed_at=at,
)
self.advance(target=CommandState::Rejected, at~, result=Some(result))
}
///|
/// Expires a non-terminal command once its deadline has been reached.
pub fn Command::expire(
self : Command,
at~ : Timestamp,
) -> Command raise CommandError {
if at.unix_seconds < self.expires_at.unix_seconds {
raise CommandError::InvalidTransition("command has not reached its expiry")
}
let result = CommandResult::new(
state=CommandState::Expired,
message="command expired",
completed_at=at,
)
self.advance(target=CommandState::Expired, at~, result=Some(result))
}
///|
fn Command::advance(
self : Command,
target~ : CommandState,
at~ : Timestamp,
result~ : CommandResult?,
) -> Command raise CommandError {
if at.unix_seconds < self.updated_at.unix_seconds {
raise CommandError::InvalidTimestamp
}
if target != CommandState::Expired &&
at.unix_seconds > self.expires_at.unix_seconds {
raise CommandError::InvalidTransition("command deadline has passed")
}
if !can_command_transition(self.state, target) {
raise CommandError::InvalidTransition(
"command lifecycle transition is not allowed",
)
}
{
id: self.id,
node_id: self.node_id,
action: self.action,
issued_at: self.issued_at,
expires_at: self.expires_at,
updated_at: at,
state: target,
result,
}
}
///|
fn can_command_transition(from : CommandState, to : CommandState) -> Bool {
match (from, to) {
(CommandState::Pending, CommandState::Accepted) => true
(CommandState::Pending, CommandState::Rejected) => true
(CommandState::Pending, CommandState::Expired) => true
(CommandState::Accepted, CommandState::Running) => true
(CommandState::Accepted, CommandState::Expired) => true
(CommandState::Running, CommandState::Succeeded) => true
(CommandState::Running, CommandState::Failed) => true
(CommandState::Running, CommandState::Expired) => true
_ => false
}
}
///|
fn is_terminal(state : CommandState) -> Bool {
match state {
CommandState::Succeeded
| CommandState::Failed
| CommandState::Expired
| CommandState::Rejected => true
_ => false
}
}