feat: processed-event ledger, idempotent dispatch
This commit is contained in:
@@ -55,6 +55,10 @@ impl ActivityPubEventHandler {
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for ActivityPubEventHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"activitypub"
|
||||
}
|
||||
|
||||
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
match event {
|
||||
DomainEvent::ReviewLogged {
|
||||
|
||||
@@ -31,6 +31,10 @@ impl ImageConversionHandler {
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for ImageConversionHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"image_conversion"
|
||||
}
|
||||
|
||||
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
let key = match event {
|
||||
DomainEvent::ImageStored { key } => key.clone(),
|
||||
|
||||
@@ -112,6 +112,10 @@ impl ImageCleanupHandler {
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for ImageCleanupHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"image_cleanup"
|
||||
}
|
||||
|
||||
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
let poster_path = match event {
|
||||
DomainEvent::MovieDeleted { poster_path, .. } => poster_path,
|
||||
|
||||
@@ -10,12 +10,12 @@ pub use repositories::{
|
||||
SqliteApContentQuery, SqliteDiaryRepository, SqliteImageRefAdapter,
|
||||
SqliteImportProfileRepository, SqliteImportSessionRepository, SqliteInsightsQuery,
|
||||
SqliteMovieDeduplicator, SqliteMovieProfileRepository, SqliteMovieRepository,
|
||||
SqlitePersonAdapter, SqliteProfileFieldsRepository, SqliteRefreshSessionAdapter,
|
||||
SqliteRemoteGoalRepository, SqliteReviewRepository, SqliteSocialRepository,
|
||||
SqliteStatsRepository, SqliteUserRepository, SqliteWatchEventRepository,
|
||||
SqliteWatchlistRepository, SqliteWebhookTokenRepository, SqliteWrapUpRepository,
|
||||
SqliteWrapUpStatsQuery, create_federated_profile_query, create_image_ref,
|
||||
create_person_adapter, create_search_adapter,
|
||||
SqlitePersonAdapter, SqliteProcessedEventLedger, SqliteProfileFieldsRepository,
|
||||
SqliteRefreshSessionAdapter, SqliteRemoteGoalRepository, SqliteReviewRepository,
|
||||
SqliteSocialRepository, SqliteStatsRepository, SqliteUserRepository,
|
||||
SqliteWatchEventRepository, SqliteWatchlistRepository, SqliteWebhookTokenRepository,
|
||||
SqliteWrapUpRepository, SqliteWrapUpStatsQuery, create_federated_profile_query,
|
||||
create_image_ref, create_person_adapter, create_search_adapter,
|
||||
};
|
||||
use repositories::{SqliteGoalRepository, SqliteUserSettingsRepository};
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ mod insights;
|
||||
mod movie;
|
||||
mod movie_dedup;
|
||||
mod persons;
|
||||
mod processed_events;
|
||||
mod profile;
|
||||
mod profile_fields;
|
||||
mod refresh_sessions;
|
||||
@@ -32,6 +33,7 @@ pub use insights::SqliteInsightsQuery;
|
||||
pub use movie::SqliteMovieRepository;
|
||||
pub use movie_dedup::SqliteMovieDeduplicator;
|
||||
pub use persons::{SqlitePersonAdapter, create_person_adapter};
|
||||
pub use processed_events::SqliteProcessedEventLedger;
|
||||
pub use profile::SqliteMovieProfileRepository;
|
||||
pub use profile_fields::SqliteProfileFieldsRepository;
|
||||
pub use refresh_sessions::SqliteRefreshSessionAdapter;
|
||||
|
||||
120
crates/adapters/sqlite/src/repositories/processed_events/mod.rs
Normal file
120
crates/adapters/sqlite/src/repositories/processed_events/mod.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, events::EventId, ports::ProcessedEventLedger};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// Matches the column default in migration 0101, so string comparison orders rows by time.
|
||||
const TIMESTAMP_FORMAT: &str = "%Y-%m-%dT%H:%M:%SZ";
|
||||
|
||||
pub struct SqliteProcessedEventLedger {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteProcessedEventLedger {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProcessedEventLedger for SqliteProcessedEventLedger {
|
||||
async fn has_processed(&self, id: &EventId, handler: &str) -> Result<bool, DomainError> {
|
||||
let found: Option<i64> =
|
||||
sqlx::query_scalar("SELECT 1 FROM processed_events WHERE event_id = ? AND handler = ?")
|
||||
.bind(id.value().to_string())
|
||||
.bind(handler)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
Ok(found.is_some())
|
||||
}
|
||||
|
||||
async fn record_processed(&self, id: &EventId, handler: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("INSERT OR IGNORE INTO processed_events (event_id, handler) VALUES (?, ?)")
|
||||
.bind(id.value().to_string())
|
||||
.bind(handler)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_older_than(&self, cutoff: chrono::NaiveDateTime) -> Result<u64, DomainError> {
|
||||
let result = sqlx::query("DELETE FROM processed_events WHERE processed_at < ?")
|
||||
.bind(cutoff.format(TIMESTAMP_FORMAT).to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn ledger() -> (SqliteProcessedEventLedger, SqlitePool) {
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
crate::migrate(&pool).await.unwrap();
|
||||
(SqliteProcessedEventLedger::new(pool.clone()), pool)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_recorded_pair_is_processed_and_others_are_not() {
|
||||
let (ledger, _) = ledger().await;
|
||||
let id = EventId::new();
|
||||
assert!(!ledger.has_processed(&id, "a").await.unwrap());
|
||||
|
||||
ledger.record_processed(&id, "a").await.unwrap();
|
||||
|
||||
assert!(ledger.has_processed(&id, "a").await.unwrap());
|
||||
assert!(!ledger.has_processed(&id, "b").await.unwrap());
|
||||
assert!(!ledger.has_processed(&EventId::new(), "a").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recording_twice_is_not_an_error() {
|
||||
let (ledger, pool) = ledger().await;
|
||||
let id = EventId::new();
|
||||
ledger.record_processed(&id, "a").await.unwrap();
|
||||
ledger.record_processed(&id, "a").await.unwrap();
|
||||
|
||||
let rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM processed_events")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_older_than_removes_only_rows_before_the_cutoff() {
|
||||
let (ledger, pool) = ledger().await;
|
||||
let (old, recent) = (EventId::new(), EventId::new());
|
||||
for (id, at) in [
|
||||
(old, "2026-01-01T00:00:00Z"),
|
||||
(recent, "2026-06-01T00:00:00Z"),
|
||||
] {
|
||||
sqlx::query(
|
||||
"INSERT INTO processed_events (event_id, handler, processed_at) VALUES (?, 'h', ?)",
|
||||
)
|
||||
.bind(id.value().to_string())
|
||||
.bind(at)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let cutoff = chrono::NaiveDate::from_ymd_opt(2026, 3, 1)
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap();
|
||||
|
||||
let removed = ledger.delete_older_than(cutoff).await.unwrap();
|
||||
|
||||
assert_eq!(removed, 1);
|
||||
assert!(!ledger.has_processed(&old, "h").await.unwrap());
|
||||
assert!(ledger.has_processed(&recent, "h").await.unwrap());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user