refactor (v2): better arch

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-06-07 21:19:54 +02:00
parent 0753f3d256
commit 839308ec19
166 changed files with 8553 additions and 884 deletions

View 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))
}

View 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),
))
}

View 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(())
}
}