feat: processed-event ledger, idempotent dispatch

This commit is contained in:
2026-10-26 00:17:28 +01:00
parent fa8e94817a
commit 5681d0c034
29 changed files with 610 additions and 24 deletions

View File

@@ -0,0 +1,2 @@
#[path = "jobs/processed_events_cleanup_test.rs"]
mod processed_events_cleanup_test;

View File

@@ -0,0 +1,35 @@
use application::jobs::{PROCESSED_EVENTS_RETENTION_DAYS, ProcessedEventsCleanupJob};
use domain::{events::EventId, ports::PeriodicJob, testing::InMemoryStore};
#[tokio::test]
async fn removes_only_rows_older_than_the_retention_window() {
let store = InMemoryStore::new();
let now = chrono::Utc::now().naive_utc();
let stale = EventId::new();
let fresh = EventId::new();
store.seed_processed_event(
&stale,
"h",
now - chrono::Duration::days(PROCESSED_EVENTS_RETENTION_DAYS + 1),
);
store.seed_processed_event(
&fresh,
"h",
now - chrono::Duration::days(PROCESSED_EVENTS_RETENTION_DAYS - 1),
);
ProcessedEventsCleanupJob::new(store.clone())
.run()
.await
.unwrap();
use domain::ports::ProcessedEventLedger;
assert!(!store.has_processed(&stale, "h").await.unwrap());
assert!(store.has_processed(&fresh, "h").await.unwrap());
}
#[test]
fn runs_daily() {
let job = ProcessedEventsCleanupJob::new(InMemoryStore::new());
assert_eq!(job.interval(), std::time::Duration::from_secs(86_400));
}

View File

@@ -1,6 +1,6 @@
use application::worker::*;
use async_trait::async_trait;
use domain::testing::NoopAckHandle;
use domain::testing::{InMemoryStore, NoopAckHandle};
use domain::value_objects::{ExternalMetadataId, MovieId};
use domain::{
errors::DomainError,
@@ -10,7 +10,7 @@ use domain::{
events::EventEnvelope,
ports::{EventConsumer, EventHandler},
};
use futures::{stream, stream::BoxStream};
use futures::{StreamExt as _, stream, stream::BoxStream};
use std::sync::{Arc, Mutex};
struct TrackingAck {
@@ -52,6 +52,9 @@ struct RecordingHandler {
#[async_trait]
impl EventHandler for RecordingHandler {
fn name(&self) -> &'static str {
"recording"
}
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
let label = match event {
DomainEvent::MovieDiscovered { .. } => "movie_discovered",
@@ -109,9 +112,14 @@ async fn dispatches_to_all_handlers() {
calls: Arc::clone(&calls),
};
WorkerService::new(Arc::new(consumer), vec![Arc::new(handler)])
.run(tokio::sync::watch::channel(false).1)
.await;
WorkerService::new(
Arc::new(consumer),
vec![Arc::new(handler)],
InMemoryStore::new(),
)
.unwrap()
.run(tokio::sync::watch::channel(false).1)
.await;
assert_eq!(*calls.lock().unwrap(), vec!["movie_discovered"]);
}
@@ -147,7 +155,8 @@ async fn acks_when_all_handlers_succeed() {
nacked: Arc::clone(&nacked),
};
WorkerService::new(Arc::new(consumer), vec![])
WorkerService::new(Arc::new(consumer), vec![], InMemoryStore::new())
.unwrap()
.run(tokio::sync::watch::channel(false).1)
.await;
@@ -181,6 +190,9 @@ async fn nacks_on_transient_handler_failure() {
struct TransientHandler;
#[async_trait]
impl EventHandler for TransientHandler {
fn name(&self) -> &'static str {
"transient"
}
async fn handle(&self, _: &DomainEvent) -> Result<(), DomainError> {
Err(DomainError::InfrastructureError("network timeout".into()))
}
@@ -193,7 +205,9 @@ async fn nacks_on_transient_handler_failure() {
nacked: Arc::clone(&nacked),
}),
vec![Arc::new(TransientHandler)],
InMemoryStore::new(),
)
.unwrap()
.run(tokio::sync::watch::channel(false).1)
.await;
@@ -227,6 +241,9 @@ async fn acks_on_permanent_handler_failure() {
struct PermanentHandler;
#[async_trait]
impl EventHandler for PermanentHandler {
fn name(&self) -> &'static str {
"permanent"
}
async fn handle(&self, _: &DomainEvent) -> Result<(), DomainError> {
Err(DomainError::NotFound("movie not on tmdb".into()))
}
@@ -239,7 +256,9 @@ async fn acks_on_permanent_handler_failure() {
nacked: Arc::clone(&nacked),
}),
vec![Arc::new(PermanentHandler)],
InMemoryStore::new(),
)
.unwrap()
.run(tokio::sync::watch::channel(false).1)
.await;
@@ -276,6 +295,9 @@ async fn nacks_if_any_handler_is_transient() {
struct OkHandler;
#[async_trait]
impl EventHandler for OkHandler {
fn name(&self) -> &'static str {
"ok"
}
async fn handle(&self, _: &DomainEvent) -> Result<(), DomainError> {
Ok(())
}
@@ -283,6 +305,9 @@ async fn nacks_if_any_handler_is_transient() {
struct TransientHandler;
#[async_trait]
impl EventHandler for TransientHandler {
fn name(&self) -> &'static str {
"transient"
}
async fn handle(&self, _: &DomainEvent) -> Result<(), DomainError> {
Err(DomainError::InfrastructureError("db gone".into()))
}
@@ -295,7 +320,9 @@ async fn nacks_if_any_handler_is_transient() {
nacked: Arc::clone(&nacked),
}),
vec![Arc::new(OkHandler), Arc::new(TransientHandler)],
InMemoryStore::new(),
)
.unwrap()
.run(tokio::sync::watch::channel(false).1)
.await;
@@ -308,3 +335,145 @@ async fn nacks_if_any_handler_is_transient() {
"should nack when any handler is transient"
);
}
// ── idempotent dispatch ───────────────────────────────────────────────────────
struct CountingHandler {
name: &'static str,
calls: Arc<Mutex<u32>>,
fail_transient_times: Mutex<u32>,
}
impl CountingHandler {
fn new(name: &'static str, fail_transient_times: u32) -> (Arc<Self>, Arc<Mutex<u32>>) {
let calls = Arc::new(Mutex::new(0));
(
Arc::new(Self {
name,
calls: Arc::clone(&calls),
fail_transient_times: Mutex::new(fail_transient_times),
}),
calls,
)
}
}
#[async_trait]
impl EventHandler for CountingHandler {
fn name(&self) -> &'static str {
self.name
}
async fn handle(&self, _: &DomainEvent) -> Result<(), DomainError> {
*self.calls.lock().unwrap() += 1;
let mut left = self.fail_transient_times.lock().unwrap();
if *left > 0 {
*left -= 1;
return Err(DomainError::InfrastructureError("flaky".into()));
}
Ok(())
}
}
/// Delivers the same event (same id) `times` times, recording ack/nack per delivery.
struct RedeliveringConsumer {
id: Option<domain::events::EventId>,
event: DomainEvent,
times: usize,
nacks: Arc<Mutex<u32>>,
}
struct CountNack(Arc<Mutex<u32>>);
#[async_trait]
impl AckHandle for CountNack {
async fn ack(&self) -> Result<(), DomainError> {
Ok(())
}
async fn nack(&self) -> Result<(), DomainError> {
*self.0.lock().unwrap() += 1;
Ok(())
}
}
impl EventConsumer for RedeliveringConsumer {
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
let nacks = Arc::clone(&self.nacks);
let id = self.id;
let event = self.event.clone();
Box::pin(stream::iter(0..self.times).then(move |_| {
let envelope =
EventEnvelope::new(id, event.clone(), Box::new(CountNack(Arc::clone(&nacks))));
async move {
// Let the previous delivery's handlers finish first: a real
// broker redelivers only after the nack.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
Ok(envelope)
}
}))
}
}
#[tokio::test]
async fn a_redelivered_event_skips_handlers_that_already_succeeded() {
let (first, first_calls) = CountingHandler::new("first", 0);
let (second, second_calls) = CountingHandler::new("second", 1);
let nacks = Arc::new(Mutex::new(0));
let consumer = RedeliveringConsumer {
id: Some(domain::events::EventId::new()),
event: movie_discovered(),
times: 2,
nacks: Arc::clone(&nacks),
};
WorkerService::new(
Arc::new(consumer),
vec![first, second],
InMemoryStore::new(),
)
.unwrap()
.run(tokio::sync::watch::channel(false).1)
.await;
assert_eq!(*nacks.lock().unwrap(), 1, "only the first delivery nacks");
assert_eq!(
*first_calls.lock().unwrap(),
1,
"first ran once, skipped on redelivery"
);
assert_eq!(
*second_calls.lock().unwrap(),
2,
"second retried and succeeded"
);
}
#[tokio::test]
async fn events_without_id_run_every_time() {
let (handler, calls) = CountingHandler::new("only", 0);
let consumer = RedeliveringConsumer {
id: None,
event: movie_discovered(),
times: 2,
nacks: Arc::new(Mutex::new(0)),
};
WorkerService::new(Arc::new(consumer), vec![handler], InMemoryStore::new())
.unwrap()
.run(tokio::sync::watch::channel(false).1)
.await;
assert_eq!(*calls.lock().unwrap(), 2);
}
#[tokio::test]
async fn duplicate_handler_names_are_rejected() {
let (a, _) = CountingHandler::new("same", 0);
let (b, _) = CountingHandler::new("same", 0);
let result = WorkerService::new(
Arc::new(VecConsumer { events: vec![] }),
vec![a, b],
InMemoryStore::new(),
);
let err = result.err().expect("duplicate names must be rejected");
assert!(err.to_string().contains("same"), "{err}");
}