use super::Error;
use std::fmt;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
#[derive(
Debug,
Clone,
Copy,
Default,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
serde::Serialize,
serde::Deserialize,
)]
pub struct Timestamp(i64);
impl Timestamp {
pub const fn from_epoch_ms(ms: i64) -> Self {
Self(ms)
}
pub const fn as_epoch_ms(self) -> i64 {
self.0
}
pub fn now() -> Self {
Self(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0),
)
}
pub fn to_system_time(self) -> Option<SystemTime> {
u64::try_from(self.0)
.ok()
.map(|ms| UNIX_EPOCH + Duration::from_millis(ms))
}
pub fn from_system_time(time: SystemTime) -> Option<Self> {
time.duration_since(UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_millis()).ok())
.map(Self)
}
pub fn checked_add(self, duration: Duration) -> Option<Self> {
i64::try_from(duration.as_millis())
.ok()
.and_then(|ms| self.0.checked_add(ms))
.map(Self)
}
pub fn try_add(self, field: &'static str, duration: Duration) -> Result<Self, Error> {
let ms = duration_to_ms(field, duration)?;
self.0
.checked_add(ms)
.map(Self)
.ok_or_else(|| Error::InvalidInput {
field: field.into(),
detail: "must not end past the latest instant 64-bit epoch milliseconds can hold"
.to_owned(),
})
}
pub fn duration_since(self, earlier: Self) -> Option<Duration> {
self.0
.checked_sub(earlier.0)
.and_then(|ms| u64::try_from(ms).ok())
.map(Duration::from_millis)
}
pub fn parse_iso8601(text: &str) -> Option<Self> {
let parsed = OffsetDateTime::parse(text, &Rfc3339).ok()?;
i64::try_from(parsed.unix_timestamp_nanos().div_euclid(1_000_000))
.ok()
.map(Self)
}
pub fn to_iso8601(self) -> String {
OffsetDateTime::from_unix_timestamp_nanos(i128::from(self.0) * 1_000_000)
.ok()
.and_then(|t| t.format(&Rfc3339).ok())
.unwrap_or_default()
}
}
impl fmt::Display for Timestamp {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}ms", self.0)
}
}
pub fn duration_from_ms(ms: i64) -> Option<Duration> {
u64::try_from(ms).ok().map(Duration::from_millis)
}
pub fn duration_from_secs(secs: f64) -> Option<Duration> {
Duration::try_from_secs_f64(secs).ok()
}
pub(crate) fn validate_positive_duration(
field: &'static str,
duration: Duration,
) -> Result<(), Error> {
if duration.is_zero() {
return Err(Error::InvalidInput {
field: field.into(),
detail: "must be a positive, non-zero duration".to_owned(),
});
}
duration_to_ms(field, duration)?;
Ok(())
}
pub fn duration_to_ms(field: &'static str, duration: Duration) -> Result<i64, Error> {
i64::try_from(duration.as_millis()).map_err(|_| Error::InvalidInput {
field: field.into(),
detail: "must fit in milliseconds as a 64-bit integer".to_owned(),
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum WorkflowStatus {
Pending,
Enqueued,
Delayed,
Success,
Error,
Cancelled,
MaxRecoveryAttemptsExceeded,
}
impl WorkflowStatus {
pub fn as_str(self) -> &'static str {
match self {
WorkflowStatus::Pending => "PENDING",
WorkflowStatus::Enqueued => "ENQUEUED",
WorkflowStatus::Delayed => "DELAYED",
WorkflowStatus::Success => "SUCCESS",
WorkflowStatus::Error => "ERROR",
WorkflowStatus::Cancelled => "CANCELLED",
WorkflowStatus::MaxRecoveryAttemptsExceeded => "MAX_RECOVERY_ATTEMPTS_EXCEEDED",
}
}
pub fn parse(value: &str) -> Option<Self> {
Some(match value {
"PENDING" => WorkflowStatus::Pending,
"ENQUEUED" => WorkflowStatus::Enqueued,
"DELAYED" => WorkflowStatus::Delayed,
"SUCCESS" => WorkflowStatus::Success,
"ERROR" => WorkflowStatus::Error,
"CANCELLED" => WorkflowStatus::Cancelled,
"MAX_RECOVERY_ATTEMPTS_EXCEEDED" => WorkflowStatus::MaxRecoveryAttemptsExceeded,
_ => return None,
})
}
pub fn is_terminal(self) -> bool {
matches!(
self,
WorkflowStatus::Success | WorkflowStatus::Error | WorkflowStatus::Cancelled
)
}
pub fn is_active(self) -> bool {
matches!(
self,
WorkflowStatus::Pending | WorkflowStatus::Enqueued | WorkflowStatus::Delayed
)
}
}
impl fmt::Display for WorkflowStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct QualifiedWorkflowName {
name: String,
#[serde(default, deserialize_with = "absent_if_empty")]
class_name: Option<String>,
#[serde(default, deserialize_with = "absent_if_empty")]
config_name: Option<String>,
}
impl QualifiedWorkflowName {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
class_name: None,
config_name: None,
}
}
pub fn method(name: impl Into<String>, class_name: impl Into<String>) -> Self {
Self {
name: name.into(),
class_name: present(class_name.into()),
config_name: None,
}
}
pub fn instance(
name: impl Into<String>,
class_name: impl Into<String>,
config_name: impl Into<String>,
) -> Self {
Self {
name: name.into(),
class_name: present(class_name.into()),
config_name: present(config_name.into()),
}
}
pub(crate) fn from_row(
name: impl Into<String>,
class_name: Option<&str>,
config_name: Option<&str>,
) -> Self {
Self {
name: name.into(),
class_name: class_name.map(str::to_owned).and_then(present),
config_name: config_name.map(str::to_owned).and_then(present),
}
}
pub fn name(&self) -> &str {
&self.name
}
pub fn class_name(&self) -> Option<&str> {
self.class_name.as_deref()
}
pub fn config_name(&self) -> Option<&str> {
self.config_name.as_deref()
}
}
fn absent_if_empty<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value: Option<String> = serde::Deserialize::deserialize(deserializer)?;
Ok(value.and_then(present))
}
fn present(value: String) -> Option<String> {
(!value.is_empty()).then_some(value)
}
impl From<&str> for QualifiedWorkflowName {
fn from(name: &str) -> Self {
Self::new(name)
}
}
impl From<String> for QualifiedWorkflowName {
fn from(name: String) -> Self {
Self::new(name)
}
}
impl From<&String> for QualifiedWorkflowName {
fn from(name: &String) -> Self {
Self::new(name)
}
}
impl From<&QualifiedWorkflowName> for QualifiedWorkflowName {
fn from(name: &QualifiedWorkflowName) -> Self {
name.clone()
}
}
impl WorkflowRecord {
pub fn qualified_name(&self) -> Option<QualifiedWorkflowName> {
let name = self.name.as_deref().filter(|name| !name.is_empty())?;
Some(QualifiedWorkflowName::from_row(
name,
self.class_name.as_deref(),
self.config_name.as_deref(),
))
}
}
impl std::fmt::Display for QualifiedWorkflowName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match (&self.class_name, &self.config_name) {
(Some(class), Some(config)) => write!(f, "{}/{class}/{config}", self.name),
(Some(class), None) => write!(f, "{}/{class}", self.name),
(None, Some(config)) => write!(f, "{}//{config}", self.name),
(None, None) => f.write_str(&self.name),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct WorkflowRecord {
pub workflow_id: String,
pub status: WorkflowStatus,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub class_name: Option<String>,
#[serde(default)]
pub config_name: Option<String>,
#[serde(default)]
pub input: Option<String>,
#[serde(default)]
pub output: Option<String>,
#[serde(default)]
pub error: Option<String>,
#[serde(default)]
pub serialization: Option<String>,
#[serde(default)]
pub executor_id: Option<String>,
#[serde(default, rename = "application_version")]
pub app_version: Option<String>,
#[serde(default)]
pub recovery_attempts: i64,
#[serde(default)]
pub queue_name: Option<String>,
#[serde(default)]
pub created_at: Timestamp,
#[serde(default)]
pub updated_at: Timestamp,
#[serde(default)]
pub started_at: Option<Timestamp>,
#[serde(default)]
pub completed_at: Option<Timestamp>,
#[serde(default)]
pub forked_from: Option<String>,
#[serde(default)]
pub parent_workflow_id: Option<String>,
#[serde(default)]
pub was_forked_from: bool,
#[serde(default)]
pub owner_xid: Option<String>,
#[serde(default, rename = "application_id")]
pub app_id: Option<String>,
#[serde(default)]
pub authenticated_user: Option<String>,
#[serde(default)]
pub authenticated_roles: Vec<String>,
#[serde(default)]
pub assumed_role: Option<String>,
#[serde(default)]
pub request: Option<String>,
#[serde(default, rename = "application_name")]
pub app_name: Option<String>,
#[serde(default)]
pub deduplication_id: Option<String>,
#[serde(default)]
pub priority: i32,
#[serde(default)]
pub queue_partition_key: Option<String>,
#[serde(default)]
pub rate_limited: bool,
#[serde(default)]
pub schedule_name: Option<String>,
#[serde(default)]
pub timeout: Option<Duration>,
#[serde(default)]
pub deadline: Option<Timestamp>,
#[serde(default)]
pub delay_until: Option<Timestamp>,
#[serde(default)]
pub debounce_deadline: Option<Timestamp>,
#[serde(default)]
pub is_debounced: bool,
#[serde(default)]
pub attributes: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WorkflowIdReusePolicy {
#[default]
ReturnExisting,
Reject,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct NewWorkflow<'a> {
pub workflow_id: &'a str,
pub name: Option<&'a str>,
pub class_name: Option<&'a str>,
pub config_name: Option<&'a str>,
pub input: Option<&'a str>,
pub serialization: Option<&'a str>,
pub queue_name: Option<&'a str>,
pub deduplication_id: Option<&'a str>,
pub priority: i32,
pub queue_partition_key: Option<&'a str>,
pub delay: Option<Duration>,
pub is_debounced: bool,
pub debounce_timeout: Option<Duration>,
pub timeout: Option<Duration>,
pub deadline: Option<Timestamp>,
pub executor_id: Option<&'a str>,
pub application_name: Option<&'a str>,
pub application_version: Option<&'a str>,
pub application_id: Option<&'a str>,
pub authenticated_user: Option<&'a str>,
pub authenticated_roles: Vec<&'a str>,
pub assumed_role: Option<&'a str>,
pub schedule_name: Option<&'a str>,
pub attributes: Option<&'a str>,
}
impl<'a> NewWorkflow<'a> {
pub fn new(workflow_id: &'a str) -> Self {
Self {
workflow_id,
..Self::default()
}
}
pub fn validate(&self) -> Result<(), Error> {
for (field, value) in [("workflow_id", Some(self.workflow_id)), ("name", self.name)] {
if value == Some("") {
return Err(Error::InvalidInput {
field: field.into(),
detail: "must not be empty".to_owned(),
});
}
}
for (field, value) in [
("class_name", &self.class_name),
("config_name", &self.config_name),
("queue_name", &self.queue_name),
("deduplication_id", &self.deduplication_id),
("queue_partition_key", &self.queue_partition_key),
("schedule_name", &self.schedule_name),
("input", &self.input),
("serialization", &self.serialization),
("application_version", &self.application_version),
] {
if *value == Some("") {
return Err(Error::InvalidInput {
field: field.into(),
detail: "must be absent rather than empty".to_owned(),
});
}
}
for (field, value) in [
("delay", self.delay),
("timeout", self.timeout),
("debounce_timeout", self.debounce_timeout),
] {
if let Some(value) = value {
validate_positive_duration(field, value)?;
}
}
validate_attributes(self.attributes)?;
Ok(())
}
pub fn initial_status(&self) -> WorkflowStatus {
match (&self.queue_name, &self.delay) {
(None, _) => WorkflowStatus::Pending,
(Some(_), None) => WorkflowStatus::Enqueued,
(Some(_), Some(_)) => WorkflowStatus::Delayed,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_step_names_are_pinned() {
use step_names::*;
let pinned = [
(SLEEP, "DBOS.sleep"),
(PATCH_PREFIX, "DBOS.patch-"),
(SEND, "DBOS.send"),
(SEND_BULK, "DBOS.sendBulk"),
(WRITE_STREAM, "DBOS.writeStream"),
(CLOSE_STREAM, "DBOS.closeStream"),
(READ_STREAM, "DBOS.readStream"),
(READ_STREAM_VALUE, "DBOS.readStreamValue"),
(SET_EVENT, "DBOS.setEvent"),
(GET_EVENT, "DBOS.getEvent"),
(GET_RESULT, "DBOS.getResult"),
(SELECT_WORKFLOW, "DBOS.selectWorkflow"),
(SELECT_STEP, "DBOS.selectStep"),
(RECV, "DBOS.recv"),
(DEBOUNCE, "DBOS.debounceDelayedWorkflow"),
(CANCEL_WORKFLOW, "DBOS.cancelWorkflow"),
(RESUME_WORKFLOW, "DBOS.resumeWorkflow"),
(DELETE_WORKFLOW, "DBOS.deleteWorkflow"),
(FORK_WORKFLOW, "DBOS.forkWorkflow"),
(REWIND_WORKFLOW, "DBOS.rewindWorkflow"),
(SET_WORKFLOW_DELAY, "DBOS.setWorkflowDelay"),
(UPDATE_WORKFLOW_ATTRIBUTES, "DBOS.updateWorkflowAttributes"),
(LIST_WORKFLOWS, "DBOS.listWorkflows"),
(LIST_WORKFLOW_STEPS, "DBOS.listWorkflowSteps"),
(CREATE_SCHEDULE, "DBOS.createSchedule"),
(UPSERT_SCHEDULE, "DBOS.upsertSchedule"),
(GET_SCHEDULE, "DBOS.getSchedule"),
(LIST_SCHEDULES, "DBOS.listSchedules"),
(UPDATE_SCHEDULE, "DBOS.updateSchedule"),
(PAUSE_SCHEDULE, "DBOS.pauseSchedule"),
(RESUME_SCHEDULE, "DBOS.resumeSchedule"),
(DELETE_SCHEDULE, "DBOS.deleteSchedule"),
];
for (constant, literal) in pinned {
assert_eq!(constant, literal);
}
}
#[test]
fn a_name_holds_one_spelling_of_absence() {
let free = QualifiedWorkflowName::new("f");
assert_eq!(QualifiedWorkflowName::method("f", ""), free);
assert_eq!(QualifiedWorkflowName::instance("f", "", ""), free);
assert_eq!(
QualifiedWorkflowName::from_row("f", Some(""), Some("")),
free
);
assert_eq!(
QualifiedWorkflowName::instance("f", "C", ""),
QualifiedWorkflowName::method("f", "C")
);
let unclassed = QualifiedWorkflowName::instance("f", "", "eu");
assert_eq!(
unclassed,
QualifiedWorkflowName::from_row("f", None, Some("eu"))
);
assert_eq!(unclassed.class_name(), None);
assert_eq!(unclassed.config_name(), Some("eu"));
}
#[test]
fn a_name_displays_each_part_in_its_place() {
assert_eq!(QualifiedWorkflowName::new("f").to_string(), "f");
assert_eq!(QualifiedWorkflowName::method("f", "C").to_string(), "f/C");
assert_eq!(
QualifiedWorkflowName::instance("f", "C", "eu").to_string(),
"f/C/eu"
);
assert_eq!(
QualifiedWorkflowName::instance("f", "", "eu").to_string(),
"f//eu"
);
}
#[test]
fn a_deserialized_name_holds_one_spelling_of_absence() {
let key: QualifiedWorkflowName =
serde_json::from_str(r#"{"name":"f","class_name":"","config_name":null}"#).unwrap();
assert_eq!(key, QualifiedWorkflowName::new("f"));
let bare: QualifiedWorkflowName = serde_json::from_str(r#"{"name":"f"}"#).unwrap();
assert_eq!(
bare,
QualifiedWorkflowName::new("f"),
"a missing part is absent"
);
let back: QualifiedWorkflowName = serde_json::from_str(
&serde_json::to_string(&QualifiedWorkflowName::instance("f", "C", "eu")).unwrap(),
)
.unwrap();
assert_eq!(back, QualifiedWorkflowName::instance("f", "C", "eu"));
}
#[test]
fn an_instant_formats_as_iso8601() {
let iso = |ms| Timestamp::from_epoch_ms(ms).to_iso8601();
assert_eq!(iso(0), "1970-01-01T00:00:00Z");
assert_eq!(iso(1_786_492_800_000), "2026-08-12T00:00:00Z");
assert_eq!(iso(1_786_492_800_123), "2026-08-12T00:00:00.123Z");
assert_eq!(iso(-1), "1969-12-31T23:59:59.999Z");
assert_eq!(iso(-86_400_000), "1969-12-31T00:00:00Z");
}
#[test]
fn an_iso8601_instant_parses_from_every_writers_format() {
let parse = Timestamp::parse_iso8601;
let expected = Some(Timestamp::from_epoch_ms(1_786_492_800_000));
assert_eq!(
parse("2026-08-12T00:00:00.000Z"),
expected,
"milliseconds, Z"
);
assert_eq!(parse("2026-08-12T00:00:00Z"), expected, "bare Z");
assert_eq!(
parse("2026-08-12T00:00:00+00:00"),
expected,
"+00:00 offset"
);
assert_eq!(
parse("2026-08-12T00:00:00.000000000Z"),
expected,
"RFC3339Nano"
);
assert_eq!(parse("2026-08-12T01:30:00+01:30"), expected);
assert_eq!(parse("2026-08-11T19:00:00-05:00"), expected);
assert_eq!(parse("2026-08-12T00:00:00+00:00"), expected);
assert_eq!(
parse("2026-08-12T00:00:00.123+00:00"),
Some(Timestamp::from_epoch_ms(1_786_492_800_123))
);
let ms = |t: Option<Timestamp>| t.unwrap().as_epoch_ms() % 1_000;
assert_eq!(ms(parse("2026-08-12T00:00:00.5Z")), 500);
assert_eq!(ms(parse("2026-08-12T00:00:00.123999Z")), 123);
for bad in [
"",
"2026-08-12",
"2026-08-12T00:00",
"not-a-date-at-all",
"2026-13-01T00:00:00Z",
"2026-08-12T24:00:00Z",
"2026-08-12T00:00:00",
"2026-08-12T00:00:00+0130",
] {
assert_eq!(parse(bad), None, "{bad:?} is not an instant this reads");
}
}
#[test]
fn an_iso8601_instant_round_trips() {
for ms in [
0,
-1,
1_786_492_800_000,
1_709_209_845_123,
951_827_445_999,
-2_203_977_600_000,
] {
let instant = Timestamp::from_epoch_ms(ms);
assert_eq!(
Timestamp::parse_iso8601(&instant.to_iso8601()),
Some(instant),
"{ms} did not survive the round trip"
);
}
}
#[test]
fn a_deadline_is_a_start_plus_a_timeout() {
let start = Timestamp::from_epoch_ms(1_700_000_000_000);
let timeout = Duration::from_secs(30);
let deadline = start.checked_add(timeout).unwrap();
assert_eq!(deadline.as_epoch_ms(), 1_700_000_030_000);
assert_eq!(deadline.duration_since(start), Some(timeout));
assert_eq!(start.duration_since(deadline), None);
}
#[test]
fn timestamps_round_trip_through_system_time() {
let t = Timestamp::from_epoch_ms(1_700_000_000_123);
let round_tripped = Timestamp::from_system_time(t.to_system_time().unwrap()).unwrap();
assert_eq!(round_tripped, t);
}
#[test]
fn an_unstorable_system_time_is_not_a_timestamp() {
assert_eq!(
Timestamp::from_system_time(UNIX_EPOCH - Duration::from_secs(1)),
None,
"before the epoch"
);
let far = UNIX_EPOCH
.checked_add(Duration::from_secs(i64::MAX as u64 / 100))
.expect("a SystemTime that far out is constructible");
assert_eq!(Timestamp::from_system_time(far), None, "too far after it");
}
#[test]
fn a_pre_epoch_instant_does_not_convert() {
assert_eq!(Timestamp::from_epoch_ms(-1).to_system_time(), None);
}
#[test]
fn durations_read_from_either_stored_unit() {
assert_eq!(duration_from_ms(1_500), Some(Duration::from_millis(1_500)));
assert_eq!(duration_from_secs(1.5), Some(Duration::from_millis(1_500)));
assert_eq!(duration_from_ms(-1), None);
assert_eq!(duration_from_secs(-1.0), None);
assert_eq!(duration_from_secs(f64::NAN), None);
assert_eq!(duration_from_secs(f64::INFINITY), None);
assert_eq!(duration_from_secs(1e300), None);
}
#[test]
fn a_duration_converts_to_milliseconds_only_while_it_fits() {
let largest = Duration::from_millis(i64::MAX as u64);
assert_eq!(duration_to_ms("timeout", largest).unwrap(), i64::MAX);
assert_eq!(duration_to_ms("timeout", Duration::ZERO).unwrap(), 0);
let largest_with_spare_nanos = largest + Duration::from_nanos(999_999);
assert_eq!(
duration_to_ms("timeout", largest_with_spare_nanos).unwrap(),
i64::MAX
);
for refused in [largest + Duration::from_millis(1), Duration::MAX] {
match duration_to_ms("timeout", refused) {
Err(Error::InvalidInput { field, detail }) => {
assert_eq!(field, "timeout");
assert_eq!(detail, "must fit in milliseconds as a 64-bit integer");
}
other => panic!("{refused:?} should have been refused, got {other:?}"),
}
}
}
#[test]
fn an_instant_past_the_largest_is_refused_not_wrapped() {
let start = Timestamp::from_epoch_ms(1_700_000_000_000);
assert_eq!(
start.try_add("delay", Duration::from_secs(1)).unwrap(),
Timestamp::from_epoch_ms(1_700_000_001_000)
);
let room = Duration::from_millis((i64::MAX - start.as_epoch_ms()) as u64);
assert_eq!(
start.try_add("delay", room).unwrap(),
Timestamp::from_epoch_ms(i64::MAX)
);
let over = room + Duration::from_millis(1);
assert!(matches!(
start.try_add("delay", over),
Err(Error::InvalidInput { ref field, .. }) if field == "delay"
));
assert!(matches!(
start.try_add("duration", Duration::MAX),
Err(Error::InvalidInput { ref field, .. }) if field == "duration"
));
}
#[test]
fn a_bounce_refuses_a_zero_or_unstorable_period() {
let workflow = QualifiedWorkflowName::new("wf");
let request = |period| DebounceRequest {
workflow: &workflow,
queue_name: "q",
deduplication_id: "key",
period,
inputs: None,
serialization: None,
application_name: None,
};
assert!(request(Duration::from_secs(1)).validate().is_ok());
for refused in [Duration::ZERO, Duration::MAX] {
assert!(
matches!(
request(refused).validate(),
Err(Error::InvalidInput { ref field, .. }) if field == "period"
),
"{refused:?} should be refused"
);
}
}
#[test]
fn an_oversized_workflow_duration_does_not_validate() {
let fits = NewWorkflow {
debounce_timeout: Some(Duration::from_millis(i64::MAX as u64)),
delay: Some(Duration::from_millis(i64::MAX as u64)),
timeout: Some(Duration::from_millis(i64::MAX as u64)),
..NewWorkflow::new("wf")
};
assert!(fits.validate().is_ok());
let timeout = NewWorkflow {
timeout: Some(Duration::MAX),
..NewWorkflow::new("wf")
};
assert!(matches!(
timeout.validate(),
Err(Error::InvalidInput { ref field, .. }) if field == "timeout"
));
let delay = NewWorkflow {
queue_name: Some("q"),
delay: Some(Duration::MAX),
..NewWorkflow::new("wf")
};
assert!(matches!(
delay.validate(),
Err(Error::InvalidInput { ref field, .. }) if field == "delay"
));
let debounce_timeout = NewWorkflow {
queue_name: Some("q"),
delay: Some(Duration::from_secs(1)),
debounce_timeout: Some(Duration::MAX),
..NewWorkflow::new("wf")
};
assert!(matches!(
debounce_timeout.validate(),
Err(Error::InvalidInput { ref field, .. }) if field == "debounce_timeout"
));
}
#[test]
fn both_readers_accept_zero() {
assert_eq!(duration_from_ms(0), Some(Duration::ZERO));
assert_eq!(duration_from_secs(0.0), Some(Duration::ZERO));
assert_eq!(duration_from_secs(-0.0), Some(Duration::ZERO));
}
#[test]
fn status_round_trips_through_its_stored_spelling() {
for status in [
WorkflowStatus::Pending,
WorkflowStatus::Enqueued,
WorkflowStatus::Delayed,
WorkflowStatus::Success,
WorkflowStatus::Error,
WorkflowStatus::Cancelled,
WorkflowStatus::MaxRecoveryAttemptsExceeded,
] {
assert_eq!(WorkflowStatus::parse(status.as_str()), Some(status));
}
}
#[test]
fn stored_spellings_are_the_shared_wire_strings() {
assert_eq!(WorkflowStatus::Pending.as_str(), "PENDING");
assert_eq!(WorkflowStatus::Enqueued.as_str(), "ENQUEUED");
assert_eq!(WorkflowStatus::Delayed.as_str(), "DELAYED");
assert_eq!(WorkflowStatus::Success.as_str(), "SUCCESS");
assert_eq!(WorkflowStatus::Error.as_str(), "ERROR");
assert_eq!(WorkflowStatus::Cancelled.as_str(), "CANCELLED");
assert_eq!(
WorkflowStatus::MaxRecoveryAttemptsExceeded.as_str(),
"MAX_RECOVERY_ATTEMPTS_EXCEEDED",
);
}
#[test]
fn an_unrecognised_status_does_not_parse() {
assert_eq!(WorkflowStatus::parse("SOMETHING_NEWER"), None);
assert_eq!(WorkflowStatus::parse("pending"), None, "matching is exact");
}
#[test]
fn terminal_states_are_the_three_that_stop_execution() {
assert!(WorkflowStatus::Success.is_terminal());
assert!(WorkflowStatus::Error.is_terminal());
assert!(WorkflowStatus::Cancelled.is_terminal());
assert!(!WorkflowStatus::Pending.is_terminal());
assert!(!WorkflowStatus::Enqueued.is_terminal());
assert!(!WorkflowStatus::Delayed.is_terminal());
assert!(!WorkflowStatus::MaxRecoveryAttemptsExceeded.is_terminal());
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum AppScope<'a> {
#[default]
Unset,
Any,
Named(Vec<&'a str>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkflowFilter<'a> {
pub workflow_ids: Vec<&'a str>,
pub workflow_id_prefixes: Vec<&'a str>,
pub app_scope: AppScope<'a>,
pub names: Vec<&'a str>,
pub class_names: Vec<&'a str>,
pub config_names: Vec<&'a str>,
pub status: Vec<WorkflowStatus>,
pub app_versions: Vec<&'a str>,
pub executor_ids: Vec<&'a str>,
pub authenticated_users: Vec<&'a str>,
pub queue_names: Vec<&'a str>,
pub queues_only: bool,
pub schedule_names: Vec<&'a str>,
pub deduplication_ids: Vec<&'a str>,
pub is_debounced: Option<bool>,
pub parent_workflow_ids: Vec<&'a str>,
pub has_parent: Option<bool>,
pub forked_from: Vec<&'a str>,
pub is_fork: Option<bool>,
pub was_forked_from: Option<bool>,
pub created_after: Option<Timestamp>,
pub created_before: Option<Timestamp>,
pub completed_after: Option<Timestamp>,
pub completed_before: Option<Timestamp>,
pub started_after: Option<Timestamp>,
pub started_before: Option<Timestamp>,
pub attributes: Option<&'a str>,
pub limit: Option<i64>,
pub offset: Option<i64>,
pub sort_desc: bool,
pub load_input: bool,
pub load_output: bool,
}
impl Default for WorkflowFilter<'_> {
fn default() -> Self {
Self {
workflow_ids: Vec::new(),
workflow_id_prefixes: Vec::new(),
app_scope: AppScope::Unset,
names: Vec::new(),
class_names: Vec::new(),
config_names: Vec::new(),
status: Vec::new(),
app_versions: Vec::new(),
executor_ids: Vec::new(),
authenticated_users: Vec::new(),
queue_names: Vec::new(),
queues_only: false,
schedule_names: Vec::new(),
deduplication_ids: Vec::new(),
is_debounced: None,
parent_workflow_ids: Vec::new(),
has_parent: None,
forked_from: Vec::new(),
is_fork: None,
was_forked_from: None,
created_after: None,
created_before: None,
completed_after: None,
completed_before: None,
started_after: None,
started_before: None,
attributes: None,
limit: None,
offset: None,
sort_desc: false,
load_input: true,
load_output: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct StepRecord {
pub workflow_id: String,
pub step_id: i32,
pub step_name: String,
#[serde(default)]
pub output: Option<String>,
#[serde(default)]
pub error: Option<String>,
#[serde(default)]
pub child_workflow_id: Option<String>,
#[serde(default)]
pub serialization: Option<String>,
#[serde(default)]
pub started_at: Option<Timestamp>,
#[serde(default)]
pub completed_at: Option<Timestamp>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome<'a> {
Output(Option<&'a str>),
Error(&'a str),
}
impl<'a> Outcome<'a> {
pub fn status(self) -> WorkflowStatus {
match self {
Outcome::Output(_) => WorkflowStatus::Success,
Outcome::Error(_) => WorkflowStatus::Error,
}
}
pub(crate) fn columns(self) -> (Option<&'a str>, Option<&'a str>) {
match self {
Outcome::Output(value) => (value, None),
Outcome::Error(message) => (None, Some(message)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AwaitedOutcome {
Succeeded {
output: Option<String>,
serialization: Option<String>,
},
Failed {
error: String,
serialization: Option<String>,
},
Cancelled,
Parked {
recovery_attempts: i64,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StepTiming {
pub started_at: Timestamp,
pub completed_at: Timestamp,
}
pub(crate) fn validate_attributes(attributes: Option<&str>) -> Result<(), Error> {
let Some(json) = attributes else {
return Ok(());
};
serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(json).map_err(|e| {
Error::InvalidInput {
field: "attributes".into(),
detail: format!("must be a JSON object: {e}"),
}
})?;
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttributeUpdate<'a> {
Replace(Option<&'a str>),
Merge(&'a str),
}
impl AttributeUpdate<'_> {
pub(crate) fn validate(&self) -> Result<(), Error> {
match *self {
AttributeUpdate::Replace(attributes) => validate_attributes(attributes),
AttributeUpdate::Merge(attributes) => validate_attributes(Some(attributes)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkflowDelay {
For(Duration),
Until(Timestamp),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutcomeWrite {
Recorded,
AlreadyFinished,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DatabaseTime {
epoch_ms: i64,
read_at: Instant,
}
impl DatabaseTime {
pub fn read(epoch_ms: i64) -> Self {
Self {
epoch_ms,
read_at: crate::rt::now(),
}
}
pub fn reaches(self, instant: Timestamp) -> Instant {
let ahead = instant.as_epoch_ms().saturating_sub(self.epoch_ms);
self.read_at + Duration::from_millis(u64::try_from(ahead).unwrap_or(0))
}
pub fn now(self) -> Timestamp {
let elapsed = crate::rt::now().saturating_duration_since(self.read_at);
let elapsed = i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX);
Timestamp::from_epoch_ms(self.epoch_ms.saturating_add(elapsed))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WakeTime {
pub at: Timestamp,
pub clock: DatabaseTime,
}
impl WakeTime {
pub fn arrives(self) -> Instant {
self.clock.reaches(self.at)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkflowInitResult {
pub status: WorkflowStatus,
pub recovery_attempts: i64,
pub deadline: Option<Timestamp>,
pub clock: DatabaseTime,
pub serialization: Option<String>,
pub should_execute: bool,
pub creator_xid: String,
}
pub fn is_valid_application_name(name: &str) -> bool {
(3..=256).contains(&name.len())
&& name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenameFrom<'a> {
Application(&'a str),
ApplicationAndUnclaimed(&'a str),
Unclaimed,
}
impl<'a> RenameFrom<'a> {
pub fn application(self) -> Option<&'a str> {
match self {
RenameFrom::Application(name) | RenameFrom::ApplicationAndUnclaimed(name) => Some(name),
RenameFrom::Unclaimed => None,
}
}
}
pub const DEFAULT_RENAME_BATCH_SIZE: u32 = 10_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenameBatching {
Unbatched,
Batched(u32),
}
impl Default for RenameBatching {
fn default() -> Self {
RenameBatching::Batched(DEFAULT_RENAME_BATCH_SIZE)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ApplicationRowCounts {
pub queues: u64,
pub schedules: u64,
pub versions: u64,
pub workflows: u64,
pub steps: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum IdleTransactionTimeout {
#[default]
Default,
ServerSetting,
After(Duration),
}
impl IdleTransactionTimeout {
pub const DEFAULT: Duration = Duration::from_secs(60);
pub const MAX: Duration = Duration::from_millis(i32::MAX as u64);
pub(crate) fn validate(&self) -> Result<(), Error> {
let detail = match self {
IdleTransactionTimeout::After(timeout) if timeout.is_zero() => {
"must be more than zero; `ServerSetting` leaves the server's setting".to_owned()
}
IdleTransactionTimeout::After(timeout) if *timeout > Self::MAX => {
format!("must be at most {:?}", Self::MAX)
}
_ => return Ok(()),
};
Err(Error::InvalidInput {
field: "idle_transaction_timeout".into(),
detail,
})
}
pub(crate) fn millis(self) -> Option<u128> {
match self {
IdleTransactionTimeout::Default => Some(Self::DEFAULT.as_millis()),
IdleTransactionTimeout::ServerSetting => None,
IdleTransactionTimeout::After(timeout) => Some(timeout.as_millis().max(1)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PollingConcurrency {
#[default]
Default,
Unlimited,
AtMost(std::num::NonZeroU32),
}
impl PollingConcurrency {
pub(crate) fn permits(self, pool_size: u32) -> usize {
match self {
PollingConcurrency::Default => usize::max((pool_size / 2) as usize, 1),
PollingConcurrency::Unlimited => tokio::sync::Semaphore::MAX_PERMITS,
PollingConcurrency::AtMost(n) => n.get() as usize,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RateLimit {
pub limit: i32,
pub period: Duration,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Change<T> {
Leave,
Set(T),
}
impl<T> Default for Change<T> {
fn default() -> Self {
Change::Leave
}
}
impl<T> Change<T> {
pub fn set(self) -> Option<T> {
match self {
Change::Set(value) => Some(value),
Change::Leave => None,
}
}
pub fn is_leave(&self) -> bool {
matches!(self, Change::Leave)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct QueueUpdate {
pub concurrency: Change<Option<i32>>,
pub worker_concurrency: Change<Option<i32>>,
pub rate_limit: Change<Option<RateLimit>>,
pub partition_concurrency: Change<Option<i32>>,
pub partition_worker_concurrency: Change<Option<i32>>,
pub partition_rate_limit: Change<Option<RateLimit>>,
pub polling_interval: Change<Duration>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpsertedQueue {
pub before: Option<QueueRecord>,
pub after: QueueRecord,
}
impl UpsertedQueue {
pub fn created(&self) -> bool {
self.before.is_none()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdatedQueue {
pub before: QueueRecord,
pub after: QueueRecord,
}
impl QueueUpdate {
pub fn apply_to(&self, record: &QueueRecord) -> QueueRecord {
QueueRecord {
name: record.name.clone(),
concurrency: self.concurrency.set().unwrap_or(record.concurrency),
worker_concurrency: self
.worker_concurrency
.set()
.unwrap_or(record.worker_concurrency),
rate_limit: self.rate_limit.set().unwrap_or(record.rate_limit),
partition_concurrency: self
.partition_concurrency
.set()
.unwrap_or(record.partition_concurrency),
partition_worker_concurrency: self
.partition_worker_concurrency
.set()
.unwrap_or(record.partition_worker_concurrency),
partition_rate_limit: self
.partition_rate_limit
.set()
.unwrap_or(record.partition_rate_limit),
polling_interval: self
.polling_interval
.set()
.unwrap_or(record.polling_interval),
application_name: record.application_name.clone(),
}
}
pub fn is_empty(&self) -> bool {
self.concurrency.is_leave()
&& self.worker_concurrency.is_leave()
&& self.rate_limit.is_leave()
&& self.partition_concurrency.is_leave()
&& self.partition_worker_concurrency.is_leave()
&& self.partition_rate_limit.is_leave()
&& self.polling_interval.is_leave()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DebounceRequest<'a> {
pub workflow: &'a QualifiedWorkflowName,
pub queue_name: &'a str,
pub deduplication_id: &'a str,
pub period: Duration,
pub inputs: Option<&'a str>,
pub serialization: Option<&'a str>,
pub application_name: Option<&'a str>,
}
impl DebounceRequest<'_> {
pub fn validate(&self) -> Result<(), Error> {
validate_positive_duration("period", self.period)?;
for (field, value) in [
("workflow.name", self.workflow.name()),
("queue_name", self.queue_name),
("deduplication_id", self.deduplication_id),
] {
if value.is_empty() {
return Err(Error::InvalidInput {
field: field.into(),
detail: "must not be empty".to_owned(),
});
}
}
for (field, value) in [
("inputs", self.inputs),
("serialization", self.serialization),
("application_name", self.application_name),
] {
if value == Some("") {
return Err(Error::InvalidInput {
field: field.into(),
detail: "must be absent rather than empty".to_owned(),
});
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Debounce {
Bounced {
workflow_id: String,
},
Held(DebounceHolder),
Unheld,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct DebounceHolder {
pub workflow_id: String,
pub is_debounced: bool,
pub workflow_name: Option<String>,
pub class_name: Option<String>,
pub config_name: Option<String>,
pub application_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QueueRecord {
pub name: String,
pub concurrency: Option<i32>,
pub worker_concurrency: Option<i32>,
pub rate_limit: Option<RateLimit>,
pub partition_concurrency: Option<i32>,
pub partition_worker_concurrency: Option<i32>,
pub partition_rate_limit: Option<RateLimit>,
pub polling_interval: Duration,
pub application_name: Option<String>,
}
impl QueueRecord {
pub fn is_partitioned(&self) -> bool {
has_partition_limit(
self.partition_concurrency,
self.partition_worker_concurrency,
self.partition_rate_limit,
)
}
}
pub(crate) fn has_partition_limit(
partition_concurrency: Option<i32>,
partition_worker_concurrency: Option<i32>,
partition_rate_limit: Option<RateLimit>,
) -> bool {
partition_concurrency.is_some()
|| partition_worker_concurrency.is_some()
|| partition_rate_limit.is_some()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewQueue<'a> {
pub name: &'a str,
pub concurrency: Option<i32>,
pub worker_concurrency: Option<i32>,
pub rate_limit: Option<RateLimit>,
pub partition_concurrency: Option<i32>,
pub partition_worker_concurrency: Option<i32>,
pub partition_rate_limit: Option<RateLimit>,
pub polling_interval: Duration,
pub application_name: Option<&'a str>,
}
impl NewQueue<'_> {
pub fn is_partitioned(&self) -> bool {
has_partition_limit(
self.partition_concurrency,
self.partition_worker_concurrency,
self.partition_rate_limit,
)
}
}
impl<'a> NewQueue<'a> {
pub fn new(name: &'a str) -> Self {
Self {
name,
concurrency: None,
worker_concurrency: None,
rate_limit: None,
partition_concurrency: None,
partition_worker_concurrency: None,
partition_rate_limit: None,
polling_interval: Duration::from_secs(1),
application_name: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OnExistingQueue {
Update,
Leave,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Dequeued {
pub workflow_ids: Vec<String>,
pub stopped_by: Option<DequeueStop>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DequeueStop {
WorkerConcurrency,
PartitionWorkerConcurrency,
RateLimit,
PartitionRateLimit,
Concurrency,
PartitionConcurrency,
}
impl DequeueStop {
pub fn spans_partitions(self) -> bool {
match self {
Self::WorkerConcurrency | Self::RateLimit | Self::Concurrency => true,
Self::PartitionWorkerConcurrency
| Self::PartitionRateLimit
| Self::PartitionConcurrency => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VersionInfo {
pub app_name: Option<String>,
pub version_id: String,
pub version_name: String,
pub version_timestamp: Timestamp,
pub created_at: Timestamp,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NotificationRecord {
pub message_uuid: String,
pub topic: Option<String>,
pub message: String,
pub serialization: Option<String>,
pub created_at: Timestamp,
pub consumed: bool,
}
pub trait StepCodec: Send + Sync + fmt::Debug {
fn encode(&self, value: serde_json::Value) -> Result<EncodedValue, Error>;
fn decode(&self, value: &str, serialization: Option<&str>) -> Result<serde_json::Value, Error>;
}
pub(crate) fn encode_step_value<T: serde::Serialize + ?Sized>(
value: &T,
codec: &dyn StepCodec,
what: &str,
) -> Result<EncodedValue, Error> {
let value = serde_json::to_value(value)
.map_err(|e| Error::Malformed(format!("{what} to record is not JSON: {e}")))?;
codec.encode(value)
}
pub(crate) fn decode_step_value<T: serde::de::DeserializeOwned>(
step: &StepRecord,
recorded: &str,
caller: StepCaller<'_>,
step_name: &str,
what: &str,
) -> Result<T, Error> {
let StepCaller {
workflow_id,
step_id,
codec,
..
} = caller;
codec
.decode(recorded, step.serialization.as_deref())
.and_then(|value| {
serde_json::from_value(value).map_err(|e| Error::Malformed(e.to_string()))
})
.map_err(|error| match error {
Error::UnknownSerialization { ref name } => {
tracing::warn!(
workflow_id,
step_id,
step_name,
serialization = %name,
"a recorded step holds {what} under a serialization this process cannot read"
);
error
}
Error::Malformed(detail) => Error::Malformed(format!(
"workflow {workflow_id} step {step_id} ({step_name}) has {what} this build cannot \
read: {detail}"
)),
other => other,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WorkflowOwner<'a> {
pub workflow_id: &'a str,
pub owner_xid: &'a str,
}
#[derive(Debug, Clone, Copy)]
pub struct StepCaller<'a> {
pub workflow_id: &'a str,
pub step_id: i32,
pub codec: &'a dyn StepCodec,
pub owner_xid: &'a str,
}
impl<'a> StepCaller<'a> {
pub fn owner(&self) -> WorkflowOwner<'a> {
WorkflowOwner {
workflow_id: self.workflow_id,
owner_xid: self.owner_xid,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GetEventCaller<'a> {
pub workflow_id: &'a str,
pub step_id: i32,
pub timeout_step_id: i32,
pub owner_xid: &'a str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InitWorkflowCaller<'a> {
pub parent_workflow_id: &'a str,
pub step_id: i32,
pub step_name: &'a str,
pub started_at: Timestamp,
pub owner_xid: &'a str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EncodedValue {
pub value: String,
pub serialization: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EventRecord {
pub key: String,
pub value: String,
pub serialization: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StreamRead {
pub status: WorkflowStatus,
pub values: Vec<EncodedValue>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AwaitedStream {
Values(Vec<EncodedValue>),
Ended,
TimedOut,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StreamRecord {
pub key: String,
pub offset: i32,
pub value: String,
pub serialization: Option<String>,
pub step_id: i32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Fork<'a> {
pub original_id: &'a str,
pub forked_id: Option<&'a str>,
pub start_step: i32,
}
impl<'a> Fork<'a> {
pub fn new(original_id: &'a str) -> Self {
Self {
original_id,
forked_id: None,
start_step: 0,
}
}
pub(crate) fn validate(&self) -> Result<(), Error> {
if self.original_id.is_empty() {
return Err(Error::InvalidInput {
field: "original_id".into(),
detail: "must not be empty".to_owned(),
});
}
if self.forked_id == Some("") {
return Err(Error::InvalidInput {
field: "forked_id".into(),
detail: "must be absent rather than empty".to_owned(),
});
}
if self.start_step < 0 {
return Err(Error::InvalidInput {
field: "start_step".into(),
detail: "must not be negative".to_owned(),
});
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ForkPoint<'a> {
LastFailure,
LastStep,
Step(i32),
StepNamed(&'a str),
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ForkOptions<'a> {
pub application_version: Option<&'a str>,
pub queue_name: Option<&'a str>,
pub queue_partition_key: Option<&'a str>,
pub timeout: Option<Duration>,
pub replacement_children: &'a [(&'a str, &'a str)],
}
impl ForkOptions<'_> {
pub(crate) fn validate(&self) -> Result<(), Error> {
for (field, value) in [
("application_version", &self.application_version),
("queue_name", &self.queue_name),
("queue_partition_key", &self.queue_partition_key),
] {
if *value == Some("") {
return Err(Error::InvalidInput {
field: field.into(),
detail: "must be absent rather than empty".to_owned(),
});
}
}
if let Some(timeout) = self.timeout {
validate_positive_duration("timeout", timeout)?;
}
for (index, (original, replacement)) in self.replacement_children.iter().enumerate() {
if original.is_empty() {
return Err(Error::InvalidInput {
field: "replacement_children".into(),
detail: "a replaced child id must not be empty".to_owned(),
});
}
if replacement.is_empty() {
return Err(Error::InvalidInput {
field: "replacement_children".into(),
detail: format!("the replacement for {original} must not be empty"),
});
}
if self.replacement_children[..index]
.iter()
.any(|(earlier, _)| earlier == original)
{
return Err(Error::InvalidInput {
field: "replacement_children".into(),
detail: format!("{original} is replaced more than once"),
});
}
}
Ok(())
}
pub(crate) fn timeout_ms(&self) -> Result<Option<i64>, Error> {
self.timeout
.map(|timeout| duration_to_ms("timeout", timeout))
.transpose()
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RewindOptions<'a> {
pub application_version: Option<&'a str>,
pub queue_name: Option<&'a str>,
pub queue_partition_key: Option<&'a str>,
}
impl RewindOptions<'_> {
pub(crate) fn validate(&self) -> Result<(), Error> {
for (field, value) in [
("application_version", &self.application_version),
("queue_name", &self.queue_name),
("queue_partition_key", &self.queue_partition_key),
] {
if *value == Some("") {
return Err(Error::InvalidInput {
field: field.into(),
detail: "must be absent rather than empty".to_owned(),
});
}
}
Ok(())
}
}
pub mod step_names {
pub const SLEEP: &str = "DBOS.sleep";
pub const PATCH_PREFIX: &str = "DBOS.patch-";
pub const SEND: &str = "DBOS.send";
pub const SEND_BULK: &str = "DBOS.sendBulk";
pub const WRITE_STREAM: &str = "DBOS.writeStream";
pub const CLOSE_STREAM: &str = "DBOS.closeStream";
pub const READ_STREAM: &str = "DBOS.readStream";
pub const READ_STREAM_VALUE: &str = "DBOS.readStreamValue";
pub const SET_EVENT: &str = "DBOS.setEvent";
pub const GET_EVENT: &str = "DBOS.getEvent";
pub const GET_RESULT: &str = "DBOS.getResult";
pub const SELECT_WORKFLOW: &str = "DBOS.selectWorkflow";
pub const SELECT_STEP: &str = "DBOS.selectStep";
pub const RECV: &str = "DBOS.recv";
pub const DEBOUNCE: &str = "DBOS.debounceDelayedWorkflow";
pub const CANCEL_WORKFLOW: &str = "DBOS.cancelWorkflow";
pub const RESUME_WORKFLOW: &str = "DBOS.resumeWorkflow";
pub const DELETE_WORKFLOW: &str = "DBOS.deleteWorkflow";
pub const FORK_WORKFLOW: &str = "DBOS.forkWorkflow";
pub const REWIND_WORKFLOW: &str = "DBOS.rewindWorkflow";
pub const SET_WORKFLOW_DELAY: &str = "DBOS.setWorkflowDelay";
pub const UPDATE_WORKFLOW_ATTRIBUTES: &str = "DBOS.updateWorkflowAttributes";
pub const LIST_WORKFLOWS: &str = "DBOS.listWorkflows";
pub const LIST_WORKFLOW_STEPS: &str = "DBOS.listWorkflowSteps";
pub const CREATE_SCHEDULE: &str = "DBOS.createSchedule";
pub const UPSERT_SCHEDULE: &str = "DBOS.upsertSchedule";
pub const GET_SCHEDULE: &str = "DBOS.getSchedule";
pub const LIST_SCHEDULES: &str = "DBOS.listSchedules";
pub const UPDATE_SCHEDULE: &str = "DBOS.updateSchedule";
pub const PAUSE_SCHEDULE: &str = "DBOS.pauseSchedule";
pub const RESUME_SCHEDULE: &str = "DBOS.resumeSchedule";
pub const DELETE_SCHEDULE: &str = "DBOS.deleteSchedule";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Message<'a> {
pub destination_id: &'a str,
pub topic: Option<&'a str>,
pub message: &'a str,
pub idempotency_key: Option<&'a str>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WrittenBy {
Workflow,
Step,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum ScheduleStatus {
Active,
Paused,
}
impl ScheduleStatus {
pub fn as_str(self) -> &'static str {
match self {
ScheduleStatus::Active => "ACTIVE",
ScheduleStatus::Paused => "PAUSED",
}
}
pub fn parse(value: &str) -> Option<Self> {
Some(match value {
"ACTIVE" => ScheduleStatus::Active,
"PAUSED" => ScheduleStatus::Paused,
_ => return None,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ScheduleRecord {
pub schedule_id: String,
pub schedule_name: String,
pub workflow_name: String,
pub workflow_class_name: Option<String>,
pub schedule: String,
pub status: ScheduleStatus,
pub context: String,
pub last_fired_at: Option<Timestamp>,
pub automatic_backfill: bool,
pub cron_timezone: Option<String>,
pub queue_name: Option<String>,
pub application_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewSchedule<'a> {
pub schedule_id: Option<&'a str>,
pub schedule_name: &'a str,
pub workflow_name: &'a str,
pub workflow_class_name: Option<&'a str>,
pub schedule: &'a str,
pub status: ScheduleStatus,
pub context: &'a str,
pub last_fired_at: Option<Timestamp>,
pub automatic_backfill: bool,
pub cron_timezone: Option<&'a str>,
pub queue_name: Option<&'a str>,
pub application_name: Option<&'a str>,
}
impl<'a> NewSchedule<'a> {
pub fn new(schedule_name: &'a str, workflow_name: &'a str, schedule: &'a str) -> Self {
Self {
schedule_id: None,
schedule_name,
workflow_name,
workflow_class_name: None,
schedule,
status: ScheduleStatus::Active,
context: "null",
last_fired_at: None,
automatic_backfill: false,
cron_timezone: None,
queue_name: None,
application_name: None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ScheduleUpdate<'a> {
pub schedule: Change<&'a str>,
pub context: Change<&'a str>,
pub automatic_backfill: Change<bool>,
pub cron_timezone: Change<Option<&'a str>>,
pub queue_name: Change<Option<&'a str>>,
}
impl ScheduleUpdate<'_> {
pub fn is_empty(&self) -> bool {
self.schedule.is_leave()
&& self.context.is_leave()
&& self.automatic_backfill.is_leave()
&& self.cron_timezone.is_leave()
&& self.queue_name.is_leave()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ScheduleFilter<'a> {
pub statuses: Vec<ScheduleStatus>,
pub workflow_names: Vec<&'a str>,
pub schedule_name_prefixes: Vec<&'a str>,
pub app_scope: AppScope<'a>,
}