use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use async_trait::async_trait;
use backbone_outbox::{inbox, OutboxError, OutboxRecord};
use chrono::{DateTime, Utc};
use uuid::Uuid;
use crate::write_hooks::{with_correlation_id, NotificationRequested};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DeliveryOutcome {
Delivered,
Retry(String),
Reject(String),
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct PendingNotification {
pub id: Uuid,
pub request: NotificationRequested,
pub aggregate_type: String,
pub aggregate_id: String,
pub company_id: Uuid,
pub org_unit_id: Option<Uuid>,
pub correlation_id: Option<String>,
pub causation_id: Option<String>,
pub occurred_at: DateTime<Utc>,
pub attempt: u32,
}
impl PendingNotification {
pub fn from_record(record: &OutboxRecord, attempt: u32) -> Result<Self, serde_json::Error> {
Ok(Self {
id: record.id,
request: serde_json::from_value(record.payload.clone())?,
aggregate_type: record.aggregate_type.clone(),
aggregate_id: record.aggregate_id.clone(),
company_id: record.company_id,
org_unit_id: record.org_unit_id,
correlation_id: record.correlation_id.clone(),
causation_id: record.causation_id.clone(),
occurred_at: record.occurred_at,
attempt,
})
}
}
#[async_trait]
pub trait NotificationDelivery: Send + Sync {
fn consumer(&self) -> &str {
"notification-delivery"
}
async fn deliver(&self, notification: &PendingNotification) -> DeliveryOutcome;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RetryPolicy {
pub max_attempts: u32,
pub base_delay: Duration,
pub max_delay: Duration,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_attempts: 5,
base_delay: Duration::from_secs(30),
max_delay: Duration::from_secs(15 * 60),
}
}
}
impl RetryPolicy {
pub fn immediate(max_attempts: u32) -> Self {
Self {
max_attempts,
base_delay: Duration::ZERO,
max_delay: Duration::ZERO,
}
}
fn backoff(&self, attempts: u32) -> Duration {
let doublings = attempts.saturating_sub(1).min(20);
self.base_delay
.saturating_mul(1u32 << doublings)
.min(self.max_delay)
}
}
struct Attempts {
made: u32,
not_before: Instant,
}
pub struct NotificationDispatcher {
pool: backbone_orm::PgPool,
schema: String,
delivery: Arc<dyn NotificationDelivery>,
policy: RetryPolicy,
retrying: Mutex<HashMap<Uuid, Attempts>>,
}
pub type SinkFuture = Pin<Box<dyn Future<Output = Result<(), OutboxError>> + Send>>;
impl NotificationDispatcher {
pub fn new(
pool: backbone_orm::PgPool,
schema: impl Into<String>,
delivery: Arc<dyn NotificationDelivery>,
) -> Self {
Self {
pool,
schema: schema.into(),
delivery,
policy: RetryPolicy::default(),
retrying: Mutex::new(HashMap::new()),
}
}
pub fn with_retry(mut self, policy: RetryPolicy) -> Self {
self.policy = RetryPolicy {
max_attempts: policy.max_attempts.max(1),
..policy
};
self
}
pub fn sink(
self: &Arc<Self>,
) -> impl Fn(OutboxRecord) -> SinkFuture + Clone + Send + Sync + 'static {
let this = self.clone();
move |record| {
let this = this.clone();
Box::pin(async move { this.deliver(record).await })
}
}
pub async fn deliver(&self, record: OutboxRecord) -> Result<(), OutboxError> {
if record.event_type != NotificationRequested::NAME {
return Ok(());
}
let id = record.id;
let made = {
let retrying = self.retrying.lock().unwrap_or_else(|e| e.into_inner());
match retrying.get(&id) {
Some(state) if state.not_before > Instant::now() => {
return Err(OutboxError::Publish(format!(
"notification {id} waits for its next attempt"
)));
}
Some(state) => state.made,
None => 0,
}
};
let notification = match PendingNotification::from_record(&record, made + 1) {
Ok(n) => n,
Err(e) => {
return Err(OutboxError::Exhausted(format!(
"the payload is not a NotificationRequested: {e}"
)))
}
};
let consumer = self.delivery.consumer();
let mut tx = self
.pool
.begin()
.await
.map_err(|e| OutboxError::Publish(format!("begin: {e}")))?;
let claimed = inbox::once(&mut *tx, &self.schema, consumer, id)
.await
.map_err(|e| OutboxError::Publish(format!("inbox: {e}")))?;
if !claimed {
tx.rollback().await.ok();
self.forget(id);
return Ok(());
}
let correlation = record
.correlation_id
.clone()
.unwrap_or_else(|| record.id.to_string());
let outcome = with_correlation_id(correlation, self.delivery.deliver(¬ification)).await;
match outcome {
DeliveryOutcome::Delivered => {
tx.commit()
.await
.map_err(|e| OutboxError::Publish(format!("commit: {e}")))?;
self.forget(id);
Ok(())
}
DeliveryOutcome::Retry(reason) => {
tx.rollback().await.ok();
let attempts = made + 1;
if attempts >= self.policy.max_attempts {
self.forget(id);
tracing::warn!(target: "backbone::notification", %id, attempts, %reason, "notification dead-lettered: retries used up");
return Err(OutboxError::Exhausted(format!(
"gave up after {attempts} attempts: {reason}"
)));
}
let wait = self.policy.backoff(attempts);
self.retrying
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(
id,
Attempts {
made: attempts,
not_before: Instant::now() + wait,
},
);
tracing::info!(target: "backbone::notification", %id, attempts, wait_secs = wait.as_secs(), %reason, "notification will be retried");
Err(OutboxError::Publish(reason))
}
DeliveryOutcome::Reject(reason) => {
tx.rollback().await.ok();
self.forget(id);
tracing::warn!(target: "backbone::notification", %id, %reason, "notification rejected");
Err(OutboxError::Exhausted(reason))
}
}
}
fn forget(&self, id: Uuid) {
self.retrying
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&id);
}
}
#[derive(Clone)]
pub struct RecordingDelivery {
consumer: String,
received: Arc<Mutex<Vec<PendingNotification>>>,
script: Arc<Mutex<std::collections::VecDeque<DeliveryOutcome>>>,
fallback: DeliveryOutcome,
}
impl Default for RecordingDelivery {
fn default() -> Self {
Self::new()
}
}
impl RecordingDelivery {
pub fn new() -> Self {
Self {
consumer: "notification-delivery".to_string(),
received: Arc::default(),
script: Arc::default(),
fallback: DeliveryOutcome::Delivered,
}
}
pub fn answering(outcomes: Vec<DeliveryOutcome>) -> Self {
let this = Self::new();
*this.script.lock().unwrap_or_else(|e| e.into_inner()) = outcomes.into();
this
}
pub fn always(outcome: DeliveryOutcome) -> Self {
Self {
fallback: outcome,
..Self::new()
}
}
pub fn named(mut self, consumer: impl Into<String>) -> Self {
self.consumer = consumer.into();
self
}
pub fn received(&self) -> Vec<PendingNotification> {
self.received
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
pub fn calls(&self) -> usize {
self.received
.lock()
.unwrap_or_else(|e| e.into_inner())
.len()
}
}
#[async_trait]
impl NotificationDelivery for RecordingDelivery {
fn consumer(&self) -> &str {
&self.consumer
}
async fn deliver(&self, notification: &PendingNotification) -> DeliveryOutcome {
self.received
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(notification.clone());
self.script
.lock()
.unwrap_or_else(|e| e.into_inner())
.pop_front()
.unwrap_or_else(|| self.fallback.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn record(event_type: &str, payload: serde_json::Value) -> OutboxRecord {
OutboxRecord::new(
event_type,
"Incident",
"i-1",
Uuid::new_v4(),
payload,
Utc::now(),
)
.with_correlation_id("corr-1")
.with_causation_id("cause-1")
}
fn request() -> serde_json::Value {
serde_json::to_value(NotificationRequested {
channel: "email".into(),
template: Some("incident_opened".into()),
body: None,
to: json!(["a@example.test"]),
data: json!({ "title": "t" }),
trigger: "Incident.after_create".into(),
})
.unwrap()
}
#[test]
fn a_request_is_read_from_its_record_with_its_correlation_and_causation() {
let rec = record(NotificationRequested::NAME, request());
let n = PendingNotification::from_record(&rec, 3).unwrap();
assert_eq!(n.id, rec.id);
assert_eq!(n.attempt, 3);
assert_eq!(n.request.channel, "email");
assert_eq!(n.request.template.as_deref(), Some("incident_opened"));
assert_eq!(n.correlation_id.as_deref(), Some("corr-1"));
assert_eq!(n.causation_id.as_deref(), Some("cause-1"));
assert_eq!(
(n.aggregate_type.as_str(), n.aggregate_id.as_str()),
("Incident", "i-1")
);
assert_eq!(n.company_id, rec.company_id);
}
#[test]
fn a_payload_that_is_not_a_request_is_refused() {
let rec = record(NotificationRequested::NAME, json!({ "nonsense": true }));
assert!(PendingNotification::from_record(&rec, 1).is_err());
}
#[test]
fn the_wait_doubles_to_a_ceiling() {
let p = RetryPolicy {
max_attempts: 9,
base_delay: Duration::from_secs(10),
max_delay: Duration::from_secs(70),
};
let waits: Vec<u64> = (1..=5).map(|n| p.backoff(n).as_secs()).collect();
assert_eq!(waits, vec![10, 20, 40, 70, 70]);
assert_eq!(RetryPolicy::immediate(3).backoff(2), Duration::ZERO);
assert_eq!(p.backoff(u32::MAX), Duration::from_secs(70));
}
#[test]
fn the_default_policy_is_five_attempts_and_a_bounded_wait() {
let p = RetryPolicy::default();
assert_eq!(p.max_attempts, 5);
assert!(p.backoff(5) <= p.max_delay);
}
#[tokio::test]
async fn the_recording_delivery_answers_its_script_then_delivers() {
let d = RecordingDelivery::answering(vec![
DeliveryOutcome::Retry("busy".into()),
DeliveryOutcome::Reject("no such template".into()),
]);
let n =
PendingNotification::from_record(&record(NotificationRequested::NAME, request()), 1)
.unwrap();
assert_eq!(d.deliver(&n).await, DeliveryOutcome::Retry("busy".into()));
assert_eq!(
d.deliver(&n).await,
DeliveryOutcome::Reject("no such template".into())
);
assert_eq!(d.deliver(&n).await, DeliveryOutcome::Delivered);
assert_eq!(d.calls(), 3);
let always = RecordingDelivery::always(DeliveryOutcome::Retry("down".into())).named("sms");
assert_eq!(always.consumer(), "sms");
assert_eq!(
always.deliver(&n).await,
DeliveryOutcome::Retry("down".into())
);
assert_eq!(
always.deliver(&n).await,
DeliveryOutcome::Retry("down".into())
);
}
}