Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
@@ -11,48 +11,32 @@ pub struct NatsConfig {
|
||||
pub subject_prefix: String,
|
||||
pub stream_name: String,
|
||||
pub consumer_name: String,
|
||||
pub max_deliver: i64,
|
||||
}
|
||||
|
||||
impl NatsConfig {
|
||||
pub fn from_env() -> anyhow::Result<Self> {
|
||||
Self::from_vars(
|
||||
std::env::var("NATS_URL").ok().as_deref(),
|
||||
std::env::var("NATS_MODE").ok().as_deref(),
|
||||
std::env::var("NATS_SUBJECT_PREFIX").ok().as_deref(),
|
||||
std::env::var("NATS_STREAM_NAME").ok().as_deref(),
|
||||
std::env::var("NATS_CONSUMER_NAME").ok().as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn from_vars(
|
||||
url: Option<&str>,
|
||||
mode: Option<&str>,
|
||||
subject_prefix: Option<&str>,
|
||||
stream_name: Option<&str>,
|
||||
consumer_name: Option<&str>,
|
||||
pub fn from_config(
|
||||
config: &::config::NatsConfig,
|
||||
events: &::config::EventsConfig,
|
||||
) -> anyhow::Result<Self> {
|
||||
let url = url.ok_or_else(|| anyhow::anyhow!("NATS_URL is not set"))?;
|
||||
let url = config
|
||||
.url
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("NATS_URL is not set"))?;
|
||||
|
||||
let mode = match mode.unwrap_or("jetstream") {
|
||||
let mode = match config.mode.as_str() {
|
||||
"core" => NatsMode::Core,
|
||||
"jetstream" => NatsMode::JetStream,
|
||||
other => anyhow::bail!("unknown NATS_MODE: {other}"),
|
||||
};
|
||||
|
||||
let subject_prefix = subject_prefix.unwrap_or("movies-diary.events").to_string();
|
||||
let stream_name = stream_name.unwrap_or("MOVIES_DIARY_EVENTS").to_string();
|
||||
let consumer_name = consumer_name.unwrap_or("worker").to_string();
|
||||
|
||||
Ok(Self {
|
||||
url: url.to_string(),
|
||||
url,
|
||||
mode,
|
||||
subject_prefix,
|
||||
stream_name,
|
||||
consumer_name,
|
||||
subject_prefix: config.subject_prefix.clone(),
|
||||
stream_name: config.stream_name.clone(),
|
||||
consumer_name: config.consumer_name.clone(),
|
||||
max_deliver: i64::from(events.queue_max_attempts),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/config.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -5,7 +5,7 @@ use async_nats::{
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::{AckHandle, DomainEvent, EventEnvelope},
|
||||
events::{AckHandle, DomainEvent, EventEnvelope, EventId, retry_backoff},
|
||||
ports::EventConsumer,
|
||||
};
|
||||
use futures::{
|
||||
@@ -15,12 +15,26 @@ use futures::{
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
|
||||
use crate::{config::NatsConfig, payload::NatsEventPayload, subject::consumer_subject_filter};
|
||||
use crate::{config::NatsConfig, payload::NatsEventMessage, subject::consumer_subject_filter};
|
||||
|
||||
// ── JetStream ack handle ─────────────────────────────────────────────────────
|
||||
|
||||
struct NatsJetStreamAckHandle {
|
||||
message: async_nats::jetstream::Message,
|
||||
event_id: Option<EventId>,
|
||||
max_deliver: i64,
|
||||
}
|
||||
|
||||
struct NakPlan {
|
||||
delay: std::time::Duration,
|
||||
exhausted: bool,
|
||||
}
|
||||
|
||||
fn nak_plan(delivered: i64, max_deliver: i64) -> NakPlan {
|
||||
NakPlan {
|
||||
delay: retry_backoff(u32::try_from(delivered.max(0)).unwrap_or(u32::MAX)),
|
||||
exhausted: max_deliver > 0 && delivered >= max_deliver,
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -38,13 +52,36 @@ impl AckHandle for NatsJetStreamAckHandle {
|
||||
}
|
||||
|
||||
async fn nack(&self) -> Result<(), DomainError> {
|
||||
tracing::debug!(
|
||||
"negatively acknowledging message with sequence {}",
|
||||
self.message.info().unwrap().stream_sequence
|
||||
);
|
||||
let info = self
|
||||
.message
|
||||
.info()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
let plan = nak_plan(info.delivered, self.max_deliver);
|
||||
if plan.exhausted {
|
||||
tracing::error!(
|
||||
event_id = ?self.event_id.map(|id| id.value()),
|
||||
sequence = info.stream_sequence,
|
||||
delivered = info.delivered,
|
||||
"event exhausted max deliveries; it remains in the stream — see JetStream max-deliveries advisory"
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
sequence = info.stream_sequence,
|
||||
delivered = info.delivered,
|
||||
"nak with backoff {:?}",
|
||||
plan.delay
|
||||
);
|
||||
}
|
||||
|
||||
self.message
|
||||
.ack_with(AckKind::Nak(None))
|
||||
.ack_with(AckKind::Nak(Some(plan.delay)))
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
}
|
||||
|
||||
async fn defer(&self, delay: std::time::Duration) -> Result<(), DomainError> {
|
||||
self.message
|
||||
.ack_with(AckKind::Nak(Some(delay)))
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
}
|
||||
@@ -62,25 +99,47 @@ impl AckHandle for NoopAck {
|
||||
async fn nack(&self) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn defer(&self, _: std::time::Duration) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Envelope construction helpers ────────────────────────────────────────────
|
||||
|
||||
fn decode_js(msg: async_nats::jetstream::Message) -> Result<EventEnvelope, DomainError> {
|
||||
let payload: NatsEventPayload = serde_json::from_slice(&msg.payload)
|
||||
fn decode_message(bytes: &[u8]) -> Result<(Option<EventId>, DomainEvent), DomainError> {
|
||||
let message: NatsEventMessage = serde_json::from_slice(bytes)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("deserialize: {e}")))?;
|
||||
let event = DomainEvent::try_from(payload)?;
|
||||
let id = message
|
||||
.event_id
|
||||
.as_deref()
|
||||
.map(|s| {
|
||||
uuid::Uuid::parse_str(s)
|
||||
.map(EventId::from_uuid)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("event_id: {e}")))
|
||||
})
|
||||
.transpose()?;
|
||||
Ok((id, DomainEvent::try_from(message.payload)?))
|
||||
}
|
||||
|
||||
fn decode_js(
|
||||
msg: async_nats::jetstream::Message,
|
||||
max_deliver: i64,
|
||||
) -> Result<EventEnvelope, DomainError> {
|
||||
let (id, event) = decode_message(&msg.payload)?;
|
||||
Ok(EventEnvelope::new(
|
||||
id,
|
||||
event,
|
||||
Box::new(NatsJetStreamAckHandle { message: msg }),
|
||||
Box::new(NatsJetStreamAckHandle {
|
||||
message: msg,
|
||||
event_id: id,
|
||||
max_deliver,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
fn decode_core(msg: async_nats::Message) -> Result<EventEnvelope, DomainError> {
|
||||
let payload: NatsEventPayload = serde_json::from_slice(&msg.payload)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("deserialize: {e}")))?;
|
||||
let event = DomainEvent::try_from(payload)?;
|
||||
Ok(EventEnvelope::new(event, Box::new(NoopAck)))
|
||||
let (id, event) = decode_message(&msg.payload)?;
|
||||
Ok(EventEnvelope::new(id, event, Box::new(NoopAck)))
|
||||
}
|
||||
|
||||
// ── Channel-bridge shared by both consumers ──────────────────────────────────
|
||||
@@ -94,6 +153,31 @@ fn consume_from_rx(rx: EnvelopeRx) -> BoxStream<'static, Result<EventEnvelope, D
|
||||
}))
|
||||
}
|
||||
|
||||
const DEDUP_WINDOW: std::time::Duration = std::time::Duration::from_secs(60 * 60);
|
||||
|
||||
const ACK_WAIT: std::time::Duration = std::time::Duration::from_secs(600);
|
||||
|
||||
fn stream_config_update(existing: &StreamConfig) -> Option<StreamConfig> {
|
||||
(existing.duplicate_window != DEDUP_WINDOW).then(|| StreamConfig {
|
||||
duplicate_window: DEDUP_WINDOW,
|
||||
..existing.clone()
|
||||
})
|
||||
}
|
||||
|
||||
fn consumer_config_update(
|
||||
existing: &jetstream::consumer::Config,
|
||||
desired_max_deliver: i64,
|
||||
) -> Option<jetstream::consumer::Config> {
|
||||
if existing.max_deliver == desired_max_deliver && existing.ack_wait == ACK_WAIT {
|
||||
return None;
|
||||
}
|
||||
Some(jetstream::consumer::Config {
|
||||
ack_wait: ACK_WAIT,
|
||||
max_deliver: desired_max_deliver,
|
||||
..existing.clone()
|
||||
})
|
||||
}
|
||||
|
||||
// ── JetStream consumer ────────────────────────────────────────────────────────
|
||||
|
||||
pub struct NatsJetStreamConsumer {
|
||||
@@ -109,9 +193,25 @@ impl NatsJetStreamConsumer {
|
||||
name: cfg.stream_name.clone(),
|
||||
subjects: vec![consumer_subject_filter(&cfg.subject_prefix)],
|
||||
max_messages: 100_000,
|
||||
duplicate_window: DEDUP_WINDOW,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
if let Some(updated) = stream_config_update(&stream.cached_info().config) {
|
||||
tracing::warn!(
|
||||
stream = %cfg.stream_name,
|
||||
"existing JetStream stream has a different duplicate window; updating to {:?}",
|
||||
DEDUP_WINDOW
|
||||
);
|
||||
if let Err(e) = js.update_stream(updated).await {
|
||||
tracing::warn!(
|
||||
"could not update stream {}: {e}; continuing. Fix manually: nats stream edit {} --dupe-window {}s",
|
||||
cfg.stream_name,
|
||||
cfg.stream_name,
|
||||
DEDUP_WINDOW.as_secs()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let subject_filter = consumer_subject_filter(&cfg.subject_prefix);
|
||||
let consumer = stream
|
||||
@@ -120,14 +220,34 @@ impl NatsJetStreamConsumer {
|
||||
pull::Config {
|
||||
durable_name: Some(cfg.consumer_name.clone()),
|
||||
filter_subject: subject_filter,
|
||||
ack_wait: std::time::Duration::from_secs(600),
|
||||
ack_wait: ACK_WAIT,
|
||||
max_deliver: cfg.max_deliver,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if let Some(updated) =
|
||||
consumer_config_update(&consumer.cached_info().config, cfg.max_deliver)
|
||||
{
|
||||
tracing::warn!(
|
||||
consumer = %cfg.consumer_name,
|
||||
"existing JetStream consumer has different max_deliver/ack_wait; updating to max_deliver={}",
|
||||
cfg.max_deliver
|
||||
);
|
||||
if let Err(e) = stream.update_consumer(updated).await {
|
||||
tracing::warn!(
|
||||
"could not update consumer {}: {e}; continuing. Fix manually: nats consumer edit {} {} --max-deliver {}",
|
||||
cfg.consumer_name,
|
||||
cfg.stream_name,
|
||||
cfg.consumer_name,
|
||||
cfg.max_deliver
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let (tx, rx) = mpsc::channel(128);
|
||||
|
||||
let max_deliver = cfg.max_deliver;
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let mut messages = match consumer.messages().await {
|
||||
@@ -144,7 +264,7 @@ impl NatsJetStreamConsumer {
|
||||
while let Some(result) = messages.next().await {
|
||||
let envelope = result
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
.and_then(decode_js);
|
||||
.and_then(|m| decode_js(m, max_deliver));
|
||||
|
||||
if tx.send(envelope).await.is_err() {
|
||||
tracing::info!("consumer channel closed, stopping message processing");
|
||||
@@ -211,3 +331,7 @@ fn _assert_send_sync() {
|
||||
check::<NatsJetStreamConsumer>();
|
||||
check::<NatsCoreConsumer>();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/consumer.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,52 +1,38 @@
|
||||
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(
|
||||
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)),
|
||||
)
|
||||
}
|
||||
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 +1 @@
|
||||
pub use event_payload::EventPayload as NatsEventPayload;
|
||||
pub use event_payload::EventMessage as NatsEventMessage;
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
98
crates/adapters/nats/src/relay.rs
Normal file
98
crates/adapters/nats/src/relay.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
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};
|
||||
|
||||
pub(crate) struct RelayMessage {
|
||||
pub subject: String,
|
||||
pub payload: Vec<u8>,
|
||||
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),
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -1,30 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn errors_without_nats_url() {
|
||||
assert!(NatsConfig::from_vars(None, None, None, None, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_with_only_url() {
|
||||
let cfg = NatsConfig::from_vars(Some("nats://localhost:4222"), None, None, None, None).unwrap();
|
||||
assert_eq!(cfg.url, "nats://localhost:4222");
|
||||
assert_eq!(cfg.mode, NatsMode::JetStream);
|
||||
assert_eq!(cfg.subject_prefix, "movies-diary.events");
|
||||
assert_eq!(cfg.stream_name, "MOVIES_DIARY_EVENTS");
|
||||
assert_eq!(cfg.consumer_name, "worker");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_mode_parsed() {
|
||||
let cfg =
|
||||
NatsConfig::from_vars(Some("nats://test:4222"), Some("core"), None, None, None).unwrap();
|
||||
assert_eq!(cfg.mode, NatsMode::Core);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_mode_errors() {
|
||||
assert!(
|
||||
NatsConfig::from_vars(Some("nats://test:4222"), Some("kafka"), None, None, None).is_err()
|
||||
);
|
||||
}
|
||||
108
crates/adapters/nats/src/tests/consumer.rs
Normal file
108
crates/adapters/nats/src/tests/consumer.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_with_the_default_window_is_updated_and_keeps_other_fields() {
|
||||
let existing = StreamConfig {
|
||||
name: "S".into(),
|
||||
max_messages: 42,
|
||||
duplicate_window: std::time::Duration::from_secs(120),
|
||||
..Default::default()
|
||||
};
|
||||
let updated = stream_config_update(&existing).unwrap();
|
||||
assert_eq!(updated.duplicate_window, DEDUP_WINDOW);
|
||||
assert_eq!(updated.max_messages, 42);
|
||||
assert_eq!(updated.name, "S");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_with_the_right_window_is_left_alone() {
|
||||
let existing = StreamConfig {
|
||||
duplicate_window: DEDUP_WINDOW,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(stream_config_update(&existing).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumer_max_deliver_drift_is_updated() {
|
||||
let existing = jetstream::consumer::Config {
|
||||
durable_name: Some("c".into()),
|
||||
filter_subject: "a.>".into(),
|
||||
max_deliver: -1,
|
||||
ack_wait: ACK_WAIT,
|
||||
max_ack_pending: 77,
|
||||
description: Some("operator".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let updated = consumer_config_update(&existing, 5).unwrap();
|
||||
assert_eq!(updated.max_deliver, 5);
|
||||
assert_eq!(updated.durable_name.as_deref(), Some("c"));
|
||||
assert_eq!(updated.filter_subject, "a.>");
|
||||
assert_eq!(updated.max_ack_pending, 77);
|
||||
assert_eq!(updated.description.as_deref(), Some("operator"));
|
||||
let same = jetstream::consumer::Config {
|
||||
max_deliver: 5,
|
||||
..existing
|
||||
};
|
||||
assert!(consumer_config_update(&same, 5).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nak_delay_follows_the_shared_backoff_by_delivery_count() {
|
||||
let secs = |d| nak_plan(d, 5).delay.as_secs();
|
||||
assert_eq!((secs(1), secs(2), secs(3), secs(4)), (10, 20, 40, 80));
|
||||
assert_eq!(nak_plan(50, 100).delay.as_secs(), 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_final_delivery_is_exhausted() {
|
||||
assert!(!nak_plan(4, 5).exhausted);
|
||||
assert!(nak_plan(5, 5).exhausted);
|
||||
assert!(nak_plan(6, 5).exhausted);
|
||||
assert!(!nak_plan(1_000, -1).exhausted, "-1 means unlimited");
|
||||
}
|
||||
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