1use std::time::Duration;
40
41use sqlx::{PgPool, Row};
42
43#[derive(Debug, Clone)]
45pub struct OutboxRow {
46 pub id: i64,
48
49 pub entity: String,
51
52 pub kind: String,
54
55 pub entity_id: String,
57
58 pub payload: serde_json::Value,
60
61 pub attempts: i32
63}
64
65#[async_trait::async_trait]
67pub trait OutboxHandler: Send + Sync {
68 type Error: std::fmt::Display + Send;
70
71 async fn handle(&self, row: &OutboxRow) -> Result<(), Self::Error>;
74}
75
76pub 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 #[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 #[must_use]
103 pub fn batch_size(mut self, batch_size: i64) -> Self {
104 self.batch_size = batch_size;
105 self
106 }
107
108 #[must_use]
110 pub fn poll_interval(mut self, poll_interval: Duration) -> Self {
111 self.poll_interval = poll_interval;
112 self
113 }
114
115 #[must_use]
117 pub fn base_backoff(mut self, base_backoff: Duration) -> Self {
118 self.base_backoff = base_backoff;
119 self
120 }
121
122 #[must_use]
124 pub fn max_attempts(mut self, max_attempts: i32) -> Self {
125 self.max_attempts = max_attempts;
126 self
127 }
128
129 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 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#[derive(Debug, Clone, PartialEq, Eq)]
218pub enum OutboxAction {
219 MarkProcessed {
221 id: i64
223 },
224
225 ScheduleRetry {
227 id: i64,
229 delay: Duration
231 }
232}
233
234pub 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#[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}