Skip to main content

shared/
cache.rs

1use crate::{env::RedisMode, response::ApiResponse};
2use axum::http::StatusCode;
3use compact_str::ToCompactString;
4use rand::distr::SampleString;
5use rustis::{
6    client::Client,
7    commands::{
8        GenericCommands, InfoSection, ScriptingCommands, ServerCommands, SetCondition,
9        SetExpiration, StringCommands,
10    },
11    resp::BulkString,
12};
13use serde::{Serialize, de::DeserializeOwned};
14use std::{
15    borrow::Cow,
16    collections::HashMap,
17    future::Future,
18    sync::{
19        Arc,
20        atomic::{AtomicU8, AtomicU64, Ordering},
21    },
22    time::{Duration, Instant},
23};
24
25const RATELIMIT_SCRIPT: &str = r#"
26local hits = redis.call('INCR', KEYS[1])
27local ttl = redis.call('TTL', KEYS[1])
28if ttl < 0 then
29  redis.call('EXPIRE', KEYS[1], ARGV[1])
30  ttl = tonumber(ARGV[1])
31end
32return {hits, ttl}
33"#;
34
35const LOCK_RELEASE_SCRIPT: &str = r#"
36if redis.call('GET', KEYS[1]) == ARGV[1] then
37  return redis.call('DEL', KEYS[1])
38end
39return 0
40"#;
41
42#[derive(Clone, Serialize)]
43pub struct BulkStringRef<'a>(
44    #[serde(
45        deserialize_with = "::rustis::resp::deserialize_byte_buf",
46        serialize_with = "::rustis::resp::serialize_byte_buf"
47    )]
48    pub &'a [u8],
49);
50
51#[derive(Clone, Debug)]
52struct DataEntry {
53    data: Arc<Vec<u8>>,
54    intended_ttl: Duration,
55}
56
57#[derive(Clone, Debug)]
58pub struct Resolution {
59    pub uuid: uuid::Uuid,
60    pub fingerprint: Arc<Vec<u8>>,
61}
62
63#[derive(Debug)]
64pub struct SharedComputeError(pub Arc<anyhow::Error>);
65
66impl std::fmt::Display for SharedComputeError {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        std::fmt::Display::fmt(&self.0, f)
69    }
70}
71
72impl std::error::Error for SharedComputeError {
73    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
74        Some(&**self.0)
75    }
76}
77
78struct DataExpiry;
79
80impl moka::Expiry<compact_str::CompactString, DataEntry> for DataExpiry {
81    fn expire_after_create(
82        &self,
83        _key: &compact_str::CompactString,
84        value: &DataEntry,
85        _created_at: Instant,
86    ) -> Option<Duration> {
87        Some(value.intended_ttl)
88    }
89}
90
91const OUTCOME_COALESCED: u8 = 0;
92const OUTCOME_REMOTE_HIT: u8 = 1;
93const OUTCOME_MISS: u8 = 2;
94
95fn is_dynamic_key_segment(segment: &str) -> bool {
96    uuid::Uuid::try_parse(segment).is_ok()
97        || (!segment.is_empty() && segment.bytes().all(|byte| byte.is_ascii_digit()))
98}
99
100fn key_prefix(key: &str) -> compact_str::CompactString {
101    if !key.split("::").any(is_dynamic_key_segment) {
102        return key.to_compact_string();
103    }
104
105    let mut prefix = compact_str::CompactString::const_new("");
106    for (index, segment) in key.split("::").enumerate() {
107        if index > 0 {
108            prefix.push_str("::");
109        }
110
111        prefix.push_str(if is_dynamic_key_segment(segment) {
112            "*"
113        } else {
114            segment
115        });
116    }
117
118    prefix
119}
120
121#[derive(Default)]
122struct CacheBucket {
123    calls: AtomicU64,
124    latency_ns_total: AtomicU64,
125}
126
127impl CacheBucket {
128    fn record(&self, latency_ns: u64) {
129        self.calls.fetch_add(1, Ordering::Relaxed);
130        self.latency_ns_total
131            .fetch_add(latency_ns, Ordering::Relaxed);
132    }
133
134    fn stats(&self) -> CacheBucketStats {
135        CacheBucketStats {
136            calls: self.calls.load(Ordering::Relaxed),
137            latency_ns_total: self.latency_ns_total.load(Ordering::Relaxed),
138        }
139    }
140}
141
142#[derive(Clone, Copy)]
143pub struct CacheBucketStats {
144    pub calls: u64,
145    pub latency_ns_total: u64,
146}
147
148impl CacheBucketStats {
149    #[inline]
150    pub fn average_latency_ns(&self) -> u64 {
151        self.latency_ns_total.checked_div(self.calls).unwrap_or(0)
152    }
153
154    fn merge(&self, other: &Self) -> Self {
155        Self {
156            calls: self.calls + other.calls,
157            latency_ns_total: self.latency_ns_total + other.latency_ns_total,
158        }
159    }
160}
161
162pub struct CacheStats {
163    pub local_hits: CacheBucketStats,
164    pub remote_hits: CacheBucketStats,
165    pub coalesced_waits: CacheBucketStats,
166    pub misses: CacheBucketStats,
167    pub max_latency_ns: u64,
168}
169
170impl CacheStats {
171    #[inline]
172    pub fn total_calls(&self) -> u64 {
173        self.local_hits.calls
174            + self.remote_hits.calls
175            + self.coalesced_waits.calls
176            + self.misses.calls
177    }
178
179    #[inline]
180    pub fn hits(&self) -> CacheBucketStats {
181        self.local_hits.merge(&self.remote_hits)
182    }
183}
184
185pub struct Cache {
186    client: Option<Arc<Client>>,
187    redis_prefix: Option<compact_str::CompactString>,
188    use_internal_cache: bool,
189    local: moka::future::Cache<compact_str::CompactString, DataEntry>,
190    local_task: tokio::task::JoinHandle<()>,
191    local_locks: LocalLocks,
192    local_locks_task: tokio::task::JoinHandle<()>,
193    local_ratelimits: moka::future::Cache<compact_str::CompactString, (u64, u64)>,
194    local_resolutions: moka::future::Cache<compact_str::CompactString, Resolution>,
195
196    cache_local_hits: CacheBucket,
197    cache_remote_hits: CacheBucket,
198    cache_coalesced_waits: CacheBucket,
199    cache_misses: CacheBucket,
200    cache_latency_ns_max: AtomicU64,
201}
202
203async fn connect_client(label: &str, url: &str) -> Client {
204    crate::retry::startup_connect(label, || Client::connect(url)).await
205}
206
207impl Cache {
208    pub async fn new(env: &crate::env::Env) -> Arc<Self> {
209        let start = std::time::Instant::now();
210
211        let client = match &env.redis_mode {
212            RedisMode::Redis { redis_url } => {
213                if let Some(redis_url) = redis_url {
214                    Some(Arc::new(connect_client("redis", redis_url).await))
215                } else {
216                    None
217                }
218            }
219            RedisMode::Sentinel {
220                cluster_name,
221                redis_sentinels,
222            } => Some(Arc::new(
223                connect_client(
224                    "redis sentinel",
225                    &format!(
226                        "redis-sentinel://{}/{cluster_name}/0",
227                        redis_sentinels.join(",")
228                    ),
229                )
230                .await,
231            )),
232        };
233
234        let local = moka::future::Cache::builder()
235            .max_capacity(16384)
236            .expire_after(DataExpiry)
237            .build();
238
239        let local_task = tokio::spawn({
240            let local = local.clone();
241
242            async move {
243                loop {
244                    tokio::time::sleep(Duration::from_secs(10)).await;
245                    local.run_pending_tasks().await;
246                }
247            }
248        });
249
250        let local_locks = LocalLocks::default();
251
252        let local_locks_task = tokio::spawn({
253            let local_locks = local_locks.clone();
254
255            async move {
256                loop {
257                    tokio::time::sleep(Duration::from_secs(10)).await;
258                    local_locks
259                        .lock()
260                        .retain(|_, semaphore| Arc::strong_count(semaphore) > 1);
261                }
262            }
263        });
264
265        let local_ratelimits = moka::future::Cache::builder().max_capacity(16384).build();
266        let local_resolutions = moka::future::Cache::builder()
267            .max_capacity(65536)
268            .time_to_idle(Duration::from_secs(3600))
269            .build();
270
271        let instance = Arc::new(Self {
272            client,
273            redis_prefix: env
274                .redis_key_prefix
275                .as_deref()
276                .map(|prefix| compact_str::format_compact!("{prefix}::")),
277            use_internal_cache: env.app_use_internal_cache,
278            local,
279            local_task,
280            local_locks,
281            local_locks_task,
282            local_ratelimits,
283            local_resolutions,
284            cache_local_hits: CacheBucket::default(),
285            cache_remote_hits: CacheBucket::default(),
286            cache_coalesced_waits: CacheBucket::default(),
287            cache_misses: CacheBucket::default(),
288            cache_latency_ns_max: AtomicU64::new(0),
289        });
290
291        let version = instance
292            .version()
293            .await
294            .unwrap_or_else(|_| "unknown".into());
295
296        tracing::info!(
297            "cache connected (redis@{}, {}ms, moka_enabled={})",
298            version,
299            start.elapsed().as_millis(),
300            env.app_use_internal_cache
301        );
302
303        instance
304    }
305
306    fn redis_key<'a>(&self, key: &'a str) -> Cow<'a, str> {
307        match &self.redis_prefix {
308            Some(prefix) => Cow::Owned(format!("{prefix}{key}")),
309            None => Cow::Borrowed(key),
310        }
311    }
312
313    pub async fn version(&self) -> Result<compact_str::CompactString, rustis::Error> {
314        let Some(client) = &self.client else {
315            return Ok("memory-only".into());
316        };
317
318        let version: String = client.info([InfoSection::Server]).await?;
319        let version = version
320            .lines()
321            .find(|line| line.starts_with("valkey_version:"))
322            .or_else(|| {
323                version
324                    .lines()
325                    .find(|line| line.starts_with("redis_version:"))
326            })
327            .unwrap_or("_:unknown")
328            .split_once(':')
329            .map_or("unknown", |(_, v)| v.trim())
330            .into();
331
332        Ok(version)
333    }
334
335    pub async fn ratelimit(
336        &self,
337        limit_identifier: impl AsRef<str>,
338        limit: u64,
339        limit_window: u64,
340        client: impl AsRef<str>,
341    ) -> Result<(), ApiResponse> {
342        let key = compact_str::format_compact!(
343            "ratelimit::{}::{}",
344            limit_identifier.as_ref(),
345            client.as_ref()
346        );
347
348        let now = chrono::Utc::now().timestamp() as u64;
349
350        let remote = match &self.client {
351            Some(redis_client) => match redis_client
352                .eval::<(u64, i64)>(
353                    RATELIMIT_SCRIPT,
354                    [self.redis_key(&key).as_ref()],
355                    [limit_window],
356                )
357                .await
358            {
359                Ok((limit_used, ttl)) => Some((limit_used, now + ttl.max(0) as u64)),
360                Err(err) => {
361                    tracing::warn!(
362                        "failed to apply redis ratelimit for {key}, falling back to local ratelimit: {err:#?}"
363                    );
364
365                    None
366                }
367            },
368            None => None,
369        };
370
371        let (limit_used, expire_unix) = match remote {
372            Some(remote) => remote,
373            None => self
374                .local_ratelimits
375                .entry(key)
376                .and_upsert_with(|entry| {
377                    let current = entry
378                        .map(|entry| entry.into_value())
379                        .filter(|(_, expire_unix)| *expire_unix > now + 2);
380
381                    std::future::ready(match current {
382                        Some((limit_used, expire_unix)) => (limit_used + 1, expire_unix),
383                        None => (1, now + limit_window),
384                    })
385                })
386                .await
387                .into_value(),
388        };
389
390        if limit_used >= limit {
391            let retry_after = expire_unix.saturating_sub(now);
392
393            return Err(ApiResponse::error(format!(
394                "you are ratelimited, retry in {retry_after}s"
395            ))
396            .with_status(StatusCode::TOO_MANY_REQUESTS)
397            .with_header("X-RateLimit-Limit", limit.to_compact_string())
398            .with_header(
399                "X-RateLimit-Remaining",
400                limit.saturating_sub(limit_used).to_compact_string(),
401            )
402            .with_header("X-RateLimit-Reset", expire_unix.to_compact_string())
403            .with_header("Retry-After", retry_after.to_compact_string()));
404        }
405
406        Ok(())
407    }
408
409    pub async fn ratelimit_reached(
410        &self,
411        limit_identifier: impl AsRef<str>,
412        limit: u64,
413        client: impl AsRef<str>,
414    ) -> bool {
415        let key = compact_str::format_compact!(
416            "ratelimit::{}::{}",
417            limit_identifier.as_ref(),
418            client.as_ref()
419        );
420
421        let remote = match &self.client {
422            Some(redis_client) => match redis_client
423                .get::<Option<u64>>(self.redis_key(&key).as_ref())
424                .await
425            {
426                Ok(limit_used) => Some(limit_used.unwrap_or(0)),
427                Err(err) => {
428                    tracing::warn!(
429                        "failed to read redis ratelimit for {key}, falling back to local ratelimit: {err:#?}"
430                    );
431
432                    None
433                }
434            },
435            None => None,
436        };
437
438        let limit_used = match remote {
439            Some(limit_used) => limit_used,
440            None => {
441                let now = chrono::Utc::now().timestamp() as u64;
442
443                self.local_ratelimits
444                    .get(&key)
445                    .await
446                    .filter(|(_, expire_unix)| *expire_unix > now + 2)
447                    .map_or(0, |(limit_used, _)| limit_used)
448            }
449        };
450
451        limit_used >= limit
452    }
453
454    #[tracing::instrument(skip(self))]
455    pub async fn lock(
456        &self,
457        lock_id: impl Into<compact_str::CompactString> + std::fmt::Debug,
458        ttl: Option<u64>,
459        timeout: Option<u64>,
460    ) -> Result<CacheLock, anyhow::Error> {
461        let lock_id = lock_id.into();
462        let ttl_secs = ttl.unwrap_or(30).max(1);
463        let deadline = timeout.map(|ms| Instant::now() + Duration::from_millis(ms));
464
465        tracing::debug!("acquiring cache lock");
466
467        let semaphore = self
468            .local_locks
469            .lock()
470            .entry(lock_id.clone())
471            .or_insert_with(|| Arc::new(tokio::sync::Semaphore::new(1)))
472            .clone();
473
474        let permit = match deadline {
475            Some(dl) => {
476                let remaining = dl.saturating_duration_since(Instant::now());
477                tokio::time::timeout(remaining, semaphore.acquire_owned())
478                    .await
479                    .map_err(|_| anyhow::anyhow!("timed out waiting for cache lock `{}`", lock_id))?
480                    .map_err(|_| anyhow::anyhow!("semaphore closed for lock `{}`", lock_id))?
481            }
482            None => semaphore
483                .acquire_owned()
484                .await
485                .map_err(|_| anyhow::anyhow!("semaphore closed for lock `{}`", lock_id))?,
486        };
487
488        let Some(redis_client) = &self.client else {
489            tracing::debug!("acquired memory cache lock");
490            return Ok(CacheLock::new(
491                lock_id,
492                HeldLock {
493                    permit: Some(permit),
494                    redis: None,
495                },
496                ttl_secs,
497            ));
498        };
499
500        let redis = RedisLock {
501            client: redis_client.clone(),
502            key: self.redis_key(&format!("lock::{lock_id}")).into(),
503            token: rand::distr::Alphanumeric
504                .sample_string(&mut rand::rng(), 32)
505                .into(),
506        };
507
508        // constructed before SET NX so a cancelled acquire still releases its key
509        let held = HeldLock {
510            permit: Some(permit),
511            redis: Some(redis.clone()),
512        };
513
514        if !redis.acquire(ttl_secs, deadline).await {
515            anyhow::bail!("timed out acquiring redis lock `{}`", lock_id);
516        }
517
518        tracing::debug!("acquired redis cache lock");
519        Ok(CacheLock::new(lock_id, held, ttl_secs))
520    }
521
522    #[tracing::instrument(
523        skip(self, fn_compute),
524        fields(cache.prefix = %key_prefix(key), cache.outcome = tracing::field::Empty)
525    )]
526    pub async fn cached<
527        T: Serialize + DeserializeOwned + Send,
528        F: FnOnce() -> Fut,
529        Fut: Future<Output = Result<T, FutErr>>,
530        FutErr: Into<anyhow::Error> + Send + Sync + 'static,
531    >(
532        &self,
533        key: &str,
534        ttl: u64,
535        fn_compute: F,
536    ) -> Result<T, anyhow::Error> {
537        let effective_moka_ttl = if self.use_internal_cache {
538            Duration::from_secs(ttl)
539        } else {
540            Duration::from_millis(50)
541        };
542
543        let start_time = Instant::now();
544
545        if let Some(entry) = self.local.get(key).await {
546            let value = rmp_serde::from_slice::<T>(&entry.data);
547            tracing::Span::current().record("cache.outcome", "local_hit");
548            self.record_call(&self.cache_local_hits, start_time.elapsed());
549
550            return Ok(value?);
551        }
552
553        let client_opt = self.client.clone();
554        let redis_key = self.redis_key(key);
555        let outcome = AtomicU8::new(OUTCOME_COALESCED);
556
557        let entry = self
558            .local
559            .try_get_with(key.to_compact_string(), {
560                let outcome = &outcome;
561
562                async move {
563                    if let Some(client) = &client_opt {
564                        tracing::debug!("checking redis cache");
565                        let cached_value: Option<BulkString> = client
566                            .get(redis_key.as_ref())
567                            .await
568                            .map_err(|err| {
569                                tracing::error!("redis get error: {:?}", err);
570                                err
571                            })
572                            .ok()
573                            .flatten();
574
575                        if let Some(value) = cached_value {
576                            tracing::debug!("found in redis cache");
577                            outcome.store(OUTCOME_REMOTE_HIT, Ordering::Relaxed);
578
579                            return Ok(DataEntry {
580                                data: Arc::new(value.to_vec()),
581                                intended_ttl: effective_moka_ttl,
582                            });
583                        }
584                    }
585
586                    outcome.store(OUTCOME_MISS, Ordering::Relaxed);
587
588                    tracing::debug!("executing compute");
589                    let result = fn_compute().await.map_err(|e| e.into())?;
590                    tracing::debug!("executed compute");
591
592                    let serialized = rmp_serde::to_vec(&result)?;
593                    let serialized_arc = Arc::new(serialized);
594
595                    if let Some(client) = &client_opt {
596                        let _ = client
597                            .set_with_options(
598                                redis_key.as_ref(),
599                                BulkStringRef(&serialized_arc),
600                                None,
601                                SetExpiration::Ex(ttl),
602                            )
603                            .await;
604                    }
605
606                    Ok::<_, anyhow::Error>(DataEntry {
607                        data: serialized_arc,
608                        intended_ttl: effective_moka_ttl,
609                    })
610                }
611            })
612            .await;
613
614        let value = match entry {
615            Ok(internal_entry) => {
616                rmp_serde::from_slice::<T>(&internal_entry.data).map_err(anyhow::Error::new)
617            }
618            Err(arc_error) => Err(anyhow::Error::new(SharedComputeError(arc_error))),
619        };
620
621        let (bucket, outcome_label) = match outcome.load(Ordering::Relaxed) {
622            OUTCOME_REMOTE_HIT => (&self.cache_remote_hits, "remote_hit"),
623            OUTCOME_MISS => (&self.cache_misses, "miss"),
624            _ => (&self.cache_coalesced_waits, "coalesced_wait"),
625        };
626
627        tracing::Span::current().record("cache.outcome", outcome_label);
628        self.record_call(bucket, start_time.elapsed());
629
630        value
631    }
632
633    fn record_call(&self, bucket: &CacheBucket, elapsed: Duration) {
634        let latency_ns = elapsed.as_nanos() as u64;
635
636        bucket.record(latency_ns);
637        self.cache_latency_ns_max
638            .fetch_max(latency_ns, Ordering::Relaxed);
639    }
640
641    pub async fn get<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, anyhow::Error> {
642        if let Some(entry) = self.local.get(key).await {
643            tracing::debug!("get: found in moka cache");
644            return Ok(Some(rmp_serde::from_slice::<T>(&entry.data)?));
645        }
646
647        if let Some(client) = &self.client {
648            tracing::debug!("get: checking redis cache");
649            let cached_value: Option<BulkString> = client.get(self.redis_key(key).as_ref()).await?;
650
651            if let Some(value) = cached_value {
652                tracing::debug!("get: found in redis cache");
653                let data = Arc::new(value.to_vec());
654                return Ok(Some(rmp_serde::from_slice::<T>(&data)?));
655            }
656        }
657
658        Ok(None)
659    }
660
661    pub async fn get_raw(&self, key: &str) -> Result<Option<Arc<Vec<u8>>>, anyhow::Error> {
662        if let Some(entry) = self.local.get(key).await {
663            tracing::debug!("get_raw: found in moka cache");
664            return Ok(Some(entry.data.clone()));
665        }
666
667        if let Some(client) = &self.client {
668            tracing::debug!("get_raw: checking redis cache");
669            let cached_value: Option<BulkString> = client.get(self.redis_key(key).as_ref()).await?;
670
671            if let Some(value) = cached_value {
672                tracing::debug!("get_raw: found in redis cache");
673                return Ok(Some(Arc::new(value.to_vec())));
674            }
675        }
676
677        Ok(None)
678    }
679
680    pub async fn set<T: Serialize + Send + Sync>(
681        &self,
682        key: &str,
683        ttl: u64,
684        value: &T,
685    ) -> Result<(), anyhow::Error> {
686        let serialized = rmp_serde::to_vec(value)?;
687        let serialized_arc = Arc::new(serialized);
688
689        let effective_moka_ttl = if self.use_internal_cache {
690            Duration::from_secs(ttl)
691        } else {
692            Duration::from_millis(50)
693        };
694
695        self.local
696            .insert(
697                key.to_compact_string(),
698                DataEntry {
699                    data: serialized_arc.clone(),
700                    intended_ttl: effective_moka_ttl,
701                },
702            )
703            .await;
704
705        if let Some(client) = &self.client {
706            client
707                .set_with_options(
708                    self.redis_key(key).as_ref(),
709                    BulkStringRef(&serialized_arc),
710                    None,
711                    SetExpiration::Ex(ttl),
712                )
713                .await?;
714        }
715
716        Ok(())
717    }
718
719    pub async fn set_raw(
720        &self,
721        key: &str,
722        ttl: u64,
723        value: impl Into<Arc<Vec<u8>>>,
724    ) -> Result<(), anyhow::Error> {
725        let serialized_arc = value.into();
726
727        let effective_moka_ttl = if self.use_internal_cache {
728            Duration::from_secs(ttl)
729        } else {
730            Duration::from_millis(50)
731        };
732
733        self.local
734            .insert(
735                key.to_compact_string(),
736                DataEntry {
737                    data: serialized_arc.clone(),
738                    intended_ttl: effective_moka_ttl,
739                },
740            )
741            .await;
742
743        if let Some(client) = &self.client {
744            client
745                .set_with_options(
746                    self.redis_key(key).as_ref(),
747                    BulkStringRef(&serialized_arc),
748                    None,
749                    SetExpiration::Ex(ttl),
750                )
751                .await?;
752        }
753
754        Ok(())
755    }
756
757    pub async fn exists(&self, key: &str) -> Result<bool, anyhow::Error> {
758        if self.local.contains_key(key) {
759            return Ok(true);
760        }
761
762        if let Some(client) = &self.client {
763            Ok(client.exists(self.redis_key(key).as_ref()).await? > 0)
764        } else {
765            Ok(false)
766        }
767    }
768
769    pub async fn list(
770        &self,
771        prefix: &str,
772    ) -> Result<Vec<compact_str::CompactString>, anyhow::Error> {
773        if let Some(client) = &self.client {
774            let Some(redis_prefix) = &self.redis_prefix else {
775                return Ok(client.keys(format!("{prefix}*")).await?);
776            };
777
778            let mut pattern = String::with_capacity(redis_prefix.len() + prefix.len() + 1);
779            for c in redis_prefix.chars() {
780                if matches!(c, '*' | '?' | '[' | ']' | '\\') {
781                    pattern.push('\\');
782                }
783                pattern.push(c);
784            }
785            pattern.push_str(prefix);
786            pattern.push('*');
787
788            let keys: Vec<compact_str::CompactString> = client.keys(pattern).await?;
789            Ok(keys
790                .into_iter()
791                .filter_map(|key| key.strip_prefix(redis_prefix.as_str()).map(Into::into))
792                .collect())
793        } else {
794            let mut keys = Vec::new();
795            for (key, _) in self.local.iter() {
796                if key.starts_with(prefix) {
797                    keys.push(key.to_compact_string());
798                }
799            }
800            Ok(keys)
801        }
802    }
803
804    pub async fn resolution(&self, key: &str) -> Option<Resolution> {
805        self.local_resolutions.get(key).await
806    }
807
808    pub async fn resolve<
809        F: FnOnce() -> Fut,
810        Fut: Future<Output = Result<Resolution, anyhow::Error>>,
811    >(
812        &self,
813        key: &str,
814        fn_resolve: F,
815    ) -> Result<Resolution, anyhow::Error> {
816        self.local_resolutions
817            .try_get_with(key.to_compact_string(), fn_resolve())
818            .await
819            .map_err(|err| anyhow::Error::new(SharedComputeError(err)))
820    }
821
822    pub async fn remove_resolution(&self, key: &str) {
823        self.local_resolutions.invalidate(key).await;
824    }
825
826    pub async fn invalidate(&self, key: &str) -> Result<(), anyhow::Error> {
827        self.local.invalidate(key).await;
828        if let Some(client) = &self.client {
829            client.del(self.redis_key(key).as_ref()).await?;
830        }
831
832        Ok(())
833    }
834
835    pub fn stats(&self) -> CacheStats {
836        CacheStats {
837            local_hits: self.cache_local_hits.stats(),
838            remote_hits: self.cache_remote_hits.stats(),
839            coalesced_waits: self.cache_coalesced_waits.stats(),
840            misses: self.cache_misses.stats(),
841            max_latency_ns: self.cache_latency_ns_max.load(Ordering::Relaxed),
842        }
843    }
844}
845
846impl Drop for Cache {
847    fn drop(&mut self) {
848        self.local_task.abort();
849        self.local_locks_task.abort();
850    }
851}
852
853type LocalLocks =
854    Arc<parking_lot::Mutex<HashMap<compact_str::CompactString, Arc<tokio::sync::Semaphore>>>>;
855
856#[derive(Clone)]
857struct RedisLock {
858    client: Arc<Client>,
859    key: compact_str::CompactString,
860    token: compact_str::CompactString,
861}
862
863impl RedisLock {
864    async fn acquire(&self, ttl_secs: u64, deadline: Option<Instant>) -> bool {
865        loop {
866            match self
867                .client
868                .set_with_options(
869                    self.key.as_str(),
870                    self.token.as_str(),
871                    SetCondition::NX,
872                    SetExpiration::Ex(ttl_secs),
873                )
874                .await
875            {
876                Ok(true) => return true,
877                Ok(false) => {}
878                Err(err) => {
879                    tracing::warn!(key = %self.key, "failed to acquire redis lock: {err:#?}")
880                }
881            }
882
883            if let Some(dl) = deadline {
884                let remaining = dl.saturating_duration_since(Instant::now());
885                if remaining.is_zero() {
886                    return false;
887                }
888                tokio::time::sleep(remaining.min(Duration::from_millis(50))).await;
889            } else {
890                tokio::time::sleep(Duration::from_millis(50)).await;
891            }
892        }
893    }
894
895    async fn release(self) {
896        match tokio::time::timeout(
897            Duration::from_secs(2),
898            self.client.eval::<i64>(
899                LOCK_RELEASE_SCRIPT,
900                [self.key.as_str()],
901                [self.token.as_str()],
902            ),
903        )
904        .await
905        {
906            Ok(Ok(_)) => {}
907            Ok(Err(err)) => {
908                tracing::warn!(key = %self.key, "failed to release redis lock: {err:#?}")
909            }
910            Err(_) => tracing::warn!(key = %self.key, "timed out releasing redis lock"),
911        }
912    }
913}
914
915struct HeldLock {
916    permit: Option<tokio::sync::OwnedSemaphorePermit>,
917    redis: Option<RedisLock>,
918}
919
920impl HeldLock {
921    async fn release(mut self) {
922        if let Some(redis) = self.redis.take() {
923            redis.release().await;
924        }
925
926        self.permit.take();
927    }
928}
929
930impl Drop for HeldLock {
931    fn drop(&mut self) {
932        let permit = self.permit.take();
933
934        if let Some(redis) = self.redis.take()
935            && let Ok(runtime) = tokio::runtime::Handle::try_current()
936        {
937            runtime.spawn(async move {
938                redis.release().await;
939                drop(permit);
940            });
941        }
942    }
943}
944
945pub struct CacheLock {
946    release_tx: tokio::sync::oneshot::Sender<()>,
947}
948
949impl CacheLock {
950    fn new(lock_id: compact_str::CompactString, held: HeldLock, ttl_secs: u64) -> Self {
951        let (release_tx, release_rx) = tokio::sync::oneshot::channel();
952
953        tokio::spawn(async move {
954            tokio::select! {
955                _ = release_rx => {}
956                _ = tokio::time::sleep(Duration::from_secs(ttl_secs)) => {
957                    tracing::warn!(%lock_id, "cache lock TTL expired; releasing");
958                }
959            }
960
961            held.release().await;
962        });
963
964        Self { release_tx }
965    }
966
967    #[inline]
968    pub fn is_active(&self) -> bool {
969        !self.release_tx.is_closed()
970    }
971}
972
973#[cfg(test)]
974mod tests {
975    use super::*;
976    use crate::database::DatabaseError;
977
978    fn memory_only() -> Cache {
979        with_client(None)
980    }
981
982    fn with_client(client: Option<Arc<Client>>) -> Cache {
983        Cache {
984            client,
985            redis_prefix: None,
986            use_internal_cache: true,
987            local: moka::future::Cache::builder()
988                .max_capacity(16)
989                .expire_after(DataExpiry)
990                .build(),
991            local_task: tokio::spawn(async {}),
992            local_locks: LocalLocks::default(),
993            local_locks_task: tokio::spawn(async {}),
994            local_ratelimits: moka::future::Cache::builder().max_capacity(16).build(),
995            local_resolutions: moka::future::Cache::builder().max_capacity(16).build(),
996            cache_local_hits: CacheBucket::default(),
997            cache_remote_hits: CacheBucket::default(),
998            cache_coalesced_waits: CacheBucket::default(),
999            cache_misses: CacheBucket::default(),
1000            cache_latency_ns_max: AtomicU64::new(0),
1001        }
1002    }
1003
1004    fn row_not_found() -> anyhow::Error {
1005        DatabaseError::Sqlx(sqlx::Error::RowNotFound).into()
1006    }
1007
1008    fn is_row_not_found(err: &anyhow::Error) -> bool {
1009        err.chain().any(|err| {
1010            matches!(
1011                err.downcast_ref::<DatabaseError>(),
1012                Some(DatabaseError::Sqlx(sqlx::Error::RowNotFound))
1013            )
1014        })
1015    }
1016
1017    #[tokio::test]
1018    async fn ratelimit_reached_reads_without_counting() {
1019        let cache = memory_only();
1020
1021        assert!(!cache.ratelimit_reached("test", 2, "client").await);
1022        assert!(!cache.ratelimit_reached("test", 2, "client").await);
1023
1024        cache.ratelimit("test", 2, 60, "client").await.unwrap();
1025        assert!(!cache.ratelimit_reached("test", 2, "client").await);
1026
1027        cache.ratelimit("test", 2, 60, "client").await.unwrap_err();
1028        assert!(cache.ratelimit_reached("test", 2, "client").await);
1029        assert!(!cache.ratelimit_reached("test", 2, "other").await);
1030    }
1031
1032    #[tokio::test]
1033    async fn cached_calls_are_bucketed_by_outcome() {
1034        let cache = memory_only();
1035
1036        cache
1037            .cached("key", 10, || async {
1038                tokio::time::sleep(Duration::from_millis(10)).await;
1039                Ok::<u8, anyhow::Error>(1)
1040            })
1041            .await
1042            .unwrap();
1043        cache
1044            .cached("key", 10, || async { Ok::<u8, anyhow::Error>(2) })
1045            .await
1046            .unwrap();
1047
1048        let stats = cache.stats();
1049
1050        assert_eq!(stats.total_calls(), 2);
1051        assert_eq!(stats.misses.calls, 1);
1052        assert_eq!(stats.local_hits.calls, 1);
1053        assert_eq!(stats.remote_hits.calls, 0);
1054        assert_eq!(stats.coalesced_waits.calls, 0);
1055
1056        assert!(stats.misses.average_latency_ns() >= Duration::from_millis(10).as_nanos() as u64);
1057        assert!(
1058            stats.local_hits.average_latency_ns() < Duration::from_millis(10).as_nanos() as u64
1059        );
1060        assert_eq!(stats.max_latency_ns, stats.misses.average_latency_ns());
1061    }
1062
1063    #[tokio::test]
1064    async fn coalesced_waits_are_not_counted_as_hits() {
1065        let cache = Arc::new(memory_only());
1066        let (release, released) = tokio::sync::oneshot::channel::<()>();
1067
1068        let first = tokio::spawn({
1069            let cache = Arc::clone(&cache);
1070            async move {
1071                cache
1072                    .cached("key", 10, || async {
1073                        released.await.unwrap();
1074                        Ok::<u8, anyhow::Error>(1)
1075                    })
1076                    .await
1077            }
1078        });
1079        tokio::time::sleep(Duration::from_millis(20)).await;
1080
1081        let second = tokio::spawn({
1082            let cache = Arc::clone(&cache);
1083            async move {
1084                cache
1085                    .cached("key", 10, || async { Ok::<u8, anyhow::Error>(2) })
1086                    .await
1087            }
1088        });
1089        tokio::time::sleep(Duration::from_millis(20)).await;
1090        release.send(()).unwrap();
1091
1092        first.await.unwrap().unwrap();
1093        second.await.unwrap().unwrap();
1094
1095        let stats = cache.stats();
1096
1097        assert_eq!(stats.misses.calls, 1);
1098        assert_eq!(stats.coalesced_waits.calls, 1);
1099        assert_eq!(stats.hits().calls, 0);
1100        assert!(stats.coalesced_waits.average_latency_ns() > 0);
1101    }
1102
1103    #[tokio::test]
1104    async fn sole_caller_receives_the_original_error() {
1105        let cache = memory_only();
1106
1107        let err = cache
1108            .cached("key", 10, || async { Err::<u8, _>(row_not_found()) })
1109            .await
1110            .unwrap_err();
1111
1112        assert!(err.downcast_ref::<SharedComputeError>().is_some());
1113        assert!(is_row_not_found(&err));
1114    }
1115
1116    #[tokio::test]
1117    async fn coalesced_callers_can_reach_the_original_error() {
1118        let cache = Arc::new(memory_only());
1119        let (release, released) = tokio::sync::oneshot::channel::<()>();
1120
1121        let first = tokio::spawn({
1122            let cache = Arc::clone(&cache);
1123            async move {
1124                cache
1125                    .cached("key", 10, || async {
1126                        released.await.unwrap();
1127                        Err::<u8, _>(row_not_found())
1128                    })
1129                    .await
1130            }
1131        });
1132        tokio::time::sleep(Duration::from_millis(20)).await;
1133
1134        let second = tokio::spawn({
1135            let cache = Arc::clone(&cache);
1136            async move {
1137                cache
1138                    .cached("key", 10, || async { Ok::<u8, anyhow::Error>(1) })
1139                    .await
1140            }
1141        });
1142        tokio::time::sleep(Duration::from_millis(20)).await;
1143        release.send(()).unwrap();
1144
1145        let first = first.await.unwrap().unwrap_err();
1146        let second = second.await.unwrap().unwrap_err();
1147
1148        assert!(is_row_not_found(&first));
1149        assert!(is_row_not_found(&second));
1150    }
1151
1152    async fn redis_backed() -> Option<Cache> {
1153        let url = std::env::var("TEST_REDIS_URL").ok()?;
1154
1155        Some(with_client(Some(Arc::new(
1156            Client::connect(url).await.unwrap(),
1157        ))))
1158    }
1159
1160    fn unique_lock_id() -> String {
1161        format!("test::{}", uuid::Uuid::new_v4())
1162    }
1163
1164    async fn wait_for_key_gone(cache: &Cache, key: &str) -> bool {
1165        let client = cache.client.as_ref().unwrap();
1166        for _ in 0..40 {
1167            if client.exists(key).await.unwrap() == 0 {
1168                return true;
1169            }
1170            tokio::time::sleep(Duration::from_millis(50)).await;
1171        }
1172
1173        false
1174    }
1175
1176    // Cache::lock
1177    #[tokio::test]
1178    async fn lock_is_exclusive_until_dropped() {
1179        let cache = memory_only();
1180
1181        let guard = cache.lock("lock", None, Some(100)).await.unwrap();
1182        assert!(cache.lock("lock", None, Some(100)).await.is_err());
1183        cache.lock("other", None, Some(100)).await.unwrap();
1184
1185        drop(guard);
1186        cache.lock("lock", None, Some(1000)).await.unwrap();
1187    }
1188
1189    #[tokio::test]
1190    async fn lock_ttl_releases_live_guard() {
1191        let cache = memory_only();
1192
1193        let guard = cache.lock("lock", Some(1), None).await.unwrap();
1194        assert!(guard.is_active());
1195
1196        let _second = cache.lock("lock", Some(30), Some(2500)).await.unwrap();
1197        assert!(!guard.is_active());
1198    }
1199
1200    #[tokio::test]
1201    async fn redis_lock_ttl_releases_live_guard_in_process() {
1202        let Some(cache) = redis_backed().await else {
1203            return;
1204        };
1205        let id = unique_lock_id();
1206
1207        let guard = cache.lock(id.as_str(), Some(1), None).await.unwrap();
1208        let second = cache.lock(id.as_str(), Some(30), Some(2500)).await.unwrap();
1209        assert!(!guard.is_active());
1210
1211        drop(guard);
1212        drop(second);
1213        assert!(wait_for_key_gone(&cache, &format!("lock::{id}")).await);
1214    }
1215
1216    #[tokio::test]
1217    async fn redis_lock_stale_guard_keeps_next_holder() {
1218        let (Some(holder), Some(contender)) = (redis_backed().await, redis_backed().await) else {
1219            return;
1220        };
1221        let id = unique_lock_id();
1222        let key = format!("lock::{id}");
1223
1224        let stale = holder.lock(id.as_str(), Some(1), None).await.unwrap();
1225        let current = contender
1226            .lock(id.as_str(), Some(30), Some(2500))
1227            .await
1228            .unwrap();
1229
1230        drop(stale);
1231        tokio::time::sleep(Duration::from_millis(500)).await;
1232
1233        let still_held = holder
1234            .client
1235            .as_ref()
1236            .unwrap()
1237            .exists(key.as_str())
1238            .await
1239            .unwrap()
1240            == 1;
1241        let still_exclusive = holder.lock(id.as_str(), Some(30), Some(200)).await.is_err();
1242
1243        drop(current);
1244        assert!(wait_for_key_gone(&contender, &key).await);
1245        assert!(still_held);
1246        assert!(still_exclusive);
1247    }
1248
1249    #[tokio::test]
1250    async fn redis_lock_drop_releases_before_ttl() {
1251        let (Some(holder), Some(contender)) = (redis_backed().await, redis_backed().await) else {
1252            return;
1253        };
1254        let id = unique_lock_id();
1255        let key = format!("lock::{id}");
1256
1257        drop(holder.lock(id.as_str(), Some(30), None).await.unwrap());
1258        assert!(wait_for_key_gone(&holder, &key).await);
1259
1260        drop(
1261            contender
1262                .lock(id.as_str(), Some(30), Some(500))
1263                .await
1264                .unwrap(),
1265        );
1266        assert!(wait_for_key_gone(&holder, &key).await);
1267    }
1268
1269    #[tokio::test]
1270    async fn redis_lock_drop_keeps_foreign_holder() {
1271        let Some(cache) = redis_backed().await else {
1272            return;
1273        };
1274        let id = unique_lock_id();
1275        let key = format!("lock::{id}");
1276        let client = cache.client.clone().unwrap();
1277
1278        let guard = cache.lock(id.as_str(), Some(1), None).await.unwrap();
1279        client
1280            .set_with_options(key.as_str(), "foreign", None, SetExpiration::Ex(30))
1281            .await
1282            .unwrap();
1283
1284        drop(guard);
1285        tokio::time::sleep(Duration::from_millis(1500)).await;
1286
1287        let value: Option<String> = client.get(key.as_str()).await.unwrap();
1288        client.del(key.as_str()).await.unwrap();
1289        assert_eq!(value.as_deref(), Some("foreign"));
1290    }
1291}