Skip to main content

rainier_session/
cache.rs

1//! [`CacheSessionStore`] — sessions in a [`Cache`], and so in Redis,
2//! a Redis Cluster, or Memcached.
3
4use std::sync::Arc;
5use std::time::Duration;
6
7use chrono::Duration as ChronoDuration;
8use rainier_cache::{Cache, CacheExt};
9use rainier_support::{BoxFuture, Result};
10
11use crate::session::SessionData;
12use crate::store::SessionStore;
13
14/// The key prefix, so sessions do not collide with cached values.
15const PREFIX: &str = "session:";
16
17/// Sessions in a cache.
18///
19/// The usual production choice, because it gets the two things a session needs
20/// from infrastructure you already have: **shared between instances**, so a load
21/// balancer can send a user anywhere, and **expiring by itself**, so nothing has
22/// to sweep old rows.
23///
24/// Works over any [`Cache`] — including a
25/// sharded Redis Cluster (`rainier_cache::RedisCache`), where each session lands
26/// on the node that owns its slot. Every operation here touches exactly one key,
27/// which is what makes that safe.
28///
29/// ```
30/// use rainier_cache::MemoryCache;
31/// use rainier_session::CacheSessionStore;
32/// use std::sync::Arc;
33///
34/// let store = CacheSessionStore::new(Arc::new(MemoryCache::new()));
35/// # let _ = store;
36/// ```
37///
38/// ## A cache can evict
39///
40/// This is the trade against a [database
41/// store](crate::DatabaseSessionStore). A cache under memory pressure evicts
42/// whatever it likes, and an evicted session logs somebody out mid-checkout.
43///
44/// For Redis, `maxmemory-policy volatile-lru` at least confines eviction to keys
45/// with a TTL; `allkeys-lru` will take sessions ahead of things you would rather
46/// lose. If being logged out is genuinely unacceptable, the database store is
47/// the one that does not evict.
48pub struct CacheSessionStore {
49    cache: Arc<dyn Cache>,
50    lifetime: Duration,
51}
52
53impl CacheSessionStore {
54    /// A store over `cache`, with two-hour sessions.
55    pub fn new(cache: Arc<dyn Cache>) -> Self {
56        Self { cache, lifetime: Duration::from_secs(2 * 60 * 60) }
57    }
58
59    /// Sessions expiring after `lifetime`.
60    pub fn with_lifetime(mut self, lifetime: ChronoDuration) -> Self {
61        if let Ok(lifetime) = lifetime.to_std() {
62            self.lifetime = lifetime;
63        }
64        self
65    }
66
67    /// The cache underneath, for sharing it.
68    pub fn cache(&self) -> &Arc<dyn Cache> {
69        &self.cache
70    }
71
72    /// How long a session lives without being touched.
73    pub fn lifetime(&self) -> Duration {
74        self.lifetime
75    }
76
77    fn key(id: &str) -> String {
78        format!("{PREFIX}{id}")
79    }
80}
81
82impl SessionStore for CacheSessionStore {
83    fn name(&self) -> &str {
84        // The driver underneath, not "cache" — an operator wants to know
85        // *which* cache, and this is what `route:list`-style diagnostics print.
86        self.cache.name()
87    }
88
89    fn read<'a>(&'a self, id: &'a str) -> BoxFuture<'a, Result<Option<SessionData>>> {
90        Box::pin(async move {
91            // `get_json` treats an unparseable value as a miss, which is what
92            // should happen to a session written by an older shape of the
93            // application: the user gets a fresh session rather than a 500 they
94            // cannot clear without deleting cookies.
95            self.cache.get_json(&Self::key(id)).await
96        })
97    }
98
99    fn write<'a>(&'a self, id: &'a str, data: &'a SessionData) -> BoxFuture<'a, Result<()>> {
100        Box::pin(async move {
101            // The TTL is set on every write, so an active session keeps sliding
102            // forward and an abandoned one expires on its own. That is the whole
103            // reason a cache suits sessions: no sweeper.
104            self.cache.put_json(&Self::key(id), data, Some(self.lifetime)).await
105        })
106    }
107
108    fn destroy<'a>(&'a self, id: &'a str) -> BoxFuture<'a, Result<()>> {
109        Box::pin(async move {
110            self.cache.forget(&Self::key(id)).await?;
111            Ok(())
112        })
113    }
114
115    /// Nothing to do: the cache expires its own keys.
116    ///
117    /// Returns zero rather than pretending, and deliberately does **not**
118    /// delegate to [`Cache::flush`] — that would empty the whole cache, which is
119    /// not what "collect expired sessions" means.
120    fn gc(&self) -> BoxFuture<'_, Result<u64>> {
121        Box::pin(async { Ok(0) })
122    }
123}
124
125impl std::fmt::Debug for CacheSessionStore {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        f.debug_struct("CacheSessionStore")
128            .field("cache", &self.cache.name())
129            .field("lifetime", &self.lifetime)
130            .finish()
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use rainier_cache::MemoryCache;
138
139    fn store() -> (CacheSessionStore, Arc<MemoryCache>) {
140        let cache = Arc::new(MemoryCache::new());
141        (CacheSessionStore::new(Arc::clone(&cache) as Arc<dyn Cache>), cache)
142    }
143
144    fn data(user: u64) -> SessionData {
145        let mut values = serde_json::Map::new();
146        values.insert("user_id".into(), user.into());
147        SessionData { values, flash: Vec::new() }
148    }
149
150    #[tokio::test]
151    async fn a_written_session_reads_back() {
152        let (store, _) = store();
153        store.write("abc", &data(42)).await.unwrap();
154
155        assert_eq!(store.read("abc").await.unwrap().unwrap().values["user_id"], 42);
156    }
157
158    #[tokio::test]
159    async fn an_unknown_session_reads_as_none() {
160        let (store, _) = store();
161        assert!(store.read("nope").await.unwrap().is_none());
162    }
163
164    #[tokio::test]
165    async fn destroying_removes_it() {
166        let (store, _) = store();
167        store.write("abc", &data(42)).await.unwrap();
168
169        store.destroy("abc").await.unwrap();
170        assert!(store.read("abc").await.unwrap().is_none());
171    }
172
173    #[tokio::test]
174    async fn keys_are_prefixed_so_they_do_not_collide_with_cached_values() {
175        let (store, cache) = store();
176        store.write("abc", &data(42)).await.unwrap();
177
178        assert!(cache.has("session:abc").await.unwrap());
179        assert!(!cache.has("abc").await.unwrap(), "the bare key must be free for other uses");
180    }
181
182    #[tokio::test]
183    async fn a_ttl_is_set_on_every_write() {
184        // An active session slides forward; an abandoned one expires without
185        // anything sweeping it. An already-elapsed lifetime proves the TTL is
186        // applied rather than the value being stored forever.
187        let brief = CacheSessionStore::new(Arc::new(MemoryCache::new()))
188            .with_lifetime(ChronoDuration::nanoseconds(1));
189
190        brief.write("brief", &data(1)).await.unwrap();
191        assert!(brief.read("brief").await.unwrap().is_none());
192    }
193
194    #[tokio::test]
195    async fn an_unreadable_session_reads_as_a_fresh_one() {
196        let (store, cache) = store();
197        cache.put("session:abc", b"not json", None).await.unwrap();
198
199        assert!(store.read("abc").await.unwrap().is_none());
200    }
201
202    #[tokio::test]
203    async fn gc_does_not_flush_the_cache() {
204        // "Collect expired sessions" must not mean "empty the cache".
205        let (store, cache) = store();
206        cache.put("something-else", b"v", None).await.unwrap();
207
208        assert_eq!(store.gc().await.unwrap(), 0);
209        assert!(cache.has("something-else").await.unwrap());
210    }
211
212    #[tokio::test]
213    async fn the_name_reports_the_driver_underneath() {
214        let (store, _) = store();
215        assert_eq!(store.name(), "memory");
216    }
217
218    #[test]
219    fn the_lifetime_is_configurable_and_ignores_a_negative_one() {
220        let (store, _) = store();
221        let store = store.with_lifetime(ChronoDuration::days(14));
222        assert_eq!(store.lifetime(), Duration::from_secs(14 * 24 * 60 * 60));
223
224        // A negative duration has no `std` equivalent; keeping the previous
225        // value beats panicking or silently meaning "expire immediately".
226        let store = store.with_lifetime(ChronoDuration::seconds(-1));
227        assert_eq!(store.lifetime(), Duration::from_secs(14 * 24 * 60 * 60));
228    }
229
230    #[tokio::test]
231    async fn it_is_not_client_side() {
232        let (store, _) = store();
233        assert!(!store.is_client_side());
234        assert!(store.decode("chosen-by-the-client").is_err());
235    }
236}