feat: worker relays outbox to nats

This commit is contained in:
2026-10-26 00:06:09 +01:00
parent 32dfc84697
commit c06d3d9207
15 changed files with 678 additions and 102 deletions

View File

@@ -2,7 +2,7 @@ use std::sync::Arc;
#[cfg(feature = "nats")]
use anyhow::Context;
use domain::ports::{EventConsumer, EventPublisher};
use domain::ports::{EventConsumer, EventPublisher, EventRelaySink};
#[derive(Clone, Copy)]
pub enum EventBusBackend {
@@ -36,21 +36,114 @@ pub async fn create_publisher(
sqlite::SqliteEventQueue::create_publisher(db_pool.clone(), &config.events).await
}
/// What the worker gets from the bus: a publisher for its own events, the
/// consumer its handlers read, and, when the bus is not the outbox, the relay
/// that moves outbox rows onto it.
struct Wiring {
publisher: Arc<dyn EventPublisher>,
consumer: Arc<dyn EventConsumer>,
relay: Option<(Arc<dyn EventConsumer>, Arc<dyn EventRelaySink>)>,
}
/// The worker always writes to the outbox. With an external bus
/// (`bus = Some((consumer, sink))`) handlers read from the bus and the relay
/// drains the outbox into it; without one, handlers drain the outbox directly.
fn compose(
(publisher, outbox): (Arc<dyn EventPublisher>, Arc<dyn EventConsumer>),
bus: Option<(Arc<dyn EventConsumer>, Arc<dyn EventRelaySink>)>,
) -> Wiring {
match bus {
None => Wiring {
publisher,
consumer: outbox,
relay: None,
},
Some((consumer, sink)) => Wiring {
publisher,
consumer,
relay: Some((outbox, sink)),
},
}
}
pub async fn create_channel(
db_pool: &sqlx::SqlitePool,
config: &config::AppConfig,
) -> anyhow::Result<(Arc<dyn EventPublisher>, Arc<dyn EventConsumer>)> {
match EventBusBackend::from_config(&config.events)? {
let outbox = sqlite::SqliteEventQueue::create_channel(db_pool.clone(), &config.events).await?;
let wiring = match EventBusBackend::from_config(&config.events)? {
EventBusBackend::Db => {
tracing::info!("event bus: DB queue");
Ok(sqlite::SqliteEventQueue::create_channel(db_pool.clone(), &config.events).await?)
compose(outbox, None)
}
#[cfg(feature = "nats")]
EventBusBackend::Nats => {
let cfg = nats::NatsConfig::from_config(&config.nats)
.context("EVENT_BUS_BACKEND=nats requires NATS_URL to be set")?;
tracing::info!("event bus: NATS ({})", cfg.url);
Ok(nats::create_channel(cfg).await?)
tracing::info!("event bus: NATS ({}), relaying the outbox", cfg.url);
compose(outbox, Some(nats::create_bus(cfg).await?))
}
};
if let Some((outbox, sink)) = wiring.relay {
crate::outbox_relay::spawn(outbox, sink);
}
Ok((wiring.publisher, wiring.consumer))
}
#[cfg(test)]
mod tests {
use super::*;
use domain::{
errors::DomainError,
events::{DomainEvent, EventEnvelope, EventId},
};
use futures::stream::BoxStream;
struct Nop;
#[async_trait::async_trait]
impl EventPublisher for Nop {
async fn publish(&self, _: &DomainEvent) -> Result<(), DomainError> {
Ok(())
}
}
#[async_trait::async_trait]
impl EventRelaySink for Nop {
async fn relay(&self, _: Option<&EventId>, _: &DomainEvent) -> Result<(), DomainError> {
Ok(())
}
}
impl EventConsumer for Nop {
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
Box::pin(futures::stream::empty())
}
}
fn outbox() -> (Arc<dyn EventPublisher>, Arc<dyn EventConsumer>) {
(Arc::new(Nop), Arc::new(Nop))
}
#[test]
fn db_mode_does_not_relay_and_consumes_the_outbox() {
let (publisher, queue) = outbox();
let queue_ptr = Arc::as_ptr(&queue);
let wiring = compose((publisher, queue), None);
assert!(wiring.relay.is_none());
assert!(std::ptr::addr_eq(Arc::as_ptr(&wiring.consumer), queue_ptr));
}
#[test]
fn bus_mode_relays_the_outbox_and_consumes_the_bus() {
let (publisher, queue) = outbox();
let queue_ptr = Arc::as_ptr(&queue);
let bus_consumer: Arc<dyn EventConsumer> = Arc::new(Nop);
let bus_ptr = Arc::as_ptr(&bus_consumer);
let sink: Arc<dyn EventRelaySink> = Arc::new(Nop);
let wiring = compose((publisher, queue), Some((bus_consumer, sink)));
let (source, _) = wiring.relay.expect("relay spawned in bus mode");
assert!(std::ptr::addr_eq(Arc::as_ptr(&source), queue_ptr));
assert!(std::ptr::addr_eq(Arc::as_ptr(&wiring.consumer), bus_ptr));
}
}

View File

@@ -10,6 +10,7 @@ mod event_bus;
mod federation;
#[cfg(feature = "federation")]
mod follow_backfill_handler;
pub mod outbox_relay;
pub mod repositories;
mod server;
mod tracing_setup;

View File

@@ -0,0 +1,48 @@
//! Moves outbox rows onto the message bus. The worker runs it when the bus is
//! not the outbox itself (`EVENT_BUS_BACKEND=nats`).
//!
//! Rows are claimed by the SQLite queue's own atomic claim, so a row is only
//! ever relayed by one claimant and failures take the queue's existing
//! backoff and dead-letter path. A row is marked done only after the sink
//! reports the bus accepted it; a crash in between re-relays the row, and the
//! bus de-duplicates on the event id.
use std::sync::Arc;
use domain::{
events::EventEnvelope,
ports::{EventConsumer, EventRelaySink},
};
use futures::StreamExt;
use tokio::task::JoinHandle;
pub fn spawn(source: Arc<dyn EventConsumer>, sink: Arc<dyn EventRelaySink>) -> JoinHandle<()> {
tokio::spawn(async move {
let mut rows = source.consume();
while let Some(next) = rows.next().await {
match next {
Ok(envelope) => relay_envelope(envelope, sink.as_ref()).await,
// A row that cannot even be decoded stays claimed; there is
// no handle to settle it with.
Err(e) => tracing::error!("outbox relay: undecodable row: {e}"),
}
}
tracing::warn!("outbox relay: source closed, relay stopped");
})
}
async fn relay_envelope(envelope: EventEnvelope, sink: &dyn EventRelaySink) {
match sink.relay(envelope.id(), &envelope.event).await {
Ok(()) => {
if let Err(e) = envelope.ack().await {
tracing::error!("outbox relay: published but could not mark done: {e}");
}
}
Err(e) => {
tracing::warn!("outbox relay: publish failed, will retry: {e}");
if let Err(e) = envelope.nack().await {
tracing::error!("outbox relay: could not nack: {e}");
}
}
}
}