use std::future::Future;
use std::time::Duration;
use sqlx::PgPool;
use crate::error::{OutboxError, Result};
use crate::record::OutboxRecord;
use crate::relay;
#[derive(Debug, Clone)]
pub struct RelayConfig {
pub schema: String,
pub batch: i64,
pub poll_interval: Duration,
}
impl RelayConfig {
pub fn new(schema: impl Into<String>) -> Self {
Self { schema: schema.into(), batch: 100, poll_interval: Duration::from_secs(1) }
}
}
pub async fn run<F, Fut, S>(pool: PgPool, cfg: RelayConfig, publish: F, shutdown: S) -> Result<()>
where
F: Fn(OutboxRecord) -> Fut,
Fut: Future<Output = std::result::Result<(), OutboxError>>,
S: Future<Output = ()>,
{
tokio::pin!(shutdown);
loop {
tokio::select! {
biased;
_ = &mut shutdown => return Ok(()),
res = relay::drain_once(&pool, &cfg.schema, cfg.batch, &publish) => {
let delivered = res?;
if (delivered as i64) < cfg.batch {
tokio::select! {
biased;
_ = &mut shutdown => return Ok(()),
_ = tokio::time::sleep(cfg.poll_interval) => {}
}
}
}
}
}
}
const MAX_BACKOFF: Duration = Duration::from_secs(60);
pub async fn supervise<F, Fut, S>(pool: PgPool, cfg: RelayConfig, publish: F, shutdown: S)
where
F: Fn(OutboxRecord) -> Fut + Clone,
Fut: Future<Output = std::result::Result<(), OutboxError>>,
S: Future<Output = ()>,
{
let schema = cfg.schema.clone();
restart_loop(
&schema,
|| run(pool.clone(), cfg.clone(), publish.clone(), std::future::pending::<()>()),
shutdown,
)
.await
}
async fn restart_loop<A, AFut, S>(schema: &str, mut attempt: A, shutdown: S)
where
A: FnMut() -> AFut,
AFut: Future<Output = Result<()>>,
S: Future<Output = ()>,
{
tokio::pin!(shutdown);
let mut backoff = Duration::from_secs(1);
loop {
let started = tokio::time::Instant::now();
tokio::select! {
biased;
_ = &mut shutdown => return,
res = attempt() => match res {
Ok(()) => return,
Err(e) => {
if started.elapsed() >= MAX_BACKOFF {
backoff = Duration::from_secs(1);
}
tracing::error!(schema, error = %e, retry_in_secs = backoff.as_secs(), "outbox relay stopped; restarting");
}
},
}
tokio::select! {
biased;
_ = &mut shutdown => return,
_ = tokio::time::sleep(backoff) => {}
}
backoff = (backoff * 2).min(MAX_BACKOFF);
}
}
#[cfg(test)]
mod supervise_tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
#[tokio::test(start_paused = true)]
async fn a_failed_relay_restarts_with_backoff_until_it_runs() {
let calls = Arc::new(AtomicUsize::new(0));
let seen = calls.clone();
let started = tokio::time::Instant::now();
restart_loop(
"payment",
move || {
let n = seen.fetch_add(1, Ordering::SeqCst);
async move { if n < 3 { Err(OutboxError::Publish("db down".into())) } else { Ok(()) } }
},
std::future::pending::<()>(),
)
.await;
assert_eq!(calls.load(Ordering::SeqCst), 4, "three failures, then a clean run");
assert_eq!(started.elapsed().as_secs(), 7);
}
#[tokio::test(start_paused = true)]
async fn shutdown_ends_supervision_while_it_waits() {
let calls = Arc::new(AtomicUsize::new(0));
let seen = calls.clone();
restart_loop(
"payment",
move || {
seen.fetch_add(1, Ordering::SeqCst);
async { Err(OutboxError::Publish("db down".into())) }
},
tokio::time::sleep(Duration::from_millis(1500)),
)
.await;
assert_eq!(calls.load(Ordering::SeqCst), 2, "the first wait ends, the second is cut short");
}
}