feat: processed-event ledger, idempotent dispatch
This commit is contained in:
@@ -23,5 +23,8 @@ pub trait EventConsumer: Send + Sync {
|
||||
|
||||
#[async_trait]
|
||||
pub trait EventHandler: Send + Sync {
|
||||
/// Stable, unique snake_case identity. It keys the processed-event ledger,
|
||||
/// so renaming a handler makes it re-run events still inside the retention window.
|
||||
fn name(&self) -> &'static str;
|
||||
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod media_server;
|
||||
pub mod movie;
|
||||
pub mod noop;
|
||||
pub mod person;
|
||||
pub mod processed_events;
|
||||
pub mod rss;
|
||||
pub mod search;
|
||||
pub mod social;
|
||||
@@ -35,6 +36,7 @@ pub use jobs::*;
|
||||
pub use media_server::*;
|
||||
pub use movie::*;
|
||||
pub use person::*;
|
||||
pub use processed_events::*;
|
||||
pub use rss::*;
|
||||
pub use search::*;
|
||||
pub use social::*;
|
||||
|
||||
16
crates/domain/src/ports/processed_events.rs
Normal file
16
crates/domain/src/ports/processed_events.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{errors::DomainError, events::EventId};
|
||||
|
||||
/// Records which handler has already completed which event, so a redelivered
|
||||
/// event does not re-run handlers that succeeded.
|
||||
#[async_trait]
|
||||
pub trait ProcessedEventLedger: Send + Sync {
|
||||
async fn has_processed(&self, id: &EventId, handler: &str) -> Result<bool, DomainError>;
|
||||
|
||||
/// Idempotent: recording an existing pair is not an error.
|
||||
async fn record_processed(&self, id: &EventId, handler: &str) -> Result<(), DomainError>;
|
||||
|
||||
/// Returns the number of rows removed.
|
||||
async fn delete_older_than(&self, cutoff: chrono::NaiveDateTime) -> Result<u64, DomainError>;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ mod store;
|
||||
mod store_goal;
|
||||
mod store_import;
|
||||
mod store_movie;
|
||||
mod store_processed_events;
|
||||
mod store_review;
|
||||
mod store_session;
|
||||
mod store_social;
|
||||
|
||||
@@ -64,6 +64,7 @@ pub struct InMemoryStore {
|
||||
pub(super) import_profiles: RwLock<HashMap<Uuid, ImportProfile>>,
|
||||
pub(super) follows: RwLock<Vec<(Uuid, SocialIdentity, FollowState)>>,
|
||||
pub(super) blocked: RwLock<Vec<(Uuid, SocialIdentity)>>,
|
||||
pub(super) processed_events: RwLock<HashMap<(Uuid, String), chrono::NaiveDateTime>>,
|
||||
}
|
||||
|
||||
impl InMemoryStore {
|
||||
@@ -85,6 +86,7 @@ impl InMemoryStore {
|
||||
import_profiles: RwLock::default(),
|
||||
follows: RwLock::default(),
|
||||
blocked: RwLock::default(),
|
||||
processed_events: RwLock::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -122,6 +124,23 @@ impl InMemoryStore {
|
||||
self.import_sessions.read().unwrap().len()
|
||||
}
|
||||
|
||||
pub fn processed_event_count(&self) -> usize {
|
||||
self.processed_events.read().unwrap().len()
|
||||
}
|
||||
|
||||
/// Inserts a ledger row with an explicit timestamp (for retention tests).
|
||||
pub fn seed_processed_event(
|
||||
&self,
|
||||
id: &crate::events::EventId,
|
||||
handler: &str,
|
||||
processed_at: chrono::NaiveDateTime,
|
||||
) {
|
||||
self.processed_events
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert((id.value(), handler.to_owned()), processed_at);
|
||||
}
|
||||
|
||||
pub fn refresh_session_count(&self) -> usize {
|
||||
self.refresh_sessions.read().unwrap().len()
|
||||
}
|
||||
|
||||
33
crates/domain/src/testing/fakes/store_processed_events.rs
Normal file
33
crates/domain/src/testing/fakes/store_processed_events.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{errors::DomainError, events::EventId, ports::ProcessedEventLedger};
|
||||
|
||||
use super::InMemoryStore;
|
||||
|
||||
#[async_trait]
|
||||
impl ProcessedEventLedger for InMemoryStore {
|
||||
async fn has_processed(&self, id: &EventId, handler: &str) -> Result<bool, DomainError> {
|
||||
Ok(self
|
||||
.processed_events
|
||||
.read()
|
||||
.unwrap()
|
||||
.contains_key(&(id.value(), handler.to_owned())))
|
||||
}
|
||||
|
||||
/// `(event_id, handler)` is the primary key; a repeat keeps the first row.
|
||||
async fn record_processed(&self, id: &EventId, handler: &str) -> Result<(), DomainError> {
|
||||
self.processed_events
|
||||
.write()
|
||||
.unwrap()
|
||||
.entry((id.value(), handler.to_owned()))
|
||||
.or_insert_with(|| chrono::Utc::now().naive_utc());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_older_than(&self, cutoff: chrono::NaiveDateTime) -> Result<u64, DomainError> {
|
||||
let mut rows = self.processed_events.write().unwrap();
|
||||
let before = rows.len();
|
||||
rows.retain(|_, at| *at >= cutoff);
|
||||
Ok((before - rows.len()) as u64)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user