use std::error::Error;
use std::fmt;
use std::time::{Duration, Instant, SystemTime};
use oxide_batch_core::{BatchStatus, ExecutionVersion, JobExecutionId, StepExecutionId};
use crate::{BoxFuture, CanonicalWriter, RepositoryError, StateEnvelopeDescriptor, hex_digest};
pub const MIN_STALE_THRESHOLD: Duration = Duration::from_mins(1);
pub const MAX_STALE_THRESHOLD: Duration = Duration::from_hours(24);
pub const DEFAULT_STALE_THRESHOLD: Duration = Duration::from_mins(15);
pub const MIN_CLOCK_SKEW: Duration = Duration::from_millis(100);
pub const MAX_CLOCK_SKEW: Duration = Duration::from_mins(1);
pub const DEFAULT_MAX_CLOCK_SKEW: Duration = Duration::from_secs(5);
#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct OwnerToken([u8; 16]);
impl OwnerToken {
#[must_use]
pub const fn from_bytes(bytes: [u8; 16]) -> Self {
Self(bytes)
}
#[must_use]
pub const fn as_bytes(&self) -> &[u8; 16] {
&self.0
}
}
impl fmt::Debug for OwnerToken {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("OwnerToken(<redacted>)")
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[non_exhaustive]
pub enum OwnerObservation {
Absent,
CurrentProcess,
OtherProcess,
}
impl OwnerObservation {
const fn code(self) -> &'static str {
match self {
Self::Absent => "ABSENT",
Self::CurrentProcess => "CURRENT_PROCESS",
Self::OtherProcess => "OTHER_PROCESS",
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct StaleThreshold(Duration);
impl StaleThreshold {
pub fn new(value: Duration) -> Result<Self, RecoveryError> {
if !(MIN_STALE_THRESHOLD..=MAX_STALE_THRESHOLD).contains(&value) {
return Err(RecoveryError::InvalidStaleThreshold);
}
Ok(Self(value))
}
#[must_use]
pub const fn get(self) -> Duration {
self.0
}
}
impl Default for StaleThreshold {
fn default() -> Self {
Self(DEFAULT_STALE_THRESHOLD)
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct MaxClockSkew(Duration);
impl MaxClockSkew {
pub fn new(value: Duration) -> Result<Self, RecoveryError> {
if !(MIN_CLOCK_SKEW..=MAX_CLOCK_SKEW).contains(&value) {
return Err(RecoveryError::InvalidMaxClockSkew);
}
Ok(Self(value))
}
#[must_use]
pub const fn get(self) -> Duration {
self.0
}
}
impl Default for MaxClockSkew {
fn default() -> Self {
Self(DEFAULT_MAX_CLOCK_SKEW)
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct MonotonicInstant(Duration);
impl MonotonicInstant {
#[must_use]
pub const fn from_duration(value: Duration) -> Self {
Self(value)
}
#[doc(hidden)]
#[must_use]
pub fn checked_elapsed_since(self, earlier: Self) -> Option<Duration> {
self.0.checked_sub(earlier.0)
}
}
pub trait MonotonicClock: Send + Sync {
fn now(&self) -> MonotonicInstant;
}
#[derive(Clone, Debug)]
pub struct SystemMonotonicClock {
origin: Instant,
}
impl SystemMonotonicClock {
#[must_use]
pub fn new() -> Self {
Self {
origin: Instant::now(),
}
}
}
impl Default for SystemMonotonicClock {
fn default() -> Self {
Self::new()
}
}
impl MonotonicClock for SystemMonotonicClock {
fn now(&self) -> MonotonicInstant {
MonotonicInstant(self.origin.elapsed())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RecoveryStepEvidence {
id: StepExecutionId,
status: BatchStatus,
checkpoint: Option<StateEnvelopeDescriptor>,
}
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct RecoveryMarkers(u8);
impl RecoveryMarkers {
const UNKNOWN_COMMIT: u8 = 1;
const COMPLETED_PARTITION: u8 = 1 << 1;
const COMMITTED_FLOW_DECISION: u8 = 1 << 2;
const AMBIGUOUS_EXTERNAL_EFFECT: u8 = 1 << 3;
#[must_use]
pub const fn new() -> Self {
Self(0)
}
#[must_use]
pub const fn with_unknown_commit(mut self, value: bool) -> Self {
if value {
self.0 |= Self::UNKNOWN_COMMIT;
}
self
}
#[must_use]
pub const fn with_completed_partition(mut self, value: bool) -> Self {
if value {
self.0 |= Self::COMPLETED_PARTITION;
}
self
}
#[must_use]
pub const fn with_committed_flow_decision(mut self, value: bool) -> Self {
if value {
self.0 |= Self::COMMITTED_FLOW_DECISION;
}
self
}
#[must_use]
pub const fn with_ambiguous_external_effect(mut self, value: bool) -> Self {
if value {
self.0 |= Self::AMBIGUOUS_EXTERNAL_EFFECT;
}
self
}
const fn contains(self, marker: u8) -> bool {
self.0 & marker != 0
}
}
impl RecoveryStepEvidence {
#[must_use]
pub const fn new(
id: StepExecutionId,
status: BatchStatus,
checkpoint: Option<StateEnvelopeDescriptor>,
) -> Self {
Self {
id,
status,
checkpoint,
}
}
#[must_use]
pub const fn id(&self) -> StepExecutionId {
self.id
}
#[must_use]
pub const fn status(&self) -> BatchStatus {
self.status
}
#[must_use]
pub const fn checkpoint(&self) -> Option<&StateEnvelopeDescriptor> {
self.checkpoint.as_ref()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RecoverySnapshot {
execution_id: JobExecutionId,
status: BatchStatus,
attempt: u32,
version: ExecutionVersion,
owner: OwnerObservation,
updated_at: SystemTime,
server_time: SystemTime,
latest_step: Option<RecoveryStepEvidence>,
markers: RecoveryMarkers,
}
impl RecoverySnapshot {
#[must_use]
#[allow(clippy::too_many_arguments)]
pub const fn new(
execution_id: JobExecutionId,
status: BatchStatus,
attempt: u32,
version: ExecutionVersion,
owner: OwnerObservation,
updated_at: SystemTime,
server_time: SystemTime,
latest_step: Option<RecoveryStepEvidence>,
markers: RecoveryMarkers,
) -> Self {
Self {
execution_id,
status,
attempt,
version,
owner,
updated_at,
server_time,
latest_step,
markers,
}
}
#[must_use]
pub const fn status(&self) -> BatchStatus {
self.status
}
#[must_use]
pub const fn owner(&self) -> OwnerObservation {
self.owner
}
#[must_use]
pub const fn updated_at(&self) -> SystemTime {
self.updated_at
}
#[must_use]
pub const fn server_time(&self) -> SystemTime {
self.server_time
}
}
pub trait RecoveryRepository: Send + Sync {
fn recovery_snapshot<'a>(
&'a self,
execution_id: JobExecutionId,
current_owner: &'a OwnerToken,
) -> BoxFuture<'a, Result<RecoverySnapshot, RepositoryError>>;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RecoveryEvidence {
snapshot: RecoverySnapshot,
inactivity: Duration,
observed_clock_offset: Duration,
observation_window: Duration,
}
impl RecoveryEvidence {
#[must_use]
pub const fn new(
snapshot: RecoverySnapshot,
inactivity: Duration,
observed_clock_offset: Duration,
observation_window: Duration,
) -> Self {
Self {
snapshot,
inactivity,
observed_clock_offset,
observation_window,
}
}
#[must_use]
pub const fn execution_id(&self) -> JobExecutionId {
self.snapshot.execution_id
}
#[must_use]
pub const fn status(&self) -> BatchStatus {
self.snapshot.status
}
#[must_use]
pub const fn attempt(&self) -> u32 {
self.snapshot.attempt
}
#[must_use]
pub const fn version(&self) -> ExecutionVersion {
self.snapshot.version
}
#[must_use]
pub const fn owner(&self) -> OwnerObservation {
self.snapshot.owner
}
#[must_use]
pub const fn inactivity(&self) -> Duration {
self.inactivity
}
#[must_use]
pub const fn updated_at(&self) -> SystemTime {
self.snapshot.updated_at
}
#[must_use]
pub const fn server_time(&self) -> SystemTime {
self.snapshot.server_time
}
#[must_use]
pub const fn observed_clock_offset(&self) -> Duration {
self.observed_clock_offset
}
#[must_use]
pub const fn observation_window(&self) -> Duration {
self.observation_window
}
#[must_use]
pub const fn latest_step(&self) -> Option<&RecoveryStepEvidence> {
self.snapshot.latest_step.as_ref()
}
#[must_use]
pub const fn unknown_commit(&self) -> bool {
self.snapshot
.markers
.contains(RecoveryMarkers::UNKNOWN_COMMIT)
}
#[must_use]
pub const fn completed_partition(&self) -> bool {
self.snapshot
.markers
.contains(RecoveryMarkers::COMPLETED_PARTITION)
}
#[must_use]
pub const fn committed_flow_decision(&self) -> bool {
self.snapshot
.markers
.contains(RecoveryMarkers::COMMITTED_FLOW_DECISION)
}
#[must_use]
pub const fn ambiguous_external_effect(&self) -> bool {
self.snapshot
.markers
.contains(RecoveryMarkers::AMBIGUOUS_EXTERNAL_EFFECT)
}
fn digest(&self) -> [u8; 32] {
let mut writer = CanonicalWriter::new("oxide-batch.recovery-evidence.v1");
writer.push_u64(self.execution_id().get());
writer.push_str(self.status().as_str());
writer.push_u64(u64::from(self.attempt()));
writer.push_u64(self.version().get());
writer.push_str(self.owner().code());
push_system_time(&mut writer, self.snapshot.updated_at);
match self.latest_step() {
Some(step) => {
writer.push_u64(step.id().get());
writer.push_str(step.status().as_str());
match step.checkpoint() {
Some(checkpoint) => {
writer.push_u64(u64::from(checkpoint.format_version()));
writer.push_str(checkpoint.schema_id().as_str());
writer.push_u64(u64::from(checkpoint.schema_version().get()));
writer
.push_u64(u64::try_from(checkpoint.encoded_len()).unwrap_or(u64::MAX));
}
None => writer.push_str("NO_CHECKPOINT"),
}
}
None => writer.push_str("NO_STEP"),
}
writer.push_u64(u64::from(self.unknown_commit()));
writer.push_u64(u64::from(self.completed_partition()));
writer.push_u64(u64::from(self.committed_flow_decision()));
writer.push_u64(u64::from(self.ambiguous_external_effect()));
writer.digest()
}
}
fn push_duration(writer: &mut CanonicalWriter, value: Duration) {
writer.push_u64(value.as_secs());
writer.push_u64(u64::from(value.subsec_nanos()));
}
fn push_system_time(writer: &mut CanonicalWriter, value: SystemTime) {
match value.duration_since(SystemTime::UNIX_EPOCH) {
Ok(duration) => {
writer.push_str("AFTER_EPOCH");
push_duration(writer, duration);
}
Err(error) => {
writer.push_str("BEFORE_EPOCH");
push_duration(writer, error.duration());
}
}
}
#[derive(Clone, Eq, PartialEq)]
pub struct RecoveryProposal {
evidence: RecoveryEvidence,
digest: [u8; 32],
}
impl RecoveryProposal {
#[must_use]
pub fn new(evidence: RecoveryEvidence) -> Self {
let digest = evidence.digest();
Self { evidence, digest }
}
#[must_use]
pub const fn evidence(&self) -> &RecoveryEvidence {
&self.evidence
}
#[must_use]
pub const fn observed_version(&self) -> ExecutionVersion {
self.evidence.version()
}
#[must_use]
pub const fn digest(&self) -> &[u8; 32] {
&self.digest
}
#[must_use]
pub fn digest_hex(&self) -> String {
hex_digest(&self.digest)
}
}
impl fmt::Debug for RecoveryProposal {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("RecoveryProposal")
.field("evidence", &self.evidence)
.field("digest", &self.digest_hex())
.finish()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum RecoveryError {
InvalidStaleThreshold,
InvalidMaxClockSkew,
ClockEvidenceUnusable,
OwnedByCurrentProcess,
NotStale {
inactivity: Duration,
threshold: StaleThreshold,
},
NotRecoverable {
status: BatchStatus,
},
Repository(RepositoryError),
}
impl fmt::Display for RecoveryError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidStaleThreshold => {
formatter.write_str("stale threshold must be between 1 minute and 24 hours")
}
Self::InvalidMaxClockSkew => {
formatter.write_str("maximum clock skew must be between 100 ms and 60 seconds")
}
Self::ClockEvidenceUnusable => {
formatter.write_str("repository and local clocks cannot provide usable evidence")
}
Self::OwnedByCurrentProcess => {
formatter.write_str("the execution is owned by the inspecting process")
}
Self::NotStale {
inactivity,
threshold,
} => write!(
formatter,
"durable inactivity of {inactivity:?} has not exceeded {:?}",
threshold.get()
),
Self::NotRecoverable { status } => {
write!(
formatter,
"an execution in {status} is not a recovery candidate"
)
}
Self::Repository(error) => error.fmt(formatter),
}
}
}
impl Error for RecoveryError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Repository(error) => Some(error),
_ => None,
}
}
}