feat: atomic social local writes, remote review enrichment
SocialCommand methods take events. Local follow ops = one FollowCommand::apply tx (rows + events). k_ap path (remote, block/unblock): best-effort publish in CompositeSocialAdapter after k_ap write; not atomic, k_ap owns tx. save_remote_review takes events, enqueued in same tx via sqlite::persistence::enqueue_events. Deps drop event_publisher: social command, ReviewObjectHandler. Remaining .publish( sites: announce-only: users/delete_account UserDeleted, person/get + get_credits PersonEnrichmentRequested, jobs/enrichment_staleness, http-axum search, ImageStored (sync_poster, poster_sync_handler, image-converter backfill, update_profile; object storage), federation_event_bridge (handler reaction), nats relay (transport). non-atomic by necessity: CompositeSocialAdapter::announce (k_ap-owned write). Everything else rides its write.
This commit is contained in:
@@ -18,4 +18,5 @@ async-trait = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
domain = { workspace = true, features = ["test-helpers"] }
|
||||
uuid = { workspace = true }
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
use activitypub::{RemoteReviewRepository, RemoteReviewUpdate};
|
||||
use anyhow::{Result, anyhow};
|
||||
use async_trait::async_trait;
|
||||
use domain::models::{Review, ReviewSource};
|
||||
use domain::{
|
||||
events::DomainEvent,
|
||||
models::{Review, ReviewSource},
|
||||
};
|
||||
|
||||
use super::SqliteFederationRepository;
|
||||
use sqlite::persistence::datetime_to_str;
|
||||
use sqlite::persistence::{datetime_to_str, enqueue_events};
|
||||
|
||||
#[async_trait]
|
||||
impl RemoteReviewRepository for SqliteFederationRepository {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn save_remote_review(
|
||||
&self,
|
||||
review: &Review,
|
||||
@@ -16,6 +20,7 @@ impl RemoteReviewRepository for SqliteFederationRepository {
|
||||
release_year: u16,
|
||||
external_metadata_id: Option<&str>,
|
||||
poster_url: Option<&str>,
|
||||
events: &[DomainEvent],
|
||||
) -> Result<()> {
|
||||
let actor_url = match review.source() {
|
||||
ReviewSource::Remote { actor_url } => actor_url.clone(),
|
||||
@@ -24,6 +29,7 @@ impl RemoteReviewRepository for SqliteFederationRepository {
|
||||
}
|
||||
};
|
||||
let movie_id = review.movie_id().value().to_string();
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path)
|
||||
VALUES (?, ?, ?, ?, NULL, ?)
|
||||
@@ -36,7 +42,7 @@ impl RemoteReviewRepository for SqliteFederationRepository {
|
||||
.bind(movie_title)
|
||||
.bind(release_year.max(1888) as i64)
|
||||
.bind(poster_url)
|
||||
.execute(&self.pool)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let id = review.id().value().to_string();
|
||||
@@ -59,8 +65,10 @@ impl RemoteReviewRepository for SqliteFederationRepository {
|
||||
.bind(&actor_url)
|
||||
.bind(ap_id)
|
||||
.bind(review.spoilers())
|
||||
.execute(&self.pool)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
enqueue_events(&mut tx, events).await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -4,3 +4,5 @@ mod actor_block_test;
|
||||
mod announce_test;
|
||||
#[path = "federation/domain_block_test.rs"]
|
||||
mod domain_block_test;
|
||||
#[path = "federation/remote_review_test.rs"]
|
||||
mod remote_review_test;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
//! A remote review and the enrichment request it triggers commit together.
|
||||
|
||||
use activitypub::RemoteReviewRepository;
|
||||
use domain::{
|
||||
events::DomainEvent,
|
||||
models::{PersistedReview, Review, ReviewSource},
|
||||
value_objects::{Comment, ExternalMetadataId, MovieId, Rating, ReviewId, UserId},
|
||||
};
|
||||
use sqlite_federation::SqliteFederationRepository;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
const ACTOR: &str = "https://remote.example/users/ann";
|
||||
const AP_ID: &str = "https://remote.example/reviews/1";
|
||||
|
||||
async fn migrated_pool() -> SqlitePool {
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
sqlite::migrate(&pool).await.unwrap();
|
||||
pool
|
||||
}
|
||||
|
||||
async fn count(pool: &SqlitePool, table: &str) -> i64 {
|
||||
sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table}"))
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn remote_review(movie_id: &MovieId) -> Review {
|
||||
let now = chrono::Utc::now().naive_utc();
|
||||
Review::from_persistence(PersistedReview {
|
||||
id: ReviewId::generate(),
|
||||
movie_id: movie_id.clone(),
|
||||
user_id: UserId::generate(),
|
||||
rating: Rating::new(4).unwrap(),
|
||||
comment: Some(Comment::new("good".into()).unwrap()),
|
||||
watched_at: now,
|
||||
created_at: now,
|
||||
source: ReviewSource::Remote {
|
||||
actor_url: ACTOR.into(),
|
||||
},
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn enrichment_requested(movie_id: &MovieId) -> DomainEvent {
|
||||
DomainEvent::MovieEnrichmentRequested {
|
||||
movie_id: movie_id.clone(),
|
||||
external_metadata_id: ExternalMetadataId::new("tmdb:603".into()).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save(pool: &SqlitePool, review: &Review, events: &[DomainEvent]) -> anyhow::Result<()> {
|
||||
SqliteFederationRepository::new(pool.clone())
|
||||
.save_remote_review(
|
||||
review,
|
||||
AP_ID,
|
||||
"The Matrix",
|
||||
1999,
|
||||
Some("tmdb:603"),
|
||||
None,
|
||||
events,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saving_a_remote_review_writes_it_and_its_event_in_one_commit() {
|
||||
let pool = migrated_pool().await;
|
||||
let movie_id = MovieId::generate();
|
||||
let review = remote_review(&movie_id);
|
||||
|
||||
save(&pool, &review, &[enrichment_requested(&movie_id)])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(count(&pool, "reviews").await, 1);
|
||||
assert_eq!(count(&pool, "movies").await, 1);
|
||||
assert_eq!(count(&pool, "event_queue").await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failing_enqueue_rolls_back_the_remote_review_and_its_movie() {
|
||||
let pool = migrated_pool().await;
|
||||
sqlx::query(
|
||||
"CREATE TRIGGER fail_event_insert BEFORE INSERT ON event_queue \
|
||||
BEGIN SELECT RAISE(ABORT, 'enqueue failed'); END",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let movie_id = MovieId::generate();
|
||||
let review = remote_review(&movie_id);
|
||||
|
||||
let result = save(&pool, &review, &[enrichment_requested(&movie_id)]).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(count(&pool, "reviews").await, 0);
|
||||
assert_eq!(count(&pool, "movies").await, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_remote_review_without_events_enqueues_nothing() {
|
||||
let pool = migrated_pool().await;
|
||||
let movie_id = MovieId::generate();
|
||||
|
||||
save(&pool, &remote_review(&movie_id), &[]).await.unwrap();
|
||||
|
||||
assert_eq!(count(&pool, "reviews").await, 1);
|
||||
assert_eq!(count(&pool, "event_queue").await, 0);
|
||||
}
|
||||
Reference in New Issue
Block a user