refactor (v2): better arch
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
104
crates/adapters/nats/src/consumer.rs
Normal file
104
crates/adapters/nats/src/consumer.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use async_nats::jetstream::{
|
||||
AckKind,
|
||||
consumer::{self, pull},
|
||||
};
|
||||
use futures::{StreamExt, future::BoxFuture, stream::BoxStream};
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::{DomainEvent, EventConsumer, EventEnvelope},
|
||||
};
|
||||
use event_payload::EventPayload;
|
||||
|
||||
pub struct NatsEventConsumer {
|
||||
consumer: Arc<consumer::Consumer<pull::Config>>,
|
||||
}
|
||||
|
||||
impl NatsEventConsumer {
|
||||
pub(crate) fn new(consumer: consumer::Consumer<pull::Config>) -> Self {
|
||||
Self {
|
||||
consumer: Arc::new(consumer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventConsumer for NatsEventConsumer {
|
||||
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
|
||||
let consumer = Arc::clone(&self.consumer);
|
||||
|
||||
Box::pin(async_stream::stream! {
|
||||
let mut messages = match consumer.messages().await {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
yield Err(DomainError::Infrastructure(
|
||||
format!("failed to open jetstream message stream: {e}")
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
while let Some(result) = messages.next().await {
|
||||
let msg = match result {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
yield Err(DomainError::Infrastructure(e.to_string()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Malformed messages are acked immediately to prevent infinite
|
||||
// redelivery of poison payloads that can never be processed.
|
||||
let payload = match EventPayload::from_json(&msg.payload) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::error!("unprocessable message payload, acking to discard: {e}");
|
||||
let _ = msg.ack().await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let event = match DomainEvent::try_from(payload) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::error!("invalid event payload, acking to discard: {e}");
|
||||
let _ = msg.ack().await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let delivered = msg.info().map(|i| i.delivered).unwrap_or(1);
|
||||
let nack_delay = backoff(delivered);
|
||||
|
||||
let msg = Arc::new(msg);
|
||||
let ack_msg = Arc::clone(&msg);
|
||||
let nack_msg = Arc::clone(&msg);
|
||||
|
||||
yield Ok(EventEnvelope::new(
|
||||
event,
|
||||
move || -> BoxFuture<'static, _> {
|
||||
Box::pin(async move {
|
||||
ack_msg.ack().await.map_err(|e| {
|
||||
DomainError::Infrastructure(format!("nats ack failed: {e}"))
|
||||
})
|
||||
})
|
||||
},
|
||||
move || -> BoxFuture<'static, _> {
|
||||
Box::pin(async move {
|
||||
nack_msg.ack_with(AckKind::Nak(Some(nack_delay))).await.map_err(|e| {
|
||||
DomainError::Infrastructure(format!("nats nack failed: {e}"))
|
||||
})
|
||||
})
|
||||
},
|
||||
));
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Exponential backoff capped at 5 minutes: 1s → 5s → 25s → 125s → 300s.
|
||||
fn backoff(delivered: i64) -> Duration {
|
||||
let exp = delivered.saturating_sub(1) as u32;
|
||||
Duration::from_secs(5u64.saturating_pow(exp).min(300))
|
||||
}
|
||||
92
crates/adapters/nats/src/lib.rs
Normal file
92
crates/adapters/nats/src/lib.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
pub mod consumer;
|
||||
pub mod publisher;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use async_nats::jetstream::{self, consumer as nats_consumer, consumer::pull};
|
||||
|
||||
use crate::{consumer::NatsEventConsumer, publisher::NatsEventPublisher};
|
||||
|
||||
// ── Subject routing ───────────────────────────────────────────────────────────
|
||||
|
||||
pub(crate) fn subject_for(event: &domain::events::DomainEvent) -> &'static str {
|
||||
use domain::events::DomainEvent;
|
||||
match event {
|
||||
DomainEvent::NoteCreated { .. } => "knotes.note.created",
|
||||
DomainEvent::NoteUpdated { .. } => "knotes.note.updated",
|
||||
DomainEvent::NoteDeleted { .. } => "knotes.note.deleted",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const SUBSCRIBE_SUBJECT: &str = "knotes.note.>";
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Configuration for the JetStream stream and durable pull consumer.
|
||||
///
|
||||
/// **Dead-letter queue**: after `max_deliver` failed attempts NATS stops
|
||||
/// redelivering and publishes an advisory to
|
||||
/// `$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.{stream}.{consumer}`.
|
||||
/// Subscribe to those with a monitoring consumer or NATS dashboard to
|
||||
/// observe dead messages.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JetStreamConfig {
|
||||
/// Name of the JetStream stream (created on first use if absent).
|
||||
pub stream_name: String,
|
||||
/// Durable consumer name — survives worker restarts.
|
||||
pub consumer_name: String,
|
||||
/// Maximum delivery attempts before the message is considered dead.
|
||||
pub max_deliver: i64,
|
||||
/// How long JetStream waits for an ack before redelivering.
|
||||
pub ack_wait: Duration,
|
||||
}
|
||||
|
||||
impl Default for JetStreamConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
stream_name: "KNOTES".into(),
|
||||
consumer_name: "knotes-worker".into(),
|
||||
max_deliver: 5,
|
||||
ack_wait: Duration::from_secs(30),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Setup ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Connect to NATS and initialise both the publisher and consumer.
|
||||
/// Creates the JetStream stream and durable pull consumer if they do not exist.
|
||||
pub async fn setup(
|
||||
url: &str,
|
||||
config: JetStreamConfig,
|
||||
) -> Result<(NatsEventPublisher, NatsEventConsumer), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let client = async_nats::connect(url).await?;
|
||||
let js = jetstream::new(client);
|
||||
|
||||
let stream = js
|
||||
.get_or_create_stream(jetstream::stream::Config {
|
||||
name: config.stream_name.clone(),
|
||||
subjects: vec![SUBSCRIBE_SUBJECT.into()],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
let nats_consumer: nats_consumer::Consumer<pull::Config> = stream
|
||||
.get_or_create_consumer(
|
||||
&config.consumer_name,
|
||||
pull::Config {
|
||||
durable_name: Some(config.consumer_name.clone()),
|
||||
ack_policy: jetstream::consumer::AckPolicy::Explicit,
|
||||
max_deliver: config.max_deliver,
|
||||
ack_wait: config.ack_wait,
|
||||
filter_subject: SUBSCRIBE_SUBJECT.into(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((
|
||||
NatsEventPublisher::new(js),
|
||||
NatsEventConsumer::new(nats_consumer),
|
||||
))
|
||||
}
|
||||
34
crates/adapters/nats/src/publisher.rs
Normal file
34
crates/adapters/nats/src/publisher.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use async_nats::jetstream;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::{DomainEvent, EventPublisher},
|
||||
};
|
||||
use event_payload::EventPayload;
|
||||
|
||||
use crate::subject_for;
|
||||
|
||||
pub struct NatsEventPublisher {
|
||||
js: jetstream::Context,
|
||||
}
|
||||
|
||||
impl NatsEventPublisher {
|
||||
pub(crate) fn new(js: jetstream::Context) -> Self {
|
||||
Self { js }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventPublisher for NatsEventPublisher {
|
||||
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
let bytes = EventPayload::from(event).to_json()?;
|
||||
self.js
|
||||
.publish(subject_for(event), bytes.into())
|
||||
.await
|
||||
.map_err(|e| DomainError::Infrastructure(format!("nats publish failed: {e}")))?
|
||||
.await
|
||||
.map_err(|e| DomainError::Infrastructure(format!("nats publish ack failed: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user