1#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
41
42use std::collections::HashMap;
43use std::future::Future;
44use std::pin::Pin;
45use std::sync::Arc;
46use std::time::Duration;
47
48use chrono::{DateTime, TimeZone as _, Utc};
49use diesel::sql_types::{BigInt, Integer, Nullable, Text};
50use diesel_async::pooled_connection::deadpool::Pool;
51use diesel_async::{RunQueryDsl as _, SimpleAsyncConnection as _};
52use futures::FutureExt as _;
53use serde::de::DeserializeOwned;
54use serde::{Deserialize, Serialize};
55use serde_json::Value;
56use tokio_util::sync::CancellationToken;
57
58use crate::config::OutboxConfig;
59use crate::db::RuntimeConnection;
60use crate::entropy::Entropy;
61use crate::events::Event;
62use crate::time::ClockSource;
63use crate::{AppState, AutumnError, AutumnResult};
64
65pub const TOPIC_EVENT: &str = "autumn.event";
67pub const TOPIC_JOB: &str = "autumn.job";
69#[cfg(feature = "mail")]
71pub const TOPIC_MAIL: &str = "autumn.mail";
72#[cfg(feature = "http-client")]
74pub const TOPIC_WEBHOOK: &str = "autumn.webhook";
75
76pub const RESERVED_TOPIC_PREFIX: &str = "autumn.";
78
79const PURGE_INTERVAL: Duration = Duration::from_secs(60);
81
82#[cfg(not(feature = "sqlite"))]
90pub const SCHEMA_SQL: &str = "\
91CREATE TABLE IF NOT EXISTS autumn_outbox (
92 seq BIGSERIAL PRIMARY KEY,
93 id TEXT NOT NULL UNIQUE,
94 aggregate TEXT NOT NULL,
95 topic TEXT NOT NULL,
96 payload TEXT NOT NULL,
97 created_at BIGINT NOT NULL,
98 available_at BIGINT NOT NULL,
99 attempts INTEGER NOT NULL DEFAULT 0,
100 last_error TEXT,
101 claim_token TEXT,
102 locked_until BIGINT,
103 dispatched_at BIGINT,
104 dead_at BIGINT
105);
106CREATE INDEX IF NOT EXISTS idx_autumn_outbox_pending
107 ON autumn_outbox (aggregate, seq)
108 WHERE dispatched_at IS NULL AND dead_at IS NULL;
109CREATE INDEX IF NOT EXISTS idx_autumn_outbox_claim
110 ON autumn_outbox (seq)
111 WHERE dispatched_at IS NULL AND dead_at IS NULL;
112CREATE INDEX IF NOT EXISTS idx_autumn_outbox_dispatched
113 ON autumn_outbox (dispatched_at)
114 WHERE dispatched_at IS NOT NULL;
115CREATE TABLE IF NOT EXISTS autumn_inbox (
116 consumer TEXT NOT NULL,
117 message_id TEXT NOT NULL,
118 seen_at BIGINT NOT NULL,
119 PRIMARY KEY (consumer, message_id)
120);
121CREATE INDEX IF NOT EXISTS idx_autumn_inbox_seen_at ON autumn_inbox (seen_at);";
122
123#[cfg(feature = "sqlite")]
125pub const SCHEMA_SQL: &str = "\
126CREATE TABLE IF NOT EXISTS autumn_outbox (
127 seq INTEGER PRIMARY KEY AUTOINCREMENT,
128 id TEXT NOT NULL UNIQUE,
129 aggregate TEXT NOT NULL,
130 topic TEXT NOT NULL,
131 payload TEXT NOT NULL,
132 created_at BIGINT NOT NULL,
133 available_at BIGINT NOT NULL,
134 attempts INTEGER NOT NULL DEFAULT 0,
135 last_error TEXT,
136 claim_token TEXT,
137 locked_until BIGINT,
138 dispatched_at BIGINT,
139 dead_at BIGINT
140);
141CREATE INDEX IF NOT EXISTS idx_autumn_outbox_pending
142 ON autumn_outbox (aggregate, seq)
143 WHERE dispatched_at IS NULL AND dead_at IS NULL;
144CREATE INDEX IF NOT EXISTS idx_autumn_outbox_dispatched
145 ON autumn_outbox (dispatched_at)
146 WHERE dispatched_at IS NOT NULL;
147CREATE TABLE IF NOT EXISTS autumn_inbox (
148 consumer TEXT NOT NULL,
149 message_id TEXT NOT NULL,
150 seen_at BIGINT NOT NULL,
151 PRIMARY KEY (consumer, message_id)
152);
153CREATE INDEX IF NOT EXISTS idx_autumn_inbox_seen_at ON autumn_inbox (seen_at);";
154
155#[cfg(not(feature = "sqlite"))]
157const SCHEMA_LOCK_KEY: i64 = 0x0A17_0B0C_0000_3062;
158
159pub async fn ensure_schema(pool: &Pool<RuntimeConnection>) -> AutumnResult<()> {
169 let mut conn = pool.get().await.map_err(|error| {
170 AutumnError::service_unavailable_msg(format!("outbox schema: no connection: {error}"))
171 })?;
172 crate::backend_select! {
173 pg => {
174 if tables_exist(&mut conn, &["autumn_outbox", "autumn_inbox"]).await? {
175 let pool = pool.clone();
178 if pool.status().max_size < 2 {
181 tracing::warn!(
182 "pool too small to build idx_autumn_outbox_claim in the background; run \
183 `{CLAIM_INDEX_SQL}` by hand to speed up the outbox claim"
184 );
185 return Ok(());
186 }
187 tokio::spawn(async move {
188 if let Err(error) = build_claim_index(&pool).await {
189 tracing::warn!(%error, "could not build idx_autumn_outbox_claim; the outbox claim stays slow on a large backlog of sent rows");
190 }
191 });
192 return Ok(());
193 }
194 conn.batch_execute(&format!(
195 "SELECT pg_advisory_xact_lock({SCHEMA_LOCK_KEY});\n{SCHEMA_SQL}"
196 ))
197 .await
198 .map_err(|error| sql_error("outbox schema", &error))
199 },
200 sqlite => {
201 conn.batch_execute(SCHEMA_SQL)
202 .await
203 .map_err(|error| sql_error("outbox schema", &error))
204 },
205 }
206}
207
208#[cfg(not(feature = "sqlite"))]
219async fn build_claim_index(pool: &Pool<RuntimeConnection>) -> Result<(), String> {
220 let mut conn = pool.get().await.map_err(|error| error.to_string())?;
221 let present = tables_exist(&mut conn, &["idx_autumn_outbox_claim"])
222 .await
223 .map_err(|error| error.to_string())?;
224 if present {
225 return Ok(());
226 }
227 conn.batch_execute(CLAIM_INDEX_SQL)
228 .await
229 .map_err(|error| error.to_string())
230}
231
232#[cfg(not(feature = "sqlite"))]
233const CLAIM_INDEX_SQL: &str = "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_autumn_outbox_claim \
234 ON autumn_outbox (seq) WHERE dispatched_at IS NULL AND dead_at IS NULL";
235
236#[cfg(not(feature = "sqlite"))]
238pub(crate) async fn tables_exist(
239 conn: &mut RuntimeConnection,
240 tables: &[&str],
241) -> AutumnResult<bool> {
242 #[derive(diesel::QueryableByName)]
243 struct Present {
244 #[diesel(sql_type = diesel::sql_types::Bool)]
245 present: bool,
246 }
247 for table in tables {
248 let row = diesel::sql_query("SELECT to_regclass($1) IS NOT NULL AS present")
249 .bind::<Text, _>(*table)
250 .get_result::<Present>(&mut *conn)
251 .await
252 .map_err(|error| sql_error("schema probe", &error))?;
253 if !row.present {
254 return Ok(false);
255 }
256 }
257 Ok(true)
258}
259
260const INSERT_SQL: &str = "INSERT INTO autumn_outbox \
268 (id, aggregate, topic, payload, created_at, available_at) \
269 VALUES ($1, $2, $3, $4, $5, $6)";
270
271const MARK_SENT_SQL: &str = "UPDATE autumn_outbox \
272 SET dispatched_at = $1, claim_token = NULL, locked_until = NULL, last_error = NULL \
273 WHERE seq = $2 AND claim_token = $3";
274
275const NACK_SQL: &str = "UPDATE autumn_outbox \
276 SET last_error = $1, available_at = $2, dead_at = $3, \
277 claim_token = NULL, locked_until = NULL \
278 WHERE seq = $4 AND claim_token = $5";
279
280const EXHAUSTED_SQL: &str = "UPDATE autumn_outbox \
283 SET attempts = $1, last_error = $2, dead_at = $3, \
284 claim_token = NULL, locked_until = NULL \
285 WHERE seq = $4 AND claim_token = $5";
286
287const RELEASE_SQL: &str = "UPDATE autumn_outbox \
291 SET claim_token = NULL, locked_until = NULL, attempts = attempts - 1 \
292 WHERE claim_token = $1 AND dispatched_at IS NULL AND dead_at IS NULL \
293 AND seq IN";
294
295const PURGE_OUTBOX_SQL: &str = "DELETE FROM autumn_outbox \
296 WHERE dispatched_at IS NOT NULL AND dispatched_at < $1";
297
298const PURGE_INBOX_SQL: &str = "DELETE FROM autumn_inbox WHERE seen_at < $1";
299
300const INBOX_INSERT_SQL: &str = "INSERT INTO autumn_inbox (consumer, message_id, seen_at) \
301 VALUES ($1, $2, $3) ON CONFLICT DO NOTHING";
302
303const DEAD_LETTERS_SQL: &str = "SELECT seq, id, aggregate, topic, payload, attempts, \
304 created_at, last_error, dead_at FROM autumn_outbox \
305 WHERE dead_at IS NOT NULL ORDER BY seq LIMIT $1";
306
307const REQUEUE_SQL: &str = "UPDATE autumn_outbox \
308 SET dead_at = NULL, attempts = 0, last_error = NULL, available_at = $1 \
309 WHERE id = $2 AND dead_at IS NOT NULL AND dispatched_at IS NULL";
310
311fn claim_sql(topics_in_list: &str) -> String {
318 let lock = crate::backend_select! {
319 pg => { " FOR UPDATE SKIP LOCKED" },
320 sqlite => { "" },
321 };
322 sql(&format!(
324 "UPDATE autumn_outbox SET claim_token = $1, locked_until = $2, attempts = attempts + 1 \
325 WHERE seq IN ( \
326 SELECT o.seq FROM autumn_outbox o \
327 WHERE o.dispatched_at IS NULL AND o.dead_at IS NULL \
328 AND o.available_at <= $3 \
329 AND (o.locked_until IS NULL OR o.locked_until <= $4) \
330 AND o.topic IN (__TOPICS__) \
331 AND NOT EXISTS ( \
332 SELECT 1 FROM autumn_outbox p \
333 WHERE p.aggregate = o.aggregate AND p.seq < o.seq \
334 AND p.dispatched_at IS NULL AND p.dead_at IS NULL) \
335 ORDER BY o.seq \
336 LIMIT $5{lock}) \
337 RETURNING seq, id, aggregate, topic, payload, attempts, created_at"
338 ))
339 .replace("__TOPICS__", topics_in_list)
340}
341
342fn sql(pg: &str) -> String {
344 crate::backend_select! {
345 pg => { pg.to_owned() },
346 sqlite => { to_sqlite_placeholders(pg) },
347 }
348}
349
350#[cfg_attr(not(any(feature = "sqlite", test)), allow(dead_code))]
352pub(crate) fn to_sqlite_placeholders(pg: &str) -> String {
353 let mut out = String::with_capacity(pg.len());
354 let mut chars = pg.chars().peekable();
355 while let Some(c) = chars.next() {
356 if c == '$' && chars.peek().is_some_and(char::is_ascii_digit) {
357 while chars.peek().is_some_and(char::is_ascii_digit) {
358 chars.next();
359 }
360 out.push('?');
361 } else {
362 out.push(c);
363 }
364 }
365 out
366}
367
368fn sql_error(context: &str, error: &diesel::result::Error) -> AutumnError {
369 AutumnError::internal_server_error_msg(format!("{context}: {error}"))
370}
371
372const fn to_millis(time: DateTime<Utc>) -> i64 {
373 time.timestamp_millis()
374}
375
376fn from_millis(millis: i64) -> DateTime<Utc> {
377 Utc.timestamp_millis_opt(millis)
378 .single()
379 .unwrap_or(DateTime::<Utc>::MIN_UTC)
380}
381
382fn duration_millis(duration: Duration) -> i64 {
383 i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)
384}
385
386#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
390#[non_exhaustive]
391pub struct OutboxMessage {
392 pub id: String,
394 pub aggregate: String,
396 pub topic: String,
398 pub payload: Value,
400 pub attempt: u32,
403 pub created_at: DateTime<Utc>,
405}
406
407impl OutboxMessage {
408 #[must_use]
410 pub fn new(
411 id: impl Into<String>,
412 aggregate: impl Into<String>,
413 topic: impl Into<String>,
414 payload: Value,
415 created_at: DateTime<Utc>,
416 ) -> Self {
417 Self {
418 id: id.into(),
419 aggregate: aggregate.into(),
420 topic: topic.into(),
421 payload,
422 attempt: 1,
423 created_at,
424 }
425 }
426
427 pub fn payload_as<T: DeserializeOwned>(&self) -> AutumnResult<T> {
433 serde_json::from_value(self.payload.clone()).map_err(|error| {
435 AutumnError::bad_request_msg(format!(
436 "outbox message {} ({}): payload does not match ({:?})",
437 self.id,
438 self.topic,
439 error.classify()
440 ))
441 })
442 }
443}
444
445#[derive(Debug, Clone, PartialEq, Eq)]
447#[non_exhaustive]
448pub struct DeadLetter {
449 pub message: OutboxMessage,
451 pub last_error: Option<String>,
453 pub dead_at: DateTime<Utc>,
455}
456
457#[derive(diesel::QueryableByName)]
458struct ClaimedRow {
459 #[diesel(sql_type = BigInt)]
460 seq: i64,
461 #[diesel(sql_type = Text)]
462 id: String,
463 #[diesel(sql_type = Text)]
464 aggregate: String,
465 #[diesel(sql_type = Text)]
466 topic: String,
467 #[diesel(sql_type = Text)]
468 payload: String,
469 #[diesel(sql_type = Integer)]
470 attempts: i32,
471 #[diesel(sql_type = BigInt)]
472 created_at: i64,
473}
474
475#[derive(diesel::QueryableByName)]
476struct DeadRow {
477 #[diesel(sql_type = BigInt)]
478 seq: i64,
479 #[diesel(sql_type = Text)]
480 id: String,
481 #[diesel(sql_type = Text)]
482 aggregate: String,
483 #[diesel(sql_type = Text)]
484 topic: String,
485 #[diesel(sql_type = Text)]
486 payload: String,
487 #[diesel(sql_type = Integer)]
488 attempts: i32,
489 #[diesel(sql_type = BigInt)]
490 created_at: i64,
491 #[diesel(sql_type = Nullable<Text>)]
492 last_error: Option<String>,
493 #[diesel(sql_type = Nullable<BigInt>)]
494 dead_at: Option<i64>,
495}
496
497fn parse_payload(id: &str, raw: &str) -> Value {
498 serde_json::from_str(raw).unwrap_or_else(|error| {
499 tracing::warn!(message_id = %id, %error, "outbox payload is not JSON; sending it as a string");
500 Value::String(raw.to_owned())
501 })
502}
503
504impl ClaimedRow {
505 fn message(&self) -> OutboxMessage {
506 OutboxMessage {
507 id: self.id.clone(),
508 aggregate: self.aggregate.clone(),
509 topic: self.topic.clone(),
510 payload: parse_payload(&self.id, &self.payload),
511 attempt: u32::try_from(self.attempts).unwrap_or(0).max(1),
512 created_at: from_millis(self.created_at),
513 }
514 }
515}
516
517#[derive(Clone)]
526pub struct Outbox {
527 state: AppState,
528}
529
530impl std::fmt::Debug for Outbox {
531 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
532 f.debug_struct("Outbox").finish_non_exhaustive()
533 }
534}
535
536impl axum::extract::FromRequestParts<AppState> for Outbox {
537 type Rejection = AutumnError;
538
539 async fn from_request_parts(
540 _parts: &mut http::request::Parts,
541 state: &AppState,
542 ) -> Result<Self, Self::Rejection> {
543 Ok(Self::new(state))
544 }
545}
546
547impl Outbox {
548 #[must_use]
550 pub fn new(state: &AppState) -> Self {
551 Self {
552 state: state.clone(),
553 }
554 }
555
556 fn relay(&self) -> AutumnResult<Arc<OutboxRelay>> {
559 self.state.extension::<OutboxRelay>().ok_or_else(|| {
560 AutumnError::internal_server_error_msg("the outbox is off; set outbox.enabled = true")
561 })
562 }
563
564 async fn insert(
565 &self,
566 conn: &mut RuntimeConnection,
567 aggregate: Option<&str>,
568 topic: &str,
569 body: &Value,
570 ) -> AutumnResult<String> {
571 self.relay()?;
572 insert_message(
573 conn,
574 self.state.entropy(),
575 self.state.clock(),
576 aggregate,
577 topic,
578 body,
579 )
580 .await
581 }
582
583 pub async fn write<T: Serialize + Sync + ?Sized>(
594 &self,
595 conn: &mut RuntimeConnection,
596 aggregate: &str,
597 topic: &str,
598 payload: &T,
599 ) -> AutumnResult<String> {
600 if topic.starts_with(RESERVED_TOPIC_PREFIX) || !self.relay()?.handlers.contains_key(topic) {
601 return Err(AutumnError::bad_request_msg(format!(
602 "outbox topic {topic:?} has no handler; register it with outbox_handler"
603 )));
604 }
605 let payload = to_json(payload, &format!("outbox payload for {topic}"))?;
606 self.insert(conn, Some(aggregate), topic, &payload).await
607 }
608
609 pub async fn publish<E: Event>(
621 &self,
622 conn: &mut RuntimeConnection,
623 event: &E,
624 ) -> AutumnResult<()> {
625 let payload = to_json(event, &format!("event {}", E::NAME))?;
626 if let Some(recorder) = self.state.extension::<crate::events::EventRecorder>() {
627 recorder.record(E::NAME, payload.clone());
628 }
629 let Some(registry) = self.state.extension::<crate::events::EventRegistry>() else {
630 return Ok(());
631 };
632 let listeners = registry.listeners_for(E::NAME);
633 let mut first_error = None;
634 for job_name in listeners
635 .iter()
636 .filter(|listener| listener.mode == crate::events::DispatchMode::Durable)
637 .filter_map(|listener| listener.job_name.as_deref())
638 {
639 let body = self.job_body(job_name, payload.clone());
640 if let Err(error) = self.insert(conn, None, TOPIC_EVENT, &body).await {
641 first_error.get_or_insert(error);
642 }
643 }
644 crate::events::run_sync_listeners(&self.state, listeners, &payload).await;
645 first_error.map_or(Ok(()), Err)
646 }
647
648 pub async fn enqueue_job<A: Serialize + Sync + ?Sized>(
660 &self,
661 conn: &mut RuntimeConnection,
662 name: &str,
663 args: &A,
664 ) -> AutumnResult<String> {
665 let args = to_json(args, &format!("job {name} args"))?;
666 let body = self.job_body(name, args);
667 self.insert(conn, None, TOPIC_JOB, &body).await
668 }
669
670 fn job_body(&self, name: &str, args: Value) -> Value {
677 let client = self
678 .state
679 .extension::<crate::job::JobClient>()
680 .or_else(crate::job::global_job_client);
681 let args = match client {
682 Some(client) => client.wrap_payload_version(name, args),
683 None => args,
684 };
685 let mut body = serde_json::json!({ "name": name, "args": args });
686 if let Some(tenant) = crate::cost::enqueuing_tenant() {
687 body["tenant"] = Value::String(tenant);
688 }
689 body
690 }
691
692 #[cfg(feature = "mail")]
701 pub async fn deliver_mail(
702 &self,
703 conn: &mut RuntimeConnection,
704 mail: crate::mail::Mail,
705 ) -> AutumnResult<String> {
706 let mail = match self.state.extension::<crate::mail::Mailer>() {
707 Some(mailer) => mailer.prepare_deferred(mail),
708 None => mail,
709 };
710 let body = to_json(&mail, "mail")?;
711 self.insert(conn, None, TOPIC_MAIL, &body).await
712 }
713
714 #[cfg(feature = "http-client")]
726 pub async fn dispatch_webhook<T: Serialize + Sync + ?Sized>(
727 &self,
728 conn: &mut RuntimeConnection,
729 topic: &str,
730 payload: &T,
731 ) -> AutumnResult<String> {
732 let payload = to_json(payload, &format!("webhook payload for {topic}"))?;
733 let body = serde_json::json!({ "topic": topic, "payload": payload });
734 self.insert(conn, None, TOPIC_WEBHOOK, &body).await
735 }
736
737 pub async fn dead_letters(
743 &self,
744 conn: &mut RuntimeConnection,
745 limit: usize,
746 ) -> AutumnResult<Vec<DeadLetter>> {
747 let rows = diesel::sql_query(sql(DEAD_LETTERS_SQL))
748 .bind::<BigInt, _>(i64::try_from(limit).unwrap_or(i64::MAX))
749 .load::<DeadRow>(conn)
750 .await
751 .map_err(|error| sql_error("outbox dead letters", &error))?;
752 Ok(rows
753 .into_iter()
754 .map(|row| DeadLetter {
755 message: ClaimedRow {
756 seq: row.seq,
757 id: row.id,
758 aggregate: row.aggregate,
759 topic: row.topic,
760 payload: row.payload,
761 attempts: row.attempts,
762 created_at: row.created_at,
763 }
764 .message(),
765 last_error: row.last_error,
766 dead_at: from_millis(row.dead_at.unwrap_or_default()),
767 })
768 .collect())
769 }
770
771 pub async fn requeue(&self, conn: &mut RuntimeConnection, id: &str) -> AutumnResult<bool> {
781 let changed = diesel::sql_query(sql(REQUEUE_SQL))
782 .bind::<BigInt, _>(to_millis(self.state.clock().now()))
783 .bind::<Text, _>(id)
784 .execute(&mut *conn)
785 .await
786 .map_err(|error| sql_error("outbox requeue", &error))?;
787 if changed > 0 {
788 #[cfg(feature = "sqlite")]
789 mark_fleet_outbox(conn).await?;
790 }
791 Ok(changed > 0)
792 }
793}
794
795fn to_json<T: Serialize + ?Sized>(value: &T, what: &str) -> AutumnResult<Value> {
796 serde_json::to_value(value).map_err(|error| {
797 AutumnError::internal_server_error_msg(format!("{what} does not serialize: {error}"))
798 })
799}
800
801async fn insert_message(
804 conn: &mut RuntimeConnection,
805 entropy: &dyn Entropy,
806 clock: &dyn ClockSource,
807 aggregate: Option<&str>,
808 topic: &str,
809 payload: &Value,
810) -> AutumnResult<String> {
811 let id = entropy.uuid_v4().to_string();
812 let now = to_millis(clock.now());
813 diesel::sql_query(sql(INSERT_SQL))
814 .bind::<Text, _>(&id)
815 .bind::<Text, _>(aggregate.unwrap_or(&id))
816 .bind::<Text, _>(topic)
817 .bind::<Text, _>(payload.to_string())
818 .bind::<BigInt, _>(now)
819 .bind::<BigInt, _>(now)
820 .execute(&mut *conn)
821 .await
822 .map_err(|error| sql_error("outbox write", &error))?;
823 #[cfg(feature = "sqlite")]
824 mark_fleet_outbox(conn).await?;
825 Ok(id)
826}
827
828#[cfg(feature = "sqlite")]
831async fn mark_fleet_outbox(conn: &mut RuntimeConnection) -> AutumnResult<()> {
832 crate::db::fleet_sweeper::mark_connection(conn, crate::db::fleet_sweeper::PendingKind::Outbox)
833 .await
834}
835
836#[derive(Debug, Clone, PartialEq, Eq)]
843pub struct Inbox {
844 consumer: String,
845}
846
847impl Inbox {
848 #[must_use]
850 pub fn new(consumer: impl Into<String>) -> Self {
851 Self {
852 consumer: consumer.into(),
853 }
854 }
855
856 pub async fn seen(&self, conn: &mut RuntimeConnection, message_id: &str) -> AutumnResult<bool> {
863 let inserted = diesel::sql_query(sql(INBOX_INSERT_SQL))
864 .bind::<Text, _>(&self.consumer)
865 .bind::<Text, _>(message_id)
866 .bind::<BigInt, _>(to_millis(crate::time::ambient_now()))
867 .execute(conn)
868 .await
869 .map_err(|error| sql_error("inbox", &error))?;
870 Ok(inserted == 0)
871 }
872}
873
874type HandlerFuture = Pin<Box<dyn Future<Output = AutumnResult<()>> + Send + 'static>>;
877
878pub type OutboxHandler = Arc<dyn Fn(AppState, OutboxMessage) -> HandlerFuture + Send + Sync>;
880
881#[derive(Clone, Default)]
883pub(crate) struct OutboxHandlers {
884 handlers: HashMap<String, OutboxHandler>,
885}
886
887impl std::fmt::Debug for OutboxHandlers {
888 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
889 f.debug_struct("OutboxHandlers")
890 .field("topics", &self.handlers.keys().collect::<Vec<_>>())
891 .finish()
892 }
893}
894
895fn is_valid_topic(topic: &str) -> bool {
898 !topic.is_empty()
899 && topic
900 .bytes()
901 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b':' | b'-'))
902}
903
904impl OutboxHandlers {
905 pub(crate) fn insert<F, Fut>(&mut self, topic: impl Into<String>, handler: F)
912 where
913 F: Fn(AppState, OutboxMessage) -> Fut + Send + Sync + 'static,
914 Fut: Future<Output = AutumnResult<()>> + Send + 'static,
915 {
916 let topic = topic.into();
917 assert!(
918 is_valid_topic(&topic),
919 "outbox topic {topic:?} must not be empty and must use only A-Z a-z 0-9 . _ : -"
920 );
921 assert!(
922 !topic.starts_with(RESERVED_TOPIC_PREFIX),
923 "outbox topic {topic:?}: the `{RESERVED_TOPIC_PREFIX}` prefix is reserved"
924 );
925 self.insert_unchecked(topic, handler);
926 }
927
928 fn insert_unchecked<F, Fut>(&mut self, topic: String, handler: F)
929 where
930 F: Fn(AppState, OutboxMessage) -> Fut + Send + Sync + 'static,
931 Fut: Future<Output = AutumnResult<()>> + Send + 'static,
932 {
933 let handler: OutboxHandler =
934 Arc::new(move |state, message| Box::pin(handler(state, message)));
935 self.handlers.insert(topic, handler);
936 }
937
938 pub(crate) fn extend(&mut self, other: Self) {
941 self.handlers.extend(other.handlers);
942 }
943
944 pub(crate) fn is_empty(&self) -> bool {
945 self.handlers.is_empty()
946 }
947}
948
949#[derive(Clone)]
952pub struct OutboxRelay {
953 config: OutboxConfig,
954 handlers: Arc<HashMap<String, OutboxHandler>>,
955 topics_in_list: Arc<str>,
956 schema_pending: Arc<std::sync::Mutex<std::collections::HashSet<usize>>>,
959}
960
961impl std::fmt::Debug for OutboxRelay {
962 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
963 f.debug_struct("OutboxRelay")
964 .field("config", &self.config)
965 .field("topics", &self.topics_in_list)
966 .finish_non_exhaustive()
967 }
968}
969
970impl OutboxRelay {
971 pub(crate) fn new(config: OutboxConfig, handlers: OutboxHandlers) -> Self {
973 let mut all = handlers;
974 all.insert_unchecked(TOPIC_EVENT.to_owned(), run_job_enqueue);
975 all.insert_unchecked(TOPIC_JOB.to_owned(), run_job_enqueue);
976 #[cfg(feature = "mail")]
977 all.insert_unchecked(TOPIC_MAIL.to_owned(), run_mail_delivery);
978 #[cfg(feature = "http-client")]
979 all.insert_unchecked(TOPIC_WEBHOOK.to_owned(), run_webhook_dispatch);
980
981 let mut topics: Vec<&String> = all.handlers.keys().collect();
982 topics.sort();
983 let topics_in_list = topics
985 .iter()
986 .map(|topic| format!("'{}'", topic.replace('\'', "''")))
987 .collect::<Vec<_>>()
988 .join(", ");
989 Self {
990 config,
991 handlers: Arc::new(all.handlers),
992 topics_in_list: topics_in_list.into(),
993 schema_pending: Arc::default(),
994 }
995 }
996
997 fn pending_schema(&self) -> std::sync::MutexGuard<'_, std::collections::HashSet<usize>> {
998 self.schema_pending
999 .lock()
1000 .unwrap_or_else(std::sync::PoisonError::into_inner)
1001 }
1002
1003 async fn ensure_pending_schema(
1005 &self,
1006 index: usize,
1007 pool: &Pool<RuntimeConnection>,
1008 ) -> AutumnResult<()> {
1009 if !self.pending_schema().contains(&index) {
1010 return Ok(());
1011 }
1012 ensure_schema(pool).await?;
1013 self.pending_schema().remove(&index);
1014 Ok(())
1015 }
1016
1017 #[must_use]
1019 pub const fn config(&self) -> &OutboxConfig {
1020 &self.config
1021 }
1022
1023 #[must_use]
1025 pub fn handles(&self, topic: &str) -> bool {
1026 self.handlers.contains_key(topic)
1027 }
1028}
1029
1030pub(crate) fn install(state: &AppState, config: &OutboxConfig, handlers: OutboxHandlers) {
1034 if !config.enabled {
1035 if !handlers.is_empty() {
1036 tracing::warn!(
1037 "outbox handlers are registered but outbox.enabled = false; the relay does not run"
1038 );
1039 }
1040 return;
1041 }
1042 let Some(pool) = mail_pool(state) else {
1043 tracing::warn!("outbox.enabled = true needs a database; the relay does not run");
1044 return;
1045 };
1046 state.insert_extension(OutboxRelay::new(config.clone(), handlers));
1047 #[cfg(feature = "mail")]
1048 if state
1049 .extension::<crate::mail::MailDeliveryQueueHandle>()
1050 .is_none()
1051 {
1052 state.insert_extension(crate::mail::MailDeliveryQueueHandle::new(OutboxMailQueue {
1053 pool,
1054 entropy: state.entropy_arc(),
1055 clock: state.clock_arc(),
1056 }));
1057 }
1058 #[cfg(not(feature = "mail"))]
1059 drop(pool);
1060}
1061
1062#[cfg(all(feature = "mail", feature = "reporting"))]
1067pub(crate) fn installs_mail_queue(state: &AppState, config: &OutboxConfig) -> bool {
1068 config.enabled && mail_pool(state).is_some()
1069}
1070
1071tokio::task_local! {
1072 static CURRENT_MESSAGE_ID: String;
1073}
1074
1075#[must_use]
1080pub fn current_message_id() -> Option<String> {
1081 CURRENT_MESSAGE_ID.try_with(Clone::clone).ok()
1082}
1083
1084pub async fn drain(state: &AppState, max: usize) -> AutumnResult<usize> {
1095 drain_until(state, max, None).await
1096}
1097
1098async fn drain_until(
1100 state: &AppState,
1101 max: usize,
1102 shutdown: Option<&CancellationToken>,
1103) -> AutumnResult<usize> {
1104 let relay = state.extension::<OutboxRelay>().ok_or_else(|| {
1105 AutumnError::internal_server_error_msg(
1106 "outbox relay is not installed; set outbox.enabled = true",
1107 )
1108 })?;
1109 let pools = relay_pools(state)?;
1114 let mut handled = 0;
1115 let mut failures = Vec::new();
1116 for (index, pool) in pools.iter().enumerate() {
1117 let drained = match relay.ensure_pending_schema(index, pool).await {
1118 Ok(()) => drain_pool(state, &relay, pool, max, shutdown).await,
1119 Err(error) => Err(error),
1120 };
1121 match drained {
1122 Ok(count) => handled += count,
1123 Err(error) => {
1124 tracing::warn!(%error, "outbox relay could not drain one pool; it goes on with the others");
1125 failures.push(error);
1126 }
1127 }
1128 }
1129 fail_if_every_pool_failed(failures, pools.len())?;
1130 Ok(handled)
1131}
1132
1133fn mail_pool(state: &AppState) -> Option<Pool<RuntimeConnection>> {
1136 relay_pools(state).ok()?.into_iter().next()
1137}
1138
1139#[cfg(feature = "sqlite")]
1148pub(crate) async fn relay_fleet_database(
1149 state: &AppState,
1150 pool: &Pool<RuntimeConnection>,
1151 max: usize,
1152 shutdown: &CancellationToken,
1153 tenant: Option<String>,
1154) -> AutumnResult<usize> {
1155 let relay = state
1156 .extension::<OutboxRelay>()
1157 .ok_or_else(|| AutumnError::internal_server_error_msg("outbox relay is not installed"))?;
1158 ensure_schema(pool).await?;
1159 let drain = drain_pool(state, &relay, pool, max, Some(shutdown));
1160 let handled = match tenant {
1161 Some(tenant) => {
1162 crate::tenancy::CURRENT_TENANT
1163 .scope(Some(tenant), drain)
1164 .await?
1165 }
1166 None => drain.await?,
1167 };
1168 let retention = duration_millis(Duration::from_millis(relay.config.retention_ms));
1169 let outbox_cutoff = to_millis(state.clock().now()).saturating_sub(retention);
1170 let inbox_cutoff = to_millis(crate::time::ambient_now()).saturating_sub(retention);
1171 purge_pool(pool, outbox_cutoff, inbox_cutoff).await?;
1172 Ok(handled)
1173}
1174
1175#[cfg(feature = "sqlite")]
1182pub(crate) async fn has_pending(conn: &mut RuntimeConnection) -> AutumnResult<bool> {
1183 #[derive(diesel::QueryableByName)]
1184 struct Pending {
1185 #[diesel(sql_type = BigInt)]
1186 n: i64,
1187 }
1188 let table: Pending = diesel::sql_query(
1189 "SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name = 'autumn_outbox'",
1190 )
1191 .get_result(&mut *conn)
1192 .await
1193 .map_err(|error| sql_error("outbox probe", &error))?;
1194 if table.n == 0 {
1195 return Ok(false);
1196 }
1197 let pending: Pending = diesel::sql_query(
1198 "SELECT COUNT(*) AS n FROM (SELECT 1 FROM autumn_outbox \
1199 WHERE dispatched_at IS NULL AND dead_at IS NULL LIMIT 1)",
1200 )
1201 .get_result(&mut *conn)
1202 .await
1203 .map_err(|error| sql_error("outbox probe", &error))?;
1204 Ok(pending.n > 0)
1205}
1206
1207fn relay_pools(state: &AppState) -> AutumnResult<Vec<Pool<RuntimeConnection>>> {
1210 let mut pools: Vec<Pool<RuntimeConnection>> = state.pool().cloned().into_iter().collect();
1211 if let Some(shards) = state.shards() {
1212 pools.extend(shards.iter().map(|shard| shard.primary_pool().clone()));
1213 }
1214 if pools.is_empty() {
1215 return Err(AutumnError::internal_server_error_msg(
1216 "outbox relay needs a database",
1217 ));
1218 }
1219 Ok(pools)
1220}
1221
1222pub(crate) async fn ensure_relay_schema(state: &AppState) -> AutumnResult<()> {
1230 let relay = state.extension::<OutboxRelay>();
1231 let pools = relay_pools(state)?;
1232 let mut failures = Vec::new();
1233 for (index, pool) in pools.iter().enumerate() {
1234 if let Err(error) = ensure_schema(pool).await {
1235 tracing::warn!(%error, "outbox could not create its tables on one pool; the relay tries again later");
1236 if let Some(relay) = &relay {
1237 relay.pending_schema().insert(index);
1238 }
1239 failures.push(error);
1240 }
1241 }
1242 fail_if_every_pool_failed(failures, pools.len())
1243}
1244
1245fn fail_if_every_pool_failed(failures: Vec<AutumnError>, pools: usize) -> AutumnResult<()> {
1248 if failures.len() == pools
1249 && let Some(error) = failures.into_iter().next()
1250 {
1251 return Err(error);
1252 }
1253 Ok(())
1254}
1255
1256async fn drain_pool(
1257 state: &AppState,
1258 relay: &OutboxRelay,
1259 pool: &Pool<RuntimeConnection>,
1260 max: usize,
1261 shutdown: Option<&CancellationToken>,
1262) -> AutumnResult<usize> {
1263 let lease = duration_millis(Duration::from_millis(relay.config.lease_ms));
1264 let mut handled = 0;
1265 while handled < max {
1266 let batch = (max - handled).min(relay.config.batch_size.max(1));
1267 let claim_token = state.entropy().uuid_v4().to_string();
1268 let lease_end = to_millis(state.clock().now()).saturating_add(lease);
1269 let mut rows = claim(relay, pool, state, &claim_token, batch).await?;
1270 if rows.is_empty() {
1271 break;
1272 }
1273 rows.sort_by_key(|row| row.seq);
1274 let total = rows.len();
1275 let mut done = 0;
1276 for row in &rows {
1277 let remaining = lease_end.saturating_sub(to_millis(state.clock().now()));
1280 if remaining <= 0 || shutdown.is_some_and(CancellationToken::is_cancelled) {
1281 break;
1282 }
1283 let budget = Duration::from_millis(u64::try_from(remaining).unwrap_or(0));
1284 handle_row(relay, pool, state, &claim_token, row, budget).await;
1285 done += 1;
1286 }
1287 handled += done;
1288 if done < total {
1289 let unhandled: Vec<i64> = rows[done..].iter().map(|row| row.seq).collect();
1290 release(pool, &claim_token, &unhandled).await;
1291 break;
1292 }
1293 }
1294 Ok(handled)
1295}
1296
1297async fn claim(
1298 relay: &OutboxRelay,
1299 pool: &Pool<RuntimeConnection>,
1300 state: &AppState,
1301 claim_token: &str,
1302 batch: usize,
1303) -> AutumnResult<Vec<ClaimedRow>> {
1304 let now = to_millis(state.clock().now());
1305 let lease = duration_millis(Duration::from_millis(relay.config.lease_ms));
1306 let mut conn = pool.get().await.map_err(|error| {
1307 AutumnError::service_unavailable_msg(format!("outbox claim: no connection: {error}"))
1308 })?;
1309 diesel::sql_query(claim_sql(&relay.topics_in_list))
1310 .bind::<Text, _>(claim_token)
1311 .bind::<BigInt, _>(now.saturating_add(lease))
1312 .bind::<BigInt, _>(now)
1313 .bind::<BigInt, _>(now)
1314 .bind::<BigInt, _>(i64::try_from(batch).unwrap_or(i64::MAX))
1315 .load::<ClaimedRow>(&mut conn)
1316 .await
1317 .map_err(|error| sql_error("outbox claim", &error))
1318}
1319
1320async fn release(pool: &Pool<RuntimeConnection>, claim_token: &str, seqs: &[i64]) {
1323 if seqs.is_empty() {
1324 return;
1325 }
1326 let list = seqs
1328 .iter()
1329 .map(i64::to_string)
1330 .collect::<Vec<_>>()
1331 .join(", ");
1332 let statement = format!("{} ({list})", sql(RELEASE_SQL));
1333 let released = match pool.get().await {
1334 Ok(mut conn) => diesel::sql_query(statement)
1335 .bind::<Text, _>(claim_token)
1336 .execute(&mut conn)
1337 .await
1338 .map_err(|error| sql_error("outbox release", &error)),
1339 Err(error) => Err(AutumnError::service_unavailable_msg(format!(
1340 "outbox release: no connection: {error}"
1341 ))),
1342 };
1343 if let Err(error) = released {
1344 tracing::warn!(%error, "outbox relay could not release its claim; the lease ends it");
1345 }
1346}
1347
1348async fn handle_row(
1349 relay: &OutboxRelay,
1350 pool: &Pool<RuntimeConnection>,
1351 state: &AppState,
1352 claim_token: &str,
1353 row: &ClaimedRow,
1354 budget: Duration,
1355) {
1356 let message = row.message();
1357 if message.attempt > relay.config.max_attempts {
1358 let recorded = dead_letter_exhausted(relay, pool, state, claim_token, row.seq).await;
1361 tracing::error!(
1362 message_id = %message.id,
1363 topic = %message.topic,
1364 "outbox message used its attempts in crashes or expired leases; moved to dead letters"
1365 );
1366 if let Err(error) = recorded {
1367 tracing::warn!(message_id = %message.id, %error, "outbox relay could not record the dead letter");
1368 }
1369 return;
1370 }
1371 let outcome = run_handler(relay, state, &message, budget).await;
1372 let recorded = match outcome {
1373 Ok(()) => mark_sent(pool, state, claim_token, row.seq).await,
1374 Err(error) => {
1375 let attempt = message.attempt;
1376 let dead = attempt >= relay.config.max_attempts;
1377 if dead {
1378 tracing::error!(
1379 message_id = %message.id,
1380 topic = %message.topic,
1381 attempt,
1382 %error,
1383 "outbox message failed its last attempt; moved to dead letters"
1384 );
1385 } else {
1386 tracing::warn!(
1387 message_id = %message.id,
1388 topic = %message.topic,
1389 attempt,
1390 %error,
1391 "outbox message failed; the relay sends it again"
1392 );
1393 }
1394 nack(
1395 relay,
1396 pool,
1397 state,
1398 claim_token,
1399 row.seq,
1400 attempt,
1401 dead,
1402 &error,
1403 )
1404 .await
1405 }
1406 };
1407 if let Err(error) = recorded {
1408 tracing::warn!(
1409 message_id = %message.id,
1410 %error,
1411 "outbox relay could not record the outcome; the message is sent again after the lease"
1412 );
1413 }
1414}
1415
1416async fn run_handler(
1419 relay: &OutboxRelay,
1420 state: &AppState,
1421 message: &OutboxMessage,
1422 budget: Duration,
1423) -> AutumnResult<()> {
1424 let Some(handler) = relay.handlers.get(&message.topic) else {
1425 return Err(AutumnError::internal_server_error_msg(format!(
1426 "no outbox handler for topic {}",
1427 message.topic
1428 )));
1429 };
1430 let Ok(future) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1432 handler(state.clone(), message.clone())
1433 })) else {
1434 return Err(AutumnError::internal_server_error_msg(
1435 "outbox handler panicked",
1436 ));
1437 };
1438 let future = std::panic::AssertUnwindSafe(future).catch_unwind();
1439 let run = CURRENT_MESSAGE_ID.scope(message.id.clone(), future);
1440 match tokio::time::timeout(budget, run).await {
1441 Ok(Ok(result)) => result,
1442 Ok(Err(_)) => Err(AutumnError::internal_server_error_msg(
1443 "outbox handler panicked",
1444 )),
1445 Err(_) => Err(AutumnError::internal_server_error_msg(
1446 "outbox handler ran past the lease",
1447 )),
1448 }
1449}
1450
1451async fn mark_sent(
1452 pool: &Pool<RuntimeConnection>,
1453 state: &AppState,
1454 claim_token: &str,
1455 seq: i64,
1456) -> AutumnResult<()> {
1457 let mut conn = pool.get().await.map_err(|error| {
1458 AutumnError::service_unavailable_msg(format!("outbox mark: no connection: {error}"))
1459 })?;
1460 diesel::sql_query(sql(MARK_SENT_SQL))
1461 .bind::<BigInt, _>(to_millis(state.clock().now()))
1462 .bind::<BigInt, _>(seq)
1463 .bind::<Text, _>(claim_token)
1464 .execute(&mut conn)
1465 .await
1466 .map_err(|error| sql_error("outbox mark", &error))?;
1467 Ok(())
1468}
1469
1470async fn dead_letter_exhausted(
1471 relay: &OutboxRelay,
1472 pool: &Pool<RuntimeConnection>,
1473 state: &AppState,
1474 claim_token: &str,
1475 seq: i64,
1476) -> AutumnResult<()> {
1477 let mut conn = pool.get().await.map_err(|error| {
1478 AutumnError::service_unavailable_msg(format!("outbox dead letter: no connection: {error}"))
1479 })?;
1480 diesel::sql_query(sql(EXHAUSTED_SQL))
1481 .bind::<Integer, _>(i32::try_from(relay.config.max_attempts).unwrap_or(i32::MAX))
1482 .bind::<Text, _>("no attempts left: earlier attempts ended in a crash or an expired lease")
1483 .bind::<BigInt, _>(to_millis(state.clock().now()))
1484 .bind::<BigInt, _>(seq)
1485 .bind::<Text, _>(claim_token)
1486 .execute(&mut conn)
1487 .await
1488 .map_err(|error| sql_error("outbox dead letter", &error))?;
1489 Ok(())
1490}
1491
1492#[allow(clippy::too_many_arguments)]
1493async fn nack(
1494 relay: &OutboxRelay,
1495 pool: &Pool<RuntimeConnection>,
1496 state: &AppState,
1497 claim_token: &str,
1498 seq: i64,
1499 attempt: u32,
1500 dead: bool,
1501 error: &AutumnError,
1502) -> AutumnResult<()> {
1503 let now = to_millis(state.clock().now());
1504 let delay = retry_delay_ms(&relay.config, attempt, state.entropy());
1505 let mut conn = pool.get().await.map_err(|error| {
1506 AutumnError::service_unavailable_msg(format!("outbox nack: no connection: {error}"))
1507 })?;
1508 diesel::sql_query(sql(NACK_SQL))
1509 .bind::<Text, _>(error.to_string())
1510 .bind::<BigInt, _>(now.saturating_add(i64::try_from(delay).unwrap_or(i64::MAX)))
1511 .bind::<Nullable<BigInt>, _>(dead.then_some(now))
1512 .bind::<BigInt, _>(seq)
1513 .bind::<Text, _>(claim_token)
1514 .execute(&mut conn)
1515 .await
1516 .map_err(|error| sql_error("outbox nack", &error))?;
1517 Ok(())
1518}
1519
1520fn retry_delay_ms(config: &OutboxConfig, attempt: u32, entropy: &dyn Entropy) -> u64 {
1523 let exponent = attempt.saturating_sub(1).min(32);
1524 let delay = config
1525 .initial_backoff_ms
1526 .saturating_mul(1_u64 << exponent)
1527 .min(config.max_backoff_ms);
1528 let half = delay / 2;
1529 half + entropy.next_u64() % (delay - half + 1)
1530}
1531
1532pub async fn purge(state: &AppState) -> AutumnResult<(usize, usize)> {
1542 let relay = state
1543 .extension::<OutboxRelay>()
1544 .ok_or_else(|| AutumnError::internal_server_error_msg("outbox relay is not installed"))?;
1545 let retention = duration_millis(Duration::from_millis(relay.config.retention_ms));
1546 let outbox_cutoff = to_millis(state.clock().now()).saturating_sub(retention);
1547 let inbox_cutoff = to_millis(crate::time::ambient_now()).saturating_sub(retention);
1548 let pools = relay_pools(state)?;
1549 let (mut outbox, mut inbox) = (0, 0);
1550 let mut failures = Vec::new();
1551 for pool in &pools {
1552 match purge_pool(pool, outbox_cutoff, inbox_cutoff).await {
1553 Ok((o, i)) => {
1554 outbox += o;
1555 inbox += i;
1556 }
1557 Err(error) => {
1558 tracing::warn!(%error, "outbox purge failed on one pool; it goes on with the others");
1559 failures.push(error);
1560 }
1561 }
1562 }
1563 fail_if_every_pool_failed(failures, pools.len())?;
1564 Ok((outbox, inbox))
1565}
1566
1567async fn purge_pool(
1568 pool: &Pool<RuntimeConnection>,
1569 outbox_cutoff: i64,
1570 inbox_cutoff: i64,
1571) -> AutumnResult<(usize, usize)> {
1572 let mut conn = pool.get().await.map_err(|error| {
1573 AutumnError::service_unavailable_msg(format!("outbox purge: no connection: {error}"))
1574 })?;
1575 let outbox = diesel::sql_query(sql(PURGE_OUTBOX_SQL))
1576 .bind::<BigInt, _>(outbox_cutoff)
1577 .execute(&mut conn)
1578 .await
1579 .map_err(|error| sql_error("outbox purge", &error))?;
1580 let inbox = diesel::sql_query(sql(PURGE_INBOX_SQL))
1581 .bind::<BigInt, _>(inbox_cutoff)
1582 .execute(&mut conn)
1583 .await
1584 .map_err(|error| sql_error("inbox purge", &error))?;
1585 Ok((outbox, inbox))
1586}
1587
1588pub(crate) fn start_relay_worker(
1592 state: AppState,
1593 shutdown: CancellationToken,
1594) -> Option<tokio::task::JoinHandle<()>> {
1595 let relay = state.extension::<OutboxRelay>()?;
1596 let poll = Duration::from_millis(relay.config.poll_interval_ms.max(1));
1597 let batch = relay.config.batch_size.max(1);
1598 Some(tokio::spawn(async move {
1599 let mut last_purge: Option<crate::time::MonotonicInstant> = None;
1600 while !shutdown.is_cancelled() {
1601 let full = match drain_until(&state, batch, Some(&shutdown)).await {
1602 Ok(handled) => handled >= batch,
1603 Err(error) => {
1604 tracing::warn!(%error, "outbox relay poll failed");
1605 false
1606 }
1607 };
1608 let now = state.monotonic();
1609 if last_purge.is_none_or(|at| now.saturating_duration_since(at) >= PURGE_INTERVAL) {
1610 last_purge = Some(now);
1611 if let Err(error) = purge(&state).await {
1612 tracing::warn!(%error, "outbox purge failed");
1613 }
1614 }
1615 if full {
1616 continue;
1617 }
1618 tokio::select! {
1619 () = shutdown.cancelled() => break,
1620 () = tokio::time::sleep(poll) => {}
1621 }
1622 }
1623 }))
1624}
1625
1626#[derive(Deserialize)]
1629struct JobBody {
1630 name: String,
1631 args: Value,
1632 #[serde(default)]
1634 tenant: Option<String>,
1635}
1636
1637pub(crate) async fn run_job_enqueue(state: AppState, message: OutboxMessage) -> AutumnResult<()> {
1638 let body: JobBody = message.payload_as()?;
1639 crate::cost::with_enqueue_tenant(body.tenant, async move {
1642 match state.extension::<crate::job::JobClient>() {
1643 Some(client) => client.enqueue(&body.name, body.args).await,
1644 None => crate::job::enqueue(&body.name, body.args).await,
1645 }
1646 })
1647 .await
1648}
1649
1650#[cfg(feature = "mail")]
1651async fn run_mail_delivery(state: AppState, message: OutboxMessage) -> AutumnResult<()> {
1652 let mail: crate::mail::Mail = message.payload_as()?;
1653 let mailer = state.extension::<crate::mail::Mailer>().ok_or_else(|| {
1654 AutumnError::internal_server_error_msg("outbox mail: no mailer installed")
1655 })?;
1656 mailer
1657 .send(mail)
1658 .await
1659 .map_err(|error| AutumnError::internal_server_error_msg(format!("outbox mail: {error}")))
1660}
1661
1662#[cfg(feature = "http-client")]
1663#[derive(Deserialize)]
1664struct WebhookBody {
1665 topic: String,
1666 payload: Value,
1667}
1668
1669#[cfg(feature = "http-client")]
1670async fn run_webhook_dispatch(state: AppState, message: OutboxMessage) -> AutumnResult<()> {
1671 let body: WebhookBody = message.payload_as()?;
1672 let manager = state
1673 .extension::<crate::webhook_outbound::WebhookOutboundManager>()
1674 .ok_or_else(|| {
1675 AutumnError::internal_server_error_msg("outbox webhook: no webhook manager installed")
1676 })?;
1677 manager
1678 .dispatch_for_message(&state, &message.id, &body.topic, &body.payload)
1679 .await
1680}
1681
1682#[cfg(feature = "mail")]
1691pub struct OutboxMailQueue {
1692 pool: Pool<RuntimeConnection>,
1693 entropy: Arc<dyn Entropy>,
1694 clock: Arc<dyn ClockSource>,
1695}
1696
1697#[cfg(feature = "mail")]
1698impl OutboxMailQueue {
1699 pub fn from_state(state: &AppState) -> AutumnResult<Self> {
1706 if state.extension::<OutboxRelay>().is_none() {
1707 return Err(AutumnError::internal_server_error_msg(
1708 "OutboxMailQueue needs the relay; set outbox.enabled = true",
1709 ));
1710 }
1711 let pool = mail_pool(state).ok_or_else(|| {
1712 AutumnError::internal_server_error_msg("OutboxMailQueue needs a database")
1713 })?;
1714 Ok(Self {
1715 pool,
1716 entropy: state.entropy_arc(),
1717 clock: state.clock_arc(),
1718 })
1719 }
1720}
1721
1722#[cfg(feature = "mail")]
1723impl crate::mail::MailDeliveryQueue for OutboxMailQueue {
1724 fn enqueue<'a>(
1725 &'a self,
1726 mail: crate::mail::Mail,
1727 ) -> Pin<Box<dyn Future<Output = Result<(), crate::mail::MailError>> + Send + 'a>> {
1728 Box::pin(async move {
1729 let queue_error = crate::mail::MailError::RuntimeUnavailable;
1730 let body =
1731 serde_json::to_value(&mail).map_err(|error| queue_error(error.to_string()))?;
1732 let mut conn = self
1733 .pool
1734 .get()
1735 .await
1736 .map_err(|error| queue_error(format!("outbox mail: no connection: {error}")))?;
1737 insert_message(
1738 &mut conn,
1739 self.entropy.as_ref(),
1740 self.clock.as_ref(),
1741 None,
1742 TOPIC_MAIL,
1743 &body,
1744 )
1745 .await
1746 .map(drop)
1747 .map_err(|error| queue_error(error.to_string()))
1748 })
1749 }
1750}
1751
1752#[cfg(kani)]
1755mod kani_proofs {
1756 use super::*;
1757
1758 #[derive(Debug)]
1759 struct AnyDraw(u64);
1760
1761 impl Entropy for AnyDraw {
1762 fn next_u64(&self) -> u64 {
1763 self.0
1764 }
1765 fn fill_bytes(&self, dest: &mut [u8]) {
1766 dest.fill(0);
1767 }
1768 }
1769
1770 #[kani::proof]
1774 fn retry_delay_is_equal_jitter_without_overflow() {
1775 let config = OutboxConfig {
1776 initial_backoff_ms: kani::any(),
1777 max_backoff_ms: kani::any(),
1778 ..OutboxConfig::default()
1779 };
1780 let attempt: u32 = kani::any();
1781 let delay = retry_delay_ms(&config, attempt, &AnyDraw(kani::any()));
1782 let exponent = attempt.saturating_sub(1).min(32);
1783 let ceiling = config
1784 .initial_backoff_ms
1785 .saturating_mul(1_u64 << exponent)
1786 .min(config.max_backoff_ms);
1787 assert!(delay <= ceiling);
1788 assert!(delay >= ceiling / 2);
1789 }
1790}
1791
1792#[cfg(test)]
1793mod tests;