feat: idempotent handlers, deterministic activity ids
This commit is contained in:
@@ -38,3 +38,34 @@ pub fn watchlist_entry_url(
|
||||
))
|
||||
.expect("base_url is always a valid URL prefix")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// k-ap derives the Create activity id from the object id (UUIDv5), so
|
||||
/// re-handling an event only re-sends the same activity id if our object
|
||||
/// ids are pure functions of the entity. Update/Delete/Move ids are minted
|
||||
/// randomly inside k-ap 0.5 with no override (see ADR-0015): those repeat
|
||||
/// semantically idempotent activities under a fresh id.
|
||||
#[test]
|
||||
fn activitypub_object_ids_are_stable_across_event_redelivery() {
|
||||
let instance = InstanceIdentity::new("https://diary.example");
|
||||
let review = ReviewId::from_uuid(uuid::Uuid::new_v4());
|
||||
let user = uuid::Uuid::new_v4();
|
||||
let movie = uuid::Uuid::new_v4();
|
||||
|
||||
assert_eq!(
|
||||
review_url(&instance, &review),
|
||||
review_url(&instance, &review)
|
||||
);
|
||||
assert_eq!(
|
||||
goal_url(&instance, user, 2026),
|
||||
goal_url(&instance, user, 2026)
|
||||
);
|
||||
assert_eq!(
|
||||
watchlist_entry_url(&instance, user, movie),
|
||||
watchlist_entry_url(&instance, user, movie)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,3 +136,29 @@ pub fn create(config: &::config::StorageConfig) -> anyhow::Result<Arc<dyn Object
|
||||
StorageConfig::from_config(config)?,
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::value_objects::{MovieId, PosterPath};
|
||||
use object_store::memory::InMemory;
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_cleanup_is_idempotent_when_run_twice() {
|
||||
let storage = Arc::new(ObjectStorageAdapter::new(Arc::new(InMemory::new())));
|
||||
storage.store("posters/m1.webp", b"img").await.unwrap();
|
||||
let handler = ImageCleanupHandler::new(storage.clone());
|
||||
let event = DomainEvent::MovieDeleted {
|
||||
movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()),
|
||||
poster_path: Some(PosterPath::new("posters/m1.webp".into()).unwrap()),
|
||||
};
|
||||
|
||||
handler.handle(&event).await.unwrap();
|
||||
handler.handle(&event).await.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
storage.get("posters/m1.webp").await,
|
||||
Err(DomainError::NotFound(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user