Skip to main content

babyrite/
cache.rs

1//! Cache module for guild channels.
2//!
3//! This module provides caching functionality for guild channels using moka cache.
4//! It includes two caches:
5//! - [`GUILD_CHANNEL_LIST_CACHE`]: Caches the list of channels for each guild.
6//! - [`GUILD_CHANNEL_CACHE`]: Caches individual guild channels.
7//!
8//! The [`CacheArgs`] struct is used to retrieve channels from the cache or fetch them from the API if not found.
9
10use anyhow::Context as _;
11use moka::future::{Cache, CacheBuilder};
12use serenity::all::{ChannelId, GuildChannel, GuildId};
13use serenity::client::Context;
14use std::collections::HashMap;
15use std::sync::LazyLock;
16
17/// Arguments for cache operations.
18pub struct CacheArgs {
19    /// The ID of the guild.
20    pub guild_id: GuildId,
21    /// The ID of the channel.
22    pub channel_id: ChannelId,
23}
24
25/// Builds a cache with the shared tuning for both channel caches:
26/// 500 entries, TTL 12 hours, TTI 1 hour.
27fn channel_cache<K, V>(name: &str) -> Cache<K, V>
28where
29    K: std::hash::Hash + Eq + Send + Sync + 'static,
30    V: Clone + Send + Sync + 'static,
31{
32    CacheBuilder::new(500)
33        .name(name)
34        .time_to_idle(std::time::Duration::from_secs(3600))
35        .time_to_live(std::time::Duration::from_secs(43200))
36        .build()
37}
38
39/// Cache for guild channel lists, mapping guild IDs to their channel lists.
40pub static GUILD_CHANNEL_LIST_CACHE: LazyLock<Cache<GuildId, HashMap<ChannelId, GuildChannel>>> =
41    LazyLock::new(|| channel_cache("guild_channel_list_cache"));
42
43/// Cache for individual guild channels, mapping channel IDs to their channel data.
44pub static GUILD_CHANNEL_CACHE: LazyLock<Cache<ChannelId, GuildChannel>> =
45    LazyLock::new(|| channel_cache("guild_channel_cache"));
46
47impl CacheArgs {
48    /// Retrieves a guild channel from cache or fetches it from the API.
49    ///
50    /// The lookup order is:
51    /// 1. Individual channel cache
52    /// 2. Guild channel list cache
53    /// 3. Discord API (with cache update)
54    #[tracing::instrument(
55        skip(self, ctx),
56        fields(guild_id = %self.guild_id, channel_id = %self.channel_id)
57    )]
58    pub async fn get(&self, ctx: &Context) -> anyhow::Result<GuildChannel> {
59        if let Some(channel) = GUILD_CHANNEL_CACHE.get(&self.channel_id).await {
60            tracing::debug!("channel cache hit");
61            return Ok(channel);
62        }
63        tracing::debug!("channel cache miss");
64
65        // `try_get_with` coalesces concurrent misses for the same guild into a
66        // single fetch, so `join_all`-ing several link expansions no longer
67        // fires one identical `channels` request per link.
68        let channel_list = GUILD_CHANNEL_LIST_CACHE
69            .try_get_with(self.guild_id, self.get_channel_list_from_api(ctx))
70            .await
71            .map_err(|e| anyhow::anyhow!("Failed to get channel list: {e}"))?;
72
73        let channel = match channel_list.get(&self.channel_id).cloned() {
74            Some(c) => c,
75            None => {
76                // Not in the channel list — it may be an active thread,
77                // which is not returned by the guild channels endpoint.
78                tracing::debug!("channel not in list, searching active threads");
79                let data = self
80                    .guild_id
81                    .get_active_threads(&ctx.http)
82                    .await
83                    .context("Failed to get active threads")?;
84                data.threads
85                    .iter()
86                    .find(|t| t.id == self.channel_id)
87                    .cloned()
88                    .ok_or_else(|| {
89                        tracing::debug!("channel not found in guild or active threads");
90                        anyhow::anyhow!("Channel not found in cache")
91                    })?
92            }
93        };
94
95        GUILD_CHANNEL_CACHE
96            .insert(self.channel_id, channel.clone())
97            .await;
98        tracing::trace!("inserted channel into cache");
99        Ok(channel)
100    }
101
102    /// Fetches the channel list from the Discord API.
103    ///
104    /// The caller inserts the result into [`GUILD_CHANNEL_LIST_CACHE`] via
105    /// `try_get_with`, so this does not touch the cache itself.
106    #[tracing::instrument(skip(self, ctx), fields(guild_id = %self.guild_id))]
107    async fn get_channel_list_from_api(
108        &self,
109        ctx: &Context,
110    ) -> anyhow::Result<HashMap<ChannelId, GuildChannel>> {
111        tracing::debug!("fetching channel list from Discord API");
112        let started = std::time::Instant::now();
113        let channels = self
114            .guild_id
115            .channels(&ctx.http)
116            .await
117            .context("Failed to get channel list")?;
118
119        tracing::debug!(
120            channels = channels.len(),
121            elapsed_ms = started.elapsed().as_millis(),
122            "fetched channel list from Discord API"
123        );
124
125        Ok(channels)
126    }
127}