Skip to main content

entity_core/
outbox.rs

1// SPDX-FileCopyrightText: 2025-2026 RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4//! Transactional-outbox drainer runtime.
5//!
6//! Entities declared with `#[entity(events(outbox))]` enqueue their
7//! lifecycle events into the `entity_outbox` table inside the same
8//! transaction as the write. This module delivers those rows:
9//!
10//! ```rust,ignore
11//! use entity_core::outbox::{OutboxDrainer, OutboxHandler, OutboxRow};
12//!
13//! struct Notifier;
14//!
15//! #[async_trait::async_trait]
16//! impl OutboxHandler for Notifier {
17//!     type Error = anyhow::Error;
18//!
19//!     async fn handle(&self, row: &OutboxRow) -> Result<(), Self::Error> {
20//!         send_somewhere(&row.entity, &row.payload).await
21//!     }
22//! }
23//!
24//! OutboxDrainer::new(pool, Notifier)
25//!     .run()   // polls until the task is aborted
26//!     .await;
27//! ```
28//!
29//! # Delivery Semantics
30//!
31//! - Rows are claimed with `FOR UPDATE SKIP LOCKED`, so multiple drainers
32//!   cooperate without double delivery.
33//! - A successful `handle` marks the row processed; a failure schedules a retry
34//!   with exponential backoff (`base_backoff * 2^attempts`).
35//! - After `max_attempts` failures the row stays unprocessed with
36//!   `next_attempt_at` in the far future — inspect and repair manually.
37//! - At-least-once delivery: handlers must be idempotent.
38
39use std::time::Duration;
40
41use sqlx::{PgPool, Row};
42
43/// One claimed outbox row handed to the [`OutboxHandler`].
44#[derive(Debug, Clone)]
45pub struct OutboxRow {
46    /// Outbox row id.
47    pub id: i64,
48
49    /// Source table name of the emitting entity.
50    pub entity: String,
51
52    /// Event kind (`created`, `updated`, `soft_deleted`, `hard_deleted`).
53    pub kind: String,
54
55    /// String form of the affected row's primary key.
56    pub entity_id: String,
57
58    /// Serialized `{Entity}Event` payload.
59    pub payload: serde_json::Value,
60
61    /// Delivery attempts made so far.
62    pub attempts: i32
63}
64
65/// User-provided delivery target for outbox rows.
66#[async_trait::async_trait]
67pub trait OutboxHandler: Send + Sync {
68    /// Handler error type.
69    type Error: std::fmt::Display + Send;
70
71    /// Deliver one event. Returning `Err` schedules a retry with
72    /// exponential backoff.
73    async fn handle(&self, row: &OutboxRow) -> Result<(), Self::Error>;
74}
75
76/// Polling drainer delivering `entity_outbox` rows to a handler.
77pub struct OutboxDrainer<H> {
78    pool:          PgPool,
79    handler:       H,
80    batch_size:    i64,
81    poll_interval: Duration,
82    base_backoff:  Duration,
83    max_attempts:  i32
84}
85
86impl<H: OutboxHandler> OutboxDrainer<H> {
87    /// Create a drainer with defaults: batch 32, poll every second,
88    /// backoff base 5s, 10 attempts.
89    #[must_use]
90    pub fn new(pool: PgPool, handler: H) -> Self {
91        Self {
92            pool,
93            handler,
94            batch_size: 32,
95            poll_interval: Duration::from_secs(1),
96            base_backoff: Duration::from_secs(5),
97            max_attempts: 10
98        }
99    }
100
101    /// Set the maximum rows claimed per polling cycle.
102    #[must_use]
103    pub fn batch_size(mut self, batch_size: i64) -> Self {
104        self.batch_size = batch_size;
105        self
106    }
107
108    /// Set the sleep between empty polling cycles.
109    #[must_use]
110    pub fn poll_interval(mut self, poll_interval: Duration) -> Self {
111        self.poll_interval = poll_interval;
112        self
113    }
114
115    /// Set the exponential-backoff base delay.
116    #[must_use]
117    pub fn base_backoff(mut self, base_backoff: Duration) -> Self {
118        self.base_backoff = base_backoff;
119        self
120    }
121
122    /// Set the retry ceiling before a row is parked.
123    #[must_use]
124    pub fn max_attempts(mut self, max_attempts: i32) -> Self {
125        self.max_attempts = max_attempts;
126        self
127    }
128
129    /// Drain continuously until the surrounding task is cancelled.
130    pub async fn run(self) {
131        loop {
132            match self.drain_once().await {
133                Ok(0) | Err(_) => tokio::time::sleep(self.poll_interval).await,
134                Ok(_) => {}
135            }
136        }
137    }
138
139    /// Claim and deliver one batch.
140    ///
141    /// Returns the number of rows processed (successfully or scheduled
142    /// for retry).
143    ///
144    /// # Errors
145    ///
146    /// Propagates database errors from claiming or updating rows.
147    pub async fn drain_once(&self) -> Result<usize, sqlx::Error> {
148        let mut tx = self.pool.begin().await?;
149
150        let rows = sqlx::query(
151            "SELECT id, entity, kind, entity_id, payload, attempts \
152             FROM entity_outbox \
153             WHERE processed_at IS NULL AND next_attempt_at <= NOW() \
154             ORDER BY id \
155             LIMIT $1 \
156             FOR UPDATE SKIP LOCKED"
157        )
158        .bind(self.batch_size)
159        .fetch_all(&mut *tx)
160        .await?;
161
162        let mut claimed = Vec::with_capacity(rows.len());
163        for row in rows {
164            claimed.push(OutboxRow {
165                id:        row.try_get("id")?,
166                entity:    row.try_get("entity")?,
167                kind:      row.try_get("kind")?,
168                entity_id: row.try_get("entity_id")?,
169                payload:   row.try_get("payload")?,
170                attempts:  row.try_get("attempts")?
171            });
172        }
173
174        let actions = process_rows(
175            &self.handler,
176            &claimed,
177            self.base_backoff,
178            self.max_attempts
179        )
180        .await;
181        let processed = actions.len();
182
183        for action in actions {
184            match action {
185                OutboxAction::MarkProcessed {
186                    id
187                } => {
188                    sqlx::query("UPDATE entity_outbox SET processed_at = NOW() WHERE id = $1")
189                        .bind(id)
190                        .execute(&mut *tx)
191                        .await?;
192                }
193                OutboxAction::ScheduleRetry {
194                    id,
195                    delay
196                } => {
197                    sqlx::query(
198                        "UPDATE entity_outbox \
199                         SET attempts = attempts + 1, \
200                             next_attempt_at = NOW() + $2 * INTERVAL '1 second' \
201                         WHERE id = $1"
202                    )
203                    .bind(id)
204                    .bind(delay.as_secs_f64())
205                    .execute(&mut *tx)
206                    .await?;
207                }
208            }
209        }
210
211        tx.commit().await?;
212        Ok(processed)
213    }
214}
215
216/// Outcome of handling one claimed outbox row.
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub enum OutboxAction {
219    /// Delivery succeeded — stamp `processed_at`.
220    MarkProcessed {
221        /// Outbox row id.
222        id: i64
223    },
224
225    /// Delivery failed — bump `attempts` and reschedule.
226    ScheduleRetry {
227        /// Outbox row id.
228        id:    i64,
229        /// Delay before the next attempt.
230        delay: Duration
231    }
232}
233
234/// Run the handler over claimed rows and decide per-row follow-up.
235///
236/// Pure decision logic extracted from [`OutboxDrainer::drain_once`] so
237/// delivery semantics are unit-testable without a database.
238pub async fn process_rows<H: OutboxHandler>(
239    handler: &H,
240    rows: &[OutboxRow],
241    base_backoff: Duration,
242    max_attempts: i32
243) -> Vec<OutboxAction> {
244    let mut actions = Vec::with_capacity(rows.len());
245    for row in rows {
246        let action = match handler.handle(row).await {
247            Ok(()) => OutboxAction::MarkProcessed {
248                id: row.id
249            },
250            Err(_) => OutboxAction::ScheduleRetry {
251                id:    row.id,
252                delay: backoff_delay(base_backoff, row.attempts, max_attempts)
253            }
254        };
255        actions.push(action);
256    }
257    actions
258}
259
260/// Compute the retry delay for a row that has already failed `attempts`
261/// times.
262///
263/// Exponential (`base * 2^attempts`) capped at one hour; once
264/// `max_attempts` is reached the row is parked ~30 days out for manual
265/// inspection.
266#[must_use]
267pub fn backoff_delay(base: Duration, attempts: i32, max_attempts: i32) -> Duration {
268    if attempts + 1 >= max_attempts {
269        return Duration::from_secs(60 * 60 * 24 * 30);
270    }
271    let exp = u32::try_from(attempts.clamp(0, 20)).unwrap_or(0);
272    let delay = base.saturating_mul(2u32.saturating_pow(exp));
273    delay.min(Duration::from_secs(3600))
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    struct FlakyHandler;
281
282    #[async_trait::async_trait]
283    impl OutboxHandler for FlakyHandler {
284        type Error = String;
285
286        async fn handle(&self, row: &OutboxRow) -> Result<(), Self::Error> {
287            if row.kind == "created" {
288                Ok(())
289            } else {
290                Err("boom".to_string())
291            }
292        }
293    }
294
295    fn row(id: i64, kind: &str, attempts: i32) -> OutboxRow {
296        OutboxRow {
297            id,
298            entity: "users".to_string(),
299            kind: kind.to_string(),
300            entity_id: "x".to_string(),
301            payload: serde_json::json!({}),
302            attempts
303        }
304    }
305
306    #[tokio::test]
307    async fn process_rows_marks_success_and_schedules_retry() {
308        let rows = vec![row(1, "created", 0), row(2, "updated", 1)];
309        let actions = process_rows(&FlakyHandler, &rows, Duration::from_secs(5), 10).await;
310        assert_eq!(
311            actions,
312            vec![
313                OutboxAction::MarkProcessed {
314                    id: 1
315                },
316                OutboxAction::ScheduleRetry {
317                    id:    2,
318                    delay: Duration::from_secs(10)
319                },
320            ]
321        );
322    }
323
324    #[tokio::test]
325    async fn process_rows_parks_exhausted_rows() {
326        let rows = vec![row(3, "updated", 9)];
327        let actions = process_rows(&FlakyHandler, &rows, Duration::from_secs(5), 10).await;
328        assert_eq!(
329            actions,
330            vec![OutboxAction::ScheduleRetry {
331                id:    3,
332                delay: Duration::from_secs(60 * 60 * 24 * 30)
333            }]
334        );
335    }
336
337    #[tokio::test]
338    async fn drainer_builder_configures_all_knobs() {
339        let pool = sqlx::PgPool::connect_lazy("postgres://localhost/unused")
340            .expect("lazy pool never connects");
341        let drainer = OutboxDrainer::new(pool, FlakyHandler)
342            .batch_size(7)
343            .poll_interval(Duration::from_millis(250))
344            .base_backoff(Duration::from_secs(2))
345            .max_attempts(3);
346        assert_eq!(drainer.batch_size, 7);
347        assert_eq!(drainer.poll_interval, Duration::from_millis(250));
348        assert_eq!(drainer.base_backoff, Duration::from_secs(2));
349        assert_eq!(drainer.max_attempts, 3);
350    }
351
352    #[test]
353    fn backoff_grows_exponentially() {
354        let base = Duration::from_secs(5);
355        assert_eq!(backoff_delay(base, 0, 10), Duration::from_secs(5));
356        assert_eq!(backoff_delay(base, 1, 10), Duration::from_secs(10));
357        assert_eq!(backoff_delay(base, 3, 10), Duration::from_secs(40));
358    }
359
360    #[test]
361    fn backoff_caps_at_one_hour() {
362        let base = Duration::from_secs(5);
363        assert_eq!(backoff_delay(base, 15, 20), Duration::from_secs(3600));
364    }
365
366    #[test]
367    fn backoff_parks_after_max_attempts() {
368        let base = Duration::from_secs(5);
369        let parked = backoff_delay(base, 9, 10);
370        assert_eq!(parked, Duration::from_secs(60 * 60 * 24 * 30));
371    }
372
373    #[test]
374    fn backoff_negative_attempts_treated_as_zero() {
375        let base = Duration::from_secs(5);
376        assert_eq!(backoff_delay(base, -3, 10), Duration::from_secs(5));
377    }
378}