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

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

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"
);
}
}

View File

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