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 1 hour, TTI 1 hour.
27///
28/// The time-to-live is not just a memory bound: a cached [`GuildChannel`] carries
29/// the permission overwrites that decide whether a link may be expanded, so a
30/// stale entry keeps authorizing against permissions that no longer exist.
31/// [`invalidate_channel`] drops entries as soon as Discord reports a change, and
32/// this bounds how long a change that never reached us — a missed event across a
33/// gateway session it could not resume — can stay in effect.
34fn channel_cache<K, V>(name: &str) -> Cache<K, V>
35where
36    K: std::hash::Hash + Eq + Send + Sync + 'static,
37    V: Clone + Send + Sync + 'static,
38{
39    CacheBuilder::new(500)
40        .name(name)
41        .time_to_idle(std::time::Duration::from_secs(3600))
42        .time_to_live(std::time::Duration::from_secs(3600))
43        .build()
44}
45
46/// Cache for guild channel lists, mapping guild IDs to their channel lists.
47pub static GUILD_CHANNEL_LIST_CACHE: LazyLock<Cache<GuildId, HashMap<ChannelId, GuildChannel>>> =
48    LazyLock::new(|| channel_cache("guild_channel_list_cache"));
49
50/// Cache for individual guild channels, mapping channel IDs to their channel data.
51pub static GUILD_CHANNEL_CACHE: LazyLock<Cache<ChannelId, GuildChannel>> =
52    LazyLock::new(|| channel_cache("guild_channel_cache"));
53
54/// Returns `true` when a channel resolved from cache is really in `guild_id`.
55fn belongs_to_guild(channel: &GuildChannel, guild_id: GuildId) -> bool {
56    channel.guild_id == guild_id
57}
58
59/// Drops every cached view of `channel_id`.
60///
61/// Both caches hold permission overwrites, and those decide whether a linked
62/// channel may be expanded. Serving them after Discord has changed them means
63/// authorizing against permissions that no longer exist — a channel made private
64/// would keep being treated as public. Callers invoke this from the gateway
65/// events that report such a change.
66///
67/// The whole guild's channel list goes too, not just the one entry: the list is a
68/// single cached value holding every channel's overwrites, so there is no way to
69/// replace one member of it.
70pub async fn invalidate_channel(guild_id: GuildId, channel_id: ChannelId) {
71    GUILD_CHANNEL_CACHE.invalidate(&channel_id).await;
72    GUILD_CHANNEL_LIST_CACHE.invalidate(&guild_id).await;
73    tracing::debug!(%guild_id, %channel_id, "invalidated channel caches");
74}
75
76impl CacheArgs {
77    /// Retrieves a guild channel from cache or fetches it from the API.
78    ///
79    /// The returned channel is always in [`Self::guild_id`]. [`GUILD_CHANNEL_CACHE`]
80    /// is keyed by channel id alone, so a hit is verified against the requested
81    /// guild before it is returned; the remaining steps are already scoped to the
82    /// guild.
83    ///
84    /// The result is only as fresh as the cache. [`invalidate_channel`] drops
85    /// entries when Discord reports a change and nothing survives the hour
86    /// regardless, but permission overwrites read from here can still lag a change
87    /// whose event never reached the bot.
88    ///
89    /// The lookup order is:
90    /// 1. Individual channel cache
91    /// 2. Guild channel list cache
92    /// 3. Discord API (with cache update)
93    #[tracing::instrument(
94        skip(self, ctx),
95        fields(guild_id = %self.guild_id, channel_id = %self.channel_id)
96    )]
97    pub async fn get(&self, ctx: &Context) -> anyhow::Result<GuildChannel> {
98        if let Some(channel) = GUILD_CHANNEL_CACHE.get(&self.channel_id).await {
99            // Channel ids are globally unique snowflakes, so a hit for another
100            // guild is not a stale entry to refresh — the caller asked for a
101            // channel that is not in the guild it named. Refuse rather than
102            // return it: the result feeds visibility checks whose role data is
103            // guild-local and would silently compare against the wrong guild.
104            if !belongs_to_guild(&channel, self.guild_id) {
105                tracing::warn!(
106                    cached_guild_id = %channel.guild_id,
107                    "channel cache hit belongs to another guild"
108                );
109                anyhow::bail!("Channel does not belong to the requested guild");
110            }
111            tracing::debug!("channel cache hit");
112            return Ok(channel);
113        }
114        tracing::debug!("channel cache miss");
115
116        // `try_get_with` coalesces concurrent misses for the same guild into a
117        // single fetch, so `join_all`-ing several link expansions no longer
118        // fires one identical `channels` request per link.
119        let channel_list = GUILD_CHANNEL_LIST_CACHE
120            .try_get_with(self.guild_id, self.get_channel_list_from_api(ctx))
121            .await
122            .map_err(|e| anyhow::anyhow!("Failed to get channel list: {e}"))?;
123
124        let channel = match channel_list.get(&self.channel_id).cloned() {
125            Some(c) => c,
126            None => {
127                // Not in the channel list — it may be an active thread,
128                // which is not returned by the guild channels endpoint.
129                tracing::debug!("channel not in list, searching active threads");
130                let data = self
131                    .guild_id
132                    .get_active_threads(&ctx.http)
133                    .await
134                    .context("Failed to get active threads")?;
135                data.threads
136                    .iter()
137                    .find(|t| t.id == self.channel_id)
138                    .cloned()
139                    .ok_or_else(|| {
140                        tracing::debug!("channel not found in guild or active threads");
141                        anyhow::anyhow!("Channel not found in cache")
142                    })?
143            }
144        };
145
146        GUILD_CHANNEL_CACHE
147            .insert(self.channel_id, channel.clone())
148            .await;
149        tracing::trace!("inserted channel into cache");
150        Ok(channel)
151    }
152
153    /// Fetches the channel list from the Discord API.
154    ///
155    /// The caller inserts the result into [`GUILD_CHANNEL_LIST_CACHE`] via
156    /// `try_get_with`, so this does not touch the cache itself.
157    #[tracing::instrument(skip(self, ctx), fields(guild_id = %self.guild_id))]
158    async fn get_channel_list_from_api(
159        &self,
160        ctx: &Context,
161    ) -> anyhow::Result<HashMap<ChannelId, GuildChannel>> {
162        tracing::debug!("fetching channel list from Discord API");
163        let started = std::time::Instant::now();
164        let channels = self
165            .guild_id
166            .channels(&ctx.http)
167            .await
168            .context("Failed to get channel list")?;
169
170        tracing::debug!(
171            channels = channels.len(),
172            elapsed_ms = started.elapsed().as_millis(),
173            "fetched channel list from Discord API"
174        );
175
176        Ok(channels)
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    // `GuildChannel` is `#[non_exhaustive]`, so it cannot be built with a struct
185    // literal outside serenity — hence `default()` plus assignment.
186    fn channel_in(guild_id: GuildId) -> GuildChannel {
187        let mut channel = GuildChannel::default();
188        channel.guild_id = guild_id;
189        channel
190    }
191
192    #[test]
193    fn channel_from_the_requested_guild_is_accepted() {
194        let guild = GuildId::new(1);
195        assert!(belongs_to_guild(&channel_in(guild), guild));
196    }
197
198    #[test]
199    fn channel_from_another_guild_is_rejected() {
200        assert!(!belongs_to_guild(
201            &channel_in(GuildId::new(2)),
202            GuildId::new(1)
203        ));
204    }
205
206    // The caches are process-wide statics, so each async test claims ids of its
207    // own rather than relying on ordering or isolation between tests.
208
209    #[tokio::test]
210    async fn invalidating_drops_the_individual_channel() {
211        let guild = GuildId::new(900);
212        let channel = ChannelId::new(901);
213        GUILD_CHANNEL_CACHE.insert(channel, channel_in(guild)).await;
214
215        invalidate_channel(guild, channel).await;
216
217        assert!(GUILD_CHANNEL_CACHE.get(&channel).await.is_none());
218    }
219
220    #[tokio::test]
221    async fn invalidating_drops_the_whole_guild_channel_list() {
222        let guild = GuildId::new(910);
223        let channel = ChannelId::new(911);
224        let other = ChannelId::new(912);
225        // The list is one cached value covering every channel in the guild, so a
226        // change to one of them has to discard all of it.
227        let mut list = HashMap::new();
228        list.insert(channel, channel_in(guild));
229        list.insert(other, channel_in(guild));
230        GUILD_CHANNEL_LIST_CACHE.insert(guild, list).await;
231
232        invalidate_channel(guild, channel).await;
233
234        assert!(GUILD_CHANNEL_LIST_CACHE.get(&guild).await.is_none());
235    }
236
237    #[tokio::test]
238    async fn invalidating_leaves_other_guilds_alone() {
239        let target = GuildId::new(920);
240        let bystander = GuildId::new(921);
241        let bystander_channel = ChannelId::new(922);
242        GUILD_CHANNEL_LIST_CACHE
243            .insert(bystander, HashMap::new())
244            .await;
245        GUILD_CHANNEL_CACHE
246            .insert(bystander_channel, channel_in(bystander))
247            .await;
248
249        invalidate_channel(target, ChannelId::new(923)).await;
250
251        assert!(GUILD_CHANNEL_LIST_CACHE.get(&bystander).await.is_some());
252        assert!(GUILD_CHANNEL_CACHE.get(&bystander_channel).await.is_some());
253    }
254}