feat: idempotent handlers, deterministic activity ids
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user