armature_cache/traits.rs
1//! Cache store trait definition.
2
3use crate::error::{CacheError, CacheResult};
4use async_trait::async_trait;
5use std::time::Duration;
6
7/// Cache store trait for different cache backends.
8#[async_trait]
9pub trait CacheStore: Send + Sync {
10 /// Get a JSON value from the cache.
11 ///
12 /// # Arguments
13 ///
14 /// * `key` - The cache key
15 ///
16 /// # Returns
17 ///
18 /// Returns `Ok(Some(value))` if the key exists, `Ok(None)` if not found,
19 /// or an error if the operation fails.
20 async fn get_json(&self, key: &str) -> CacheResult<Option<String>>;
21
22 /// Set a JSON value in the cache.
23 ///
24 /// # Arguments
25 ///
26 /// * `key` - The cache key
27 /// * `value` - The JSON string value
28 /// * `ttl` - Optional time-to-live duration
29 async fn set_json(&self, key: &str, value: String, ttl: Option<Duration>) -> CacheResult<()>;
30
31 /// Delete a key from the cache.
32 ///
33 /// # Arguments
34 ///
35 /// * `key` - The cache key to delete
36 async fn delete(&self, key: &str) -> CacheResult<()>;
37
38 /// Check if a key exists in the cache.
39 ///
40 /// # Arguments
41 ///
42 /// * `key` - The cache key to check
43 async fn exists(&self, key: &str) -> CacheResult<bool>;
44
45 /// Clear all keys from the cache.
46 ///
47 /// **Warning:** This operation may be destructive and affect all keys.
48 async fn clear(&self) -> CacheResult<()>;
49
50 /// Get the TTL (time-to-live) of a key.
51 ///
52 /// # Arguments
53 ///
54 /// * `key` - The cache key
55 ///
56 /// # Returns
57 ///
58 /// Returns `Ok(Some(duration))` if the key has a TTL, `Ok(None)` if the key
59 /// has no expiration or doesn't exist.
60 async fn ttl(&self, key: &str) -> CacheResult<Option<Duration>>;
61
62 /// Set or update the expiration time for a key.
63 ///
64 /// # Arguments
65 ///
66 /// * `key` - The cache key
67 /// * `ttl` - The new time-to-live duration
68 async fn expire(&self, key: &str, ttl: Duration) -> CacheResult<()>;
69
70 /// Increment a numeric value.
71 ///
72 /// # Arguments
73 ///
74 /// * `key` - The cache key
75 /// * `delta` - The amount to increment by
76 ///
77 /// # Returns
78 ///
79 /// Returns the new value after incrementing.
80 async fn increment(&self, key: &str, delta: i64) -> CacheResult<i64>;
81
82 /// Decrement a numeric value.
83 ///
84 /// # Arguments
85 ///
86 /// * `key` - The cache key
87 /// * `delta` - The amount to decrement by
88 ///
89 /// # Returns
90 ///
91 /// Returns the new value after decrementing.
92 async fn decrement(&self, key: &str, delta: i64) -> CacheResult<i64>;
93
94 // ========== Native Batch Primitives ==========
95 //
96 // These are the low-level batch operations that backends can override with
97 // a single native command (e.g. Redis `MGET`/`MSET`/variadic `DEL`) to turn
98 // N round-trips into one. The DEFAULT implementations fall back to the
99 // per-key loop (run concurrently), so existing `CacheStore` impls keep
100 // working unchanged. The higher-level `get_many`/`set_many`/`delete_many`
101 // methods and `ParallelCacheOps` delegate here, so overriding these three
102 // methods is enough to accelerate all batch APIs.
103
104 /// Get multiple keys in a single batch operation.
105 ///
106 /// Returns a vector of `Option<String>` in the **same order** as `keys`;
107 /// `None` indicates a missing key.
108 ///
109 /// The default implementation issues one `get_json` per key concurrently;
110 /// backends should override this with a native multi-get (e.g. `MGET`).
111 async fn mget(&self, keys: &[&str]) -> CacheResult<Vec<Option<String>>> {
112 use futures::future::try_join_all;
113
114 let futures = keys.iter().map(|key| self.get_json(key));
115 try_join_all(futures).await
116 }
117
118 /// Set multiple key/value pairs in a single batch operation.
119 ///
120 /// The default implementation issues one `set_json` per pair concurrently;
121 /// backends should override this with a native multi-set (e.g. `MSET`, or a
122 /// pipeline of `SET ... EX` when a TTL is required).
123 async fn mset(&self, items: &[(&str, String)], ttl: Option<Duration>) -> CacheResult<()> {
124 use futures::future::try_join_all;
125
126 let futures = items
127 .iter()
128 .map(|(key, value)| self.set_json(key, value.clone(), ttl));
129 try_join_all(futures).await?;
130 Ok(())
131 }
132
133 /// Delete multiple keys in a single batch operation.
134 ///
135 /// The default implementation issues one `delete` per key concurrently;
136 /// backends should override this with a variadic `DEL`/`UNLINK`.
137 async fn mdel(&self, keys: &[&str]) -> CacheResult<()> {
138 use futures::future::try_join_all;
139
140 let futures = keys.iter().map(|key| self.delete(key));
141 try_join_all(futures).await?;
142 Ok(())
143 }
144
145 // ========== Batch Operations (Parallel) ==========
146
147 /// Get multiple keys in parallel.
148 ///
149 /// This operation fetches multiple cache keys concurrently, significantly
150 /// reducing total latency compared to sequential gets.
151 ///
152 /// # Arguments
153 ///
154 /// * `keys` - Slice of cache keys to fetch
155 ///
156 /// # Returns
157 ///
158 /// Returns a vector of `Option<String>` in the same order as the input keys.
159 /// `None` indicates the key was not found.
160 ///
161 /// # Performance
162 ///
163 /// - **Sequential:** O(n * network_latency)
164 /// - **Parallel:** O(max(network_latencies)) ≈ O(network_latency)
165 /// - **Speedup:** 10-100x for network-bound operations
166 ///
167 /// # Examples
168 ///
169 /// ```no_run
170 /// # use armature_cache::*;
171 /// # async fn example(cache: &impl CacheStore) -> CacheResult<()> {
172 /// // Fetch 100 user profiles in parallel
173 /// let keys: Vec<String> = (1..=100).map(|i| format!("user:{}", i)).collect();
174 /// let key_refs: Vec<&str> = keys.iter().map(|s| s.as_str()).collect();
175 /// let profiles = cache.get_many(&key_refs).await?;
176 ///
177 /// // Sequential: ~1000ms (10ms * 100)
178 /// // Parallel: ~15ms (max of all parallel requests)
179 /// # Ok(())
180 /// # }
181 /// ```
182 async fn get_many(&self, keys: &[&str]) -> CacheResult<Vec<Option<String>>> {
183 // Delegate to the native batch primitive so backends that override
184 // `mget` (e.g. Redis `MGET`) accelerate this path automatically.
185 self.mget(keys).await
186 }
187
188 /// Set multiple key-value pairs in parallel.
189 ///
190 /// # Arguments
191 ///
192 /// * `items` - Slice of (key, value) tuples
193 /// * `ttl` - Optional time-to-live for all keys
194 ///
195 /// # Performance
196 ///
197 /// 10-100x faster than sequential sets for network-bound operations.
198 ///
199 /// # Examples
200 ///
201 /// ```no_run
202 /// # use armature_cache::*;
203 /// # async fn example(cache: &impl CacheStore) -> CacheResult<()> {
204 /// use std::time::Duration;
205 ///
206 /// let items = vec![
207 /// ("user:1", r#"{"name":"Alice"}"#.to_string()),
208 /// ("user:2", r#"{"name":"Bob"}"#.to_string()),
209 /// ];
210 ///
211 /// cache.set_many(&items, Some(Duration::from_secs(3600))).await?;
212 /// # Ok(())
213 /// # }
214 /// ```
215 async fn set_many(&self, items: &[(&str, String)], ttl: Option<Duration>) -> CacheResult<()> {
216 // Delegate to the native batch primitive (e.g. Redis `MSET`/pipeline).
217 self.mset(items, ttl).await
218 }
219
220 /// Delete multiple keys in parallel.
221 ///
222 /// # Arguments
223 ///
224 /// * `keys` - Slice of cache keys to delete
225 ///
226 /// # Performance
227 ///
228 /// 10-100x faster than sequential deletes.
229 ///
230 /// # Examples
231 ///
232 /// ```no_run
233 /// # use armature_cache::*;
234 /// # async fn example(cache: &impl CacheStore) -> CacheResult<()> {
235 /// // Bulk cache invalidation
236 /// let keys = vec!["session:1", "session:2", "session:3"];
237 /// cache.delete_many(&keys).await?;
238 /// # Ok(())
239 /// # }
240 /// ```
241 async fn delete_many(&self, keys: &[&str]) -> CacheResult<()> {
242 // Delegate to the native batch primitive (e.g. Redis variadic `DEL`).
243 self.mdel(keys).await
244 }
245
246 /// Check existence of multiple keys in parallel.
247 ///
248 /// # Arguments
249 ///
250 /// * `keys` - Slice of cache keys to check
251 ///
252 /// # Returns
253 ///
254 /// Returns a vector of booleans in the same order as input keys.
255 ///
256 /// # Examples
257 ///
258 /// ```no_run
259 /// # use armature_cache::*;
260 /// # async fn example(cache: &impl CacheStore) -> CacheResult<()> {
261 /// let keys = vec!["user:1", "user:2", "user:3"];
262 /// let exists = cache.exists_many(&keys).await?;
263 ///
264 /// for (key, exists) in keys.iter().zip(exists.iter()) {
265 /// println!("{}: {}", key, exists);
266 /// }
267 /// # Ok(())
268 /// # }
269 /// ```
270 async fn exists_many(&self, keys: &[&str]) -> CacheResult<Vec<bool>> {
271 use futures::future::try_join_all;
272
273 let futures = keys.iter().map(|key| self.exists(key));
274 try_join_all(futures).await
275 }
276
277 /// Get TTL for multiple keys in parallel.
278 ///
279 /// # Arguments
280 ///
281 /// * `keys` - Slice of cache keys
282 ///
283 /// # Returns
284 ///
285 /// Returns a vector of `Option<Duration>` for each key.
286 ///
287 /// # Examples
288 ///
289 /// ```no_run
290 /// # use armature_cache::*;
291 /// # async fn example(cache: &impl CacheStore) -> CacheResult<()> {
292 /// let keys = vec!["session:1", "session:2"];
293 /// let ttls = cache.ttl_many(&keys).await?;
294 ///
295 /// for (key, ttl) in keys.iter().zip(ttls.iter()) {
296 /// match ttl {
297 /// Some(duration) => println!("{}: expires in {:?}", key, duration),
298 /// None => println!("{}: no expiration", key),
299 /// }
300 /// }
301 /// # Ok(())
302 /// # }
303 /// ```
304 async fn ttl_many(&self, keys: &[&str]) -> CacheResult<Vec<Option<Duration>>> {
305 use futures::future::try_join_all;
306
307 let futures = keys.iter().map(|key| self.ttl(key));
308 try_join_all(futures).await
309 }
310
311 // ========== Set Primitives (persistent, cross-instance indexes) ==========
312 //
313 // Low-level primitives for maintaining a set-of-strings value at a given
314 // key *in the backing store itself*. These exist so higher-level indexes
315 // built on top of a `CacheStore` — e.g. `TaggedCache`'s tag -> member-key
316 // index (see `crate::invalidation`) — are visible to every process /
317 // instance sharing that store, not just the process that wrote them.
318 //
319 // The DEFAULT implementations below are a portable but NON-ATOMIC
320 // read-modify-write layered on `get_json`/`set_json`: correct for a single
321 // writer or low-contention use, but concurrent `set_add`/`set_remove`
322 // calls against the SAME `set_key` from different instances can race and
323 // lose an update (last write wins). Backends with a native set type
324 // should override these three methods for atomicity — `RedisCache` does,
325 // via `SADD`/`SREM`/`SMEMBERS`.
326
327 /// Whether this backend's [`Self::set_add`]/[`Self::set_remove`]/
328 /// [`Self::set_members`] are backed by a native, atomic set type rather
329 /// than the trait's default non-atomic read-modify-write.
330 ///
331 /// `RedisCache` overrides this to return `true` (its implementations use
332 /// `SADD`/`SREM`/`SMEMBERS`). Every other backend — including
333 /// `InMemoryCache` and `MemcachedCache` — keeps this default `false`,
334 /// since they inherit the default set primitives above.
335 ///
336 /// [`crate::invalidation::TaggedCache::new`] checks this capability and
337 /// logs a warning once, at construction time, when the backing store
338 /// answers `false` — giving operators a runtime signal (not just a doc
339 /// comment) that concurrent tag-index updates against that deployment
340 /// can race and silently lose an update.
341 fn supports_atomic_sets(&self) -> bool {
342 false
343 }
344
345 /// Add `member` to the persistent string set stored at `set_key`.
346 ///
347 /// A no-op if `member` is already present.
348 ///
349 /// # Examples
350 ///
351 /// ```no_run
352 /// # use armature_cache::*;
353 /// # async fn example(cache: &impl CacheStore) -> CacheResult<()> {
354 /// // Build a persisted set of member keys under "tag:users".
355 /// cache.set_add("tag:users", "user:1").await?;
356 /// cache.set_add("tag:users", "user:2").await?;
357 /// cache.set_add("tag:users", "user:1").await?; // duplicate: no-op
358 ///
359 /// let members = cache.set_members("tag:users").await?;
360 /// assert_eq!(members.len(), 2);
361 /// # Ok(())
362 /// # }
363 /// ```
364 async fn set_add(&self, set_key: &str, member: &str) -> CacheResult<()> {
365 let mut members = self.set_members(set_key).await?;
366 if !members.iter().any(|m| m == member) {
367 members.push(member.to_string());
368 let json = serde_json::to_string(&members)
369 .map_err(|e| CacheError::Serialization(e.to_string()))?;
370 self.set_json(set_key, json, None).await?;
371 }
372 Ok(())
373 }
374
375 /// Remove `member` from the persistent string set stored at `set_key`.
376 ///
377 /// A no-op if `set_key` or `member` doesn't exist. Deletes `set_key`
378 /// entirely once its last member is removed, so an emptied set doesn't
379 /// linger as a zero-length entry.
380 ///
381 /// # Examples
382 ///
383 /// ```no_run
384 /// # use armature_cache::*;
385 /// # async fn example(cache: &impl CacheStore) -> CacheResult<()> {
386 /// cache.set_add("tag:users", "user:1").await?;
387 /// cache.set_remove("tag:users", "user:1").await?;
388 ///
389 /// // The set is now empty; `set_key` itself is removed rather than left
390 /// // behind as a zero-length entry.
391 /// assert!(cache.set_members("tag:users").await?.is_empty());
392 /// # Ok(())
393 /// # }
394 /// ```
395 async fn set_remove(&self, set_key: &str, member: &str) -> CacheResult<()> {
396 let mut members = self.set_members(set_key).await?;
397 let before = members.len();
398 members.retain(|m| m != member);
399
400 if members.len() != before {
401 if members.is_empty() {
402 self.delete(set_key).await?;
403 } else {
404 let json = serde_json::to_string(&members)
405 .map_err(|e| CacheError::Serialization(e.to_string()))?;
406 self.set_json(set_key, json, None).await?;
407 }
408 }
409 Ok(())
410 }
411
412 /// Read every member of the persistent string set stored at `set_key`.
413 ///
414 /// Returns an empty `Vec` if `set_key` doesn't exist.
415 ///
416 /// # Examples
417 ///
418 /// ```no_run
419 /// # use armature_cache::*;
420 /// # async fn example(cache: &impl CacheStore) -> CacheResult<()> {
421 /// cache.set_add("tag:users", "user:1").await?;
422 /// cache.set_add("tag:users", "user:2").await?;
423 ///
424 /// let mut members = cache.set_members("tag:users").await?;
425 /// members.sort();
426 /// assert_eq!(members, vec!["user:1".to_string(), "user:2".to_string()]);
427 ///
428 /// // A set_key that was never written returns an empty Vec.
429 /// assert!(cache.set_members("tag:unused").await?.is_empty());
430 /// # Ok(())
431 /// # }
432 /// ```
433 async fn set_members(&self, set_key: &str) -> CacheResult<Vec<String>> {
434 match self.get_json(set_key).await? {
435 Some(json) => {
436 serde_json::from_str(&json).map_err(|e| CacheError::Deserialization(e.to_string()))
437 }
438 None => Ok(Vec::new()),
439 }
440 }
441}
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446 use crate::tiered::InMemoryCache;
447
448 // `InMemoryCache` does NOT override `mget`/`mset`/`mdel`, so these tests
449 // exercise the trait's DEFAULT (per-key loop) implementations.
450
451 #[tokio::test]
452 async fn test_mget_default_fallback_preserves_order() {
453 let cache = InMemoryCache::new();
454 cache.set_json("a", "1".to_string(), None).await.unwrap();
455 cache.set_json("c", "3".to_string(), None).await.unwrap();
456
457 let got = cache.mget(&["a", "b", "c"]).await.unwrap();
458 assert_eq!(
459 got,
460 vec![Some("1".to_string()), None, Some("3".to_string())]
461 );
462 }
463
464 #[tokio::test]
465 async fn test_mset_and_mdel_default_fallback() {
466 let cache = InMemoryCache::new();
467 cache
468 .mset(&[("x", "10".to_string()), ("y", "20".to_string())], None)
469 .await
470 .unwrap();
471 assert_eq!(cache.get_json("x").await.unwrap(), Some("10".to_string()));
472 assert_eq!(cache.get_json("y").await.unwrap(), Some("20".to_string()));
473
474 cache.mdel(&["x", "y"]).await.unwrap();
475 assert_eq!(cache.get_json("x").await.unwrap(), None);
476 assert_eq!(cache.get_json("y").await.unwrap(), None);
477 }
478
479 #[tokio::test]
480 async fn test_public_batch_methods_delegate_to_primitives() {
481 let cache = InMemoryCache::new();
482 cache.set_json("k1", "v1".to_string(), None).await.unwrap();
483
484 // get_many delegates to mget
485 let got = cache.get_many(&["k1", "k2"]).await.unwrap();
486 assert_eq!(got, vec![Some("v1".to_string()), None]);
487
488 // set_many delegates to mset
489 cache
490 .set_many(&[("k3", "v3".to_string())], None)
491 .await
492 .unwrap();
493 assert_eq!(cache.get_json("k3").await.unwrap(), Some("v3".to_string()));
494
495 // delete_many delegates to mdel
496 cache.delete_many(&["k1", "k3"]).await.unwrap();
497 assert_eq!(cache.get_json("k1").await.unwrap(), None);
498 assert_eq!(cache.get_json("k3").await.unwrap(), None);
499 }
500
501 #[tokio::test]
502 async fn test_mget_empty_keys() {
503 let cache = InMemoryCache::new();
504 let got = cache.mget(&[]).await.unwrap();
505 assert!(got.is_empty());
506 }
507
508 /// Regression for Finding 3: backends that don't override the default,
509 /// non-atomic `set_add`/`set_remove`/`set_members` must report
510 /// `supports_atomic_sets() == false` so callers (e.g. `TaggedCache::new`)
511 /// can warn operators. `InMemoryCache` never overrides these, so it must
512 /// keep the trait's default answer.
513 #[tokio::test]
514 async fn test_supports_atomic_sets_default_is_false() {
515 let cache = InMemoryCache::new();
516 assert!(!cache.supports_atomic_sets());
517 }
518}