use crate::model::Event;
use std::sync::Arc;
#[derive(Clone)]
#[non_exhaustive]
pub struct OutboxConfig<P> {
pub batch_size: u32,
pub retention_days: i64,
pub gc_interval_secs: u64,
pub poll_interval_secs: u64,
pub lock_timeout_mins: i64,
pub idempotency_strategy: IdempotencyStrategy<P>,
pub dlq_threshold: u32,
pub dlq_interval_secs: u64,
}
impl<P> Default for OutboxConfig<P> {
fn default() -> Self {
Self {
batch_size: 100,
retention_days: 7,
gc_interval_secs: 3600,
poll_interval_secs: 10,
lock_timeout_mins: 5,
idempotency_strategy: IdempotencyStrategy::None,
dlq_threshold: 10,
dlq_interval_secs: 300,
}
}
}
pub type IdempotencyDeriver<P> = Arc<dyn Fn(&Event<P>) -> String + Send + Sync + 'static>;
#[derive(Clone)]
pub enum IdempotencyStrategy<P> {
Provided,
Custom(IdempotencyDeriver<P>),
Uuid,
None,
}
impl<P> IdempotencyStrategy<P> {
pub fn custom<F>(f: F) -> Self
where
F: Fn(&Event<P>) -> String + Send + Sync + 'static,
{
Self::Custom(Arc::new(f))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use rstest::rstest;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct TestPayload;
fn default_cfg() -> OutboxConfig<TestPayload> {
OutboxConfig::default()
}
#[rstest]
fn default_batch_size_is_100() {
assert_eq!(default_cfg().batch_size, 100);
}
#[rstest]
fn default_retention_days_is_7() {
assert_eq!(default_cfg().retention_days, 7);
}
#[rstest]
fn default_gc_interval_secs_is_3600() {
assert_eq!(default_cfg().gc_interval_secs, 3600);
}
#[rstest]
fn default_poll_interval_secs_is_10() {
assert_eq!(default_cfg().poll_interval_secs, 10);
}
#[rstest]
fn default_lock_timeout_mins_is_5() {
assert_eq!(default_cfg().lock_timeout_mins, 5);
}
#[rstest]
fn default_idempotency_strategy_is_none() {
assert!(matches!(
default_cfg().idempotency_strategy,
IdempotencyStrategy::None
));
}
#[rstest]
fn clone_preserves_scalar_fields() {
let cfg = OutboxConfig::<TestPayload> {
batch_size: 42,
retention_days: 3,
gc_interval_secs: 99,
poll_interval_secs: 1,
lock_timeout_mins: 2,
idempotency_strategy: IdempotencyStrategy::Uuid,
dlq_threshold: 10,
dlq_interval_secs: 1,
};
let cloned = cfg.clone();
assert_eq!(cloned.batch_size, 42);
assert_eq!(cloned.retention_days, 3);
assert_eq!(cloned.gc_interval_secs, 99);
assert_eq!(cloned.poll_interval_secs, 1);
assert_eq!(cloned.lock_timeout_mins, 2);
assert_eq!(cloned.dlq_threshold, 10);
assert_eq!(cloned.dlq_interval_secs, 1);
assert!(matches!(
cloned.idempotency_strategy,
IdempotencyStrategy::Uuid
));
}
#[rstest]
fn clone_preserves_custom_strategy_callback() {
let cfg = OutboxConfig::<TestPayload> {
batch_size: 1,
retention_days: 1,
gc_interval_secs: 1,
poll_interval_secs: 1,
lock_timeout_mins: 1,
idempotency_strategy: IdempotencyStrategy::custom(|_| "fp".to_string()),
dlq_threshold: 10,
dlq_interval_secs: 1,
};
let cloned = cfg.clone();
match cloned.idempotency_strategy {
IdempotencyStrategy::Custom(f) => {
let e = Event::new(
crate::object::EventType::new("t"),
crate::object::Payload::new(TestPayload),
None,
);
assert_eq!(f(&e), "fp");
}
_ => panic!("expected Custom variant after clone"),
}
}
#[rstest]
fn custom_constructor_wraps_callback_into_arc() {
let prefix = "tenant-x".to_string();
let strategy: IdempotencyStrategy<TestPayload> =
IdempotencyStrategy::custom(move |_| format!("{prefix}:tok"));
let IdempotencyStrategy::Custom(f) = strategy else {
panic!("expected Custom variant");
};
let e = Event::new(
crate::object::EventType::new("t"),
crate::object::Payload::new(TestPayload),
None,
);
assert_eq!(f(&e), "tenant-x:tok");
}
}