feat: actor cache TTL with staleness-aware re-fetch

Adds fetched_at to RemoteActor, configurable TTL via builder
(.actor_cache_ttl_secs, default 24h), and get_or_refresh_remote_actor
helper that re-fetches stale actors from origin.

Closes #3
This commit is contained in:
2026-05-30 02:46:54 +02:00
parent f08d11034d
commit 7171a1791a
8 changed files with 69 additions and 0 deletions

View File

@@ -375,6 +375,7 @@ impl Object for DbActor {
followers_url: json.followers.as_ref().map(|u| u.to_string()), followers_url: json.followers.as_ref().map(|u| u.to_string()),
following_url: json.following.as_ref().map(|u| u.to_string()), following_url: json.following.as_ref().map(|u| u.to_string()),
also_known_as: json.also_known_as.clone(), also_known_as: json.also_known_as.clone(),
fetched_at: Some(Utc::now()),
}; };
data.actor_repo.upsert_remote_actor(actor).await?; data.actor_repo.upsert_remote_actor(actor).await?;

View File

@@ -63,6 +63,7 @@ pub struct FederationData {
pub(crate) allow_registration: bool, pub(crate) allow_registration: bool,
pub(crate) software_name: String, pub(crate) software_name: String,
pub(crate) event_publisher: Option<Arc<dyn EventPublisher>>, pub(crate) event_publisher: Option<Arc<dyn EventPublisher>>,
pub(crate) actor_cache_ttl: std::time::Duration,
} }
impl FederationData { impl FederationData {
@@ -79,6 +80,7 @@ impl FederationData {
allow_registration: bool, allow_registration: bool,
software_name: String, software_name: String,
event_publisher: Option<Arc<dyn EventPublisher>>, event_publisher: Option<Arc<dyn EventPublisher>>,
actor_cache_ttl: std::time::Duration,
) -> Self { ) -> Self {
let domain = base_url let domain = base_url
.trim_start_matches("https://") .trim_start_matches("https://")
@@ -100,6 +102,7 @@ impl FederationData {
allow_registration, allow_registration,
software_name, software_name,
event_publisher, event_publisher,
actor_cache_ttl,
} }
} }
} }

View File

@@ -8,6 +8,8 @@ pub use actor::ActorRepository;
pub use blocklist::BlocklistRepository; pub use blocklist::BlocklistRepository;
pub use follow::FollowRepository; pub use follow::FollowRepository;
use chrono::{DateTime, Utc};
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum FollowerStatus { pub enum FollowerStatus {
Pending, Pending,
@@ -35,6 +37,10 @@ pub struct RemoteActor {
pub followers_url: Option<String>, pub followers_url: Option<String>,
pub following_url: Option<String>, pub following_url: Option<String>,
pub also_known_as: Vec<String>, pub also_known_as: Vec<String>,
/// When this actor was last fetched from the origin instance.
/// `None` means unknown — treated as always-fresh to avoid
/// breaking existing consumers that don't populate this field.
pub fetched_at: Option<DateTime<Utc>>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]

View File

@@ -1,5 +1,9 @@
use activitypub_federation::fetch::object_id::ObjectId;
use url::Url; use url::Url;
use crate::actors::DbActor;
use crate::repository::RemoteActor;
use super::ActivityPubService; use super::ActivityPubService;
impl ActivityPubService { impl ActivityPubService {
@@ -17,4 +21,42 @@ impl ActivityPubService {
.map_err(|e| anyhow::anyhow!("{e}"))?; .map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(res.object) Ok(res.object)
} }
/// Get a cached remote actor, re-fetching from origin if stale.
///
/// Returns `None` if the actor has never been seen. Staleness is
/// determined by `actor_cache_ttl_secs` (builder config).
pub async fn get_or_refresh_remote_actor(
&self,
actor_url: &str,
) -> anyhow::Result<Option<RemoteActor>> {
let data = self.federation_config.to_request_data();
let cached = data.actor_repo.get_remote_actor(actor_url).await?;
if let Some(ref actor) = cached {
let is_fresh = actor
.fetched_at
.map(|t| {
let age = chrono::Utc::now().signed_duration_since(t);
age < chrono::Duration::from_std(data.actor_cache_ttl).unwrap_or_default()
})
.unwrap_or(true);
if is_fresh {
return Ok(cached);
}
}
let url = match Url::parse(actor_url) {
Ok(u) => u,
Err(_) => return Ok(cached),
};
match ObjectId::<DbActor>::from(url)
.dereference_forced(&data)
.await
{
Ok(_) => Ok(data.actor_repo.get_remote_actor(actor_url).await?),
Err(e) => {
tracing::warn!(actor_url, error = %e, "re-fetch failed, using stale cache");
Ok(cached)
}
}
}
} }

View File

@@ -48,6 +48,7 @@ impl ActivityPubService {
followers_url: Some(remote_actor.followers_url.to_string()), followers_url: Some(remote_actor.followers_url.to_string()),
following_url: Some(remote_actor.following_url.to_string()), following_url: Some(remote_actor.following_url.to_string()),
also_known_as: remote_actor.also_known_as.clone(), also_known_as: remote_actor.also_known_as.clone(),
fetched_at: Some(chrono::Utc::now()),
}; };
// Save BEFORE delivering — prevents lost state on process restart. // Save BEFORE delivering — prevents lost state on process restart.
data.follow_repo data.follow_repo
@@ -356,6 +357,7 @@ impl ActivityPubService {
followers_url: None, followers_url: None,
following_url: None, following_url: None,
also_known_as: vec![], also_known_as: vec![],
fetched_at: None,
}, },
}; };
actors.push(actor); actors.push(actor);
@@ -403,6 +405,7 @@ impl ActivityPubService {
followers_url: Some(format!("{}/followers", target_actor_url)), followers_url: Some(format!("{}/followers", target_actor_url)),
following_url: Some(format!("{}/following", target_actor_url)), following_url: Some(format!("{}/following", target_actor_url)),
also_known_as: target.also_known_as, also_known_as: target.also_known_as,
fetched_at: None,
}; };
data.follow_repo data.follow_repo
.add_following(local_user_id, target_as_remote, &follow_id) .add_following(local_user_id, target_as_remote, &follow_id)

View File

@@ -34,6 +34,8 @@ pub const DELIVERY_INITIAL_DELAY_SECS: u64 = 1;
pub const HTTP_FETCH_TIMEOUT_SECS: u64 = 30; pub const HTTP_FETCH_TIMEOUT_SECS: u64 = 30;
/// Sleep between backfill send batches. /// Sleep between backfill send batches.
pub const BATCH_FETCH_SLEEP_MS: u64 = 100; pub const BATCH_FETCH_SLEEP_MS: u64 = 100;
/// Default actor cache TTL in seconds (24 hours).
pub const ACTOR_CACHE_TTL_SECS: u64 = 24 * 60 * 60;
#[derive(Clone)] #[derive(Clone)]
pub struct ActivityPubService { pub struct ActivityPubService {
@@ -59,6 +61,7 @@ pub struct ActivityPubServiceBuilder {
delivery_max_attempts: u32, delivery_max_attempts: u32,
delivery_initial_delay_secs: u64, delivery_initial_delay_secs: u64,
signed_fetch_actor_id: Option<uuid::Uuid>, signed_fetch_actor_id: Option<uuid::Uuid>,
actor_cache_ttl_secs: u64,
} }
impl ActivityPubServiceBuilder { impl ActivityPubServiceBuilder {
@@ -115,6 +118,13 @@ impl ActivityPubServiceBuilder {
self self
} }
/// How long cached remote actors are considered fresh (seconds, default 24h).
/// After this duration, the next access re-fetches the actor from origin.
pub fn actor_cache_ttl_secs(mut self, v: u64) -> Self {
self.actor_cache_ttl_secs = v;
self
}
/// Set a local actor whose keypair signs all outgoing fetch requests /// Set a local actor whose keypair signs all outgoing fetch requests
/// (HTTP Signature on GETs). Required for federating with instances /// (HTTP Signature on GETs). Required for federating with instances
/// that enforce authorized-fetch / Secure Mode. /// that enforce authorized-fetch / Secure Mode.
@@ -157,6 +167,7 @@ impl ActivityPubServiceBuilder {
self.allow_registration, self.allow_registration,
self.software_name, self.software_name,
self.event_publisher, self.event_publisher,
std::time::Duration::from_secs(self.actor_cache_ttl_secs),
); );
let signing_actor = if let Some(uid) = self.signed_fetch_actor_id { let signing_actor = if let Some(uid) = self.signed_fetch_actor_id {
let actor = crate::actors::build_local_actor( let actor = crate::actors::build_local_actor(
@@ -199,6 +210,7 @@ impl ActivityPubService {
delivery_max_attempts: DELIVERY_MAX_ATTEMPTS, delivery_max_attempts: DELIVERY_MAX_ATTEMPTS,
delivery_initial_delay_secs: DELIVERY_INITIAL_DELAY_SECS, delivery_initial_delay_secs: DELIVERY_INITIAL_DELAY_SECS,
signed_fetch_actor_id: None, signed_fetch_actor_id: None,
actor_cache_ttl_secs: ACTOR_CACHE_TTL_SECS,
} }
} }

View File

@@ -520,6 +520,7 @@ async fn setup(blocklist: MemBlocklistRepo, local_user_id: uuid::Uuid) -> TestSe
false, false,
"test".to_string(), "test".to_string(),
None, None,
std::time::Duration::from_secs(24 * 60 * 60),
); );
let config = FederationConfig::builder() let config = FederationConfig::builder()

View File

@@ -374,6 +374,7 @@ fn make_data(
false, false,
"test".to_string(), "test".to_string(),
None, None,
std::time::Duration::from_secs(24 * 60 * 60),
) )
} }