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(¬ification)).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}