use std::fmt;
pub const MAX_SCHEDULER_STATE_RECORDS: usize = 1_024;
pub const MAX_SCHEDULER_OWNER_ID_BYTES: usize = 128;
pub const MAX_SCHEDULER_CLAIM_TTL_MS: u64 = 24 * 60 * 60 * 1_000;
pub const MAX_SCHEDULER_CLOCK_SKEW_MS: u64 = 5 * 60 * 1_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DurableTaskMisfirePolicyV1 {
FireOnce,
Skip,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SchedulerStateError {
InvalidState(&'static str),
CapacityExceeded {
max_records: usize,
},
Conflict(&'static str),
Fenced,
Unavailable,
UpdateRequired,
}
impl fmt::Display for SchedulerStateError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UpdateRequired => formatter.write_str("NO MORE SUPPORTED PLEASE UPDATE"),
_ => write!(formatter, "{self:?}"),
}
}
}
impl std::error::Error for SchedulerStateError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SchedulerStateRegistrationV1 {
pub task_id: String,
pub definition_hash: String,
pub initial_next_run_ms: u64,
pub misfire_policy: DurableTaskMisfirePolicyV1,
}
impl SchedulerStateRegistrationV1 {
pub fn validate(&self) -> Result<(), SchedulerStateError> {
validate_task_id(&self.task_id)?;
validate_definition_hash(&self.definition_hash)
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct SchedulerStateClaimRequestV1 {
pub task_id: String,
pub owner_id: String,
pub now_ms: u64,
pub claim_ttl_ms: u64,
pub max_clock_skew_ms: u64,
}
impl fmt::Debug for SchedulerStateClaimRequestV1 {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("SchedulerStateClaimRequestV1")
.field("now_ms", &self.now_ms)
.field("claim_ttl_ms", &self.claim_ttl_ms)
.field("max_clock_skew_ms", &self.max_clock_skew_ms)
.finish_non_exhaustive()
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct SchedulerStateClaimV1 {
pub task_id: String,
pub fencing_epoch: u64,
pub lease_until_ms: u64,
pub attempt: u32,
pub(crate) owner_id: String,
}
impl SchedulerStateClaimV1 {
pub fn new(
task_id: String,
owner_id: String,
fencing_epoch: u64,
lease_until_ms: u64,
attempt: u32,
) -> Result<Self, SchedulerStateError> {
validate_task_id(&task_id)?;
validate_owner_id(&owner_id)?;
if fencing_epoch == 0 || attempt == 0 {
return Err(SchedulerStateError::InvalidState("invalid claim state"));
}
Ok(Self {
task_id,
owner_id,
fencing_epoch,
lease_until_ms,
attempt,
})
}
pub fn owner_id(&self) -> &str {
&self.owner_id
}
}
impl fmt::Debug for SchedulerStateClaimV1 {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("SchedulerStateClaimV1")
.field("fencing_epoch", &self.fencing_epoch)
.field("lease_until_ms", &self.lease_until_ms)
.field("attempt", &self.attempt)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SchedulerStateRecordV1 {
pub task_id: String,
pub definition_hash: String,
pub next_run_ms: u64,
pub attempts: u32,
pub misfire_policy: DurableTaskMisfirePolicyV1,
pub completed: bool,
pub last_receipt_epoch: Option<u64>,
pub claim: Option<SchedulerStateClaimV1>,
pub fencing_epoch: u64,
}
impl SchedulerStateRecordV1 {
pub fn validate(&self) -> Result<(), SchedulerStateError> {
SchedulerStateRegistrationV1 {
task_id: self.task_id.clone(),
definition_hash: self.definition_hash.clone(),
initial_next_run_ms: self.next_run_ms,
misfire_policy: self.misfire_policy,
}
.validate()?;
if self.completed
&& (self.claim.is_some() || self.attempts != 0 || self.last_receipt_epoch.is_none())
|| self.last_receipt_epoch == Some(0)
|| self
.last_receipt_epoch
.is_some_and(|epoch| epoch > self.fencing_epoch)
|| self.fencing_epoch == 0 && (self.attempts != 0 || self.last_receipt_epoch.is_some())
|| self.claim.as_ref().is_some_and(|claim| {
claim.task_id != self.task_id
|| claim.fencing_epoch > self.fencing_epoch
|| claim.attempt != self.attempts
})
{
return Err(SchedulerStateError::InvalidState("invalid task state"));
}
if let Some(claim) = &self.claim {
Self::validate_claim(claim)?;
}
Ok(())
}
fn validate_claim(claim: &SchedulerStateClaimV1) -> Result<(), SchedulerStateError> {
SchedulerStateClaimV1::new(
claim.task_id.clone(),
claim.owner_id.clone(),
claim.fencing_epoch,
claim.lease_until_ms,
claim.attempt,
)?;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SchedulerStateCompletionV1 {
pub claim: SchedulerStateClaimV1,
pub completed_at_ms: u64,
pub next_run_ms: Option<u64>,
pub settled: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SchedulerStateStatsV1 {
pub records: usize,
pub claimed: usize,
pub completed: usize,
}
pub trait SchedulerStateProvider: Send + Sync {
fn register(
&self,
registration: &SchedulerStateRegistrationV1,
max_records: usize,
) -> Result<SchedulerStateRecordV1, SchedulerStateError>;
fn record(&self, task_id: &str) -> Result<Option<SchedulerStateRecordV1>, SchedulerStateError>;
fn try_claim(
&self,
request: &SchedulerStateClaimRequestV1,
) -> Result<Option<SchedulerStateClaimV1>, SchedulerStateError>;
fn renew_claim(
&self,
claim: &SchedulerStateClaimV1,
now_ms: u64,
lease_until_ms: u64,
) -> Result<(), SchedulerStateError>;
fn complete(
&self,
completion: &SchedulerStateCompletionV1,
) -> Result<SchedulerStateRecordV1, SchedulerStateError>;
fn stats(&self) -> Result<SchedulerStateStatsV1, SchedulerStateError>;
}
pub(crate) fn validate_provider_bounds(
max_records: usize,
owner_id: &str,
claim_ttl_ms: u64,
max_clock_skew_ms: u64,
) -> Result<(), SchedulerStateError> {
if max_records == 0 || max_records > MAX_SCHEDULER_STATE_RECORDS {
return Err(SchedulerStateError::InvalidState("invalid record limit"));
}
validate_owner_id(owner_id)?;
if claim_ttl_ms == 0 || claim_ttl_ms > MAX_SCHEDULER_CLAIM_TTL_MS {
return Err(SchedulerStateError::InvalidState("invalid claim ttl"));
}
if max_clock_skew_ms > MAX_SCHEDULER_CLOCK_SKEW_MS {
return Err(SchedulerStateError::InvalidState("invalid clock skew"));
}
Ok(())
}
pub(crate) fn validate_task_id(task_id: &str) -> Result<(), SchedulerStateError> {
if task_id.is_empty()
|| task_id.len() > super::MAX_TASK_ID_BYTES
|| !task_id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
{
return Err(SchedulerStateError::InvalidState("invalid task id"));
}
Ok(())
}
pub(crate) fn validate_owner_id(owner_id: &str) -> Result<(), SchedulerStateError> {
if owner_id.is_empty()
|| owner_id.len() > MAX_SCHEDULER_OWNER_ID_BYTES
|| !owner_id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
{
return Err(SchedulerStateError::InvalidState("invalid owner id"));
}
Ok(())
}
fn validate_definition_hash(definition_hash: &str) -> Result<(), SchedulerStateError> {
if definition_hash.len() != 64 || !definition_hash.bytes().all(|byte| byte.is_ascii_hexdigit())
{
return Err(SchedulerStateError::InvalidState("invalid definition hash"));
}
Ok(())
}