feat: processed-event ledger, idempotent dispatch
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::EventEnvelope,
|
||||
ports::{EventConsumer, EventHandler},
|
||||
ports::{EventConsumer, EventHandler, ProcessedEventLedger},
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use tokio::sync::Semaphore;
|
||||
@@ -12,20 +13,42 @@ const DEFAULT_CONCURRENCY: usize = 4;
|
||||
pub struct WorkerService {
|
||||
consumer: Arc<dyn EventConsumer>,
|
||||
handlers: Vec<Arc<dyn EventHandler>>,
|
||||
ledger: Arc<dyn ProcessedEventLedger>,
|
||||
semaphore: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
impl WorkerService {
|
||||
pub fn new(consumer: Arc<dyn EventConsumer>, handlers: Vec<Arc<dyn EventHandler>>) -> Self {
|
||||
Self {
|
||||
/// Fails if two handlers share a name: the name keys the processed-event
|
||||
/// ledger, so a collision would make one handler's success skip the other.
|
||||
pub fn new(
|
||||
consumer: Arc<dyn EventConsumer>,
|
||||
handlers: Vec<Arc<dyn EventHandler>>,
|
||||
ledger: Arc<dyn ProcessedEventLedger>,
|
||||
) -> Result<Self, DomainError> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for handler in &handlers {
|
||||
if !seen.insert(handler.name()) {
|
||||
return Err(DomainError::ValidationError(format!(
|
||||
"duplicate event handler name `{}`",
|
||||
handler.name()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
consumer,
|
||||
handlers,
|
||||
ledger,
|
||||
semaphore: Arc::new(Semaphore::new(DEFAULT_CONCURRENCY)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn handler_names(&self) -> Vec<&'static str> {
|
||||
self.handlers.iter().map(|h| h.name()).collect()
|
||||
}
|
||||
|
||||
pub async fn run(self, mut shutdown: tokio::sync::watch::Receiver<bool>) {
|
||||
let handlers = Arc::new(self.handlers);
|
||||
let ledger = self.ledger;
|
||||
let mut tasks = tokio::task::JoinSet::new();
|
||||
let mut stream = self.consumer.consume();
|
||||
|
||||
@@ -43,8 +66,9 @@ impl WorkerService {
|
||||
let permit = self.semaphore.clone().acquire_owned().await;
|
||||
let Ok(permit) = permit else { break };
|
||||
let h = Arc::clone(&handlers);
|
||||
let ledger = Arc::clone(&ledger);
|
||||
tasks.spawn(async move {
|
||||
dispatch(h, envelope).await;
|
||||
dispatch(h, ledger, envelope).await;
|
||||
drop(permit);
|
||||
});
|
||||
}
|
||||
@@ -64,15 +88,48 @@ impl WorkerService {
|
||||
}
|
||||
}
|
||||
|
||||
async fn dispatch(handlers: Arc<Vec<Arc<dyn EventHandler>>>, envelope: EventEnvelope) {
|
||||
async fn dispatch(
|
||||
handlers: Arc<Vec<Arc<dyn EventHandler>>>,
|
||||
ledger: Arc<dyn ProcessedEventLedger>,
|
||||
envelope: EventEnvelope,
|
||||
) {
|
||||
let id = envelope.id().copied();
|
||||
let mut any_transient = false;
|
||||
for handler in handlers.iter() {
|
||||
if let Err(e) = handler.handle(&envelope.event).await {
|
||||
if e.is_transient() {
|
||||
tracing::warn!("transient handler error, will retry: {e}");
|
||||
let name = handler.name();
|
||||
if let Some(id) = &id {
|
||||
match ledger.has_processed(id, name).await {
|
||||
Ok(true) => {
|
||||
tracing::debug!(handler = name, "already processed, skipping");
|
||||
continue;
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
// Running anyway could repeat a side effect; retry the delivery instead.
|
||||
tracing::warn!(handler = name, "ledger lookup failed, will retry: {e}");
|
||||
any_transient = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
match handler.handle(&envelope.event).await {
|
||||
Ok(()) => {
|
||||
if let Some(id) = &id
|
||||
&& let Err(e) = ledger.record_processed(id, name).await
|
||||
{
|
||||
// The work is done; failing the delivery would only repeat it.
|
||||
tracing::warn!(handler = name, "recording processed event failed: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) if e.is_transient() => {
|
||||
tracing::warn!(handler = name, "transient handler error, will retry: {e}");
|
||||
any_transient = true;
|
||||
} else {
|
||||
tracing::warn!("permanent handler error (not retrying): {e}");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
handler = name,
|
||||
"permanent handler error (not retrying): {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user