179 lines
6.9 KiB
Rust
179 lines
6.9 KiB
Rust
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use domain::{
|
|
errors::DomainError,
|
|
events::EventEnvelope,
|
|
ports::{Begin, EventConsumer, EventHandler, ProcessedEventLedger},
|
|
};
|
|
use futures::StreamExt;
|
|
use tokio::sync::Semaphore;
|
|
|
|
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 {
|
|
/// 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();
|
|
|
|
loop {
|
|
tokio::select! {
|
|
biased;
|
|
_ = shutdown.changed() => {
|
|
tracing::info!("shutdown signal received, stopping event consumption");
|
|
break;
|
|
}
|
|
item = stream.next() => {
|
|
match item {
|
|
Some(Ok(envelope)) => {
|
|
tracing::info!(event = ?envelope.event, "received event");
|
|
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, ledger, envelope).await;
|
|
drop(permit);
|
|
});
|
|
}
|
|
Some(Err(e)) => tracing::error!("event consumer error: {e}"),
|
|
None => break,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let in_flight = tasks.len();
|
|
if in_flight > 0 {
|
|
tracing::info!(in_flight, "draining in-flight tasks before shutdown");
|
|
}
|
|
while tasks.join_next().await.is_some() {}
|
|
tracing::info!("worker shut down gracefully");
|
|
}
|
|
}
|
|
|
|
/// Margin added to a claim's remaining lease before a blocked delivery is
|
|
/// retried, so the retry lands after the lease has lapsed rather than on it.
|
|
const DEFER_MARGIN: Duration = Duration::from_secs(5);
|
|
|
|
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;
|
|
// Longest wait among handlers blocked behind another delivery's live claim.
|
|
let mut blocked_for: Option<Duration> = None;
|
|
for handler in handlers.iter() {
|
|
let name = handler.name();
|
|
let mut claim = None;
|
|
if let Some(id) = &id {
|
|
match ledger.begin(id, name).await {
|
|
Ok(Begin::Run(token)) => claim = Some(token),
|
|
Ok(Begin::AlreadyDone) => {
|
|
tracing::debug!(handler = name, "already processed, skipping");
|
|
continue;
|
|
}
|
|
Ok(Begin::InProgressElsewhere { retry_after }) => {
|
|
tracing::debug!(handler = name, "running in another delivery, deferring");
|
|
blocked_for = Some(blocked_for.map_or(retry_after, |b| b.max(retry_after)));
|
|
continue;
|
|
}
|
|
Err(e) => {
|
|
// Running anyway could repeat a side effect; retry the delivery instead.
|
|
tracing::warn!(handler = name, "ledger claim failed, nacking delivery: {e}");
|
|
any_transient = true;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
let result = handler.handle(&envelope.event).await;
|
|
match &result {
|
|
Ok(()) => {
|
|
if let (Some(id), Some(token)) = (&id, &claim)
|
|
&& let Err(e) = ledger.complete(id, name, token).await
|
|
{
|
|
// The work is done; failing the delivery would only repeat it.
|
|
// The claim lapses after its lease and the handler may run once more.
|
|
tracing::warn!(handler = name, "completing ledger claim failed: {e}");
|
|
}
|
|
}
|
|
Err(e) => {
|
|
// Permanent failures are released too, not recorded: if a sibling
|
|
// handler's transient failure redelivers this event, a handler that
|
|
// failed permanently runs (and fails) again.
|
|
if let (Some(id), Some(token)) = (&id, &claim)
|
|
&& let Err(le) = ledger.release(id, name, token).await
|
|
{
|
|
tracing::warn!(handler = name, "releasing ledger claim failed: {le}");
|
|
}
|
|
if e.is_transient() {
|
|
tracing::warn!(
|
|
handler = name,
|
|
"transient handler error, nacking delivery (the backend logs if attempts are exhausted): {e}"
|
|
);
|
|
any_transient = true;
|
|
} else {
|
|
tracing::warn!(
|
|
handler = name,
|
|
"permanent handler error (not retrying): {e}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// A blocked handler cannot be retried before its claim is done or lapsed,
|
|
// so wait that long without spending a retry attempt; any transient failure
|
|
// in the same delivery is retried then too. A plain nack would burn the
|
|
// retry budget (and dead-letter) long before a crashed claim's lease ends.
|
|
if let Some(wait) = blocked_for {
|
|
if let Err(e) = envelope.defer(wait + DEFER_MARGIN).await {
|
|
tracing::error!("defer failed: {e}");
|
|
}
|
|
} else if any_transient {
|
|
if let Err(e) = envelope.nack().await {
|
|
tracing::error!("nack failed: {e}");
|
|
}
|
|
} else if let Err(e) = envelope.ack().await {
|
|
tracing::error!("ack failed: {e}");
|
|
}
|
|
}
|