use crate::registry::QualifiedWorkflowName;
use crate::serialization::{PayloadEncoding, decode_error, encode_error};
use std::borrow::Cow;
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub enum EngineOnly {}
impl std::fmt::Display for EngineOnly {
fn fmt(&self, _: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match *self {}
}
}
impl std::error::Error for EngineOnly {}
pub trait DurableError:
serde::Serialize + serde::de::DeserializeOwned + std::error::Error + 'static
{
}
impl<E> DurableError for E where
E: serde::Serialize + serde::de::DeserializeOwned + std::error::Error + 'static
{
}
pub type Result<T, E = EngineOnly> = std::result::Result<T, Error<E>>;
#[derive(Debug, thiserror::Error, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub enum Error<E = EngineOnly> {
#[error(transparent)]
Application(E),
#[error("cannot {operation} before DBOS is launched")]
NotLaunched {
operation: Cow<'static, str>,
},
#[error("cannot {operation} after DBOS is launched")]
AlreadyLaunched {
operation: Cow<'static, str>,
},
#[error("{operation} must be called from within a workflow")]
NotInWorkflow {
operation: Cow<'static, str>,
},
#[error("{operation} cannot be called from within a step")]
InsideStep {
operation: Cow<'static, str>,
},
#[error("cannot {operation} from within a workflow")]
InsideWorkflow {
operation: Cow<'static, str>,
},
#[error(
"{operation} was called on a different DBOS instance than the one running this workflow"
)]
WrongInstance {
operation: Cow<'static, str>,
},
#[error("invalid configuration: {0}")]
Config(String),
#[error("invalid argument to {operation}: {detail}")]
InvalidArgument {
operation: Cow<'static, str>,
detail: String,
},
#[error("a workflow is already registered as {workflow}")]
AlreadyRegistered {
#[serde(alias = "key")]
workflow: QualifiedWorkflowName,
},
#[error("could not serialize the workflow {what}: {message}")]
Serialization {
what: Cow<'static, str>,
message: String,
#[serde(skip)]
#[source]
source: Option<serde_json::Error>,
},
#[error("could not deserialize the workflow {what}: {message}")]
Deserialization {
what: Cow<'static, str>,
message: String,
#[serde(skip)]
#[source]
source: Option<serde_json::Error>,
},
#[error("serialization {name} is not available")]
UnknownSerialization {
name: String,
},
#[error("no workflow is registered as {workflow}")]
NotRegistered {
#[serde(alias = "key")]
workflow: QualifiedWorkflowName,
},
#[error("no workflow exists with id {workflow_id}")]
WorkflowNotFound {
workflow_id: String,
},
#[error("the workflow {workflow_id} was interrupted by shutdown and left PENDING")]
Interrupted {
workflow_id: String,
},
#[error("the workflow {workflow_id} failed: {message}")]
WorkflowFailed {
workflow_id: String,
message: String,
},
#[error("{name}: {message}")]
Portable {
name: String,
message: String,
code: Option<serde_json::Value>,
data: Option<serde_json::Value>,
},
#[error("the workflow {workflow_id} was cancelled")]
WorkflowCancelled {
workflow_id: String,
},
#[error("the workflow {workflow_id} this one was awaiting was cancelled")]
AwaitedWorkflowCancelled {
workflow_id: String,
},
#[error("the workflow {workflow_id} exceeded {recovery_attempts} recovery attempts")]
MaxRecoveryAttemptsExceeded {
workflow_id: String,
recovery_attempts: i64,
},
#[error("the step {step} failed: {message}")]
StepFailed {
step: String,
message: String,
},
#[error(
"step {step} was built {built} but polled {polled}: a step takes its id where it is built"
)]
StepBuiltElsewhere {
step: String,
built: std::borrow::Cow<'static, str>,
polled: std::borrow::Cow<'static, str>,
},
#[error(
"patch {patch} in workflow {workflow_id} lost step {step_id} to a call built before it \
was awaited: await a patch where it is written, from sequential workflow code"
)]
PatchNondeterminism {
workflow_id: String,
patch: String,
step_id: i32,
},
#[error("the step {step} exceeded its {}ms timeout", timeout.as_millis())]
StepTimeout {
step: String,
timeout: std::time::Duration,
},
#[error(
"no value arrived on stream {key} of workflow {workflow_id}{}",
timeout.map_or_else(String::new, |t| format!(" within {}ms", t.as_millis()))
)]
StreamTimeout {
workflow_id: String,
key: String,
timeout: Option<std::time::Duration>,
},
#[error("the step {step} failed on all {attempts} attempts")]
MaxStepRetriesExceeded {
step: String,
attempts: u32,
errors: Vec<Error<E>>,
},
#[error(transparent)]
SystemDatabase(crate::sysdb::Error),
}
impl<E> From<E> for Error<E> {
fn from(error: E) -> Self {
Error::Application(error)
}
}
pub(crate) fn refusal_detail(field: &str, detail: &str) -> String {
format!("`{field}` {detail}")
}
impl<E> Error<E> {
pub(crate) fn from_sysdb(error: crate::sysdb::Error) -> Self {
match error {
crate::sysdb::Error::UnknownSerialization { name } => {
Error::UnknownSerialization { name }
}
other => Self::from_execution(other),
}
}
pub(crate) fn from_execution(error: crate::sysdb::Error) -> Self {
match error {
crate::sysdb::Error::WorkflowCancelled { workflow_id } => {
Error::WorkflowCancelled { workflow_id }
}
other => Error::SystemDatabase(other),
}
}
pub(crate) fn from_refusal(
error: crate::sysdb::Error,
refuse: impl FnOnce(&str, String) -> Self,
) -> Self {
match error {
crate::sysdb::Error::InvalidInput { field, detail } => refuse(&field, detail),
other => Self::from_sysdb(other),
}
}
pub(crate) fn refused_argument(operation: &'static str, error: crate::sysdb::Error) -> Self {
Self::from_refusal(error, |field, detail| Error::InvalidArgument {
operation: operation.into(),
detail: refusal_detail(field, &detail),
})
}
pub(crate) fn encode_failed(what: &'static str, source: serde_json::Error) -> Self {
Error::Serialization {
what: what.into(),
message: source.to_string(),
source: Some(source),
}
}
pub(crate) fn decode_failed(what: &'static str, source: serde_json::Error) -> Self {
Error::Deserialization {
what: what.into(),
message: source.to_string(),
source: Some(source),
}
}
pub(crate) fn map_application<E2>(self, f: impl Fn(E) -> E2 + Copy) -> Error<E2> {
match self {
Error::Application(error) => Error::Application(f(error)),
Error::NotLaunched { operation } => Error::NotLaunched { operation },
Error::AlreadyLaunched { operation } => Error::AlreadyLaunched { operation },
Error::NotInWorkflow { operation } => Error::NotInWorkflow { operation },
Error::InsideStep { operation } => Error::InsideStep { operation },
Error::InsideWorkflow { operation } => Error::InsideWorkflow { operation },
Error::WrongInstance { operation } => Error::WrongInstance { operation },
Error::Config(message) => Error::Config(message),
Error::InvalidArgument { operation, detail } => {
Error::InvalidArgument { operation, detail }
}
Error::AlreadyRegistered { workflow } => Error::AlreadyRegistered { workflow },
Error::Serialization {
what,
message,
source,
} => Error::Serialization {
what,
message,
source,
},
Error::Deserialization {
what,
message,
source,
} => Error::Deserialization {
what,
message,
source,
},
Error::UnknownSerialization { name } => Error::UnknownSerialization { name },
Error::NotRegistered { workflow } => Error::NotRegistered { workflow },
Error::WorkflowNotFound { workflow_id } => Error::WorkflowNotFound { workflow_id },
Error::Interrupted { workflow_id } => Error::Interrupted { workflow_id },
Error::WorkflowFailed {
workflow_id,
message,
} => Error::WorkflowFailed {
workflow_id,
message,
},
Error::Portable {
name,
message,
code,
data,
} => Error::Portable {
name,
message,
code,
data,
},
Error::WorkflowCancelled { workflow_id } => Error::WorkflowCancelled { workflow_id },
Error::AwaitedWorkflowCancelled { workflow_id } => {
Error::AwaitedWorkflowCancelled { workflow_id }
}
Error::MaxRecoveryAttemptsExceeded {
workflow_id,
recovery_attempts,
} => Error::MaxRecoveryAttemptsExceeded {
workflow_id,
recovery_attempts,
},
Error::StepFailed { step, message } => Error::StepFailed { step, message },
Error::StepTimeout { step, timeout } => Error::StepTimeout { step, timeout },
Error::StreamTimeout {
workflow_id,
key,
timeout,
} => Error::StreamTimeout {
workflow_id,
key,
timeout,
},
Error::StepBuiltElsewhere {
step,
built,
polled,
} => Error::StepBuiltElsewhere {
step,
built,
polled,
},
Error::PatchNondeterminism {
workflow_id,
patch,
step_id,
} => Error::PatchNondeterminism {
workflow_id,
patch,
step_id,
},
Error::MaxStepRetriesExceeded {
step,
attempts,
errors,
} => Error::MaxStepRetriesExceeded {
step,
attempts,
errors: errors
.into_iter()
.map(|error| error.map_application(f))
.collect(),
},
Error::SystemDatabase(error) => Error::SystemDatabase(error),
}
}
pub(crate) fn control(&self) -> Option<Error<EngineOnly>> {
match self {
Error::WorkflowCancelled { workflow_id } => Some(Error::WorkflowCancelled {
workflow_id: workflow_id.clone(),
}),
Error::Interrupted { workflow_id } => Some(Error::Interrupted {
workflow_id: workflow_id.clone(),
}),
Error::SystemDatabase(error) if !error.should_record() => {
Some(Error::SystemDatabase(error.clone()))
}
_ => None,
}
}
}
impl Error<EngineOnly> {
pub fn lift<E>(self) -> Error<E> {
self.map_application(|impossible| match impossible {})
}
}
#[derive(Debug)]
pub(crate) enum Failure {
Recorded(EncodedError),
Control(Error<EngineOnly>),
}
const UNENCODABLE_ERROR: &str = "the error could not be encoded by its serializer";
#[derive(Debug, Clone)]
pub(crate) struct EncodedError {
pub(crate) encoded: String,
pub(crate) encoding: PayloadEncoding,
}
impl EncodedError {
pub(crate) fn of<E: DurableError>(error: &Error<E>, encoding: PayloadEncoding) -> Self {
match encode_error::<E, EngineOnly>(error, &encoding) {
Ok(encoded) => Self { encoded, encoding },
Err(refused) => Self::refused(error, refused, encoding, "error"),
}
}
pub(crate) fn refused<E: DurableError>(
error: &Error<E>,
refused: Error<EngineOnly>,
encoding: PayloadEncoding,
what: &'static str,
) -> Self {
let said = match refused {
Error::Serialization { message, .. } => message,
other => other.to_string(),
};
let stand_in = Error::<E>::Serialization {
what: what.into(),
message: format!("{said}; the error was: {error}"),
source: None,
};
tracing::warn!(
what,
error = %stand_in,
"an error could not be encoded; its message is recorded instead"
);
let encoded = encode_error::<E, EngineOnly>(&stand_in, &encoding).unwrap_or_else(|_| {
tracing::warn!(
what,
"the serializer refused the stand-in too; a fixed marker is recorded"
);
UNENCODABLE_ERROR.to_owned()
});
Self { encoded, encoding }
}
pub(crate) fn decode<E: DurableError>(&self, workflow_id: &str) -> Error<E> {
decode_error(&self.encoded, &self.encoding).unwrap_or_else(|| Error::WorkflowFailed {
workflow_id: workflow_id.to_owned(),
message: self.encoded.clone(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, thiserror::Error, serde::Serialize, serde::Deserialize, PartialEq)]
#[error("the card was declined after {attempts} attempts")]
struct CardDeclined {
attempts: u32,
}
#[test]
fn an_application_error_round_trips_whole() {
let error: Error<CardDeclined> = CardDeclined { attempts: 3 }.into();
let json = serde_json::to_string(&error).unwrap();
let back: Error<CardDeclined> = serde_json::from_str(&json).unwrap();
let Error::Application(back) = back else {
panic!("expected an application error, got {back:?}")
};
assert_eq!(back, CardDeclined { attempts: 3 }, "the field survives");
assert_eq!(error.to_string(), back.to_string());
}
#[test]
fn a_registration_error_recorded_as_key_still_decodes() {
for (json, expected) in [
(
r#"{"NotRegistered":{"key":{"name":"f","class_name":"C","config_name":null}}}"#,
"no workflow is registered as f/C",
),
(
r#"{"AlreadyRegistered":{"key":{"name":"f","class_name":null,"config_name":null}}}"#,
"a workflow is already registered as f",
),
] {
let back: Error = serde_json::from_str(json).unwrap();
assert_eq!(back.to_string(), expected);
}
}
#[test]
fn the_blanket_conversion_lifts_an_application_error() {
fn fallible() -> Result<(), CardDeclined> {
Err(CardDeclined { attempts: 1 })?
}
assert!(matches!(
fallible().unwrap_err(),
Error::Application(CardDeclined { attempts: 1 })
));
}
#[test]
fn an_engine_error_lifts_into_any_channel() {
let engine: Error = Error::NotLaunched {
operation: "run a workflow".into(),
};
let lifted: Error<CardDeclined> = engine.lift();
assert_eq!(
lifted.to_string(),
"cannot run a workflow before DBOS is launched"
);
}
#[test]
fn an_execution_sees_cancellation_at_the_top_level() {
let cancelled = crate::sysdb::Error::WorkflowCancelled {
workflow_id: "wf-1".to_owned(),
};
assert!(matches!(
Error::<CardDeclined>::from_execution(cancelled.clone()),
Error::WorkflowCancelled { workflow_id } if workflow_id == "wf-1"
));
assert!(matches!(
Error::<CardDeclined>::from_sysdb(cancelled),
Error::WorkflowCancelled { workflow_id } if workflow_id == "wf-1"
));
let conflict = crate::sysdb::Error::WorkflowConflict {
workflow_id: "wf-1".to_owned(),
};
assert!(matches!(
Error::<CardDeclined>::from_execution(conflict),
Error::SystemDatabase(crate::sysdb::Error::WorkflowConflict { .. })
));
}
#[test]
fn only_control_errors_report_themselves_as_control() {
let cancelled: Error<CardDeclined> = Error::WorkflowCancelled {
workflow_id: "wf-1".to_owned(),
};
assert!(cancelled.control().is_some());
let failed: Error<CardDeclined> = CardDeclined { attempts: 1 }.into();
assert!(
failed.control().is_none(),
"an application failure is an outcome, not a signal"
);
}
#[test]
fn a_typed_refusal_is_an_outcome_and_database_trouble_a_signal() {
let refused: Error = Error::SystemDatabase(crate::sysdb::Error::AlreadyRegistered {
kind: "Schedule".into(),
name: "nightly".to_owned(),
});
assert!(
refused.control().is_none(),
"a refusal is the call's answer, so a workflow returning it has failed"
);
let signals: [crate::sysdb::Error; 4] = [
crate::sysdb::Error::Backend(crate::sysdb::BackendError {
message: "relation does not exist".to_owned(),
sqlstate: Some("42P01".to_owned()),
kind: crate::sysdb::BackendErrorKind::Permanent,
}),
crate::sysdb::Error::WorkflowCancelled {
workflow_id: "wf-1".to_owned(),
},
crate::sysdb::Error::StepNondeterminism {
workflow_id: "wf-1".to_owned(),
step_id: 0,
},
crate::sysdb::Error::Malformed("unreadable".to_owned()),
];
for signal in signals {
let error: Error = Error::SystemDatabase(signal);
assert!(error.control().is_some(), "{error} is a signal");
}
}
#[derive(Debug, thiserror::Error, serde::Serialize, serde::Deserialize)]
#[error("no keys fit")]
struct BadKeys {
by_pair: std::collections::HashMap<(u8, u8), u8>,
}
fn bad_keys() -> Error<BadKeys> {
BadKeys {
by_pair: [((1, 2), 3)].into_iter().collect(),
}
.into()
}
#[test]
fn an_error_its_encoding_refuses_is_recorded_as_the_refusal() {
for encoding in [PayloadEncoding::Native, PayloadEncoding::Portable] {
let recorded = EncodedError::of(&bad_keys(), encoding.clone());
assert_eq!(recorded.encoding, encoding);
let read = recorded.decode::<BadKeys>("wf-1");
let Error::Serialization { what, message, .. } = &read else {
panic!("{} read back as {read:?}", encoding.name())
};
assert_eq!(what, "error", "{}", encoding.name());
assert!(
message.contains("key must be a string") && message.ends_with("no keys fit"),
"{}: {message}",
encoding.name()
);
}
}
#[test]
fn a_steps_refused_error_is_recorded_as_a_refusal_of_the_step_error() {
let retried: Error<BadKeys> = Error::MaxStepRetriesExceeded {
step: "charge".to_owned(),
attempts: 2,
errors: vec![bad_keys(), bad_keys()],
};
for error in [bad_keys(), retried] {
for encoding in [PayloadEncoding::Native, PayloadEncoding::Portable] {
let refused = encode_error::<BadKeys, EngineOnly>(&error, &encoding)
.expect_err("the encoding refuses a non-string key");
let recorded =
EncodedError::refused(&error, refused, encoding.clone(), "step error");
let read = recorded.decode::<BadKeys>("wf-1");
let Error::Serialization { what, message, .. } = &read else {
panic!("{} read back as {read:?}", encoding.name())
};
assert_eq!(what, "step error", "{}", encoding.name());
assert!(
message.contains("key must be a string")
&& message.ends_with(&error.to_string()),
"{}: {message}",
encoding.name()
);
}
}
}
#[test]
fn a_serializer_that_refuses_everything_records_a_fixed_marker() {
use crate::serialization::{CustomSerializer, Payload, PayloadSlot, Serializer};
#[derive(Debug)]
struct Refuses;
impl CustomSerializer for Refuses {
fn name(&self) -> &str {
"refuses"
}
fn serialize(
&self,
_: Payload<'_>,
) -> std::result::Result<String, Box<dyn std::error::Error + Send + Sync>> {
Err("nothing goes".into())
}
fn deserialize(
&self,
_: &str,
_: PayloadSlot<'_>,
) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
Err("nothing comes back".into())
}
}
let error: Error<CardDeclined> = CardDeclined { attempts: 3 }.into();
let recorded = EncodedError::of(&error, Serializer::custom(Refuses).encoding());
assert_eq!(recorded.encoded, UNENCODABLE_ERROR);
let read = recorded.decode::<CardDeclined>("wf-1");
assert!(
matches!(&read, Error::Deserialization { what, .. } if what == "error"),
"{read:?}"
);
}
#[test]
fn a_serializer_that_writes_nul_bytes_records_a_fixed_marker() {
use crate::serialization::{CustomSerializer, Payload, PayloadSlot, Serializer};
#[derive(Debug)]
struct WritesNul;
impl CustomSerializer for WritesNul {
fn name(&self) -> &str {
"writes_nul"
}
fn serialize(
&self,
value: Payload<'_>,
) -> std::result::Result<String, Box<dyn std::error::Error + Send + Sync>> {
Ok(format!("{}\0", serde_json::to_string(&value)?))
}
fn deserialize(
&self,
_: &str,
_: PayloadSlot<'_>,
) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
Err("nothing comes back".into())
}
}
let encoding = Serializer::custom(WritesNul).encoding();
let error: Error<CardDeclined> = CardDeclined { attempts: 3 }.into();
let refused = encode_error::<CardDeclined, EngineOnly>(&error, &encoding)
.expect_err("a NUL byte is refused");
assert!(
matches!(&refused, Error::Serialization { what, message, .. }
if what == "error" && message.contains("NUL")),
"{refused:?}"
);
let recorded = EncodedError::of(&error, encoding);
assert_eq!(recorded.encoded, UNENCODABLE_ERROR);
}
}