Skip to main content

backbone_core/
notification.rs

1//! Delivery of `NotificationRequested` rows: the consumer side of the notification contract.
2//!
3//! A hook trigger's `send_email` / `notify` and a workflow's `send_email` / `notify` /
4//! `send_notification` / `send_sms` never tell anyone anything inside the write's transaction: they
5//! stage a [`NotificationRequested`](crate::write_hooks::NotificationRequested) row in the
6//! module's outbox, which commits with the write. This module is what reads those rows back out:
7//!
8//! - [`NotificationDelivery`] is the one trait a notification module (mail, push, SMS) implements:
9//!   deliver one request, and say whether it was [delivered](DeliveryOutcome::Delivered), should be
10//!   [tried again](DeliveryOutcome::Retry) or can [never be delivered](DeliveryOutcome::Reject).
11//! - [`NotificationDispatcher`] is the outbox relay's sink: it routes `NotificationRequested`
12//!   records to the registered delivery, claims each with `inbox::once` so a redelivered row is not
13//!   delivered twice, and maps the outcome onto the relay's own retry and dead-letter semantics.
14//! - [`RecordingDelivery`] is a test double a notification module's own tests, and the framework's,
15//!   drive the dispatcher with.
16//!
17//! ```ignore
18//! let dispatcher = Arc::new(NotificationDispatcher::new(pool.clone(), "hr", Arc::new(MailDelivery::new(..))));
19//! // the relay: every staged event goes to the sink; a record that is not a request is accepted and ignored
20//! runner::supervise(pool, RelayConfig::new("hr"), dispatcher.sink(), shutdown);
21//! ```
22//!
23//! # Guarantees
24//!
25//! **At least once, and one effect when the provider honours the id.** The claim and the delivery
26//! share one transaction: it is opened, the `(consumer, event id)` is claimed, the delivery runs, and
27//! the transaction commits only when the delivery answered `Delivered`. A redelivery of a delivered
28//! row finds the claim and does nothing; two relays handing the same row at once serialize on the
29//! claim; a `Retry` or a `Reject` rolls the claim back, so the row is offered again or dead. What no
30//! database can do is make a mail provider's send atomic with a commit: a crash after the provider
31//! accepted the message and before the commit delivers it again, which is why
32//! [`PendingNotification::id`] is handed to the delivery as the idempotency key to give the provider.
33//!
34//! **Correlation and causation are preserved.** The delivery runs inside the staging write's
35//! correlation id (so anything it writes or logs carries it), and [`PendingNotification`] carries the
36//! record's correlation id and causation id; [`PendingNotification::id`] is the id to cite as the
37//! causation of anything the delivery stages in turn.
38//!
39//! **Retry is counted here, not in the table.** The relay re-hands a pending row on every pass and
40//! keeps no attempt count, so the dispatcher counts [`Retry`](DeliveryOutcome::Retry) results per
41//! record and waits between them ([`RetryPolicy`]): within the backoff window a record is left
42//! pending without calling the delivery, and after [`RetryPolicy::max_attempts`] the dispatcher
43//! reports the record exhausted, which the relay marks dead (`failed_at`, `failure_reason`) and
44//! `relay::reemit` hands back once the cause is fixed. The count and the wait live in this
45//! process: a restart forgets them, and each instance of a service counts for itself. A `Reject`
46//! is dead at once, with the delivery's reason.
47
48use std::collections::HashMap;
49use std::future::Future;
50use std::pin::Pin;
51use std::sync::{Arc, Mutex};
52use std::time::{Duration, Instant};
53
54use async_trait::async_trait;
55use backbone_outbox::{inbox, OutboxError, OutboxRecord};
56use chrono::{DateTime, Utc};
57use uuid::Uuid;
58
59use crate::write_hooks::{with_correlation_id, NotificationRequested};
60
61/// What a delivery says about one request.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum DeliveryOutcome {
64    /// The notification was handed to the provider (sent, queued by it, or recorded). The request
65    /// is done and will not be offered again.
66    Delivered,
67    /// The provider could not take it now (a timeout, a 5xx, a rate limit): offer it again later.
68    /// The text is the reason, kept for the log and for the dead letter when the retries run out.
69    Retry(String),
70    /// The request can never be delivered however often it is offered (an unknown template, an
71    /// address no provider will accept). It is dead-lettered at once with this reason.
72    Reject(String),
73}
74
75/// One staged request, as a delivery sees it.
76///
77/// Built from the outbox record by the dispatcher. Marked `non_exhaustive`: it can gain fields
78/// without breaking an implementation, which reads it and never builds it.
79#[derive(Debug, Clone)]
80#[non_exhaustive]
81pub struct PendingNotification {
82    /// The outbox row id: unique to this request, and the same on every redelivery. Give it to the
83    /// provider as the idempotency key; cite it as the `causation_id` of anything the delivery stages.
84    pub id: Uuid,
85    /// What was asked: channel, template or body, recipients, variables, and the trigger that asked.
86    pub request: NotificationRequested,
87    /// The aggregate type the staging write was about.
88    pub aggregate_type: String,
89    /// The aggregate it was about.
90    pub aggregate_id: String,
91    /// The company the request belongs to.
92    pub company_id: Uuid,
93    /// The org unit it was staged under, when one was bound.
94    pub org_unit_id: Option<Uuid>,
95    /// The correlation id of the write (or request) that staged it.
96    pub correlation_id: Option<String>,
97    /// The id of what caused the request: the event, trigger or workflow step that staged it.
98    pub causation_id: Option<String>,
99    /// When the request was staged.
100    pub occurred_at: DateTime<Utc>,
101    /// Which attempt this is, counting from 1.
102    pub attempt: u32,
103}
104
105impl PendingNotification {
106    /// The request of an outbox record of type [`NotificationRequested::NAME`], as attempt
107    /// `attempt`.
108    ///
109    /// # Errors
110    /// The payload is not a [`NotificationRequested`]: nothing offered it again would change that.
111    pub fn from_record(record: &OutboxRecord, attempt: u32) -> Result<Self, serde_json::Error> {
112        Ok(Self {
113            id: record.id,
114            request: serde_json::from_value(record.payload.clone())?,
115            aggregate_type: record.aggregate_type.clone(),
116            aggregate_id: record.aggregate_id.clone(),
117            company_id: record.company_id,
118            org_unit_id: record.org_unit_id,
119            correlation_id: record.correlation_id.clone(),
120            causation_id: record.causation_id.clone(),
121            occurred_at: record.occurred_at,
122            attempt,
123        })
124    }
125}
126
127/// What a notification module implements: perform one request.
128///
129/// The dispatcher calls it with the request claimed and a database transaction open (see the
130/// [module](self) guarantees), so keep it to the provider call: it holds a pool connection until it
131/// answers. It must not stage rows on the dispatcher's behalf; to record that something was sent it
132/// writes through its own service, inside the correlation the dispatcher bound.
133#[async_trait]
134pub trait NotificationDelivery: Send + Sync {
135    /// The name this delivery claims requests under in the inbox. Two deliveries that must each
136    /// handle every request (a mail module and an audit mirror) name themselves apart; a single
137    /// delivery keeps the default.
138    fn consumer(&self) -> &str {
139        "notification-delivery"
140    }
141
142    /// Perform `notification`.
143    async fn deliver(&self, notification: &PendingNotification) -> DeliveryOutcome;
144}
145
146/// How many times, and how far apart, a dispatcher offers a request that answers
147/// [`DeliveryOutcome::Retry`].
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub struct RetryPolicy {
150    /// The attempt after which a request that still answers `Retry` is dead-lettered. At least 1.
151    pub max_attempts: u32,
152    /// The wait after the first `Retry`; each further one doubles it.
153    pub base_delay: Duration,
154    /// The longest wait between two attempts.
155    pub max_delay: Duration,
156}
157
158impl Default for RetryPolicy {
159    /// Five attempts, 30 seconds apart at first, doubling to at most 15 minutes.
160    fn default() -> Self {
161        Self {
162            max_attempts: 5,
163            base_delay: Duration::from_secs(30),
164            max_delay: Duration::from_secs(15 * 60),
165        }
166    }
167}
168
169impl RetryPolicy {
170    /// `max_attempts` attempts with no wait between them: the relay's pass interval is the only
171    /// spacing. For tests, and for a delivery that does its own pacing.
172    pub fn immediate(max_attempts: u32) -> Self {
173        Self {
174            max_attempts,
175            base_delay: Duration::ZERO,
176            max_delay: Duration::ZERO,
177        }
178    }
179
180    /// The wait after the `attempts`-th `Retry`.
181    fn backoff(&self, attempts: u32) -> Duration {
182        let doublings = attempts.saturating_sub(1).min(20);
183        self.base_delay
184            .saturating_mul(1u32 << doublings)
185            .min(self.max_delay)
186    }
187}
188
189/// What the dispatcher remembers about a record that has answered `Retry`.
190struct Attempts {
191    made: u32,
192    not_before: Instant,
193}
194
195/// The outbox relay's sink for `NotificationRequested` rows: see the [module](self) docs.
196pub struct NotificationDispatcher {
197    pool: backbone_orm::PgPool,
198    schema: String,
199    delivery: Arc<dyn NotificationDelivery>,
200    policy: RetryPolicy,
201    retrying: Mutex<HashMap<Uuid, Attempts>>,
202}
203
204/// The future a relay sink returns.
205pub type SinkFuture = Pin<Box<dyn Future<Output = Result<(), OutboxError>> + Send>>;
206
207impl NotificationDispatcher {
208    /// A dispatcher for the requests staged in `schema.outbox_events`, claiming them in
209    /// `schema.inbox_consumed` on `pool`, and handing each to `delivery`.
210    pub fn new(
211        pool: backbone_orm::PgPool,
212        schema: impl Into<String>,
213        delivery: Arc<dyn NotificationDelivery>,
214    ) -> Self {
215        Self {
216            pool,
217            schema: schema.into(),
218            delivery,
219            policy: RetryPolicy::default(),
220            retrying: Mutex::new(HashMap::new()),
221        }
222    }
223
224    /// Use `policy` for requests that answer `Retry`.
225    pub fn with_retry(mut self, policy: RetryPolicy) -> Self {
226        self.policy = RetryPolicy {
227            max_attempts: policy.max_attempts.max(1),
228            ..policy
229        };
230        self
231    }
232
233    /// The sink as a cloneable closure, in the shape `backbone_outbox::runner::supervise` and
234    /// `relay::drain_once` take.
235    pub fn sink(
236        self: &Arc<Self>,
237    ) -> impl Fn(OutboxRecord) -> SinkFuture + Clone + Send + Sync + 'static {
238        let this = self.clone();
239        move |record| {
240            let this = this.clone();
241            Box::pin(async move { this.deliver(record).await })
242        }
243    }
244
245    /// The relay's sink: hand `record` to the delivery when it is a notification request.
246    ///
247    /// A record of any other type is accepted and ignored, so the sink can share a relay with other
248    /// consumers. The answer is the relay's: `Ok` marks the row published (delivered, or already
249    /// delivered), [`OutboxError::Publish`] leaves it pending for the next pass (a retry, a backoff
250    /// not yet over, a database that could not be reached), [`OutboxError::Exhausted`] marks it dead
251    /// with the reason (a rejection, the retries used up, a payload that is not a request).
252    pub async fn deliver(&self, record: OutboxRecord) -> Result<(), OutboxError> {
253        if record.event_type != NotificationRequested::NAME {
254            return Ok(());
255        }
256        let id = record.id;
257        let made = {
258            let retrying = self.retrying.lock().unwrap_or_else(|e| e.into_inner());
259            match retrying.get(&id) {
260                // Still inside the wait after its last `Retry`: leave it pending, untouched.
261                Some(state) if state.not_before > Instant::now() => {
262                    return Err(OutboxError::Publish(format!(
263                        "notification {id} waits for its next attempt"
264                    )));
265                }
266                Some(state) => state.made,
267                None => 0,
268            }
269        };
270
271        let notification = match PendingNotification::from_record(&record, made + 1) {
272            Ok(n) => n,
273            Err(e) => {
274                return Err(OutboxError::Exhausted(format!(
275                    "the payload is not a NotificationRequested: {e}"
276                )))
277            }
278        };
279
280        // The claim and the delivery share a transaction: committed only when delivered.
281        let consumer = self.delivery.consumer();
282        let mut tx = self
283            .pool
284            .begin()
285            .await
286            .map_err(|e| OutboxError::Publish(format!("begin: {e}")))?;
287        let claimed = inbox::once(&mut *tx, &self.schema, consumer, id)
288            .await
289            .map_err(|e| OutboxError::Publish(format!("inbox: {e}")))?;
290        if !claimed {
291            // Delivered before (or being delivered by another relay that committed first).
292            tx.rollback().await.ok();
293            self.forget(id);
294            return Ok(());
295        }
296
297        let correlation = record
298            .correlation_id
299            .clone()
300            .unwrap_or_else(|| record.id.to_string());
301        let outcome = with_correlation_id(correlation, self.delivery.deliver(&notification)).await;
302        match outcome {
303            DeliveryOutcome::Delivered => {
304                tx.commit()
305                    .await
306                    .map_err(|e| OutboxError::Publish(format!("commit: {e}")))?;
307                self.forget(id);
308                Ok(())
309            }
310            DeliveryOutcome::Retry(reason) => {
311                tx.rollback().await.ok();
312                let attempts = made + 1;
313                if attempts >= self.policy.max_attempts {
314                    self.forget(id);
315                    tracing::warn!(target: "backbone::notification", %id, attempts, %reason, "notification dead-lettered: retries used up");
316                    return Err(OutboxError::Exhausted(format!(
317                        "gave up after {attempts} attempts: {reason}"
318                    )));
319                }
320                let wait = self.policy.backoff(attempts);
321                self.retrying
322                    .lock()
323                    .unwrap_or_else(|e| e.into_inner())
324                    .insert(
325                        id,
326                        Attempts {
327                            made: attempts,
328                            not_before: Instant::now() + wait,
329                        },
330                    );
331                tracing::info!(target: "backbone::notification", %id, attempts, wait_secs = wait.as_secs(), %reason, "notification will be retried");
332                Err(OutboxError::Publish(reason))
333            }
334            DeliveryOutcome::Reject(reason) => {
335                tx.rollback().await.ok();
336                self.forget(id);
337                tracing::warn!(target: "backbone::notification", %id, %reason, "notification rejected");
338                Err(OutboxError::Exhausted(reason))
339            }
340        }
341    }
342
343    fn forget(&self, id: Uuid) {
344        self.retrying
345            .lock()
346            .unwrap_or_else(|e| e.into_inner())
347            .remove(&id);
348    }
349}
350
351/// A [`NotificationDelivery`] for tests: records every request it is handed and answers from a
352/// script.
353///
354/// Answers `Delivered` once the script runs out, so `RecordingDelivery::new()` delivers everything and
355/// `RecordingDelivery::answering(vec![Retry(..), Retry(..), Delivered])` fails twice first.
356#[derive(Clone)]
357pub struct RecordingDelivery {
358    consumer: String,
359    received: Arc<Mutex<Vec<PendingNotification>>>,
360    script: Arc<Mutex<std::collections::VecDeque<DeliveryOutcome>>>,
361    fallback: DeliveryOutcome,
362}
363
364impl Default for RecordingDelivery {
365    fn default() -> Self {
366        Self::new()
367    }
368}
369
370impl RecordingDelivery {
371    /// A delivery that records requests and delivers all of them.
372    pub fn new() -> Self {
373        Self {
374            consumer: "notification-delivery".to_string(),
375            received: Arc::default(),
376            script: Arc::default(),
377            fallback: DeliveryOutcome::Delivered,
378        }
379    }
380
381    /// A delivery that answers `outcomes` in turn, then `Delivered`.
382    pub fn answering(outcomes: Vec<DeliveryOutcome>) -> Self {
383        let this = Self::new();
384        *this.script.lock().unwrap_or_else(|e| e.into_inner()) = outcomes.into();
385        this
386    }
387
388    /// A delivery that answers `outcome` to every request.
389    pub fn always(outcome: DeliveryOutcome) -> Self {
390        Self {
391            fallback: outcome,
392            ..Self::new()
393        }
394    }
395
396    /// Claim requests under `consumer` instead of the default name.
397    pub fn named(mut self, consumer: impl Into<String>) -> Self {
398        self.consumer = consumer.into();
399        self
400    }
401
402    /// Every request handed to the delivery so far, in order, redeliveries included.
403    pub fn received(&self) -> Vec<PendingNotification> {
404        self.received
405            .lock()
406            .unwrap_or_else(|e| e.into_inner())
407            .clone()
408    }
409
410    /// How many times the delivery was called.
411    pub fn calls(&self) -> usize {
412        self.received
413            .lock()
414            .unwrap_or_else(|e| e.into_inner())
415            .len()
416    }
417}
418
419#[async_trait]
420impl NotificationDelivery for RecordingDelivery {
421    fn consumer(&self) -> &str {
422        &self.consumer
423    }
424
425    async fn deliver(&self, notification: &PendingNotification) -> DeliveryOutcome {
426        self.received
427            .lock()
428            .unwrap_or_else(|e| e.into_inner())
429            .push(notification.clone());
430        self.script
431            .lock()
432            .unwrap_or_else(|e| e.into_inner())
433            .pop_front()
434            .unwrap_or_else(|| self.fallback.clone())
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use serde_json::json;
442
443    fn record(event_type: &str, payload: serde_json::Value) -> OutboxRecord {
444        OutboxRecord::new(
445            event_type,
446            "Incident",
447            "i-1",
448            Uuid::new_v4(),
449            payload,
450            Utc::now(),
451        )
452        .with_correlation_id("corr-1")
453        .with_causation_id("cause-1")
454    }
455
456    fn request() -> serde_json::Value {
457        serde_json::to_value(NotificationRequested {
458            channel: "email".into(),
459            template: Some("incident_opened".into()),
460            body: None,
461            to: json!(["a@example.test"]),
462            data: json!({ "title": "t" }),
463            trigger: "Incident.after_create".into(),
464        })
465        .unwrap()
466    }
467
468    #[test]
469    fn a_request_is_read_from_its_record_with_its_correlation_and_causation() {
470        let rec = record(NotificationRequested::NAME, request());
471        let n = PendingNotification::from_record(&rec, 3).unwrap();
472        assert_eq!(n.id, rec.id);
473        assert_eq!(n.attempt, 3);
474        assert_eq!(n.request.channel, "email");
475        assert_eq!(n.request.template.as_deref(), Some("incident_opened"));
476        assert_eq!(n.correlation_id.as_deref(), Some("corr-1"));
477        assert_eq!(n.causation_id.as_deref(), Some("cause-1"));
478        assert_eq!(
479            (n.aggregate_type.as_str(), n.aggregate_id.as_str()),
480            ("Incident", "i-1")
481        );
482        assert_eq!(n.company_id, rec.company_id);
483    }
484
485    #[test]
486    fn a_payload_that_is_not_a_request_is_refused() {
487        let rec = record(NotificationRequested::NAME, json!({ "nonsense": true }));
488        assert!(PendingNotification::from_record(&rec, 1).is_err());
489    }
490
491    #[test]
492    fn the_wait_doubles_to_a_ceiling() {
493        let p = RetryPolicy {
494            max_attempts: 9,
495            base_delay: Duration::from_secs(10),
496            max_delay: Duration::from_secs(70),
497        };
498        let waits: Vec<u64> = (1..=5).map(|n| p.backoff(n).as_secs()).collect();
499        assert_eq!(waits, vec![10, 20, 40, 70, 70]);
500        assert_eq!(RetryPolicy::immediate(3).backoff(2), Duration::ZERO);
501        // A huge attempt count neither overflows nor panics.
502        assert_eq!(p.backoff(u32::MAX), Duration::from_secs(70));
503    }
504
505    #[test]
506    fn the_default_policy_is_five_attempts_and_a_bounded_wait() {
507        let p = RetryPolicy::default();
508        assert_eq!(p.max_attempts, 5);
509        assert!(p.backoff(5) <= p.max_delay);
510    }
511
512    #[tokio::test]
513    async fn the_recording_delivery_answers_its_script_then_delivers() {
514        let d = RecordingDelivery::answering(vec![
515            DeliveryOutcome::Retry("busy".into()),
516            DeliveryOutcome::Reject("no such template".into()),
517        ]);
518        let n =
519            PendingNotification::from_record(&record(NotificationRequested::NAME, request()), 1)
520                .unwrap();
521        assert_eq!(d.deliver(&n).await, DeliveryOutcome::Retry("busy".into()));
522        assert_eq!(
523            d.deliver(&n).await,
524            DeliveryOutcome::Reject("no such template".into())
525        );
526        assert_eq!(d.deliver(&n).await, DeliveryOutcome::Delivered);
527        assert_eq!(d.calls(), 3);
528        let always = RecordingDelivery::always(DeliveryOutcome::Retry("down".into())).named("sms");
529        assert_eq!(always.consumer(), "sms");
530        assert_eq!(
531            always.deliver(&n).await,
532            DeliveryOutcome::Retry("down".into())
533        );
534        assert_eq!(
535            always.deliver(&n).await,
536            DeliveryOutcome::Retry("down".into())
537        );
538    }
539}