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:
2026-10-26 01:12:58 +01:00
parent 6c295866a0
commit 9a4d0c1714
29 changed files with 1115 additions and 408 deletions

View File

@@ -101,7 +101,6 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
movie_repo: std::sync::Arc::clone(&movie_repo),
diary_repo,
review_store,
event_publisher: std::sync::Arc::clone(&event_publisher),
instance: instance.clone(),
});
let watchlist_handler = std::sync::Arc::new(watchlist_handler::WatchlistObjectHandler {

View File

@@ -1,7 +1,7 @@
use anyhow::Result;
use async_trait::async_trait;
use chrono::NaiveDateTime;
use domain::models::Review;
use domain::{events::DomainEvent, models::Review};
pub struct RemoteReviewUpdate<'a> {
pub ap_id: &'a str,
@@ -16,6 +16,7 @@ pub struct RemoteReviewUpdate<'a> {
#[async_trait]
pub trait RemoteReviewRepository: Send + Sync {
#[allow(clippy::too_many_arguments)]
async fn save_remote_review(
&self,
review: &Review,
@@ -24,6 +25,7 @@ pub trait RemoteReviewRepository: Send + Sync {
release_year: u16,
external_metadata_id: Option<&str>,
poster_url: Option<&str>,
events: &[DomainEvent],
) -> Result<()>;
async fn delete_remote_review(&self, ap_id: &str, actor_url: &str) -> Result<()>;

View File

@@ -4,7 +4,7 @@ use async_trait::async_trait;
use domain::{
events::DomainEvent,
models::ReviewSource,
ports::{DiaryQuery, EventPublisher, LocalApContentQuery, MovieQuery},
ports::{DiaryQuery, LocalApContentQuery, MovieQuery},
value_objects::{
Comment, ExternalMetadataId, InstanceIdentity, MovieId, Rating, ReviewId, UserId,
},
@@ -21,7 +21,6 @@ pub struct ReviewObjectHandler {
pub movie_repo: Arc<dyn MovieQuery>,
pub diary_repo: Arc<dyn DiaryQuery>,
pub review_store: Arc<dyn RemoteReviewRepository>,
pub event_publisher: Arc<dyn EventPublisher>,
pub instance: InstanceIdentity,
}
@@ -160,6 +159,20 @@ impl ApObjectHandler for ReviewObjectHandler {
spoilers: obj.contains_spoilers,
});
// The enrichment request rides the review write (one transaction).
let events: Vec<DomainEvent> = obj
.external_metadata_id
.as_ref()
.and_then(|ext_id| ExternalMetadataId::new(ext_id.clone()).ok())
.map(
|external_metadata_id| DomainEvent::MovieEnrichmentRequested {
movie_id: movie_id.clone(),
external_metadata_id,
},
)
.into_iter()
.collect();
self.review_store
.save_remote_review(
&review,
@@ -168,21 +181,10 @@ impl ApObjectHandler for ReviewObjectHandler {
obj.release_year,
obj.external_metadata_id.as_deref(),
obj.poster_url.as_deref(),
&events,
)
.await?;
if let Some(ref ext_id_str) = obj.external_metadata_id
&& let Ok(external_metadata_id) = ExternalMetadataId::new(ext_id_str.clone())
{
let _ = self
.event_publisher
.publish(&DomainEvent::MovieEnrichmentRequested {
movie_id: movie_id.clone(),
external_metadata_id,
})
.await;
}
Ok(())
}

View File

@@ -3,7 +3,10 @@ use std::sync::Arc;
use async_trait::async_trait;
use domain::{
errors::DomainError,
ports::{BlockQuery, FollowGraphQuery, LocalSocial, SocialCommand, UserRepository},
events::DomainEvent,
ports::{
BlockQuery, EventPublisher, FollowGraphQuery, LocalSocial, SocialCommand, UserRepository,
},
value_objects::{
FollowRelation, FollowTarget, InstanceIdentity, SocialActor, SocialIdentity, UserId,
},
@@ -16,6 +19,7 @@ pub struct CompositeSocialAdapter {
ap_service: Arc<ActivityPubService>,
user_repo: Arc<dyn UserRepository>,
instance: InstanceIdentity,
event_publisher: Arc<dyn EventPublisher>,
}
impl CompositeSocialAdapter {
@@ -24,12 +28,23 @@ impl CompositeSocialAdapter {
ap_service: Arc<ActivityPubService>,
user_repo: Arc<dyn UserRepository>,
instance: InstanceIdentity,
event_publisher: Arc<dyn EventPublisher>,
) -> Self {
Self {
local,
ap_service,
user_repo,
instance,
event_publisher,
}
}
/// Best-effort publish for writes k_ap owns (no shared transaction).
async fn announce(&self, events: &[DomainEvent]) {
for event in events {
if let Err(e) = self.event_publisher.publish(event).await {
tracing::warn!("failed to publish {event:?} after k_ap write: {e}");
}
}
}
}
@@ -40,11 +55,19 @@ fn ap_err(e: anyhow::Error) -> DomainError {
#[async_trait]
impl SocialCommand for CompositeSocialAdapter {
async fn follow(&self, follower: &UserId, target: &FollowTarget) -> Result<(), DomainError> {
async fn follow(
&self,
follower: &UserId,
target: &FollowTarget,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let identity = self.local.resolve_target(target).await?;
if let SocialIdentity::Local(_) = identity {
return self.local.follow_resolved(follower, &identity).await;
return self
.local
.follow_resolved(follower, &identity, events)
.await;
}
let handle = match target {
@@ -61,24 +84,33 @@ impl SocialCommand for CompositeSocialAdapter {
SocialIdentity::Remote { actor_url } => actor_url.clone(),
},
};
// Not atomic: k_ap owns the transaction, so the events are published
// best-effort after its write succeeds.
self.ap_service
.follow(follower.value(), &handle)
.await
.map_err(ap_err)
.map_err(ap_err)?;
self.announce(events).await;
Ok(())
}
async fn unfollow(
&self,
follower: &UserId,
target: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
match target {
SocialIdentity::Local(_) => self.local.unfollow(follower, target).await,
SocialIdentity::Remote { .. } => self
.ap_service
.unfollow(follower.value(), &self.instance.actor_url_of(target))
.await
.map_err(ap_err),
SocialIdentity::Local(_) => self.local.unfollow(follower, target, events).await,
// Not atomic: k_ap owns the transaction.
SocialIdentity::Remote { .. } => {
self.ap_service
.unfollow(follower.value(), &self.instance.actor_url_of(target))
.await
.map_err(ap_err)?;
self.announce(events).await;
Ok(())
}
}
}
@@ -86,14 +118,19 @@ impl SocialCommand for CompositeSocialAdapter {
&self,
owner: &UserId,
requester: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
match requester {
SocialIdentity::Local(_) => self.local.accept_follow(owner, requester).await,
SocialIdentity::Remote { .. } => self
.ap_service
.accept_follower(owner.value(), &self.instance.actor_url_of(requester))
.await
.map_err(ap_err),
SocialIdentity::Local(_) => self.local.accept_follow(owner, requester, events).await,
// Not atomic: k_ap owns the transaction.
SocialIdentity::Remote { .. } => {
self.ap_service
.accept_follower(owner.value(), &self.instance.actor_url_of(requester))
.await
.map_err(ap_err)?;
self.announce(events).await;
Ok(())
}
}
}
@@ -101,14 +138,19 @@ impl SocialCommand for CompositeSocialAdapter {
&self,
owner: &UserId,
requester: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
match requester {
SocialIdentity::Local(_) => self.local.reject_follow(owner, requester).await,
SocialIdentity::Remote { .. } => self
.ap_service
.reject_follower(owner.value(), &self.instance.actor_url_of(requester))
.await
.map_err(ap_err),
SocialIdentity::Local(_) => self.local.reject_follow(owner, requester, events).await,
// Not atomic: k_ap owns the transaction.
SocialIdentity::Remote { .. } => {
self.ap_service
.reject_follower(owner.value(), &self.instance.actor_url_of(requester))
.await
.map_err(ap_err)?;
self.announce(events).await;
Ok(())
}
}
}
@@ -116,31 +158,52 @@ impl SocialCommand for CompositeSocialAdapter {
&self,
owner: &UserId,
follower: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
match follower {
SocialIdentity::Local(_) => self.local.remove_follower(owner, follower).await,
SocialIdentity::Remote { .. } => self
.ap_service
.remove_follower(owner.value(), &self.instance.actor_url_of(follower))
.await
.map_err(ap_err),
SocialIdentity::Local(_) => self.local.remove_follower(owner, follower, events).await,
// Not atomic: k_ap owns the transaction.
SocialIdentity::Remote { .. } => {
self.ap_service
.remove_follower(owner.value(), &self.instance.actor_url_of(follower))
.await
.map_err(ap_err)?;
self.announce(events).await;
Ok(())
}
}
}
async fn block(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
/// Not atomic: k_ap owns the transaction.
async fn block(
&self,
blocker: &UserId,
target: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let actor_url = self.instance.actor_url_of(target);
self.ap_service
.block_actor(blocker.value(), &actor_url)
.await
.map_err(ap_err)
.map_err(ap_err)?;
self.announce(events).await;
Ok(())
}
async fn unblock(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
/// Not atomic: k_ap owns the transaction.
async fn unblock(
&self,
blocker: &UserId,
target: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let actor_url = self.instance.actor_url_of(target);
self.ap_service
.unblock_actor(blocker.value(), &actor_url)
.await
.map_err(ap_err)
.map_err(ap_err)?;
self.announce(events).await;
Ok(())
}
}