feat: idempotent handlers, deterministic activity ids

This commit is contained in:
2026-10-26 01:27:03 +01:00
parent 7f9c252d35
commit 4cb2972f63
11 changed files with 719 additions and 19 deletions

View File

@@ -8,15 +8,14 @@ use domain::{
use crate::movies::{commands::EnrichMovieCommand, deps::EnrichMovieDeps};
pub async fn execute(deps: &EnrichMovieDeps, cmd: EnrichMovieCommand) -> Result<(), DomainError> {
deps.movie_profile.upsert(&cmd.profile).await?;
let persons = extract_persons(&cmd.profile.cast, &cmd.profile.crew);
if !persons.is_empty() {
deps.person_command.upsert_batch(&persons).await?;
}
let Some(movie) = deps.movie_query.get_movie_by_id(&cmd.movie_id).await? else {
tracing::warn!(movie_id = %cmd.movie_id.value(), "enrich_movie: movie not found after profile upsert");
tracing::warn!(movie_id = %cmd.movie_id.value(), "enrich_movie: movie not found, storing profile only");
deps.movie_profile.upsert(&cmd.profile).await?;
return Ok(());
};
@@ -37,10 +36,14 @@ pub async fn execute(deps: &EnrichMovieDeps, cmd: EnrichMovieCommand) -> Result<
.await?;
}
// Written last: a stored fresh profile is what makes a redelivered
// enrichment skip, so it must only exist once every other effect landed.
deps.movie_profile.upsert(&cmd.profile).await?;
tracing::info!(
movie_id = %cmd.movie_id.value(),
persons = persons.len(),
"enrich_movie: profile stored and search index updated"
"enrich_movie: search index updated and profile stored"
);
Ok(())
}

View File

@@ -109,7 +109,22 @@ impl EventHandler for PosterSyncHandler {
DomainEvent::MovieDiscovered {
movie_id,
external_metadata_id,
} => (movie_id.value(), external_metadata_id.value().to_owned()),
} => {
// The poster is stored under the movie id and the movie row is
// updated atomically with `PosterSynced`; a poster already keyed
// by this movie means an earlier delivery completed.
let id_str = movie_id.value().to_string();
let already_synced = self
.movie_query
.get_movie_by_id(&MovieId::from_uuid(movie_id.value()))
.await?
.and_then(|m| m.poster_path().map(|p| p.value().contains(&id_str)))
.unwrap_or(false);
if already_synced {
return Ok(());
}
(movie_id.value(), external_metadata_id.value().to_owned())
}
DomainEvent::MovieEnrichmentRequested {
movie_id,
external_metadata_id,

View File

@@ -12,15 +12,14 @@ pub async fn execute(
end_date: chrono::NaiveDate,
) -> Result<(), DomainError> {
if let Ok(Some(rec)) = deps.wrapup_repo.get_by_id(&wrapup_id).await
&& (rec.status == WrapUpStatus::Ready || rec.status == WrapUpStatus::Generating)
&& rec.status == WrapUpStatus::Ready
{
tracing::debug!(
"wrapup {} already {:?}, skipping",
wrapup_id.value(),
rec.status
);
tracing::debug!("wrapup {} already ready, skipping", wrapup_id.value());
return Ok(());
}
// A record still `Generating` is an earlier delivery that died mid-way. The
// ledger guarantees no concurrent run, so resume it: generation is a pure
// read of stats and `set_complete` is the single atomic completion write.
deps.wrapup_repo
.update_status(&wrapup_id, &WrapUpStatus::Generating, None)