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

@@ -45,6 +45,17 @@ impl EventHandler for ImageConversionHandler {
return Ok(());
}
// `get` falls back to `{key}.webp` / `{key}.avif`, which would re-encode
// an already converted image on redelivery. Probe the exact key first:
// once the original is gone, an earlier delivery finished the conversion.
match self.storage.get_stream(&key).await {
Ok(_) => {}
Err(DomainError::NotFound(_)) => {
tracing::debug!("original {key} already converted, skipping");
return Ok(());
}
Err(e) => return Err(e),
}
let bytes = self.storage.get(&key).await?;
let format = self.format;

View File

@@ -162,3 +162,33 @@ async fn converts_jpeg_to_webp_and_swaps_key() {
);
assert!(storage.get("avatars/u1.webp").await.is_ok());
}
#[tokio::test]
async fn image_conversion_is_idempotent_when_run_twice() {
for format in [Format::Webp, Format::Avif] {
let storage = in_memory_storage();
storage.store("posters/m1", &tiny_jpeg()).await.unwrap();
let image_ref = MockImageRef::new();
let handler = ImageConversionHandler::new(
Arc::clone(&storage) as Arc<dyn ObjectStorage>,
Arc::clone(&image_ref) as Arc<dyn ImageRefCommand>,
format,
);
let event = DomainEvent::ImageStored {
key: "posters/m1".into(),
};
let new_key = format!("posters/m1{}", format.extension());
handler.handle(&event).await.unwrap();
let converted = storage.get(&new_key).await.unwrap();
// Redelivery after the original was swapped and deleted.
handler.handle(&event).await.unwrap();
assert_eq!(image_ref.swaps().len(), 1, "one swap");
assert_eq!(
storage.get(&new_key).await.unwrap(),
converted,
"converted bytes are not re-encoded"
);
}
}