feat: worker relays outbox to nats
This commit is contained in:
@@ -223,3 +223,7 @@ fn _assert_send_sync() {
|
||||
check::<NatsJetStreamConsumer>();
|
||||
check::<NatsCoreConsumer>();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/consumer.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,52 +1,41 @@
|
||||
mod config;
|
||||
mod consumer;
|
||||
mod payload;
|
||||
mod publisher;
|
||||
mod relay;
|
||||
mod subject;
|
||||
|
||||
pub use config::{NatsConfig, NatsMode};
|
||||
pub use consumer::{NatsCoreConsumer, NatsJetStreamConsumer};
|
||||
pub use publisher::NatsEventPublisher;
|
||||
pub use relay::NatsRelaySink;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{EventConsumer, EventPublisher};
|
||||
use domain::ports::{EventConsumer, EventRelaySink};
|
||||
|
||||
pub async fn create_publisher(cfg: NatsConfig) -> anyhow::Result<Arc<dyn EventPublisher>> {
|
||||
let client = async_nats::connect(&cfg.url).await?;
|
||||
let publisher: Arc<dyn EventPublisher> = match cfg.mode {
|
||||
NatsMode::Core => Arc::new(NatsEventPublisher::new_core(client, cfg.subject_prefix)),
|
||||
NatsMode::JetStream => Arc::new(NatsEventPublisher::new_jetstream(
|
||||
client,
|
||||
cfg.subject_prefix,
|
||||
)),
|
||||
};
|
||||
|
||||
tracing::info!("NATS publisher created (mode: {:?})", cfg.mode);
|
||||
Ok(publisher)
|
||||
}
|
||||
|
||||
pub async fn create_channel(
|
||||
/// The worker's NATS side: where handlers read events from, and where the
|
||||
/// outbox relay writes them to.
|
||||
pub async fn create_bus(
|
||||
cfg: NatsConfig,
|
||||
) -> anyhow::Result<(Arc<dyn EventPublisher>, Arc<dyn EventConsumer>)> {
|
||||
) -> anyhow::Result<(Arc<dyn EventConsumer>, Arc<dyn EventRelaySink>)> {
|
||||
let client = async_nats::connect(&cfg.url).await?;
|
||||
|
||||
let publisher: Arc<dyn EventPublisher> = match cfg.mode {
|
||||
NatsMode::Core => Arc::new(NatsEventPublisher::new_core(
|
||||
client.clone(),
|
||||
cfg.subject_prefix.clone(),
|
||||
)),
|
||||
NatsMode::JetStream => Arc::new(NatsEventPublisher::new_jetstream(
|
||||
client.clone(),
|
||||
cfg.subject_prefix.clone(),
|
||||
)),
|
||||
let (consumer, sink): (Arc<dyn EventConsumer>, Arc<dyn EventRelaySink>) = match cfg.mode {
|
||||
NatsMode::Core => {
|
||||
tracing::info!(
|
||||
"core NATS mode: no broker-side dedup or redelivery; relying on consumer ledger"
|
||||
);
|
||||
(
|
||||
Arc::new(NatsCoreConsumer::create(&cfg, client.clone()).await?),
|
||||
Arc::new(NatsRelaySink::new_core(client, cfg.subject_prefix)),
|
||||
)
|
||||
}
|
||||
// The consumer creates the stream the relay publishes into.
|
||||
NatsMode::JetStream => (
|
||||
Arc::new(NatsJetStreamConsumer::create(&cfg, client.clone()).await?),
|
||||
Arc::new(NatsRelaySink::new_jetstream(client, cfg.subject_prefix)),
|
||||
),
|
||||
};
|
||||
|
||||
let consumer: Arc<dyn EventConsumer> = match cfg.mode {
|
||||
NatsMode::Core => Arc::new(NatsCoreConsumer::create(&cfg, client).await?),
|
||||
NatsMode::JetStream => Arc::new(NatsJetStreamConsumer::create(&cfg, client).await?),
|
||||
};
|
||||
|
||||
tracing::info!("NATS channel created (mode: {:?})", cfg.mode);
|
||||
Ok((publisher, consumer))
|
||||
tracing::info!("NATS bus created (mode: {:?})", cfg.mode);
|
||||
Ok((consumer, sink))
|
||||
}
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
pub use event_payload::EventMessage as NatsEventMessage;
|
||||
pub use event_payload::EventPayload as NatsEventPayload;
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
use async_nats::{Client, jetstream};
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher};
|
||||
|
||||
use crate::{payload::NatsEventPayload, subject::event_to_subject};
|
||||
|
||||
enum PublisherMode {
|
||||
Core(Client),
|
||||
JetStream(jetstream::Context),
|
||||
}
|
||||
|
||||
pub struct NatsEventPublisher {
|
||||
mode: PublisherMode,
|
||||
subject_prefix: String,
|
||||
}
|
||||
|
||||
impl NatsEventPublisher {
|
||||
pub fn new_core(client: Client, subject_prefix: String) -> Self {
|
||||
Self {
|
||||
mode: PublisherMode::Core(client),
|
||||
subject_prefix,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_jetstream(client: Client, subject_prefix: String) -> Self {
|
||||
Self {
|
||||
mode: PublisherMode::JetStream(jetstream::new(client)),
|
||||
subject_prefix,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventPublisher for NatsEventPublisher {
|
||||
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
let subject = event_to_subject(&self.subject_prefix, event);
|
||||
let payload = serde_json::to_vec(&NatsEventPayload::from(event))
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
match &self.mode {
|
||||
PublisherMode::Core(client) => client
|
||||
.publish(subject, payload.into())
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
|
||||
|
||||
PublisherMode::JetStream(js) => js
|
||||
.publish(subject, payload.into())
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn _assert_send_sync() {
|
||||
fn check<T: Send + Sync>() {}
|
||||
check::<NatsEventPublisher>();
|
||||
}
|
||||
104
crates/adapters/nats/src/relay.rs
Normal file
104
crates/adapters/nats/src/relay.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use async_nats::{Client, HeaderMap, header::NATS_MESSAGE_ID, jetstream};
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::{DomainEvent, EventId},
|
||||
ports::EventRelaySink,
|
||||
};
|
||||
use event_payload::EventPayload;
|
||||
|
||||
use crate::{payload::NatsEventMessage, subject::event_to_subject};
|
||||
|
||||
/// What goes on the wire for one outbox row. Built purely from the row, so
|
||||
/// relaying it again (after a crash between publish and mark-done) yields the
|
||||
/// same `Nats-Msg-Id` and JetStream drops the duplicate.
|
||||
pub(crate) struct RelayMessage {
|
||||
pub subject: String,
|
||||
pub payload: Vec<u8>,
|
||||
/// `None` for rows queued before identities existed: those get no dedup.
|
||||
pub msg_id: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn relay_message(
|
||||
prefix: &str,
|
||||
id: Option<&EventId>,
|
||||
event: &DomainEvent,
|
||||
) -> Result<RelayMessage, DomainError> {
|
||||
let msg_id = id.map(|id| id.value().to_string());
|
||||
let payload = serde_json::to_vec(&NatsEventMessage {
|
||||
event_id: msg_id.clone(),
|
||||
payload: EventPayload::from(event),
|
||||
})
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(RelayMessage {
|
||||
subject: event_to_subject(prefix, event),
|
||||
payload,
|
||||
msg_id,
|
||||
})
|
||||
}
|
||||
|
||||
enum Transport {
|
||||
Core(Client),
|
||||
JetStream(jetstream::Context),
|
||||
}
|
||||
|
||||
/// Publishes outbox rows to NATS. JetStream mode waits for the broker ack and
|
||||
/// sets `Nats-Msg-Id`; core mode is fire-and-forget with no broker-side dedup.
|
||||
pub struct NatsRelaySink {
|
||||
transport: Transport,
|
||||
subject_prefix: String,
|
||||
}
|
||||
|
||||
impl NatsRelaySink {
|
||||
pub fn new_core(client: Client, subject_prefix: String) -> Self {
|
||||
Self {
|
||||
transport: Transport::Core(client),
|
||||
subject_prefix,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_jetstream(client: Client, subject_prefix: String) -> Self {
|
||||
Self {
|
||||
transport: Transport::JetStream(jetstream::new(client)),
|
||||
subject_prefix,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn infra(e: impl ToString) -> DomainError {
|
||||
DomainError::InfrastructureError(e.to_string())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventRelaySink for NatsRelaySink {
|
||||
async fn relay(&self, id: Option<&EventId>, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
let message = relay_message(&self.subject_prefix, id, event)?;
|
||||
match &self.transport {
|
||||
Transport::Core(client) => client
|
||||
.publish(message.subject, message.payload.into())
|
||||
.await
|
||||
.map_err(infra),
|
||||
Transport::JetStream(js) => {
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Some(msg_id) = &message.msg_id {
|
||||
headers.insert(NATS_MESSAGE_ID, msg_id.as_str());
|
||||
}
|
||||
js.publish_with_headers(message.subject, headers, message.payload.into())
|
||||
.await
|
||||
.map_err(infra)?
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(infra)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn _assert_send_sync() {
|
||||
fn check<T: Send + Sync>() {}
|
||||
check::<NatsRelaySink>();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/relay.rs"]
|
||||
mod tests;
|
||||
46
crates/adapters/nats/src/tests/consumer.rs
Normal file
46
crates/adapters/nats/src/tests/consumer.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use super::*;
|
||||
use domain::value_objects::UserId;
|
||||
use event_payload::{EventMessage, EventPayload};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn payload() -> EventPayload {
|
||||
EventPayload::from(&DomainEvent::UserUpdated {
|
||||
user_id: UserId::from_uuid(Uuid::new_v4()),
|
||||
})
|
||||
}
|
||||
|
||||
fn encode(event_id: Option<&str>) -> Vec<u8> {
|
||||
serde_json::to_vec(&EventMessage {
|
||||
event_id: event_id.map(str::to_owned),
|
||||
payload: payload(),
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_message_reads_the_event_id() {
|
||||
let id = Uuid::new_v4();
|
||||
let (decoded, event) = decode_message(&encode(Some(&id.to_string()))).unwrap();
|
||||
assert_eq!(decoded.unwrap().value(), id);
|
||||
assert!(matches!(event, DomainEvent::UserUpdated { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_message_accepts_a_legacy_payload_without_an_id() {
|
||||
let legacy = serde_json::to_vec(&payload()).unwrap();
|
||||
let (decoded, _) = decode_message(&legacy).unwrap();
|
||||
assert!(decoded.is_none());
|
||||
let (decoded, _) = decode_message(&encode(None)).unwrap();
|
||||
assert!(decoded.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_message_rejects_a_malformed_id() {
|
||||
let err = decode_message(&encode(Some("not-a-uuid"))).unwrap_err();
|
||||
assert!(err.to_string().contains("event_id"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_message_rejects_garbage() {
|
||||
assert!(decode_message(b"{not json").is_err());
|
||||
}
|
||||
37
crates/adapters/nats/src/tests/relay.rs
Normal file
37
crates/adapters/nats/src/tests/relay.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use super::*;
|
||||
use domain::value_objects::UserId;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn event() -> DomainEvent {
|
||||
DomainEvent::UserUpdated {
|
||||
user_id: UserId::from_uuid(Uuid::new_v4()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relaying_the_same_row_twice_sets_the_same_msg_id() {
|
||||
let id = EventId::new();
|
||||
let event = event();
|
||||
let first = relay_message("p", Some(&id), &event).unwrap();
|
||||
let second = relay_message("p", Some(&id), &event).unwrap();
|
||||
assert_eq!(first.msg_id, Some(id.value().to_string()));
|
||||
assert_eq!(first.msg_id, second.msg_id);
|
||||
assert_eq!(first.subject, "p.user.updated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_legacy_row_without_an_id_has_no_msg_id() {
|
||||
let message = relay_message("p", None, &event()).unwrap();
|
||||
assert_eq!(message.msg_id, None);
|
||||
let decoded: serde_json::Value = serde_json::from_slice(&message.payload).unwrap();
|
||||
assert!(decoded.get("event_id").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_body_carries_the_event_id_for_the_consumer() {
|
||||
let id = EventId::new();
|
||||
let message = relay_message("p", Some(&id), &event()).unwrap();
|
||||
let decoded: serde_json::Value = serde_json::from_slice(&message.payload).unwrap();
|
||||
assert_eq!(decoded["event_id"], id.value().to_string());
|
||||
assert_eq!(decoded["type"], "UserUpdated");
|
||||
}
|
||||
Reference in New Issue
Block a user