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 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 #[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}