use std::collections::HashSet;
use std::sync::Arc;
use std::time::SystemTime;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use crate::checkpoint::{PendingStep, StepPlacement, StepWriter};
use crate::client::Client;
use crate::connection::Connection;
use crate::context::Ctx;
use crate::cron::{CronSchedule, firing_id, trigger_id};
use crate::error::{Error, Result};
use crate::handle::WorkflowHandle;
use crate::instance::DBOS;
use crate::registry::{QualifiedWorkflowName, Snapshot, WorkflowRef};
use crate::serialization::{
Floats, NonFinite, PayloadEncoding, SerializationFormat, Serializer, decode, encode,
encode_input,
};
use crate::sysdb::INTERNAL_QUEUE;
use crate::sysdb::types::{
Change, NewSchedule, NewWorkflow, ScheduleFilter, ScheduleRecord, ScheduleStatus,
ScheduleUpdate, Timestamp, WorkflowIdReusePolicy, step_names,
};
use crate::workflow::{DuplicationPolicy, QueueRequest, init_or_join, new_workflow};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(bound(serialize = "C: Serialize", deserialize = "C: DeserializeOwned"))]
pub struct ScheduledWorkflowInput<C = ()> {
#[serde(with = "rfc3339")]
pub scheduled_time: SystemTime,
#[serde(with = "json_data")]
pub context: C,
}
mod rfc3339 {
use std::time::SystemTime;
use serde::{Deserialize, Deserializer, Serializer, de};
pub(super) fn serialize<S: Serializer>(
time: &SystemTime,
serializer: S,
) -> Result<S::Ok, S::Error> {
let timestamp = jiff::Timestamp::try_from(*time).map_err(serde::ser::Error::custom)?;
serializer.collect_str(×tamp)
}
pub(super) fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<SystemTime, D::Error> {
let text = String::deserialize(deserializer)?;
let timestamp: jiff::Timestamp = text.parse().map_err(de::Error::custom)?;
Ok(SystemTime::from(timestamp))
}
}
mod json_data {
use serde::de::DeserializeOwned;
use crate::serialization::{Floats, NonFinite};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de, ser};
pub(super) fn serialize<C: Serialize, S: Serializer>(
context: &C,
serializer: S,
) -> Result<S::Ok, S::Error> {
serde_json::to_value(Floats::new(context, NonFinite::Refuse))
.map_err(ser::Error::custom)?
.serialize(serializer)
}
pub(super) fn deserialize<'de, C: DeserializeOwned, D: Deserializer<'de>>(
deserializer: D,
) -> Result<C, D::Error> {
serde_json::from_value(serde_json::Value::deserialize(deserializer)?)
.map_err(de::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScheduleSpec {
pub schedule_name: String,
pub schedule: String,
pub workflow: QualifiedWorkflowName,
context: serde_json::Value,
pub automatic_backfill: bool,
pub cron_timezone: Option<String>,
pub queue_name: Option<String>,
pub app_name: Option<String>,
}
impl ScheduleSpec {
pub fn new<C, R, E>(
schedule_name: impl Into<String>,
workflow: &WorkflowRef<ScheduledWorkflowInput<C>, R, E>,
schedule: impl Into<String>,
context: &C,
) -> Result<Self>
where
C: Serialize,
{
Self::for_workflow(schedule_name, workflow.qualified_name(), schedule, context)
}
pub fn for_workflow<C>(
schedule_name: impl Into<String>,
workflow: impl Into<QualifiedWorkflowName>,
schedule: impl Into<String>,
context: &C,
) -> Result<Self>
where
C: Serialize,
{
Ok(Self {
schedule_name: schedule_name.into(),
schedule: schedule.into(),
workflow: workflow.into(),
context: serde_json::to_value(Floats::new(context, NonFinite::Refuse))
.map_err(|source| Error::encode_failed("schedule context", source))?,
automatic_backfill: false,
cron_timezone: None,
queue_name: None,
app_name: None,
})
}
pub fn context(&self) -> &serde_json::Value {
&self.context
}
fn validate(&self, operation: &'static str) -> Result<()> {
let refuse = |detail: String| Error::InvalidArgument {
operation: operation.into(),
detail: format!("schedule `{}`: {detail}", self.schedule_name),
};
if self.schedule_name.is_empty() {
return Err(Error::InvalidArgument {
operation: operation.into(),
detail: "a schedule needs a name".to_owned(),
});
}
if self.workflow.name().is_empty() {
return Err(refuse(
"`workflow` must not be empty: nothing could run the schedule".to_owned(),
));
}
if self.workflow.config_name().is_some() {
return Err(refuse(format!(
"the configured instance {} cannot be scheduled",
self.workflow
)));
}
CronSchedule::parse(&self.schedule, self.cron_timezone.as_deref()).map_err(refuse)?;
Ok(())
}
fn encode_context(&self, configured: &Serializer) -> Result<String> {
encode(&self.context, &configured.encoding(), "schedule context")
}
fn as_new<'a>(&'a self, context: &'a str) -> NewSchedule<'a> {
NewSchedule {
workflow_class_name: self.workflow.class_name(),
context,
automatic_backfill: self.automatic_backfill,
cron_timezone: self.cron_timezone.as_deref(),
queue_name: self.queue_name.as_deref(),
application_name: self.app_name.as_deref(),
..NewSchedule::new(&self.schedule_name, self.workflow.name(), &self.schedule)
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ScheduleChange<'a> {
pub schedule: Change<&'a str>,
pub context: Change<serde_json::Value>,
pub automatic_backfill: Change<bool>,
pub cron_timezone: Change<Option<&'a str>>,
pub queue_name: Change<Option<&'a str>>,
}
impl ScheduleChange<'_> {
fn validate(&self, name: &str, operation: &'static str) -> Result<()> {
let refuse = |detail: String| Error::InvalidArgument {
operation: operation.into(),
detail: format!("schedule `{name}`: {detail}"),
};
let timezone = match self.cron_timezone {
Change::Set(timezone) => timezone,
Change::Leave => None,
};
match self.schedule {
Change::Set(expression) => {
CronSchedule::parse(expression, timezone).map_err(refuse)?;
}
Change::Leave if timezone.is_some() => {
CronSchedule::parse("* * * * *", timezone).map_err(refuse)?;
}
Change::Leave => {}
}
Ok(())
}
fn encode_context(&self, configured: &Serializer) -> Result<Option<String>> {
match &self.context {
Change::Set(context) => Ok(Some(encode(
context,
&configured.encoding(),
"schedule context",
)?)),
Change::Leave => Ok(None),
}
}
}
#[derive(Debug, Clone)]
pub struct Schedule {
id: String,
schedule_name: String,
workflow: QualifiedWorkflowName,
schedule: String,
status: ScheduleStatus,
context: String,
serializer: Serializer,
last_fired_at: Option<SystemTime>,
automatic_backfill: bool,
cron_timezone: Option<String>,
queue_name: Option<String>,
app_name: Option<String>,
}
impl PartialEq for Schedule {
fn eq(&self, other: &Self) -> bool {
let Schedule {
id,
schedule_name,
workflow,
schedule,
status,
context,
serializer: _,
last_fired_at,
automatic_backfill,
cron_timezone,
queue_name,
app_name,
} = self;
*id == other.id
&& *schedule_name == other.schedule_name
&& *workflow == other.workflow
&& *schedule == other.schedule
&& *status == other.status
&& *context == other.context
&& *last_fired_at == other.last_fired_at
&& *automatic_backfill == other.automatic_backfill
&& *cron_timezone == other.cron_timezone
&& *queue_name == other.queue_name
&& *app_name == other.app_name
}
}
impl Eq for Schedule {}
impl Schedule {
pub(crate) fn from_record(record: ScheduleRecord, serializer: &Serializer) -> Self {
Self {
workflow: QualifiedWorkflowName::from_row(
record.workflow_name,
record.workflow_class_name.as_deref(),
None,
),
id: record.schedule_id,
schedule_name: record.schedule_name,
schedule: record.schedule,
status: record.status,
context: record.context,
serializer: serializer.clone(),
last_fired_at: record.last_fired_at.and_then(Timestamp::to_system_time),
automatic_backfill: record.automatic_backfill,
cron_timezone: record.cron_timezone,
queue_name: record.queue_name,
app_name: record.application_name,
}
}
pub fn schedule_name(&self) -> &str {
&self.schedule_name
}
pub fn id(&self) -> &str {
&self.id
}
pub fn workflow(&self) -> &QualifiedWorkflowName {
&self.workflow
}
pub fn schedule(&self) -> &str {
&self.schedule
}
pub fn status(&self) -> ScheduleStatus {
self.status
}
pub fn context(&self) -> &str {
&self.context
}
pub fn decode_context<C: DeserializeOwned>(&self) -> Result<C> {
decode_context(&self.context, &self.serializer)
}
pub fn last_fired_at(&self) -> Option<SystemTime> {
self.last_fired_at
}
pub fn automatic_backfill(&self) -> bool {
self.automatic_backfill
}
pub fn cron_timezone(&self) -> Option<&str> {
self.cron_timezone.as_deref()
}
pub fn queue_name(&self) -> Option<&str> {
self.queue_name.as_deref()
}
pub fn app_name(&self) -> Option<&str> {
self.app_name.as_deref()
}
}
fn refuse_inside_workflow(operation: &'static str) -> Result<()> {
match Ctx::current() {
Some(_) => Err(Error::InsideWorkflow {
operation: operation.into(),
}),
None => Ok(()),
}
}
impl DBOS {
pub fn create_schedule<'a>(&self, spec: &'a ScheduleSpec) -> PendingStep<'a, ()> {
const OPERATION: &str = "create a schedule";
let built = self.executor(OPERATION).and_then(|executor| {
spec.validate(OPERATION)?;
let placement = StepPlacement::of(executor.connection(), OPERATION)?;
Ok((executor, placement))
});
PendingStep::placed(
step_names::CREATE_SCHEDULE,
built,
move |executor, placement| async move {
let conn = executor.connection();
if placement
.check(conn, step_names::CREATE_SCHEDULE)
.await?
.is_none()
{
refuse_unregistered(&executor, &spec.workflow, OPERATION)?;
refuse_unknown_queue(
conn,
&spec.schedule_name,
spec.queue_name.as_deref(),
OPERATION,
)
.await?;
}
conn.create_schedule(spec, placement.step_writer()).await
},
)
}
pub async fn apply_schedules(&self, specs: &[ScheduleSpec]) -> Result<()> {
const OPERATION: &str = "apply schedules";
let executor = self.executor(OPERATION)?;
refuse_inside_workflow(OPERATION)?;
let mut checked = HashSet::new();
for spec in specs {
spec.validate(OPERATION)?;
refuse_unregistered(&executor, &spec.workflow, OPERATION)?;
if checked.insert(spec.queue_name.as_deref()) {
refuse_unknown_queue(
executor.connection(),
&spec.schedule_name,
spec.queue_name.as_deref(),
OPERATION,
)
.await?;
}
}
executor.connection().apply_schedules(specs).await
}
pub fn list_schedules<'a>(
&self,
filter: &'a ScheduleFilter<'a>,
) -> PendingStep<'a, Vec<Schedule>> {
PendingStep::placed(
step_names::LIST_SCHEDULES,
self.placed("list schedules"),
move |executor, placement| async move {
let records = executor
.connection()
.list_schedules(filter, placement.step_writer())
.await?;
let serializer = executor.serializer();
Ok(records
.into_iter()
.map(|record| Schedule::from_record(record, serializer))
.collect())
},
)
}
pub fn get_schedule<'a>(&self, name: &'a str) -> PendingStep<'a, Option<Schedule>> {
PendingStep::placed(
step_names::GET_SCHEDULE,
self.placed("read a schedule"),
move |executor, placement| async move {
let record = executor
.connection()
.get_schedule(name, placement.step_writer())
.await?;
Ok(record.map(|record| Schedule::from_record(record, executor.serializer())))
},
)
}
pub fn update_schedule<'a>(
&self,
name: &'a str,
change: &'a ScheduleChange<'a>,
) -> PendingStep<'a, ()> {
const OPERATION: &str = "update a schedule";
let built = self.executor(OPERATION).and_then(|executor| {
change.validate(name, OPERATION)?;
let context = change.encode_context(executor.serializer())?;
let placement = StepPlacement::of(executor.connection(), OPERATION)?;
Ok(((executor, context), placement))
});
PendingStep::placed(
step_names::UPDATE_SCHEDULE,
built,
move |(executor, context), placement| async move {
let conn = executor.connection();
if let Change::Set(queue) = change.queue_name
&& placement
.check(conn, step_names::UPDATE_SCHEDULE)
.await?
.is_none()
{
refuse_unknown_queue(conn, name, queue, OPERATION).await?;
}
conn.update_schedule(name, change, context.as_deref(), placement.step_writer())
.await
},
)
}
pub fn pause_schedule<'a>(&self, name: &'a str) -> PendingStep<'a, ()> {
PendingStep::placed(
step_names::PAUSE_SCHEDULE,
self.placed("pause a schedule"),
move |executor, placement| async move {
executor
.connection()
.set_schedule_status(name, ScheduleStatus::Paused, placement.step_writer())
.await
},
)
}
pub fn resume_schedule<'a>(&self, name: &'a str) -> PendingStep<'a, ()> {
PendingStep::placed(
step_names::RESUME_SCHEDULE,
self.placed("resume a schedule"),
move |executor, placement| async move {
executor
.connection()
.set_schedule_status(name, ScheduleStatus::Active, placement.step_writer())
.await
},
)
}
pub fn delete_schedule<'a>(&self, name: &'a str) -> PendingStep<'a, ()> {
PendingStep::placed(
step_names::DELETE_SCHEDULE,
self.placed("delete a schedule"),
move |executor, placement| async move {
executor
.connection()
.delete_schedule(name, placement.step_writer())
.await
},
)
}
pub async fn backfill_schedule<R, E>(
&self,
name: &str,
start: SystemTime,
end: SystemTime,
) -> Result<Vec<WorkflowHandle<R, E>>> {
const OPERATION: &str = "backfill a schedule";
let executor = self.executor(OPERATION)?;
refuse_inside_workflow(OPERATION)?;
executor
.connection()
.backfill_schedule(name, start, end, Some(executor.workflows()))
.await
}
pub async fn trigger_schedule<R, E>(&self, name: &str) -> Result<WorkflowHandle<R, E>> {
const OPERATION: &str = "trigger a schedule";
let executor = self.executor(OPERATION)?;
refuse_inside_workflow(OPERATION)?;
executor
.connection()
.trigger_schedule(name, Some(executor.workflows()))
.await
}
}
fn refuse_unregistered(
executor: &crate::Executor,
workflow: &QualifiedWorkflowName,
operation: &'static str,
) -> Result<()> {
if executor.workflows().contains_key(workflow) {
return Ok(());
}
Err(Error::InvalidArgument {
operation: operation.into(),
detail: format!("no workflow is registered as {workflow}"),
})
}
async fn refuse_unknown_queue(
conn: &Connection,
schedule_name: &str,
queue: Option<&str>,
operation: &'static str,
) -> Result<()> {
let Some(queue) = queue else {
return Ok(());
};
if queue == INTERNAL_QUEUE || conn.queue(queue).await?.is_some() {
return Ok(());
}
Err(Error::InvalidArgument {
operation: operation.into(),
detail: format!(
"schedule `{schedule_name}`: queue `{queue}` is not registered; register it before \
scheduling onto it"
),
})
}
impl Client {
pub async fn create_schedule(&self, spec: &ScheduleSpec) -> Result<()> {
spec.validate("create a schedule")?;
self.connection().create_schedule(spec, None).await
}
pub async fn apply_schedules(&self, specs: &[ScheduleSpec]) -> Result<()> {
for spec in specs {
spec.validate("apply schedules")?;
}
self.connection().apply_schedules(specs).await
}
pub async fn list_schedules(&self, filter: &ScheduleFilter<'_>) -> Result<Vec<Schedule>> {
let conn = self.connection();
let records = conn.list_schedules(filter, None).await?;
Ok(records
.into_iter()
.map(|record| Schedule::from_record(record, conn.serializer()))
.collect())
}
pub async fn get_schedule(&self, name: &str) -> Result<Option<Schedule>> {
let conn = self.connection();
let record = conn.get_schedule(name, None).await?;
Ok(record.map(|record| Schedule::from_record(record, conn.serializer())))
}
pub async fn update_schedule(&self, name: &str, change: &ScheduleChange<'_>) -> Result<()> {
change.validate(name, "update a schedule")?;
let context = change.encode_context(self.connection().serializer())?;
self.connection()
.update_schedule(name, change, context.as_deref(), None)
.await
}
pub async fn pause_schedule(&self, name: &str) -> Result<()> {
self.connection()
.set_schedule_status(name, ScheduleStatus::Paused, None)
.await
}
pub async fn resume_schedule(&self, name: &str) -> Result<()> {
self.connection()
.set_schedule_status(name, ScheduleStatus::Active, None)
.await
}
pub async fn delete_schedule(&self, name: &str) -> Result<()> {
self.connection().delete_schedule(name, None).await
}
pub async fn backfill_schedule<R, E>(
&self,
name: &str,
start: SystemTime,
end: SystemTime,
) -> Result<Vec<WorkflowHandle<R, E>>> {
self.connection()
.backfill_schedule(name, start, end, None)
.await
}
pub async fn trigger_schedule<R, E>(&self, name: &str) -> Result<WorkflowHandle<R, E>> {
self.connection().trigger_schedule(name, None).await
}
}
impl Connection {
pub(crate) async fn create_schedule(
&self,
spec: &ScheduleSpec,
caller: Option<StepWriter<'_>>,
) -> Result<()> {
let context = spec.encode_context(self.serializer())?;
self.sysdb()
.create_schedule(&spec.as_new(&context), self.step_caller(caller))
.await
.map_err(Error::from_sysdb)?;
tracing::info!(schedule = spec.schedule_name, "created a schedule");
Ok(())
}
pub(crate) async fn apply_schedules(&self, specs: &[ScheduleSpec]) -> Result<()> {
let contexts = specs
.iter()
.map(|spec| spec.encode_context(self.serializer()))
.collect::<Result<Vec<_>>>()?;
let rows: Vec<NewSchedule<'_>> = specs
.iter()
.zip(&contexts)
.map(|(spec, context)| spec.as_new(context))
.collect();
self.sysdb()
.apply_schedules(&rows)
.await
.map_err(Error::from_sysdb)?;
tracing::info!(schedules = specs.len(), "applied schedules");
Ok(())
}
pub(crate) async fn list_schedules(
&self,
filter: &ScheduleFilter<'_>,
caller: Option<StepWriter<'_>>,
) -> Result<Vec<ScheduleRecord>> {
self.sysdb()
.list_schedules(filter, self.step_caller(caller))
.await
.map_err(Error::from_sysdb)
}
pub(crate) async fn get_schedule(
&self,
name: &str,
caller: Option<StepWriter<'_>>,
) -> Result<Option<ScheduleRecord>> {
self.sysdb()
.get_schedule(name, self.step_caller(caller))
.await
.map_err(Error::from_sysdb)
}
pub(crate) async fn update_schedule(
&self,
name: &str,
change: &ScheduleChange<'_>,
context: Option<&str>,
caller: Option<StepWriter<'_>>,
) -> Result<()> {
let update = ScheduleUpdate {
schedule: change.schedule,
context: context.map_or(Change::Leave, Change::Set),
automatic_backfill: change.automatic_backfill,
cron_timezone: change.cron_timezone,
queue_name: change.queue_name,
};
self.sysdb()
.update_schedule(name, &update, self.step_caller(caller))
.await
.map_err(Error::from_sysdb)?;
tracing::info!(schedule = name, "updated a schedule");
Ok(())
}
pub(crate) async fn set_schedule_status(
&self,
name: &str,
status: ScheduleStatus,
caller: Option<StepWriter<'_>>,
) -> Result<()> {
self.sysdb()
.set_schedule_status(name, status, self.step_caller(caller))
.await
.map_err(Error::from_sysdb)?;
tracing::info!(
schedule = name,
status = status.as_str(),
"set a schedule's status"
);
Ok(())
}
pub(crate) async fn delete_schedule(
&self,
name: &str,
caller: Option<StepWriter<'_>>,
) -> Result<()> {
self.sysdb()
.delete_schedule(name, self.step_caller(caller))
.await
.map_err(Error::from_sysdb)?;
tracing::info!(schedule = name, "deleted a schedule");
Ok(())
}
pub(crate) async fn backfill_schedule<R, E>(
self: &Arc<Self>,
name: &str,
start: SystemTime,
end: SystemTime,
workflows: Option<&Snapshot>,
) -> Result<Vec<WorkflowHandle<R, E>>> {
let record = self.get_schedule_or_not_found(name).await?;
let cron = CronSchedule::parse(&record.schedule, record.cron_timezone.as_deref()).map_err(
|detail| Error::InvalidArgument {
operation: "backfill a schedule".into(),
detail: format!("schedule `{name}`: {detail}"),
},
)?;
let end = to_jiff(end)?;
let mut cursor = to_jiff(start)?;
let firing = self.prepare_firing(&record, workflows).await?;
let mut handles = Vec::new();
let mut first_failure = None;
while let Some(tick) = cron.next_after(cursor) {
cursor = tick.timestamp();
if cursor >= end {
break;
}
let workflow_id = firing_id(name, &tick);
match self
.fire_unless_fired(&record, &firing, cursor, &workflow_id)
.await
{
Ok(_) => handles.push(WorkflowHandle::polling(Arc::clone(self), workflow_id, true)),
Err(error) => {
tracing::warn!(workflow_id, error = %error, "could not backfill a tick");
first_failure.get_or_insert(error);
}
}
}
tracing::info!(
schedule = name,
ticks = handles.len(),
"backfilled a schedule"
);
match first_failure {
Some(error) => Err(error),
None => Ok(handles),
}
}
pub(crate) async fn trigger_schedule<R, E>(
self: &Arc<Self>,
name: &str,
workflows: Option<&Snapshot>,
) -> Result<WorkflowHandle<R, E>> {
let record = self.get_schedule_or_not_found(name).await?;
let now = jiff::Timestamp::now();
let workflow_id = trigger_id(name, now);
let firing = self.prepare_firing(&record, workflows).await?;
self.enqueue_run(&record, &firing, SystemTime::from(now), &workflow_id)
.await?;
tracing::info!(schedule = name, workflow_id, "triggered a schedule");
Ok(WorkflowHandle::polling(Arc::clone(self), workflow_id, true))
}
async fn get_schedule_or_not_found(&self, name: &str) -> Result<ScheduleRecord> {
self.get_schedule(name, None).await?.ok_or_else(|| {
Error::SystemDatabase(crate::sysdb::Error::NotRegistered {
kind: "Schedule".into(),
name: name.to_owned(),
})
})
}
pub(crate) async fn fire_unless_fired(
&self,
record: &ScheduleRecord,
firing: &Firing,
at: jiff::Timestamp,
workflow_id: &str,
) -> Result<bool> {
let existing = self
.sysdb()
.get_workflow(workflow_id)
.await
.map_err(Error::from_sysdb)?;
if existing.is_some() {
return Ok(false);
}
self.enqueue_run(record, firing, SystemTime::from(at), workflow_id)
.await?;
Ok(true)
}
async fn enqueue_run(
&self,
record: &ScheduleRecord,
firing: &Firing,
scheduled_time: SystemTime,
workflow_id: &str,
) -> Result<()> {
let encoding = PayloadEncoding::resolve(firing.serialization, self.serializer().encoding());
let input = encode_input(
&ScheduledWorkflowInput {
scheduled_time,
context: &firing.context,
},
&encoding,
)?;
let queue = QueueRequest::new(record.queue_name.as_deref().unwrap_or(INTERNAL_QUEUE));
let new = NewWorkflow {
name: Some(firing.workflow.name()),
class_name: firing.workflow.class_name(),
input: Some(&input),
serialization: Some(encoding.name()),
executor_id: None,
application_name: firing.owner.as_deref(),
application_version: firing.version.as_deref(),
schedule_name: Some(&record.schedule_name),
..new_workflow(workflow_id, Some(&queue))
};
init_or_join(
self,
&new,
DuplicationPolicy::Reject,
WorkflowIdReusePolicy::ReturnExisting,
None,
)
.await?;
tracing::debug!(
schedule = record.schedule_name,
workflow_id,
"enqueued a scheduled run"
);
Ok(())
}
}
pub(crate) struct Firing {
owner: Option<String>,
version: Option<String>,
context: serde_json::Value,
serialization: SerializationFormat,
workflow: QualifiedWorkflowName,
}
impl Connection {
pub(crate) async fn prepare_firing(
&self,
record: &ScheduleRecord,
workflows: Option<&Snapshot>,
) -> Result<Firing> {
let owner = record
.application_name
.as_deref()
.or(self.app_name())
.map(str::to_owned);
let version = self
.sysdb()
.get_latest_application_version(owner.as_deref())
.await
.map_err(Error::from_sysdb)?
.map(|version| version.version_name);
let context = decode_context(&record.context, self.serializer())?;
let workflow = QualifiedWorkflowName::from_row(
record.workflow_name.as_str(),
record.workflow_class_name.as_deref(),
None,
);
let serialization = workflows
.and_then(|workflows| workflows.get(&workflow))
.map_or(SerializationFormat::Default, |registered| {
registered.serialization
});
Ok(Firing {
owner,
version,
context,
serialization,
workflow,
})
}
}
fn decode_context<C: DeserializeOwned>(context: &str, configured: &Serializer) -> Result<C> {
const WHAT: &str = "schedule context";
let value: serde_json::Value = decode(Some(context), &configured.encoding(), WHAT)?;
serde_json::from_value(value).map_err(|source| Error::decode_failed(WHAT, source))
}
fn to_jiff(time: SystemTime) -> Result<jiff::Timestamp> {
jiff::Timestamp::try_from(time).map_err(|error| Error::InvalidArgument {
operation: "backfill a schedule".into(),
detail: format!("{time:?} is outside the supported range: {error}"),
})
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
use crate::error::EngineOnly;
#[test]
fn the_input_encodes_its_tick_as_rfc3339_text() {
let input = ScheduledWorkflowInput {
scheduled_time: SystemTime::UNIX_EPOCH + Duration::from_secs(1_759_665_600),
context: "emea",
};
let encoded =
encode::<_, EngineOnly>(&input, &PayloadEncoding::Native, "argument").unwrap();
assert_eq!(
encoded,
r#"{"scheduled_time":"2025-10-05T12:00:00Z","context":"emea"}"#
);
let decoded: ScheduledWorkflowInput<String> =
decode::<_, EngineOnly>(Some(&encoded), &PayloadEncoding::Native, "argument").unwrap();
assert_eq!(decoded.scheduled_time, input.scheduled_time);
assert_eq!(decoded.context, "emea");
}
#[test]
fn a_non_finite_float_in_a_context_is_refused() {
let key = QualifiedWorkflowName::new("report");
assert!(ScheduleSpec::for_workflow("nan", key.clone(), "0 * * * * *", &f64::NAN).is_err());
assert!(ScheduleSpec::for_workflow("ok", key, "0 * * * * *", &1.5f64).is_ok());
let input = ScheduledWorkflowInput {
scheduled_time: SystemTime::UNIX_EPOCH,
context: f64::INFINITY,
};
assert!(encode::<_, EngineOnly>(&input, &PayloadEncoding::Native, "argument").is_err());
}
#[test]
fn a_schedule_reads_back_everything_its_row_holds() {
let schedule = Schedule::from_record(
ScheduleRecord {
schedule_id: "id-1".into(),
schedule_name: "nightly".into(),
workflow_name: "report".into(),
workflow_class_name: Some("Reports".into()),
schedule: "0 0 2 * * *".into(),
status: ScheduleStatus::Paused,
context: r#""emea""#.into(),
last_fired_at: Some(Timestamp::from_epoch_ms(1_759_665_600_000)),
automatic_backfill: true,
cron_timezone: Some("Europe/Paris".into()),
queue_name: Some("reports".into()),
application_name: Some("app".into()),
},
&Serializer::RustSerde,
);
assert_eq!(schedule.id(), "id-1");
assert_eq!(schedule.schedule_name(), "nightly");
assert_eq!(
schedule.workflow(),
&QualifiedWorkflowName::method("report", "Reports")
);
assert_eq!(schedule.schedule(), "0 0 2 * * *");
assert_eq!(schedule.status(), ScheduleStatus::Paused);
assert_eq!(schedule.context(), r#""emea""#);
assert_eq!(schedule.decode_context::<String>().unwrap(), "emea");
assert_eq!(
schedule.last_fired_at(),
Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_759_665_600))
);
assert!(schedule.automatic_backfill());
assert_eq!(schedule.cron_timezone(), Some("Europe/Paris"));
assert_eq!(schedule.queue_name(), Some("reports"));
assert_eq!(schedule.app_name(), Some("app"));
}
#[derive(Debug)]
struct Backwards;
impl crate::CustomSerializer for Backwards {
fn name(&self) -> &str {
"backwards_json"
}
fn serialize(
&self,
value: crate::Payload<'_>,
) -> std::result::Result<String, Box<dyn std::error::Error + Send + Sync>> {
Ok(serde_json::to_string(&value)?.chars().rev().collect())
}
fn deserialize(
&self,
text: &str,
into: crate::PayloadSlot<'_>,
) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
let json: String = text.chars().rev().collect();
let mut deserializer = serde_json::Deserializer::from_str(&json);
into.fill(&mut deserializer)?;
Ok(deserializer.end()?)
}
}
#[derive(Debug)]
struct MessagePack;
impl crate::CustomSerializer for MessagePack {
fn name(&self) -> &str {
"msgpack_hex"
}
fn serialize(
&self,
value: crate::Payload<'_>,
) -> std::result::Result<String, Box<dyn std::error::Error + Send + Sync>> {
let bytes = rmp_serde::to_vec(&value)?;
Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
}
fn deserialize(
&self,
text: &str,
into: crate::PayloadSlot<'_>,
) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
let bytes = (0..text.len())
.step_by(2)
.map(|at| u8::from_str_radix(&text[at..at + 2], 16))
.collect::<std::result::Result<Vec<u8>, _>>()?;
into.fill(&mut rmp_serde::Deserializer::new(bytes.as_slice()))
}
}
#[test]
fn a_context_reads_back_through_a_format_that_is_not_human_readable() {
let binary = Serializer::custom(MessagePack);
let address = std::net::Ipv4Addr::new(10, 0, 0, 1);
let spec = ScheduleSpec::for_workflow("s", "w", "* * * * *", &address).unwrap();
let stored = spec.encode_context(&binary).unwrap();
assert!(
decode::<std::net::Ipv4Addr, crate::error::EngineOnly>(
Some(&stored),
&binary.encoding(),
"context"
)
.is_err()
);
assert_eq!(
decode_context::<std::net::Ipv4Addr>(&stored, &binary).unwrap(),
address
);
let fired: serde_json::Value = decode_context(&stored, &binary).unwrap();
let input = crate::serialization::encode_input::<_, crate::error::EngineOnly>(
&ScheduledWorkflowInput {
scheduled_time: SystemTime::UNIX_EPOCH,
context: fired,
},
&binary.encoding(),
)
.unwrap();
let read: ScheduledWorkflowInput<std::net::Ipv4Addr> =
crate::serialization::decode_input::<_, crate::error::EngineOnly>(
Some(&input),
&binary.encoding(),
)
.unwrap();
assert_eq!(read.context, address);
let typed = crate::serialization::encode_input::<_, crate::error::EngineOnly>(
&ScheduledWorkflowInput {
scheduled_time: SystemTime::UNIX_EPOCH,
context: address,
},
&binary.encoding(),
)
.unwrap();
assert_eq!(typed, input);
}
#[test]
fn a_context_is_read_by_the_configured_serializer_alone() {
let custom = Serializer::custom(Backwards);
let spec = ScheduleSpec::for_workflow("s", "w", "* * * * *", &[1, 2]).unwrap();
let sealed = spec.encode_context(&custom).unwrap();
assert_eq!(sealed, "]2,1[");
let plain = spec.encode_context(&Serializer::RustSerde).unwrap();
assert_eq!(plain, "[1,2]");
assert_eq!(decode_context::<Vec<u8>>(&sealed, &custom).unwrap(), [1, 2]);
assert!(decode_context::<Vec<u8>>(&plain, &custom).is_err());
assert_eq!(
decode_context::<Vec<u8>>(&plain, &Serializer::RustSerde).unwrap(),
[1, 2]
);
assert!(decode_context::<Vec<u8>>(&sealed, &Serializer::RustSerde).is_err());
}
#[test]
fn an_empty_class_name_reads_as_none() {
let schedule = Schedule::from_record(
ScheduleRecord {
schedule_id: "id".into(),
schedule_name: "s".into(),
workflow_name: "w".into(),
workflow_class_name: Some(String::new()),
schedule: "* * * * *".into(),
status: ScheduleStatus::Active,
context: "null".into(),
last_fired_at: None,
automatic_backfill: false,
cron_timezone: None,
queue_name: None,
application_name: None,
},
&Serializer::RustSerde,
);
assert_eq!(schedule.workflow(), &QualifiedWorkflowName::new("w"));
assert_eq!(schedule.last_fired_at(), None);
}
#[test]
fn views_of_one_row_are_equal_whatever_reads_them() {
let record = ScheduleRecord {
schedule_id: "id".into(),
schedule_name: "s".into(),
workflow_name: "w".into(),
workflow_class_name: None,
schedule: "* * * * *".into(),
status: ScheduleStatus::Active,
context: "null".into(),
last_fired_at: None,
automatic_backfill: false,
cron_timezone: None,
queue_name: None,
application_name: None,
};
let one = Schedule::from_record(record.clone(), &Serializer::custom(Backwards));
let other = Schedule::from_record(record.clone(), &Serializer::custom(Backwards));
assert_eq!(one, other);
assert_eq!(
one,
Schedule::from_record(record.clone(), &Serializer::RustSerde)
);
let changed = ScheduleRecord {
context: "1".into(),
..record
};
assert_ne!(one, Schedule::from_record(changed, &Serializer::RustSerde));
}
#[test]
fn a_spec_refuses_what_no_executor_could_fire() {
let spec = |cron: &str, timezone: Option<&str>| ScheduleSpec {
cron_timezone: timezone.map(str::to_owned),
..ScheduleSpec::for_workflow("s", "w", cron, &()).unwrap()
};
assert!(spec("* * * * *", None).validate("test").is_ok());
for (cron, timezone, expected) in [
("not a cron", None, "invalid cron schedule"),
("0 0 31 2 *", None, "never fires"),
("* * * * *", Some("Nowhere/Special"), "invalid timezone"),
] {
let error = spec(cron, timezone).validate("test").unwrap_err();
assert!(error.to_string().contains(expected), "{cron}: {error}");
}
let instance = ScheduleSpec::for_workflow(
"s",
QualifiedWorkflowName::instance("w", "Class", "config"),
"* * * * *",
&(),
)
.unwrap();
assert!(
instance
.validate("test")
.unwrap_err()
.to_string()
.contains("configured instance")
);
}
}