1use std::any::Any;
40use std::sync::Arc;
41use std::time::Duration;
42
43use autumn_web::cache::{
44 Cache, CacheBackendError, CacheFuture, FillEpoch, FillLockStatus, InvalidationError,
45 RawCacheBytes, jittered_ttl, record_invalidation_failure,
46};
47use redis::AsyncCommands as _;
48use redis::aio::ConnectionManager;
49use thiserror::Error;
50use tracing::{debug, warn};
51
52const RELEASE_FILL_LOCK_SCRIPT: &str = r"
57if redis.call('GET', KEYS[1]) == ARGV[1] then
58 return redis.call('DEL', KEYS[1])
59else
60 return 0
61end
62";
63
64const FENCED_INSERT_SCRIPT: &str = r"
69local current = redis.call('GET', KEYS[2])
70if not current then current = '0' end
71if current ~= ARGV[1] then return 0 end
72if ARGV[3] == '' then
73 redis.call('SET', KEYS[1], ARGV[2])
74else
75 redis.call('PSETEX', KEYS[1], ARGV[3], ARGV[2])
76end
77return 1
78";
79
80static FENCED_INSERT_WARNED: std::sync::atomic::AtomicBool =
82 std::sync::atomic::AtomicBool::new(false);
83
84static FENCED_INSERT: std::sync::LazyLock<redis::Script> =
86 std::sync::LazyLock::new(|| redis::Script::new(FENCED_INSERT_SCRIPT));
87
88const EPOCH_SEGMENT: &str = "__autumn_epoch__:";
91
92fn is_reserved_key(key: &str) -> bool {
95 key.starts_with(EPOCH_SEGMENT)
96}
97
98#[derive(Debug, Error)]
100pub enum RedisCacheError {
101 #[error("Redis connection error: {0}")]
102 Connection(#[from] redis::RedisError),
103 #[error("missing Redis URL in cache config")]
104 MissingUrl,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118#[non_exhaustive]
119pub struct InvalidationRetry {
120 pub max_attempts: u32,
122 pub base_backoff: Duration,
124 pub max_backoff: Duration,
126}
127
128impl Default for InvalidationRetry {
129 fn default() -> Self {
132 Self::new(3, Duration::from_millis(20), Duration::from_millis(200))
133 }
134}
135
136impl InvalidationRetry {
137 #[must_use]
140 pub const fn new(max_attempts: u32, base_backoff: Duration, max_backoff: Duration) -> Self {
141 Self {
142 max_attempts,
143 base_backoff,
144 max_backoff,
145 }
146 }
147
148 #[must_use]
150 pub const fn attempts(&self) -> u32 {
151 if self.max_attempts == 0 {
152 1
153 } else {
154 self.max_attempts
155 }
156 }
157
158 #[must_use]
162 pub fn backoff(&self, attempt: u32) -> Duration {
163 let nominal = self
164 .base_backoff
165 .saturating_mul(1_u32 << attempt.min(20))
166 .min(self.max_backoff);
167 jittered_ttl(nominal, 0.5).min(self.max_backoff)
168 }
169}
170
171#[derive(Clone)]
203pub struct RedisCache {
204 manager: ConnectionManager,
205 key_prefix: String,
206 invalidation_retry: InvalidationRetry,
207}
208
209fn ttl_millis_for_redis(ttl: std::time::Duration) -> u64 {
210 let millis = ttl.as_millis().max(1);
211 u64::try_from(millis).unwrap_or(u64::MAX)
212}
213
214fn escape_redis_glob(s: &str) -> String {
224 let mut escaped = String::with_capacity(s.len());
225 for ch in s.chars() {
226 if matches!(ch, '\\' | '*' | '?' | '[') {
227 escaped.push('\\');
228 }
229 escaped.push(ch);
230 }
231 escaped
232}
233
234impl RedisCache {
235 pub async fn connect(
241 url: &str,
242 key_prefix: impl Into<String>,
243 ) -> Result<Self, RedisCacheError> {
244 let client = autumn_web::redis_tls::open_client(url)?;
251 let manager = ConnectionManager::new(client).await?;
252 Ok(Self {
253 manager,
254 key_prefix: key_prefix.into(),
255 invalidation_retry: InvalidationRetry::default(),
256 })
257 }
258
259 pub async fn from_config(
266 config: &autumn_web::config::CacheRedisConfig,
267 ) -> Result<Self, RedisCacheError> {
268 let url = config.url.as_deref().ok_or(RedisCacheError::MissingUrl)?;
269 Self::connect(url, &config.key_prefix).await
270 }
271
272 #[must_use]
274 pub const fn with_invalidation_retry(mut self, retry: InvalidationRetry) -> Self {
275 self.invalidation_retry = retry;
276 self
277 }
278
279 fn prefixed(&self, key: &str) -> String {
280 format!("{}:{}", self.key_prefix, key)
281 }
282
283 fn fill_lock_key(&self, key: &str) -> String {
295 format!("__autumn_fill_lock__:{}:{}", self.key_prefix, key)
296 }
297
298 fn epoch_key(&self, namespace: &str) -> String {
305 self.prefixed(&format!("{EPOCH_SEGMENT}{namespace}"))
306 }
307
308 async fn bump_epoch(&self, namespace: &str) -> Result<(), InvalidationError> {
310 let key = self.epoch_key(namespace);
311 self.retry_invalidation(&key, || {
312 let mut conn = self.manager.clone();
313 let key = key.clone();
314 async move { conn.incr::<_, _, i64>(key, 1).await.map(|_| ()) }
315 })
316 .await
317 }
318
319 async fn invalidate_namespace_fenced(&self, namespace: &str) -> Result<(), InvalidationError> {
325 let bumped = self.bump_epoch(namespace).await;
326 let swept = self
327 .sweep_with_retry(&self.namespace_pattern(namespace))
328 .await;
329 bumped.and(swept)
330 }
331
332 fn redis_get(&self, key: &str) -> Option<Vec<u8>> {
333 self.try_redis_get(key).ok().flatten()
334 }
335
336 fn try_redis_get(&self, key: &str) -> Result<Option<Vec<u8>>, redis::RedisError> {
338 let prefixed = self.prefixed(key);
339 let mut conn = self.manager.clone();
340 tokio::task::block_in_place(|| {
341 tokio::runtime::Handle::current()
342 .block_on(async move { conn.get::<_, Option<Vec<u8>>>(&prefixed).await })
343 })
344 }
345
346 fn redis_set(&self, key: &str, bytes: Vec<u8>, ttl: Option<std::time::Duration>) {
347 let _ = self.try_redis_set(key, bytes, ttl);
350 }
351
352 fn try_redis_set(
354 &self,
355 key: &str,
356 bytes: Vec<u8>,
357 ttl: Option<std::time::Duration>,
358 ) -> Result<(), CacheBackendError> {
359 if is_reserved_key(key) {
360 warn!(key, "RedisCache: refused a write to a reserved epoch key");
361 return Err(CacheBackendError::new("the key is reserved"));
362 }
363 let prefixed = self.prefixed(key);
364 let mut conn = self.manager.clone();
365 tokio::task::block_in_place(|| {
366 tokio::runtime::Handle::current().block_on(async move {
367 if let Some(ttl) = ttl {
368 let millis = ttl_millis_for_redis(ttl);
369 redis::cmd("PSETEX")
370 .arg(&prefixed)
371 .arg(millis)
372 .arg(bytes)
373 .query_async::<()>(&mut conn)
374 .await
375 } else {
376 conn.set::<_, _, ()>(&prefixed, bytes).await
377 }
378 })
379 })
380 .map_err(|error| CacheBackendError::new(error.to_string()))
381 }
382
383 async fn sweep(&self, pattern: &str) -> Result<(), redis::RedisError> {
393 let mut conn = self.manager.clone();
394 let mut cursor: u64 = 0;
395 loop {
396 let (next_cursor, keys): (u64, Vec<String>) = redis::cmd("SCAN")
397 .arg(cursor)
398 .arg("MATCH")
399 .arg(pattern)
400 .arg("COUNT")
401 .arg(100u32)
402 .query_async(&mut conn)
403 .await?;
404 let epoch_prefix = self.prefixed(EPOCH_SEGMENT);
405 let keys: Vec<String> = keys
406 .into_iter()
407 .filter(|key| !key.starts_with(&epoch_prefix))
408 .collect();
409 if !keys.is_empty() {
410 conn.del::<_, ()>(keys).await?;
411 }
412 cursor = next_cursor;
413 if cursor == 0 {
414 return Ok(());
415 }
416 }
417 }
418
419 async fn retry_invalidation<F, Fut>(
424 &self,
425 target: &str,
426 mut op: F,
427 ) -> Result<(), InvalidationError>
428 where
429 F: FnMut() -> Fut + Send,
430 Fut: std::future::Future<Output = Result<(), redis::RedisError>> + Send,
431 {
432 let attempts = self.invalidation_retry.attempts();
433 let mut last_error = String::new();
434 for attempt in 0..attempts {
435 if attempt > 0 {
436 tokio::time::sleep(self.invalidation_retry.backoff(attempt - 1)).await;
437 }
438 match op().await {
439 Ok(()) => return Ok(()),
440 Err(error) => {
441 debug!(
442 target,
443 attempt = attempt + 1,
444 attempts,
445 error = %error,
446 "RedisCache: invalidation attempt failed"
447 );
448 last_error = error.to_string();
449 }
450 }
451 }
452 Err(InvalidationError::new(attempts, last_error))
453 }
454
455 async fn delete_key(&self, key: &str) -> Result<(), InvalidationError> {
457 if is_reserved_key(key) {
458 warn!(key, "RedisCache: refused to delete a reserved epoch key");
459 return Ok(());
460 }
461 let prefixed = self.prefixed(key);
462 self.retry_invalidation(key, || {
463 let mut conn = self.manager.clone();
464 let prefixed = prefixed.clone();
465 async move { conn.del::<_, ()>(prefixed).await }
466 })
467 .await
468 }
469
470 async fn sweep_with_retry(&self, pattern: &str) -> Result<(), InvalidationError> {
472 self.retry_invalidation(pattern, || self.sweep(pattern))
473 .await
474 }
475
476 fn namespace_pattern(&self, namespace: &str) -> String {
482 format!(
483 "{}:{}:*",
484 escape_redis_glob(&self.key_prefix),
485 escape_redis_glob(namespace)
486 )
487 }
488}
489
490fn block_on<T>(future: impl std::future::Future<Output = T>) -> T {
493 tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
494}
495
496fn report_dropped_invalidation_error(target: &str, error: &InvalidationError) {
498 warn!(
499 target,
500 error = %error,
501 "RedisCache: invalidation failed; stale data can be served until its TTL"
502 );
503 record_invalidation_failure();
504}
505
506impl Cache for RedisCache {
507 fn get_value(&self, key: &str) -> Option<Arc<dyn Any + Send + Sync>> {
514 self.redis_get(key)
515 .map(|bytes| Arc::new(RawCacheBytes(bytes)) as Arc<dyn Any + Send + Sync>)
516 }
517
518 fn insert_value(&self, key: &str, value: Arc<dyn Any + Send + Sync>) {
528 let bytes: Option<Vec<u8>> = value
531 .downcast_ref::<RawCacheBytes>()
532 .map(|raw| raw.0.clone())
533 .or_else(|| {
534 value
535 .downcast_ref::<String>()
536 .and_then(|s| serde_json::to_vec(s).ok())
537 })
538 .or_else(|| {
539 value
540 .downcast_ref::<i64>()
541 .and_then(|n| serde_json::to_vec(n).ok())
542 })
543 .or_else(|| {
544 value
545 .downcast_ref::<i32>()
546 .and_then(|n| serde_json::to_vec(n).ok())
547 });
548
549 if let Some(bytes) = bytes {
550 self.redis_set(key, bytes, None);
552 debug!(key, "RedisCache: inserted via insert_value");
553 }
554 }
555
556 fn insert_raw_bytes(&self, key: &str, bytes: Vec<u8>, ttl: Option<std::time::Duration>) {
566 self.redis_set(key, bytes, ttl);
567 debug!(key, "RedisCache: inserted via insert_raw_bytes");
568 }
569
570 fn try_get_value(
572 &self,
573 key: &str,
574 ) -> Result<Option<Arc<dyn Any + Send + Sync>>, CacheBackendError> {
575 self.try_redis_get(key)
576 .map(|found| {
577 found.map(|bytes| Arc::new(RawCacheBytes(bytes)) as Arc<dyn Any + Send + Sync>)
578 })
579 .map_err(|error| CacheBackendError::new(error.to_string()))
580 }
581
582 fn try_insert_raw_bytes(
584 &self,
585 key: &str,
586 bytes: Vec<u8>,
587 ttl: Option<std::time::Duration>,
588 ) -> Result<(), CacheBackendError> {
589 self.try_redis_set(key, bytes, ttl)
590 }
591
592 fn try_invalidate(&self, key: &str) -> Result<(), InvalidationError> {
596 if is_reserved_key(key) {
597 return Err(InvalidationError::new(0, "the key is reserved"));
598 }
599 block_on(self.delete_key(key))
600 }
601
602 fn shares_fill_epoch(&self) -> bool {
604 true
605 }
606
607 fn fill_epoch(&self, namespace: &str) -> FillEpoch {
610 let key = self.epoch_key(namespace);
611 let mut conn = self.manager.clone();
612 let read: Result<Option<u64>, _> = block_on(async move { conn.get(&key).await });
613 match read {
614 Ok(epoch) => FillEpoch::Sampled(epoch.unwrap_or(0)),
615 Err(error) => {
616 debug!(namespace, error = %error, "RedisCache: epoch read failed");
617 FillEpoch::Unavailable
618 }
619 }
620 }
621
622 fn insert_raw_bytes_if_epoch(
625 &self,
626 key: &str,
627 bytes: Vec<u8>,
628 ttl: Option<std::time::Duration>,
629 namespace: &str,
630 sampled: u64,
631 ) -> bool {
632 if is_reserved_key(key) {
633 warn!(key, "RedisCache: refused a write to a reserved epoch key");
634 return false;
635 }
636 let prefixed = self.prefixed(key);
637 let epoch_key = self.epoch_key(namespace);
638 let ttl_ms = ttl.map_or_else(String::new, |ttl| ttl_millis_for_redis(ttl).to_string());
639 let mut conn = self.manager.clone();
640 let result: Result<i64, _> = block_on(async move {
641 FENCED_INSERT
642 .key(&prefixed)
643 .key(&epoch_key)
644 .arg(sampled.to_string())
645 .arg(bytes)
646 .arg(ttl_ms)
647 .invoke_async(&mut conn)
648 .await
649 });
650 match result {
651 Ok(stored) => stored == 1,
652 Err(error) => {
653 if !FENCED_INSERT_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
656 warn!(
657 key,
658 error = %error,
659 "RedisCache: the fenced insert failed, so cached values are not stored. \
660 The Redis user needs +eval, +evalsha and +script|load"
661 );
662 }
663 debug!(key, error = %error, "RedisCache: fenced insert failed");
664 false
665 }
666 }
667 }
668
669 fn invalidate(&self, key: &str) {
673 match block_on(self.delete_key(key)) {
674 Ok(()) => debug!(key, "RedisCache: invalidated"),
675 Err(error) => report_dropped_invalidation_error(key, &error),
676 }
677 }
678
679 fn invalidate_namespace(&self, namespace: &str) -> bool {
682 match block_on(self.invalidate_namespace_fenced(namespace)) {
686 Ok(()) => true,
687 Err(error) => {
688 debug!(namespace, error = %error, "RedisCache: namespace sweep failed");
689 false
690 }
691 }
692 }
693
694 fn clear(&self) {
695 let pattern = format!("{}:*", escape_redis_glob(&self.key_prefix));
697 if let Err(error) = block_on(self.sweep_with_retry(&pattern)) {
698 report_dropped_invalidation_error(&pattern, &error);
699 }
700 }
701
702 fn invalidate_async<'a>(
705 &'a self,
706 key: &'a str,
707 ) -> CacheFuture<'a, Result<(), InvalidationError>> {
708 Box::pin(self.delete_key(key))
709 }
710
711 fn invalidate_namespace_async<'a>(
713 &'a self,
714 namespace: &'a str,
715 ) -> CacheFuture<'a, Result<(), InvalidationError>> {
716 Box::pin(self.invalidate_namespace_fenced(namespace))
717 }
718
719 fn try_acquire_fill_lock(&self, key: &str, token: &str, ttl: Duration) -> FillLockStatus {
724 let lock_key = self.fill_lock_key(key);
725 let millis = ttl_millis_for_redis(ttl);
726 let mut conn = self.manager.clone();
727 let token = token.to_owned();
728 let acquired = tokio::task::block_in_place(|| {
729 tokio::runtime::Handle::current().block_on(async move {
730 redis::cmd("SET")
731 .arg(&lock_key)
732 .arg(&token)
733 .arg("NX")
734 .arg("PX")
735 .arg(millis)
736 .query_async::<Option<String>>(&mut conn)
737 .await
738 })
739 });
740 match acquired {
741 Ok(Some(_)) => FillLockStatus::Acquired,
742 Ok(None) => FillLockStatus::Held,
743 Err(e) => {
744 debug!(key, error = %e, "RedisCache: fill lock acquire failed, treating as held");
745 FillLockStatus::Held
746 }
747 }
748 }
749
750 fn release_fill_lock(&self, key: &str, token: &str) {
754 let lock_key = self.fill_lock_key(key);
755 let mut conn = self.manager.clone();
756 let token = token.to_owned();
757 let result: Result<i64, _> = tokio::task::block_in_place(|| {
758 tokio::runtime::Handle::current().block_on(async move {
759 let script = redis::Script::new(RELEASE_FILL_LOCK_SCRIPT);
760 script
761 .key(&lock_key)
762 .arg(&token)
763 .invoke_async(&mut conn)
764 .await
765 })
766 });
767 match result {
768 Ok(_) => debug!(key, "RedisCache: released fill lock"),
769 Err(e) => {
770 debug!(key, error = %e, "RedisCache: fill lock release failed");
771 }
772 }
773 }
774}
775
776pub struct RedisCachePlugin;
786
787impl RedisCachePlugin {
788 #[must_use]
790 pub const fn new() -> Self {
791 Self
792 }
793}
794
795impl Default for RedisCachePlugin {
796 fn default() -> Self {
797 Self::new()
798 }
799}
800
801impl autumn_web::plugin::Plugin for RedisCachePlugin {
802 fn contract(&self) -> Option<autumn_web::plugin_contract::PluginContract> {
805 Some(autumn_web::plugin_contract::lockstep_contract(
806 env!("CARGO_PKG_NAME"),
807 env!("CARGO_PKG_VERSION"),
808 ))
809 }
810
811 fn build(self, app: autumn_web::app::AppBuilder) -> autumn_web::app::AppBuilder {
812 app.on_startup(|state| async move {
813 let config = state
815 .extension::<autumn_web::config::AutumnConfig>()
816 .expect("AutumnConfig must be registered before RedisCachePlugin startup");
817
818 if !config.cache.is_redis() {
819 return Ok(());
820 }
821
822 let redis_cfg = &config.cache.redis;
823 let cache = RedisCache::from_config(redis_cfg).await.map_err(|e| {
824 autumn_web::AutumnError::service_unavailable_msg(format!(
825 "Failed to connect RedisCache: {e}"
826 ))
827 })?;
828
829 state.set_cache(Arc::new(cache));
830 tracing::info!("RedisCache registered as global application cache");
831 register_cache_health_indicator(&state, &config);
832 Ok(())
833 })
834 }
835}
836
837fn register_cache_health_indicator(
841 state: &autumn_web::AppState,
842 config: &autumn_web::config::AutumnConfig,
843) {
844 let Some(url) = config.cache.redis.url.as_deref() else {
845 return;
846 };
847 match autumn_web::redis_health::RedisHealthIndicator::shared(state, url) {
849 Ok(indicator) => {
850 let indicator = indicator.configured(&config.health);
851 let group = autumn_web::actuator::HealthIndicator::group(&indicator);
852 if let Err(error) = state.health_indicator_registry().register(
853 "redis:cache",
854 group,
855 Arc::new(indicator),
856 ) {
857 tracing::warn!("{error}");
858 }
859 }
860 Err(error) => tracing::warn!(error = %error, "redis:cache health indicator not registered"),
861 }
862}
863
864#[cfg(test)]
865mod tests {
866 use super::*;
867
868 #[test]
869 fn cache_health_indicator_follows_the_health_config() {
870 let state = autumn_web::AppState::for_test();
871 let mut config = autumn_web::config::AutumnConfig::default();
872 config.cache.redis.url = Some("redis://127.0.0.1:1".to_owned());
873 config.health.redis_readiness = true;
874
875 register_cache_health_indicator(&state, &config);
876
877 assert!(state.health_indicator_registry().contains("redis:cache"));
878 }
879
880 #[test]
881 fn no_cache_health_indicator_without_a_url() {
882 let state = autumn_web::AppState::for_test();
883 let config = autumn_web::config::AutumnConfig::default();
884
885 register_cache_health_indicator(&state, &config);
886
887 assert!(!state.health_indicator_registry().contains("redis:cache"));
888 }
889 use autumn_web::cache::{FillEpoch, get_cached, insert_cached, insert_cached_fenced};
890 use std::sync::atomic::{AtomicUsize, Ordering};
891 use testcontainers::runners::AsyncRunner;
892 use testcontainers_modules::redis::Redis as RedisImage;
893
894 #[test]
895 fn contract_declares_lockstep_with_own_crate() {
896 use autumn_web::plugin::Plugin;
897
898 let contract = RedisCachePlugin::new().contract().expect("a contract");
899 assert_eq!(contract.plugin, env!("CARGO_PKG_NAME"));
900 assert_eq!(
901 contract.plugin_version.as_deref(),
902 Some(env!("CARGO_PKG_VERSION"))
903 );
904 assert_eq!(
905 contract.autumn_web.as_deref(),
906 Some(autumn_web::plugin_contract::lockstep_range(env!("CARGO_PKG_VERSION")).as_str())
907 );
908 assert!(contract.experimental_surfaces.is_empty());
909 }
910
911 #[test]
912 fn redis_ttl_millis_preserves_subsecond_precision() {
913 assert_eq!(
914 ttl_millis_for_redis(std::time::Duration::from_millis(100)),
915 100
916 );
917 assert_eq!(
918 ttl_millis_for_redis(std::time::Duration::from_millis(1_500)),
919 1_500
920 );
921 }
922
923 #[test]
924 fn redis_ttl_millis_never_uses_zero() {
925 assert_eq!(ttl_millis_for_redis(std::time::Duration::ZERO), 1);
926 }
927
928 #[test]
938 fn the_cache_never_builds_a_redis_client_outside_the_shared_tls_guard() {
939 let needles = [concat!("Client::", "open("), concat!("build_with", "_tls(")];
941 let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
942 let mut files = Vec::new();
943 collect_rust_sources(&src, &mut files);
944 assert!(!files.is_empty(), "no sources under {}", src.display());
945 files.sort();
946
947 let mut offenders = Vec::new();
948 for file in files {
949 let source = std::fs::read_to_string(&file).expect("read source");
950 for (index, line) in source.lines().enumerate() {
951 let trimmed = line.trim_start();
952 if trimmed.starts_with("//") || trimmed.starts_with('*') {
953 continue;
954 }
955 if needles.iter().any(|needle| line.contains(needle)) {
956 offenders.push(format!(
957 "{}:{}: {}",
958 file.strip_prefix(&src).unwrap_or(&file).display(),
959 index + 1,
960 line.trim()
961 ));
962 }
963 }
964 }
965 assert!(
966 offenders.is_empty(),
967 "build Redis clients through `autumn_web::redis_tls::open_client`, \
968 not directly — a `rediss://` URL otherwise reaches rustls with no \
969 process-level CryptoProvider installed and panics (#2172):\n{}",
970 offenders.join("\n")
971 );
972 }
973
974 fn collect_rust_sources(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
976 for entry in std::fs::read_dir(dir).expect("read_dir") {
977 let path = entry.expect("dir entry").path();
978 if path.is_dir() {
979 collect_rust_sources(&path, out);
980 } else if path.extension().is_some_and(|ext| ext == "rs") {
981 out.push(path);
982 }
983 }
984 }
985
986 #[test]
989 fn the_shared_guard_classifies_the_tls_schemes_this_cache_is_deployed_on() {
990 assert!(autumn_web::redis_tls::url_needs_tls_crypto_provider(
991 "rediss://cache.redis.cache.windows.net:6380/"
992 ));
993 assert!(!autumn_web::redis_tls::url_needs_tls_crypto_provider(
994 "redis://127.0.0.1:6379/"
995 ));
996 }
997
998 #[test]
999 fn escape_redis_glob_leaves_plain_prefixes_unchanged() {
1000 assert_eq!(escape_redis_glob("myapp:cache"), "myapp:cache");
1001 }
1002
1003 #[test]
1004 fn escape_redis_glob_escapes_scan_match_metacharacters() {
1005 assert_eq!(escape_redis_glob("tenant:*"), r"tenant:\*");
1011 assert_eq!(escape_redis_glob("a?b"), r"a\?b");
1012 assert_eq!(escape_redis_glob("[ab]"), r"\[ab]");
1013 assert_eq!(escape_redis_glob(r"back\slash"), r"back\\slash");
1014 }
1015
1016 #[tokio::test(flavor = "multi_thread")]
1017 async fn rediss_scheme_is_usable_without_a_tls_cargo_feature_error() {
1018 let result = RedisCache::connect("rediss://127.0.0.1:1/", "test").await;
1030 let Err(err) = result else {
1031 panic!("connecting to a closed port must fail");
1032 };
1033 let message = err.to_string();
1034 assert!(
1035 !message.contains("feature is not enabled") && !message.contains("without the tls"),
1036 "the `redis` crate must be built with a TLS feature (tokio-rustls-comp) \
1037 so `rediss://` URLs actually connect: {message}"
1038 );
1039 }
1040
1041 async fn admin_conn(url: &str) -> redis::aio::MultiplexedConnection {
1045 autumn_web::redis_tls::open_client(url)
1046 .expect("client")
1047 .get_multiplexed_async_connection()
1048 .await
1049 .expect("admin connection")
1050 }
1051
1052 async fn reject_writes(admin: &mut redis::aio::MultiplexedConnection) {
1055 redis::cmd("REPLICAOF")
1056 .arg("127.0.0.1")
1057 .arg(1)
1058 .query_async::<()>(admin)
1059 .await
1060 .expect("REPLICAOF");
1061 }
1062
1063 async fn accept_writes(admin: &mut redis::aio::MultiplexedConnection) {
1065 redis::cmd("REPLICAOF")
1066 .arg("NO")
1067 .arg("ONE")
1068 .query_async::<()>(admin)
1069 .await
1070 .expect("REPLICAOF NO ONE");
1071 }
1072
1073 async fn key_exists(admin: &mut redis::aio::MultiplexedConnection, key: &str) -> bool {
1074 admin.exists::<_, bool>(key).await.expect("EXISTS")
1075 }
1076
1077 const FAST_RETRY: InvalidationRetry = InvalidationRetry {
1078 max_attempts: 3,
1079 base_backoff: Duration::from_millis(1),
1080 max_backoff: Duration::from_millis(5),
1081 };
1082
1083 #[test]
1084 fn application_keys_cannot_use_the_reserved_epoch_segment() {
1085 assert!(is_reserved_key("__autumn_epoch__:orders"));
1086 assert!(!is_reserved_key("orders:abc"));
1087 assert!(!is_reserved_key("x:__autumn_epoch__:orders"));
1088 }
1089
1090 #[test]
1091 fn default_retry_policy_is_bounded() {
1092 let retry = InvalidationRetry::default();
1093 assert_eq!(retry.max_attempts, 3);
1094 assert!(retry.base_backoff <= retry.max_backoff);
1095 assert!(retry.max_backoff <= Duration::from_secs(1));
1096 }
1097
1098 #[test]
1099 fn retry_backoff_grows_and_never_passes_the_cap() {
1100 let retry = InvalidationRetry {
1101 max_attempts: 10,
1102 base_backoff: Duration::from_millis(10),
1103 max_backoff: Duration::from_millis(80),
1104 };
1105 for _ in 0..200 {
1106 for attempt in 0..10 {
1107 let nominal = retry
1108 .base_backoff
1109 .saturating_mul(1 << attempt.min(20))
1110 .min(retry.max_backoff);
1111 let delay = retry.backoff(attempt);
1112 assert!(delay <= retry.max_backoff, "{delay:?} passes the cap");
1113 assert!(delay >= nominal / 2, "{delay:?} below half of {nominal:?}");
1114 }
1115 }
1116 }
1117
1118 #[test]
1119 fn a_zero_attempt_policy_still_tries_once() {
1120 let retry = InvalidationRetry {
1121 max_attempts: 0,
1122 ..InvalidationRetry::default()
1123 };
1124 assert_eq!(retry.attempts(), 1);
1125 }
1126
1127 #[tokio::test]
1130 #[ignore = "requires Docker (testcontainers)"]
1131 async fn redis_invalidate_async_surfaces_readonly_del_failure() {
1132 let container = RedisImage::default().start().await.unwrap();
1133 let port = container.get_host_port_ipv4(6379).await.unwrap();
1134 let url = format!("redis://127.0.0.1:{port}");
1135 let mut admin = admin_conn(&url).await;
1136 let cache = RedisCache::connect(&url, "inv-fail")
1137 .await
1138 .unwrap()
1139 .with_invalidation_retry(FAST_RETRY);
1140
1141 admin.set::<_, _, ()>("inv-fail:k", "1").await.unwrap();
1142 reject_writes(&mut admin).await;
1143
1144 let err = cache
1145 .invalidate_async("k")
1146 .await
1147 .expect_err("a rejected DEL must surface as an error");
1148 assert_eq!(err.attempts(), 3, "all attempts must run: {err}");
1149 assert!(
1150 err.reason().to_ascii_lowercase().contains("read only"),
1151 "the reason must carry the Redis error: {err}"
1152 );
1153 assert!(
1154 key_exists(&mut admin, "inv-fail:k").await,
1155 "the key must still exist, so the error is true"
1156 );
1157
1158 accept_writes(&mut admin).await;
1159 cache
1160 .invalidate_async("k")
1161 .await
1162 .expect("DEL succeeds once Redis accepts writes");
1163 assert!(!key_exists(&mut admin, "inv-fail:k").await);
1164 }
1165
1166 #[tokio::test]
1167 #[ignore = "requires Docker (testcontainers)"]
1168 async fn redis_invalidate_retries_until_del_succeeds() {
1169 let container = RedisImage::default().start().await.unwrap();
1170 let port = container.get_host_port_ipv4(6379).await.unwrap();
1171 let url = format!("redis://127.0.0.1:{port}");
1172 let mut admin = admin_conn(&url).await;
1173 let cache = RedisCache::connect(&url, "inv-retry")
1176 .await
1177 .unwrap()
1178 .with_invalidation_retry(InvalidationRetry {
1179 max_attempts: 50,
1180 base_backoff: Duration::from_millis(10),
1181 max_backoff: Duration::from_millis(10),
1182 });
1183
1184 admin.set::<_, _, ()>("inv-retry:k", "1").await.unwrap();
1185 reject_writes(&mut admin).await;
1186
1187 let mut healer = admin.clone();
1188 let heal = tokio::spawn(async move {
1189 tokio::time::sleep(Duration::from_millis(30)).await;
1190 accept_writes(&mut healer).await;
1191 });
1192
1193 cache
1194 .invalidate_async("k")
1195 .await
1196 .expect("a transient DEL failure must be retried, not dropped");
1197 heal.await.unwrap();
1198 assert!(!key_exists(&mut admin, "inv-retry:k").await);
1199 }
1200
1201 #[tokio::test]
1202 #[ignore = "requires Docker (testcontainers)"]
1203 async fn redis_invalidate_namespace_async_surfaces_failure() {
1204 let container = RedisImage::default().start().await.unwrap();
1205 let port = container.get_host_port_ipv4(6379).await.unwrap();
1206 let url = format!("redis://127.0.0.1:{port}");
1207 let mut admin = admin_conn(&url).await;
1208 let cache = RedisCache::connect(&url, "inv-ns")
1209 .await
1210 .unwrap()
1211 .with_invalidation_retry(FAST_RETRY);
1212
1213 admin.set::<_, _, ()>("inv-ns:reads:1", "1").await.unwrap();
1214 reject_writes(&mut admin).await;
1215
1216 let err = cache
1217 .invalidate_namespace_async("reads")
1218 .await
1219 .expect_err("a rejected sweep must surface as an error");
1220 assert_eq!(err.attempts(), 3, "{err}");
1221 assert!(key_exists(&mut admin, "inv-ns:reads:1").await);
1222
1223 accept_writes(&mut admin).await;
1224 cache
1225 .invalidate_namespace_async("reads")
1226 .await
1227 .expect("the sweep succeeds once Redis accepts writes");
1228 assert!(!key_exists(&mut admin, "inv-ns:reads:1").await);
1229 }
1230
1231 #[tokio::test(flavor = "multi_thread")]
1234 #[ignore = "requires Docker (testcontainers)"]
1235 async fn redis_fill_on_another_replica_cannot_resurrect_an_invalidated_value() {
1236 let container = RedisImage::default().start().await.unwrap();
1237 let port = container.get_host_port_ipv4(6379).await.unwrap();
1238 let url = format!("redis://127.0.0.1:{port}");
1239 let mut admin = admin_conn(&url).await;
1240 let replica_a = RedisCache::connect(&url, "fence").await.unwrap();
1241 let replica_b = RedisCache::connect(&url, "fence").await.unwrap();
1242
1243 let epoch = replica_b.fill_epoch("reads");
1245 assert_eq!(epoch, FillEpoch::Sampled(0));
1246
1247 replica_a
1249 .invalidate_namespace_async("reads")
1250 .await
1251 .expect("invalidation");
1252
1253 let stored = insert_cached_fenced(
1255 &replica_b,
1256 "reads:k",
1257 "old".to_string(),
1258 None,
1259 "reads",
1260 epoch,
1261 );
1262 assert!(!stored, "the shared epoch moved");
1263 assert!(!key_exists(&mut admin, "fence:reads:k").await);
1264
1265 let epoch = replica_b.fill_epoch("reads");
1267 assert!(insert_cached_fenced(
1268 &replica_b,
1269 "reads:forever",
1270 1_i64,
1271 None,
1272 "reads",
1273 epoch
1274 ));
1275 let left: i64 = redis::cmd("PTTL")
1276 .arg("fence:reads:forever")
1277 .query_async(&mut admin)
1278 .await
1279 .unwrap();
1280 assert_eq!(left, -1, "no TTL means no expiry");
1281
1282 let epoch = replica_b.fill_epoch("reads");
1284 assert_eq!(epoch, FillEpoch::Sampled(1));
1285 let ttl = Some(Duration::from_secs(60));
1286 assert!(insert_cached_fenced(
1287 &replica_b,
1288 "reads:k",
1289 "new".to_string(),
1290 ttl,
1291 "reads",
1292 epoch
1293 ));
1294 assert_eq!(
1295 get_cached::<String>(&replica_a, "reads:k").as_deref(),
1296 Some("new")
1297 );
1298 let left: i64 = redis::cmd("PTTL")
1299 .arg("fence:reads:k")
1300 .query_async(&mut admin)
1301 .await
1302 .unwrap();
1303 assert!(left > 0, "the TTL must survive the Lua insert: {left}");
1304 }
1305
1306 #[tokio::test(flavor = "multi_thread")]
1307 #[ignore = "requires Docker (testcontainers)"]
1308 async fn redis_epoch_is_per_namespace_and_survives_clear() {
1309 let container = RedisImage::default().start().await.unwrap();
1310 let port = container.get_host_port_ipv4(6379).await.unwrap();
1311 let url = format!("redis://127.0.0.1:{port}");
1312 let cache = RedisCache::connect(&url, "fence-ns").await.unwrap();
1313
1314 cache.invalidate_namespace_async("a").await.unwrap();
1315 assert_eq!(cache.fill_epoch("a"), FillEpoch::Sampled(1));
1316 assert_eq!(cache.fill_epoch("b"), FillEpoch::Sampled(0));
1317
1318 cache.clear();
1321 assert_eq!(cache.fill_epoch("a"), FillEpoch::Sampled(1));
1322
1323 cache
1325 .invalidate_async("__autumn_epoch__:a")
1326 .await
1327 .expect("a refused delete is not an error");
1328 cache.invalidate("__autumn_epoch__:a");
1329 assert_eq!(cache.fill_epoch("a"), FillEpoch::Sampled(1));
1330
1331 let mut admin = admin_conn(&url).await;
1333 assert!(key_exists(&mut admin, "fence-ns:__autumn_epoch__:a").await);
1334 }
1335
1336 #[tokio::test(flavor = "multi_thread")]
1337 #[ignore = "requires Docker (testcontainers)"]
1338 async fn redis_failed_epoch_bump_fails_the_invalidation() {
1339 let container = RedisImage::default().start().await.unwrap();
1340 let port = container.get_host_port_ipv4(6379).await.unwrap();
1341 let url = format!("redis://127.0.0.1:{port}");
1342 let mut admin = admin_conn(&url).await;
1343 let cache = RedisCache::connect(&url, "fence-fail")
1344 .await
1345 .unwrap()
1346 .with_invalidation_retry(FAST_RETRY);
1347
1348 reject_writes(&mut admin).await;
1350 assert!(
1351 cache.invalidate_namespace_async("reads").await.is_err(),
1352 "a `true` must not be reported when the shared fence did not move"
1353 );
1354 assert!(!cache.invalidate_namespace("reads"));
1355 accept_writes(&mut admin).await;
1356 assert!(cache.invalidate_namespace_async("reads").await.is_ok());
1357 }
1358
1359 static GLOBAL_STATE: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
1362
1363 #[tokio::test]
1366 #[ignore = "requires Docker (testcontainers)"]
1367 async fn coherence_async_invalidation_reaches_redis_and_reports_failure() {
1368 let _guard = GLOBAL_STATE.lock().await;
1369 let container = RedisImage::default().start().await.unwrap();
1370 let port = container.get_host_port_ipv4(6379).await.unwrap();
1371 let url = format!("redis://127.0.0.1:{port}");
1372 let mut admin = admin_conn(&url).await;
1373 let cache = RedisCache::connect(&url, "e2e")
1374 .await
1375 .unwrap()
1376 .with_invalidation_retry(FAST_RETRY);
1377 autumn_web::cache::set_global_cache(Arc::new(cache));
1378 let namespace = "e2e::reads::count";
1379
1380 admin
1381 .set::<_, _, ()>(format!("e2e:{namespace}:a"), "1")
1382 .await
1383 .unwrap();
1384 let complete = autumn_web::cache::coherence::invalidate_namespace_async(namespace).await;
1385 let gone = !key_exists(&mut admin, &format!("e2e:{namespace}:a")).await;
1386
1387 admin
1388 .set::<_, _, ()>(format!("e2e:{namespace}:b"), "1")
1389 .await
1390 .unwrap();
1391 reject_writes(&mut admin).await;
1392 let before = autumn_web::cache::invalidation_failures_total();
1393 let refused = autumn_web::cache::coherence::invalidate_namespace_async(namespace).await;
1394 let after = autumn_web::cache::invalidation_failures_total();
1395 let kept = key_exists(&mut admin, &format!("e2e:{namespace}:b")).await;
1396 accept_writes(&mut admin).await;
1397 autumn_web::cache::clear_global_cache();
1398
1399 assert!(complete && gone, "a healthy Redis drops the namespace");
1400 assert!(!refused, "a rejected sweep must report incomplete");
1401 assert_eq!(after, before + 1, "and count one failure");
1402 assert!(kept, "the key is still there, so the report is true");
1403 }
1404
1405 #[tokio::test(flavor = "multi_thread")]
1408 #[ignore = "requires Docker (testcontainers)"]
1409 async fn redis_sync_invalidate_counts_final_failure() {
1410 let _guard = GLOBAL_STATE.lock().await;
1411 let container = RedisImage::default().start().await.unwrap();
1412 let port = container.get_host_port_ipv4(6379).await.unwrap();
1413 let url = format!("redis://127.0.0.1:{port}");
1414 let mut admin = admin_conn(&url).await;
1415 let cache = RedisCache::connect(&url, "inv-sync")
1416 .await
1417 .unwrap()
1418 .with_invalidation_retry(FAST_RETRY);
1419
1420 admin.set::<_, _, ()>("inv-sync:k", "1").await.unwrap();
1421 reject_writes(&mut admin).await;
1422
1423 let before = autumn_web::cache::invalidation_failures_total();
1424 autumn_web::cache::Cache::invalidate(&cache, "k");
1425 let after = autumn_web::cache::invalidation_failures_total();
1426 assert_eq!(
1427 after,
1428 before + 1,
1429 "a final DEL failure must increment autumn_cache_invalidation_failures_total once"
1430 );
1431 assert!(key_exists(&mut admin, "inv-sync:k").await);
1432
1433 accept_writes(&mut admin).await;
1434 }
1435
1436 #[tokio::test(flavor = "multi_thread")]
1437 #[ignore = "requires Docker (testcontainers)"]
1438 async fn redis_cache_insert_get_invalidate() {
1439 let container = RedisImage::default().start().await.unwrap();
1440 let port = container.get_host_port_ipv4(6379).await.unwrap();
1441 let url = format!("redis://127.0.0.1:{port}");
1442
1443 let cache = RedisCache::connect(&url, "test").await.unwrap();
1444
1445 insert_cached(&cache, "hello", "world".to_string(), None);
1447
1448 let raw = cache.get_value("hello").expect("should be present");
1450 let raw_bytes = raw.downcast_ref::<RawCacheBytes>().expect("RawCacheBytes");
1451 let v: serde_json::Value = serde_json::from_slice(&raw_bytes.0).unwrap();
1452 assert_eq!(v, serde_json::json!("world"));
1453
1454 let s: Option<String> = get_cached(&cache, "hello");
1456 assert_eq!(s.as_deref(), Some("world"));
1457
1458 autumn_web::cache::Cache::invalidate(&cache, "hello");
1460 assert!(cache.get_value("hello").is_none());
1461 }
1462
1463 #[tokio::test(flavor = "multi_thread")]
1464 #[ignore = "requires Docker (testcontainers)"]
1465 async fn redis_try_methods_round_trip_and_report_outage() {
1466 let container = RedisImage::default().start().await.unwrap();
1467 let port = container.get_host_port_ipv4(6379).await.unwrap();
1468 let url = format!("redis://127.0.0.1:{port}");
1469 let cache = RedisCache::connect(&url, "test").await.unwrap();
1470
1471 cache
1472 .try_insert_raw_bytes("k", b"1".to_vec(), None)
1473 .unwrap();
1474 assert!(cache.try_get_value("k").unwrap().is_some());
1475 assert!(cache.try_get_value("absent").unwrap().is_none());
1476 cache.try_invalidate("k").unwrap();
1477 assert!(cache.try_get_value("k").unwrap().is_none());
1478
1479 container.stop().await.unwrap();
1481 assert!(cache.try_get_value("k").is_err());
1482 assert!(
1483 cache
1484 .try_insert_raw_bytes("k", b"1".to_vec(), None)
1485 .is_err()
1486 );
1487 }
1488
1489 #[tokio::test(flavor = "multi_thread")]
1490 #[ignore = "requires Docker (testcontainers)"]
1491 async fn redis_cache_cross_replica_invalidation() {
1492 let container = RedisImage::default().start().await.unwrap();
1493 let port = container.get_host_port_ipv4(6379).await.unwrap();
1494 let url = format!("redis://127.0.0.1:{port}");
1495
1496 let replica_a = RedisCache::connect(&url, "xreplica").await.unwrap();
1498 let replica_b = RedisCache::connect(&url, "xreplica").await.unwrap();
1499
1500 let start = std::time::Instant::now();
1502 insert_cached(&replica_a, "key", "value".to_string(), None);
1503
1504 let seen: Option<String> = get_cached(&replica_b, "key");
1506 let elapsed = start.elapsed();
1507
1508 assert_eq!(
1509 seen.as_deref(),
1510 Some("value"),
1511 "replica B must read the value written by replica A"
1512 );
1513
1514 autumn_web::cache::Cache::invalidate(&replica_a, "key");
1516
1517 let gone: Option<String> = get_cached(&replica_b, "key");
1519 assert!(gone.is_none());
1520 assert!(
1521 elapsed.as_millis() < 50,
1522 "cross-replica lag {elapsed:?} exceeded 50 ms"
1523 );
1524 }
1525
1526 #[tokio::test(flavor = "multi_thread")]
1527 #[ignore = "requires Docker (testcontainers)"]
1528 async fn redis_cache_serde_struct_round_trip() {
1529 #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1530 struct Item {
1531 id: i32,
1532 name: String,
1533 }
1534
1535 let container = RedisImage::default().start().await.unwrap();
1536 let port = container.get_host_port_ipv4(6379).await.unwrap();
1537 let url = format!("redis://127.0.0.1:{port}");
1538
1539 let cache_a = RedisCache::connect(&url, "structs").await.unwrap();
1540 let cache_b = RedisCache::connect(&url, "structs").await.unwrap();
1541
1542 let item = Item {
1543 id: 1,
1544 name: "widget".into(),
1545 };
1546 insert_cached(&cache_a, "item:1", item.clone(), None);
1547
1548 let retrieved: Option<Item> = get_cached(&cache_a, "item:1");
1550 assert_eq!(retrieved, Some(item.clone()));
1551
1552 let from_b: Option<Item> = get_cached(&cache_b, "item:1");
1554 assert_eq!(from_b, Some(item));
1555 }
1556
1557 #[tokio::test(flavor = "multi_thread")]
1558 #[ignore = "requires Docker (testcontainers)"]
1559 async fn redis_cache_response_layer_caches_http_gets() {
1560 use std::convert::Infallible;
1561
1562 use autumn_web::cache::CacheResponseLayer;
1563 use autumn_web::reexports::axum::body::{Body, to_bytes};
1564 use autumn_web::reexports::http::{Request, StatusCode};
1565 use tower::{Service, ServiceBuilder, ServiceExt};
1566
1567 let container = RedisImage::default().start().await.unwrap();
1568 let port = container.get_host_port_ipv4(6379).await.unwrap();
1569 let url = format!("redis://127.0.0.1:{port}");
1570
1571 let cache: Arc<dyn autumn_web::cache::Cache> =
1572 Arc::new(RedisCache::connect(&url, "http-response").await.unwrap());
1573 let counter = Arc::new(AtomicUsize::new(0));
1574
1575 let inner = {
1576 let counter = counter.clone();
1577 tower::service_fn(move |_req: Request<Body>| {
1578 let counter = counter.clone();
1579 async move {
1580 counter.fetch_add(1, Ordering::SeqCst);
1581 Ok::<_, Infallible>(
1582 autumn_web::reexports::axum::response::Response::builder()
1583 .status(StatusCode::OK)
1584 .header("x-cache-test", "redis")
1585 .body(Body::from("redis-body"))
1586 .expect("infallible response builder"),
1587 )
1588 }
1589 })
1590 };
1591
1592 let mut svc = ServiceBuilder::new()
1593 .layer(CacheResponseLayer::from_shared(cache.clone()))
1594 .service(inner);
1595
1596 let req = Request::get("/redis-backed")
1597 .body(Body::empty())
1598 .expect("infallible response builder");
1599 let resp = svc
1600 .ready()
1601 .await
1602 .expect("service ready")
1603 .call(req)
1604 .await
1605 .expect("infallible service");
1606 assert_eq!(resp.status(), StatusCode::OK);
1607
1608 let req = Request::get("/redis-backed")
1609 .body(Body::empty())
1610 .expect("infallible response builder");
1611 let resp = svc
1612 .ready()
1613 .await
1614 .expect("service ready")
1615 .call(req)
1616 .await
1617 .expect("infallible service");
1618
1619 assert_eq!(resp.status(), StatusCode::OK);
1620 assert_eq!(
1621 resp.headers()
1622 .get("x-cache-test")
1623 .and_then(|v| v.to_str().ok()),
1624 Some("redis")
1625 );
1626 let body = to_bytes(resp.into_body(), usize::MAX)
1627 .await
1628 .expect("body collection");
1629 assert_eq!(body.as_ref(), b"redis-body");
1630 assert_eq!(counter.load(Ordering::SeqCst), 1);
1631
1632 cache.invalidate("http:/redis-backed");
1633 }
1634
1635 #[tokio::test(flavor = "multi_thread")]
1636 #[ignore = "requires Docker (testcontainers)"]
1637 async fn redis_fill_lock_acquire_conflict_release() {
1638 let container = RedisImage::default().start().await.unwrap();
1639 let port = container.get_host_port_ipv4(6379).await.unwrap();
1640 let url = format!("redis://127.0.0.1:{port}");
1641 let cache = RedisCache::connect(&url, "fill-lock-test").await.unwrap();
1642
1643 assert_eq!(
1644 cache.try_acquire_fill_lock("k", "token-a", Duration::from_secs(10)),
1645 FillLockStatus::Acquired
1646 );
1647 assert_eq!(
1648 cache.try_acquire_fill_lock("k", "token-b", Duration::from_secs(10)),
1649 FillLockStatus::Held,
1650 "a second replica must not acquire a lock already held"
1651 );
1652
1653 cache.release_fill_lock("k", "token-b");
1655 assert_eq!(
1656 cache.try_acquire_fill_lock("k", "token-b", Duration::from_secs(10)),
1657 FillLockStatus::Held,
1658 "releasing with a stale/foreign token must be a no-op"
1659 );
1660
1661 cache.release_fill_lock("k", "token-a");
1663 assert_eq!(
1664 cache.try_acquire_fill_lock("k", "token-b", Duration::from_secs(10)),
1665 FillLockStatus::Acquired
1666 );
1667 }
1668
1669 #[tokio::test(flavor = "multi_thread")]
1670 #[ignore = "requires Docker (testcontainers)"]
1671 async fn redis_fill_lock_expires_after_ttl() {
1672 let container = RedisImage::default().start().await.unwrap();
1673 let port = container.get_host_port_ipv4(6379).await.unwrap();
1674 let url = format!("redis://127.0.0.1:{port}");
1675 let cache = RedisCache::connect(&url, "fill-lock-ttl-test")
1676 .await
1677 .unwrap();
1678
1679 assert_eq!(
1680 cache.try_acquire_fill_lock("k", "crashed-filler", Duration::from_millis(200)),
1681 FillLockStatus::Acquired
1682 );
1683 tokio::time::sleep(Duration::from_millis(400)).await;
1685
1686 assert_eq!(
1687 cache.try_acquire_fill_lock("k", "new-filler", Duration::from_secs(10)),
1688 FillLockStatus::Acquired,
1689 "the lock TTL must bound damage from a crashed filler"
1690 );
1691 }
1692
1693 #[tokio::test(flavor = "multi_thread")]
1694 #[ignore = "requires Docker (testcontainers)"]
1695 async fn redis_lock_wait_timeout_falls_back_to_self_fill() {
1696 use autumn_web::cache::GetOrComputeOptions;
1697
1698 let container = RedisImage::default().start().await.unwrap();
1699 let port = container.get_host_port_ipv4(6379).await.unwrap();
1700 let url = format!("redis://127.0.0.1:{port}");
1701 let cache: Arc<dyn Cache> = Arc::new(
1702 RedisCache::connect(&url, "lock-timeout-test")
1703 .await
1704 .unwrap(),
1705 );
1706
1707 let key = "stuck-key";
1709 assert_eq!(
1710 cache.try_acquire_fill_lock(key, "stuck-holder", Duration::from_secs(60)),
1711 FillLockStatus::Acquired
1712 );
1713
1714 let fill_count = Arc::new(AtomicUsize::new(0));
1715 let fc = fill_count.clone();
1716 let opts = GetOrComputeOptions::new()
1717 .distributed_fill_lock(true)
1718 .lock_wait_timeout(Duration::from_millis(300))
1719 .lock_poll_interval(Duration::from_millis(50));
1720
1721 let value: i32 =
1722 autumn_web::cache::get_or_compute_with(&cache, key, opts, move || async move {
1723 fc.fetch_add(1, Ordering::SeqCst);
1724 Ok::<i32, String>(123)
1725 })
1726 .await
1727 .unwrap();
1728
1729 assert_eq!(value, 123);
1730 assert_eq!(
1731 fill_count.load(Ordering::SeqCst),
1732 1,
1733 "after the wait timeout the caller must fall back to filling locally"
1734 );
1735 }
1736
1737 #[tokio::test(flavor = "multi_thread")]
1738 #[ignore = "requires Docker (testcontainers)"]
1739 async fn redis_lock_wait_timeout_not_overshot_by_poll_interval() {
1740 use autumn_web::cache::GetOrComputeOptions;
1741
1742 let container = RedisImage::default().start().await.unwrap();
1743 let port = container.get_host_port_ipv4(6379).await.unwrap();
1744 let url = format!("redis://127.0.0.1:{port}");
1745 let cache: Arc<dyn Cache> = Arc::new(
1746 RedisCache::connect(&url, "lock-timeout-overshoot-test")
1747 .await
1748 .unwrap(),
1749 );
1750
1751 let key = "stuck-key-overshoot";
1753 assert_eq!(
1754 cache.try_acquire_fill_lock(key, "stuck-holder", Duration::from_secs(60)),
1755 FillLockStatus::Acquired
1756 );
1757
1758 let opts = GetOrComputeOptions::new()
1762 .distributed_fill_lock(true)
1763 .lock_wait_timeout(Duration::from_millis(100))
1764 .lock_poll_interval(Duration::from_secs(5));
1765
1766 let start = std::time::Instant::now();
1767 let value: i32 =
1768 autumn_web::cache::get_or_compute_with(&cache, key, opts, move || async move {
1769 Ok::<i32, String>(456)
1770 })
1771 .await
1772 .unwrap();
1773 let elapsed = start.elapsed();
1774
1775 assert_eq!(value, 456);
1776 assert!(
1777 elapsed < Duration::from_millis(500),
1778 "lock_wait_timeout must bound the wait even when lock_poll_interval is much larger; took {elapsed:?}"
1779 );
1780 }
1781
1782 #[tokio::test(flavor = "multi_thread")]
1783 #[ignore = "requires Docker (testcontainers)"]
1784 async fn redis_get_or_compute_round_trip() {
1785 let container = RedisImage::default().start().await.unwrap();
1786 let port = container.get_host_port_ipv4(6379).await.unwrap();
1787 let url = format!("redis://127.0.0.1:{port}");
1788 let cache: Arc<dyn Cache> = Arc::new(
1789 RedisCache::connect(&url, "read-through-test")
1790 .await
1791 .unwrap(),
1792 );
1793
1794 let fill_count = Arc::new(AtomicUsize::new(0));
1795 let fc = fill_count.clone();
1796 let v: String = autumn_web::cache::get_or_compute(
1797 &cache,
1798 "rt-key",
1799 Some(Duration::from_secs(60)),
1800 move || async move {
1801 fc.fetch_add(1, Ordering::SeqCst);
1802 Ok::<String, String>("value-from-redis".to_string())
1803 },
1804 )
1805 .await
1806 .unwrap();
1807 assert_eq!(v, "value-from-redis");
1808
1809 let fc = fill_count.clone();
1810 let v: String = autumn_web::cache::get_or_compute(
1811 &cache,
1812 "rt-key",
1813 Some(Duration::from_secs(60)),
1814 move || async move {
1815 fc.fetch_add(1, Ordering::SeqCst);
1816 Ok::<String, String>("should-not-run".to_string())
1817 },
1818 )
1819 .await
1820 .unwrap();
1821 assert_eq!(
1822 v, "value-from-redis",
1823 "second call must hit via RawCacheBytes deserialization"
1824 );
1825 assert_eq!(fill_count.load(Ordering::SeqCst), 1);
1826 }
1827
1828 #[tokio::test(flavor = "multi_thread")]
1829 #[ignore = "requires Docker (testcontainers)"]
1830 async fn redis_cross_replica_single_fill() {
1831 use autumn_web::cache::GetOrComputeOptions;
1832
1833 let container = RedisImage::default().start().await.unwrap();
1834 let port = container.get_host_port_ipv4(6379).await.unwrap();
1835 let url = format!("redis://127.0.0.1:{port}");
1836
1837 let replica_a: Arc<dyn Cache> =
1840 Arc::new(RedisCache::connect(&url, "xrepl-fill").await.unwrap());
1841 let replica_b: Arc<dyn Cache> =
1842 Arc::new(RedisCache::connect(&url, "xrepl-fill").await.unwrap());
1843
1844 let fill_count = Arc::new(AtomicUsize::new(0));
1845 let key = "hot-key";
1846
1847 let mut handles = Vec::new();
1848 for replica in [replica_a, replica_b] {
1849 for _ in 0..4 {
1850 let replica = replica.clone();
1851 let fc = fill_count.clone();
1852 let opts = GetOrComputeOptions::new()
1853 .distributed_fill_lock(true)
1854 .lock_poll_interval(Duration::from_millis(30))
1855 .lock_wait_timeout(Duration::from_secs(5));
1856 handles.push(tokio::spawn(async move {
1857 autumn_web::cache::get_or_compute_with::<String, String, _, _>(
1858 &replica,
1859 key,
1860 opts,
1861 move || async move {
1862 fc.fetch_add(1, Ordering::SeqCst);
1863 tokio::time::sleep(Duration::from_millis(300)).await;
1864 Ok("cross-replica-value".to_string())
1865 },
1866 )
1867 .await
1868 }));
1869 }
1870 }
1871
1872 for h in handles {
1873 let v = h.await.unwrap().unwrap();
1874 assert_eq!(v, "cross-replica-value");
1875 }
1876
1877 assert_eq!(
1878 fill_count.load(Ordering::SeqCst),
1879 1,
1880 "the distributed fill lock must limit total fills to 1 across both replicas"
1881 );
1882 }
1883}