hybrid.rs 297 lignes · 10000 octets
//! In-memory cache plus durable write-through [`GuardStore`].
//!
//! The hot path (`is_banned`, `acl_for_domain`, `active_bans_for_domain`)
//! reads from the in-process `DashMap`-backed [`MemoryStore`] cache
//! (O(1), no I/O). Writes propagate to the durable backend in a
//! detached Tokio task — fire-and-forget — so the federation hot path
//! is never blocked by Postgres latency.
//!
//! On boot, call [`HybridStore::rehydrate`] to repopulate the cache
//! from the durable backend (active bans + every ACL row).
//!
//! Events are **not** cached: the analytical
//! `count_events_since` query is delegated straight to the durable
//! backend because event volume can be high and they are read only
//! by the detector chain (already off the request path).

use std::sync::Arc;

use async_trait::async_trait;
use chrono::{DateTime, Utc};
use rustwarden_core::error::AppError;
use uuid::Uuid;

use super::{AclEntry, BanEntry, GuardStore, MemoryStore};
use crate::events::{GuardEvent, GuardEventKind};

/// Composite store: `MemoryStore` cache on top, durable backend below.
pub struct HybridStore {
    cache: MemoryStore,
    durable: Arc<dyn GuardStore>,
}

impl HybridStore {
    /// Build a new hybrid store. Call [`Self::rehydrate`] right after
    /// to populate the cache with the current state of `durable`.
    #[must_use]
    pub fn new(durable: Arc<dyn GuardStore>) -> Self {
        Self {
            cache: MemoryStore::new(),
            durable,
        }
    }

    /// Borrow the durable backend (useful for tests + analytics).
    #[must_use]
    pub fn durable(&self) -> &Arc<dyn GuardStore> {
        &self.durable
    }

    /// Populate the cache from the durable backend. Should be called
    /// once at app boot, before serving requests.
    ///
    /// # Errors
    ///
    /// Propagates errors from the durable backend.
    pub async fn rehydrate(&self) -> Result<(usize, usize), AppError> {
        let now = Utc::now();
        let bans = self.durable.all_active_bans(now).await?;
        let bans_count = bans.len();
        for b in bans {
            self.cache.insert_ban(&b).await?;
        }
        let acls = self.durable.list_acl().await?;
        let acl_count = acls.len();
        for a in acls {
            self.cache.upsert_acl(&a).await?;
        }
        tracing::info!(
            bans = bans_count,
            acl = acl_count,
            "fediverse guard hybrid store rehydrated"
        );
        Ok((bans_count, acl_count))
    }
}

/// Spawn a fire-and-forget write to the durable backend. The
/// federation hot path must never await persistence.
fn spawn_durable<F>(future: F)
where
    F: std::future::Future<Output = Result<(), AppError>> + Send + 'static,
{
    tokio::spawn(async move {
        if let Err(e) = future.await {
            tracing::warn!(error = %e, "fediverse guard durable write failed");
        }
    });
}

#[async_trait]
impl GuardStore for HybridStore {
    async fn insert_event(&self, event: &GuardEvent) -> Result<(), AppError> {
        // Events are not cached. Delegate to durable directly so the
        // count_events_since query reflects this insert; spawn the
        // actual I/O so callers don't block on Postgres latency.
        let durable = self.durable.clone();
        let event = event.clone();
        spawn_durable(async move { durable.insert_event(&event).await });
        Ok(())
    }

    async fn count_events_since(
        &self,
        domain: &str,
        kind: GuardEventKind,
        since: DateTime<Utc>,
    ) -> Result<u64, AppError> {
        // Analytical query — go to durable.
        self.durable.count_events_since(domain, kind, since).await
    }

    async fn insert_ban(&self, ban: &BanEntry) -> Result<(), AppError> {
        self.cache.insert_ban(ban).await?;
        let durable = self.durable.clone();
        let ban = ban.clone();
        spawn_durable(async move { durable.insert_ban(&ban).await });
        Ok(())
    }

    async fn active_bans_for_domain(
        &self,
        domain: &str,
        now: DateTime<Utc>,
    ) -> Result<Vec<BanEntry>, AppError> {
        self.cache.active_bans_for_domain(domain, now).await
    }

    async fn all_active_bans(&self, now: DateTime<Utc>) -> Result<Vec<BanEntry>, AppError> {
        self.cache.all_active_bans(now).await
    }

    async fn lift_ban(
        &self,
        ban_id: Uuid,
        by: Option<Uuid>,
        at: DateTime<Utc>,
    ) -> Result<(), AppError> {
        self.cache.lift_ban(ban_id, by, at).await?;
        let durable = self.durable.clone();
        spawn_durable(async move { durable.lift_ban(ban_id, by, at).await });
        Ok(())
    }

    async fn upsert_acl(&self, entry: &AclEntry) -> Result<(), AppError> {
        self.cache.upsert_acl(entry).await?;
        let durable = self.durable.clone();
        let entry = entry.clone();
        spawn_durable(async move { durable.upsert_acl(&entry).await });
        Ok(())
    }

    async fn acl_for_domain(&self, domain: &str) -> Result<Option<AclEntry>, AppError> {
        self.cache.acl_for_domain(domain).await
    }

    async fn delete_acl(&self, id: Uuid) -> Result<(), AppError> {
        self.cache.delete_acl(id).await?;
        let durable = self.durable.clone();
        spawn_durable(async move { durable.delete_acl(id).await });
        Ok(())
    }

    async fn list_acl(&self) -> Result<Vec<AclEntry>, AppError> {
        self.cache.list_acl().await
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::events::{GuardEvent, GuardEventKind, Severity};
    use crate::store::{AclKind, MemoryStore};

    fn durable_with_active_ban() -> Arc<dyn GuardStore> {
        Arc::new(MemoryStore::new())
    }

    #[tokio::test]
    async fn rehydrate_loads_active_bans_and_acl_from_durable() {
        let durable = Arc::new(MemoryStore::new());
        let now = Utc::now();

        // Seed durable with one active ban + one expired (should not
        // be cached) + one ACL entry.
        let active = BanEntry {
            id: Uuid::new_v4(),
            domain: "evil.tld".into(),
            actor_uri: None,
            reason: "spam".into(),
            severity: "suspend".into(),
            triggered_by: "auto:test".into(),
            banned_at: now,
            expires_at: Some(now + chrono::Duration::hours(1)),
            lifted_at: None,
            lifted_by: None,
        };
        let expired = BanEntry {
            id: Uuid::new_v4(),
            domain: "stale.tld".into(),
            actor_uri: None,
            reason: "old".into(),
            severity: "suspend".into(),
            triggered_by: "auto:test".into(),
            banned_at: now - chrono::Duration::hours(3),
            expires_at: Some(now - chrono::Duration::hours(1)),
            lifted_at: None,
            lifted_by: None,
        };
        durable.insert_ban(&active).await.unwrap();
        durable.insert_ban(&expired).await.unwrap();
        durable
            .upsert_acl(&AclEntry {
                id: Uuid::new_v4(),
                kind: AclKind::Allow,
                domain: "peer.tld".into(),
                reason: None,
                created_by: Uuid::new_v4(),
                created_at: now,
                updated_at: now,
            })
            .await
            .unwrap();

        let hybrid = HybridStore::new(durable.clone() as Arc<dyn GuardStore>);
        let (bans_n, acl_n) = hybrid.rehydrate().await.unwrap();
        assert_eq!(bans_n, 1, "only the active ban must be cached");
        assert_eq!(acl_n, 1);

        // Cache should answer the hot path without hitting durable.
        let active_cached = hybrid
            .active_bans_for_domain("evil.tld", now)
            .await
            .unwrap();
        assert_eq!(active_cached.len(), 1);
        let acl_cached = hybrid.acl_for_domain("peer.tld").await.unwrap();
        assert!(acl_cached.is_some());
    }

    #[tokio::test]
    async fn writes_reach_durable_after_spawn_drains() {
        let durable: Arc<dyn GuardStore> = durable_with_active_ban();
        let hybrid = HybridStore::new(durable.clone());

        let now = Utc::now();
        let ban = BanEntry {
            id: Uuid::new_v4(),
            domain: "x.tld".into(),
            actor_uri: None,
            reason: "test".into(),
            severity: "suspend".into(),
            triggered_by: "auto:t".into(),
            banned_at: now,
            expires_at: Some(now + chrono::Duration::hours(1)),
            lifted_at: None,
            lifted_by: None,
        };
        hybrid.insert_ban(&ban).await.unwrap();

        // Cache is immediate.
        let cached = hybrid.active_bans_for_domain("x.tld", now).await.unwrap();
        assert_eq!(cached.len(), 1);

        // The detached task needs a tick to land in durable.
        tokio::task::yield_now().await;
        // Allow the spawned task to run.
        for _ in 0..5 {
            let durable_view = durable.active_bans_for_domain("x.tld", now).await.unwrap();
            if !durable_view.is_empty() {
                return;
            }
            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
        }
        panic!("durable write never landed");
    }

    #[tokio::test]
    async fn count_events_since_delegates_to_durable() {
        let durable: Arc<dyn GuardStore> = Arc::new(MemoryStore::new());
        // Seed event directly in durable (HybridStore would have spawned).
        durable
            .insert_event(&GuardEvent::new(
                GuardEventKind::SignatureFailure,
                "x.tld",
                Severity::Medium,
            ))
            .await
            .unwrap();

        let hybrid = HybridStore::new(durable);
        let cutoff = Utc::now() - chrono::Duration::minutes(1);
        let n = hybrid
            .count_events_since("x.tld", GuardEventKind::SignatureFailure, cutoff)
            .await
            .unwrap();
        assert_eq!(n, 1);
    }
}