use es_entity::errlanes;
use super::Ordering;
#[derive(Debug, Clone, Copy, PartialEq, Eq, errlanes::Rejection)]
#[rejection(code = "OBIX_COMMIT_LANE_DISABLED")]
#[error(
"the commit lane is disabled on this outbox — set MailboxConfig::commit_lane = CommitLane::Enabled"
)]
pub struct CommitLaneDisabled;
#[derive(Debug, errlanes::Rejection)]
pub enum SubscriptionRejection {
#[error("no job for ({subscriber_type}, {key})")]
#[rejection(code = "OBIX_SUBSCRIPTION_NO_SUCH_JOB")]
NoSuchJob {
subscriber_type: String,
key: String,
},
#[error("no subscription has ever existed for ({subscriber_type}, {key})")]
#[rejection(code = "OBIX_SUBSCRIPTION_NO_SUCH_SUBSCRIPTION")]
NoSuchSubscription {
subscriber_type: String,
key: String,
},
#[error("checkpoint {checkpoint} behind target {target} after {waited:?}")]
#[rejection(code = "OBIX_SUBSCRIPTION_CAUGHT_UP_TIMEOUT")]
CaughtUpTimeout {
checkpoint: super::StreamPosition,
target: super::StreamPosition,
waited: std::time::Duration,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, errlanes::Classify)]
#[classify(fatal(Config))]
#[error(
"subscription is checkpointed on the {stored} lane but registered with Ordering::{configured}; switching lanes is unsupported — register a new job type instead"
)]
pub struct LaneMismatch {
pub stored: Ordering,
pub configured: Ordering,
}
#[derive(Debug, errlanes::Classify)]
pub enum CouldNotDecodeStored {
#[classify(fatal(CorruptState))]
#[error("could not decode a subscriber job's execution state")]
ExecutionState(#[source] serde_json::Error),
#[classify(fatal(CorruptState))]
#[error("could not decode a keyed subscription's instance_config")]
InstanceConfig(#[source] serde_json::Error),
#[classify(fatal(CorruptState))]
#[error("could not parse a keyed subscription's persisted key for {job_type}")]
Key { job_type: ::job::JobType },
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::ObixFault;
use es_entity::errlanes::{Fail, FatalKind, Fault, Rejection, ResultExt, lanes};
use std::error::Error as _;
#[test]
fn commit_lane_disabled_has_a_stable_code() {
let code: &'static str = CommitLaneDisabled.code().into();
assert_eq!(code, "OBIX_COMMIT_LANE_DISABLED");
}
#[test]
fn subscription_rejection_codes_are_stable() {
let no_such_job: &'static str = SubscriptionRejection::NoSuchJob {
subscriber_type: String::new(),
key: String::new(),
}
.code()
.into();
assert_eq!(no_such_job, "OBIX_SUBSCRIPTION_NO_SUCH_JOB");
let no_such_subscription: &'static str = SubscriptionRejection::NoSuchSubscription {
subscriber_type: String::new(),
key: String::new(),
}
.code()
.into();
assert_eq!(
no_such_subscription,
"OBIX_SUBSCRIPTION_NO_SUCH_SUBSCRIPTION"
);
let timeout: &'static str = SubscriptionRejection::CaughtUpTimeout {
checkpoint: super::super::StreamPosition::Insert(crate::EventSequence::BEGIN),
target: super::super::StreamPosition::Insert(crate::EventSequence::BEGIN),
waited: std::time::Duration::ZERO,
}
.code()
.into();
assert_eq!(timeout, "OBIX_SUBSCRIPTION_CAUGHT_UP_TIMEOUT");
}
#[test]
fn lane_mismatch_is_fatal_config() {
let mismatch = LaneMismatch {
stored: Ordering::Insert,
configured: Ordering::Commit,
};
let err: Fail<SubscriptionRejection, lanes!(Transient, Fatal)> = mismatch.into();
match err {
Fail::Fatal(f) => {
assert_eq!(f.kind, FatalKind::Config);
assert!(f.source().unwrap().downcast_ref::<LaneMismatch>().is_some());
}
other => panic!("expected Fatal(Config), got {other:?}"),
}
}
#[test]
fn execution_state_decode_failure_is_fatal_corrupt_state() {
let decode_failure =
serde_json::from_value::<u64>(serde_json::json!("not a number")).unwrap_err();
let err: ObixFault = CouldNotDecodeStored::ExecutionState(decode_failure).into();
match err {
Fault::Fatal(fatal) => {
assert_eq!(fatal.kind, FatalKind::CorruptState);
let wrapper = fatal.source().expect("the wrapper is the Fatal's source");
assert_eq!(
wrapper.to_string(),
"could not decode a subscriber job's execution state"
);
assert!(wrapper.source().is_some(), "serde error stays in the chain");
}
other => panic!("expected Fatal(CorruptState), got {other:?}"),
}
}
#[test]
fn a_decode_wrapper_must_reach_a_carrier_before_it_reaches_a_box() {
fn undecodable() -> CouldNotDecodeStored {
CouldNotDecodeStored::ExecutionState(
serde_json::from_value::<u64>(serde_json::json!("not a number")).unwrap_err(),
)
}
fn boxed(e: impl std::error::Error + Send + Sync + 'static) -> ObixFault {
let boxed: Box<dyn std::error::Error + Send + Sync> = Box::new(e);
Fault::classify(&*boxed).narrow_denied()
}
assert!(
matches!(boxed(undecodable()), Fault::Fatal(f) if f.kind == FatalKind::Invariant),
"boxing the wrapper raw loses the override",
);
let laned = Err::<(), _>(undecodable())
.widen_via_builtin()
.expect_err("still an error");
assert!(matches!(boxed(laned), Fault::Fatal(f) if f.kind == FatalKind::CorruptState));
}
#[test]
fn a_raw_sqlx_error_keeps_its_lane_across_a_box() {
for error in [
sqlx::Error::PoolTimedOut,
sqlx::Error::RowNotFound,
sqlx::Error::Protocol("synthesized".into()),
] {
let eager: ObixFault = clone_shape(&error).into();
let boxed: Box<dyn std::error::Error + Send + Sync> = Box::new(error);
let recovered = Fault::classify(&*boxed).narrow_denied();
assert_eq!(recovered.lane(), eager.lane());
match (recovered, eager) {
(Fault::Fatal(a), Fault::Fatal(b)) => assert_eq!(a.kind, b.kind),
(Fault::Transient(a), Fault::Transient(b)) => assert_eq!(a.kind, b.kind),
(a, b) => panic!("lane mismatch: {a:?} vs {b:?}"),
}
}
}
fn clone_shape(error: &sqlx::Error) -> sqlx::Error {
match error {
sqlx::Error::PoolTimedOut => sqlx::Error::PoolTimedOut,
sqlx::Error::RowNotFound => sqlx::Error::RowNotFound,
sqlx::Error::Protocol(m) => sqlx::Error::Protocol(m.clone()),
other => panic!("unhandled shape {other:?}"),
}
}
#[test]
fn instance_config_decode_failure_is_fatal_corrupt_state() {
let decode_failure =
serde_json::from_value::<u64>(serde_json::json!("not a number")).unwrap_err();
let err: ObixFault = CouldNotDecodeStored::InstanceConfig(decode_failure).into();
assert!(matches!(err, Fault::Fatal(f) if f.kind == FatalKind::CorruptState));
}
#[test]
fn key_decode_failure_is_fatal_corrupt_state_and_hides_no_message_it_should_not() {
let err: ObixFault = CouldNotDecodeStored::Key {
job_type: ::job::JobType::new("test-job-type"),
}
.into();
match err {
Fault::Fatal(fatal) => {
assert_eq!(fatal.kind, FatalKind::CorruptState);
let wrapper = fatal.source().expect("the wrapper is the Fatal's source");
assert!(wrapper.to_string().contains("test-job-type"));
}
other => panic!("expected Fatal(CorruptState), got {other:?}"),
}
}
#[test]
fn a_stored_decode_failure_is_fatal_corrupt_state_in_a_fail_carrier_too() {
let decode_failure =
serde_json::from_value::<u64>(serde_json::json!("not a number")).unwrap_err();
let err: Fail<SubscriptionRejection, lanes!(Transient, Fatal)> =
CouldNotDecodeStored::ExecutionState(decode_failure).into();
assert!(matches!(err, Fail::Fatal(f) if f.kind == FatalKind::CorruptState));
}
}