use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionKnowledge {
NotDispatched,
InFlight,
Completed,
OutcomeUnknown,
}
impl ExecutionKnowledge {
#[must_use]
pub const fn after_worker_loss(self) -> Self {
match self {
Self::InFlight => Self::OutcomeUnknown,
Self::NotDispatched | Self::Completed | Self::OutcomeUnknown => self,
}
}
pub fn after_dispatch(
self,
disposition: RequestDisposition,
) -> Result<Self, ExecutionTransitionError> {
match (self, disposition) {
(Self::NotDispatched, RequestDisposition::FirstDispatch)
| (Self::OutcomeUnknown, RequestDisposition::Replay) => Ok(Self::InFlight),
_ => Err(ExecutionTransitionError {
knowledge: self,
disposition,
}),
}
}
pub fn after_terminal_outcome(self) -> Result<Self, ExecutionTransitionError> {
if matches!(self, Self::InFlight | Self::OutcomeUnknown) {
Ok(Self::Completed)
} else {
Err(ExecutionTransitionError {
knowledge: self,
disposition: RequestDisposition::Completed,
})
}
}
pub fn after_completed_probe(self) -> Result<Self, ExecutionTransitionError> {
if self == Self::OutcomeUnknown {
Ok(Self::Completed)
} else {
Err(ExecutionTransitionError {
knowledge: self,
disposition: RequestDisposition::CompleteFromProbe,
})
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ReplayContract {
Convergent,
ProbeRequired,
NeverReplay,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ProbeResolution {
AlreadyCompleted,
SafeToReplay,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct ReplayAllowance {
attempts_used: u8,
max_attempts: u8,
}
impl ReplayAllowance {
#[must_use]
pub const fn new(attempts_used: u8, max_attempts: u8) -> Self {
Self {
attempts_used,
max_attempts,
}
}
#[must_use]
pub const fn attempts_used(self) -> u8 {
self.attempts_used
}
#[must_use]
pub const fn max_attempts(self) -> u8 {
self.max_attempts
}
#[must_use]
pub const fn next_attempt(self) -> Option<u8> {
match self.attempts_used.checked_add(1) {
Some(next) if next <= self.max_attempts => Some(next),
Some(_) | None => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum RequestDisposition {
FirstDispatch,
AwaitTerminal,
Completed,
Replay,
HoldForProbe,
CompleteFromProbe,
RejectAmbiguousOutcome,
RejectReplayExhausted,
RejectUnexpectedProbeResolution,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("cannot apply {disposition:?} to invocation in {knowledge:?}")]
pub struct ExecutionTransitionError {
knowledge: ExecutionKnowledge,
disposition: RequestDisposition,
}
#[must_use]
pub const fn request_disposition(
knowledge: ExecutionKnowledge,
contract: ReplayContract,
probe_resolution: Option<ProbeResolution>,
allowance: ReplayAllowance,
) -> RequestDisposition {
if probe_resolution.is_some()
&& !(matches!(knowledge, ExecutionKnowledge::OutcomeUnknown)
&& matches!(contract, ReplayContract::ProbeRequired))
{
return RequestDisposition::RejectUnexpectedProbeResolution;
}
match knowledge {
ExecutionKnowledge::NotDispatched => RequestDisposition::FirstDispatch,
ExecutionKnowledge::InFlight => RequestDisposition::AwaitTerminal,
ExecutionKnowledge::Completed => RequestDisposition::Completed,
ExecutionKnowledge::OutcomeUnknown => match contract {
ReplayContract::Convergent => replay_or_exhausted(allowance),
ReplayContract::ProbeRequired => match probe_resolution {
None => RequestDisposition::HoldForProbe,
Some(ProbeResolution::AlreadyCompleted) => RequestDisposition::CompleteFromProbe,
Some(ProbeResolution::SafeToReplay) => replay_or_exhausted(allowance),
},
ReplayContract::NeverReplay => RequestDisposition::RejectAmbiguousOutcome,
},
}
}
const fn replay_or_exhausted(allowance: ReplayAllowance) -> RequestDisposition {
if allowance.next_attempt().is_some() {
RequestDisposition::Replay
} else {
RequestDisposition::RejectReplayExhausted
}
}
#[cfg(test)]
mod tests {
use super::{
ExecutionKnowledge, ProbeResolution, ReplayAllowance, ReplayContract, RequestDisposition,
request_disposition,
};
const AVAILABLE: ReplayAllowance = ReplayAllowance::new(0, 1);
const EXHAUSTED: ReplayAllowance = ReplayAllowance::new(1, 1);
#[test]
fn steady_execution_states_ignore_replay_contract() {
for contract in [
ReplayContract::Convergent,
ReplayContract::ProbeRequired,
ReplayContract::NeverReplay,
] {
assert_eq!(
request_disposition(ExecutionKnowledge::NotDispatched, contract, None, EXHAUSTED,),
RequestDisposition::FirstDispatch
);
assert_eq!(
request_disposition(ExecutionKnowledge::InFlight, contract, None, AVAILABLE,),
RequestDisposition::AwaitTerminal
);
assert_eq!(
request_disposition(ExecutionKnowledge::Completed, contract, None, AVAILABLE,),
RequestDisposition::Completed
);
}
}
#[test]
fn ambiguous_outcomes_obey_contract_evidence_and_budget() {
let unknown = ExecutionKnowledge::OutcomeUnknown;
assert_eq!(
request_disposition(unknown, ReplayContract::Convergent, None, AVAILABLE),
RequestDisposition::Replay
);
assert_eq!(
request_disposition(unknown, ReplayContract::Convergent, None, EXHAUSTED),
RequestDisposition::RejectReplayExhausted
);
assert_eq!(
request_disposition(unknown, ReplayContract::ProbeRequired, None, AVAILABLE),
RequestDisposition::HoldForProbe
);
assert_eq!(
request_disposition(
unknown,
ReplayContract::ProbeRequired,
Some(ProbeResolution::AlreadyCompleted),
AVAILABLE,
),
RequestDisposition::CompleteFromProbe
);
assert_eq!(
request_disposition(
unknown,
ReplayContract::ProbeRequired,
Some(ProbeResolution::SafeToReplay),
AVAILABLE,
),
RequestDisposition::Replay
);
assert_eq!(
request_disposition(unknown, ReplayContract::NeverReplay, None, AVAILABLE),
RequestDisposition::RejectAmbiguousOutcome
);
}
#[test]
fn probe_evidence_cannot_authorize_an_unrelated_invocation() {
assert_eq!(
request_disposition(
ExecutionKnowledge::OutcomeUnknown,
ReplayContract::NeverReplay,
Some(ProbeResolution::SafeToReplay),
AVAILABLE,
),
RequestDisposition::RejectUnexpectedProbeResolution
);
assert_eq!(
request_disposition(
ExecutionKnowledge::NotDispatched,
ReplayContract::ProbeRequired,
Some(ProbeResolution::SafeToReplay),
AVAILABLE,
),
RequestDisposition::RejectUnexpectedProbeResolution
);
}
#[test]
fn state_transitions_reject_unauthorized_replay_and_double_completion() {
assert_eq!(
ExecutionKnowledge::NotDispatched.after_dispatch(RequestDisposition::FirstDispatch),
Ok(ExecutionKnowledge::InFlight)
);
assert!(
ExecutionKnowledge::OutcomeUnknown
.after_dispatch(RequestDisposition::FirstDispatch)
.is_err()
);
assert_eq!(
ExecutionKnowledge::InFlight.after_worker_loss(),
ExecutionKnowledge::OutcomeUnknown
);
assert_eq!(
ExecutionKnowledge::OutcomeUnknown.after_terminal_outcome(),
Ok(ExecutionKnowledge::Completed)
);
assert!(
ExecutionKnowledge::Completed
.after_terminal_outcome()
.is_err()
);
}
}