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

@@ -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};

View File

@@ -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;

View 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());
}
}