Skip to main content

apiplant_cache/
lib.rs

1//! # apiplant-cache
2//!
3//! An optional Redis cache, for functions to use.
4//!
5//! Nothing in the framework caches through it. Resources, permissions, hooks
6//! and the admin manifest behave identically whether or not a cache is
7//! configured, and that is deliberate: a cache in front of CRUD would have to
8//! guess when a row changes, and guessing wrong means serving stale data
9//! through an API that never said it might. What a cache *is* good for is the
10//! work a function does — a third-party response worth memoising, a rate-limit
11//! counter, a one-time token with a natural expiry — and that is work only the
12//! function author can invalidate correctly.
13//!
14//! So the cache is exposed, deliberately, only through a function's `Context`:
15//!
16//! ```toml
17//! [cache]
18//! url = "redis://127.0.0.1:6379"
19//! prefix = "my-app:"
20//! ```
21//!
22//! ```ignore
23//! if let Some(hit) = ctx.cache_get("rates:eur")? { return Ok(hit); }
24//! let rates = fetch_rates()?;
25//! ctx.cache_set("rates:eur", &rates, Some(900))?;
26//! ```
27//!
28//! ## Failing soft
29//!
30//! A cache that is down is not an outage. Every operation here returns a
31//! [`Result`], and the host reports failures to the function rather than to the
32//! caller — a function that treats a miss and an error alike keeps working when
33//! Redis is restarted, which is the whole point of the data being disposable.
34
35use std::time::Duration;
36
37use apiplant_core::CacheConfig;
38use redis::AsyncCommands;
39use serde::{Deserialize, Serialize};
40use serde_json::{json, Value};
41
42/// What went wrong talking to the cache.
43#[derive(Debug, thiserror::Error)]
44pub enum CacheError {
45    /// The `[cache]` section can't produce a working client.
46    #[error("cache configuration: {0}")]
47    Config(String),
48
49    /// The request a function made isn't a cache operation.
50    #[error("invalid cache request: {0}")]
51    Request(String),
52
53    /// Redis was unreachable, or took too long.
54    #[error("cache: {0}")]
55    Backend(String),
56}
57
58/// A connected cache, shared by every worker.
59///
60/// Cloning is cheap: the underlying [`redis::aio::ConnectionManager`] is
61/// reference-counted and reconnects on its own, so a clone shares one
62/// connection rather than opening another.
63#[derive(Clone)]
64pub struct Cache {
65    connection: redis::aio::ConnectionManager,
66    prefix: String,
67    default_ttl_secs: u64,
68    timeout: Duration,
69}
70
71impl std::fmt::Debug for Cache {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        // The URL can carry a password, so it isn't kept or printed.
74        f.debug_struct("Cache")
75            .field("prefix", &self.prefix)
76            .field("default_ttl_secs", &self.default_ttl_secs)
77            .finish()
78    }
79}
80
81impl Cache {
82    /// Connect the cache an app's `[cache]` section describes.
83    ///
84    /// `Ok(None)` means the app configured none, which is the default and not
85    /// an error. `Err` means it asked for one that can't be reached — worth
86    /// failing the boot over, because a function written against a cache and
87    /// silently given none is a bug that only shows up as a load spike.
88    pub async fn connect(config: &CacheConfig) -> Result<Option<Cache>, CacheError> {
89        if !config.is_active() {
90            return Ok(None);
91        }
92        let url = config.url.trim();
93        let client =
94            redis::Client::open(url).map_err(|e| CacheError::Config(format!("{url}: {e}")))?;
95        let timeout = Duration::from_secs(config.timeout_secs.max(1));
96
97        let connection = tokio::time::timeout(timeout, redis::aio::ConnectionManager::new(client))
98            .await
99            .map_err(|_| CacheError::Backend(format!("timed out connecting to {url}")))?
100            .map_err(|e| CacheError::Backend(format!("{url}: {e}")))?;
101
102        Ok(Some(Cache {
103            connection,
104            prefix: config.prefix.clone(),
105            default_ttl_secs: config.default_ttl_secs,
106            timeout,
107        }))
108    }
109
110    /// The physical key for a logical one.
111    fn key(&self, key: &str) -> Result<String, CacheError> {
112        prefixed(&self.prefix, key)
113    }
114
115    /// Run one operation, given as the JSON a function sent across the ABI, and
116    /// return the JSON reply.
117    ///
118    /// This is the single entry point the host bridge uses, which is what keeps
119    /// the ABI to one `cache` call instead of one per verb — a new operation
120    /// costs a variant of [`Op`], not a change to the contract every compiled
121    /// function was built against.
122    pub async fn execute(&self, request: &str) -> Result<Value, CacheError> {
123        let op: Op = serde_json::from_str(request)
124            .map_err(|e| CacheError::Request(format!("{e}; expected {}", Op::grammar())))?;
125        match op {
126            Op::Get { key } => {
127                let value = self.get(&key).await?;
128                Ok(json!({ "hit": value.is_some(), "value": value }))
129            }
130            Op::Set { key, value, ttl } => {
131                self.set(&key, &value, ttl).await?;
132                Ok(json!({ "ok": true }))
133            }
134            Op::Delete { key } => Ok(json!({ "deleted": self.delete(&key).await? })),
135            Op::Exists { key } => Ok(json!({ "exists": self.exists(&key).await? })),
136            Op::Incr { key, by, ttl } => {
137                let value = self.incr(&key, by, ttl).await?;
138                Ok(json!({ "value": value }))
139            }
140            Op::Ttl { key } => Ok(json!({ "ttl": self.ttl(&key).await? })),
141        }
142    }
143
144    /// Read a value, or `None` when the key is absent or expired.
145    ///
146    /// A value that isn't valid JSON (something else wrote it) comes back as a
147    /// JSON string rather than as an error — the caller asked for whatever is
148    /// there.
149    pub async fn get(&self, key: &str) -> Result<Option<Value>, CacheError> {
150        let key = self.key(key)?;
151        let stored: Option<String> = self.run("get", self.connection.clone().get(&key)).await?;
152        Ok(stored.map(|text| serde_json::from_str(&text).unwrap_or(Value::String(text))))
153    }
154
155    /// Write a value. `ttl` of `None` uses `[cache] default_ttl_secs`; `0`
156    /// means "no expiry" in both places.
157    pub async fn set(&self, key: &str, value: &Value, ttl: Option<u64>) -> Result<(), CacheError> {
158        let key = self.key(key)?;
159        let payload =
160            serde_json::to_string(value).map_err(|e| CacheError::Request(e.to_string()))?;
161        let ttl = ttl.unwrap_or(self.default_ttl_secs);
162        let mut connection = self.connection.clone();
163        if ttl == 0 {
164            self.run::<()>("set", connection.set(&key, payload)).await
165        } else {
166            self.run::<()>("setex", connection.set_ex(&key, payload, ttl))
167                .await
168        }
169    }
170
171    /// Remove a key. `true` when it was there.
172    pub async fn delete(&self, key: &str) -> Result<bool, CacheError> {
173        let key = self.key(key)?;
174        let removed: i64 = self.run("del", self.connection.clone().del(&key)).await?;
175        Ok(removed > 0)
176    }
177
178    /// Whether a key is present and unexpired.
179    pub async fn exists(&self, key: &str) -> Result<bool, CacheError> {
180        let key = self.key(key)?;
181        self.run("exists", self.connection.clone().exists(&key))
182            .await
183    }
184
185    /// Add to a counter and return its new value, creating it at zero first.
186    ///
187    /// Atomic on the server, which is what makes it usable for rate limiting
188    /// from several workers (or several hosts) at once — a read-modify-write
189    /// through [`get`](Self::get) and [`set`](Self::set) would not be.
190    pub async fn incr(&self, key: &str, by: i64, ttl: Option<u64>) -> Result<i64, CacheError> {
191        let key = self.key(key)?;
192        let mut connection = self.connection.clone();
193        let value: i64 = self.run("incrby", connection.incr(&key, by)).await?;
194
195        // An expiry is only applied when the counter is new, so a window that
196        // started 50 seconds ago doesn't get another full minute on every hit.
197        let ttl = ttl.unwrap_or(self.default_ttl_secs);
198        if ttl > 0 && value == by {
199            self.run::<bool>("expire", connection.expire(&key, ttl as i64))
200                .await?;
201        }
202        Ok(value)
203    }
204
205    /// Seconds until a key expires: `None` when it is absent or has no expiry.
206    pub async fn ttl(&self, key: &str) -> Result<Option<i64>, CacheError> {
207        let key = self.key(key)?;
208        let seconds: i64 = self.run("ttl", self.connection.clone().ttl(&key)).await?;
209        // Redis answers -2 for "no such key" and -1 for "no expiry"; both are
210        // "there is no deadline to report".
211        Ok((seconds >= 0).then_some(seconds))
212    }
213
214    /// Apply the configured timeout to one command and label its failure.
215    ///
216    /// Every operation goes through here so that a wedged Redis costs one
217    /// request its `timeout_secs` and nothing more — without it, a function
218    /// blocking on the cache would hold its worker indefinitely.
219    async fn run<T>(
220        &self,
221        command: &str,
222        future: impl std::future::Future<Output = redis::RedisResult<T>>,
223    ) -> Result<T, CacheError> {
224        match tokio::time::timeout(self.timeout, future).await {
225            Ok(Ok(value)) => Ok(value),
226            Ok(Err(e)) => Err(CacheError::Backend(format!("{command}: {e}"))),
227            Err(_) => Err(CacheError::Backend(format!(
228                "{command}: timed out after {:?}",
229                self.timeout
230            ))),
231        }
232    }
233}
234
235/// The physical key for a logical one: `prefix` keeps several apps from
236/// colliding in one Redis.
237///
238/// An empty key is refused rather than addressed, because `""` under a prefix
239/// is the prefix itself — a real key that a second app would happily overwrite.
240fn prefixed(prefix: &str, key: &str) -> Result<String, CacheError> {
241    let key = key.trim();
242    if key.is_empty() {
243        return Err(CacheError::Request("a cache key cannot be empty".into()));
244    }
245    Ok(format!("{prefix}{key}"))
246}
247
248/// One cache operation, as a function sends it over the ABI.
249#[derive(Debug, Clone, Serialize, Deserialize)]
250#[serde(tag = "op", rename_all = "lowercase")]
251pub enum Op {
252    Get {
253        key: String,
254    },
255    Set {
256        key: String,
257        value: Value,
258        #[serde(default)]
259        ttl: Option<u64>,
260    },
261    #[serde(alias = "del")]
262    Delete {
263        key: String,
264    },
265    Exists {
266        key: String,
267    },
268    #[serde(alias = "increment")]
269    Incr {
270        key: String,
271        #[serde(default = "one")]
272        by: i64,
273        #[serde(default)]
274        ttl: Option<u64>,
275    },
276    Ttl {
277        key: String,
278    },
279}
280
281fn one() -> i64 {
282    1
283}
284
285impl Op {
286    /// The accepted requests, for the error a malformed one produces.
287    pub fn grammar() -> &'static str {
288        r#"{"op":"get"|"set"|"delete"|"exists"|"incr"|"ttl","key":"…", …}"#
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn a_cache_is_only_connected_when_one_is_configured() {
298        // `connect` is async, but the "nothing configured" answer is decided
299        // before any I/O — checked here through the same predicate it uses.
300        assert!(!CacheConfig::default().is_active());
301        assert!(CacheConfig {
302            url: "redis://127.0.0.1:6379".into(),
303            ..CacheConfig::default()
304        }
305        .is_active());
306    }
307
308    #[test]
309    fn operations_parse_from_the_json_a_function_sends() {
310        let get: Op = serde_json::from_str(r#"{"op":"get","key":"k"}"#).unwrap();
311        assert!(matches!(get, Op::Get { key } if key == "k"));
312
313        let set: Op =
314            serde_json::from_str(r#"{"op":"set","key":"k","value":{"a":1},"ttl":60}"#).unwrap();
315        match set {
316            Op::Set { key, value, ttl } => {
317                assert_eq!(key, "k");
318                assert_eq!(value["a"], 1);
319                assert_eq!(ttl, Some(60));
320            }
321            other => panic!("expected a set, got {other:?}"),
322        }
323
324        // No `ttl` means "use the configured default", which is not the same as
325        // `ttl: 0` ("never expire").
326        let no_ttl: Op = serde_json::from_str(r#"{"op":"set","key":"k","value":null}"#).unwrap();
327        assert!(matches!(no_ttl, Op::Set { ttl: None, .. }));
328        let never: Op =
329            serde_json::from_str(r#"{"op":"set","key":"k","value":1,"ttl":0}"#).unwrap();
330        assert!(matches!(never, Op::Set { ttl: Some(0), .. }));
331
332        // `incr` defaults to one, and `del` is accepted for `delete`.
333        let incr: Op = serde_json::from_str(r#"{"op":"incr","key":"hits"}"#).unwrap();
334        assert!(matches!(incr, Op::Incr { by: 1, .. }));
335        assert!(matches!(
336            serde_json::from_str::<Op>(r#"{"op":"del","key":"k"}"#).unwrap(),
337            Op::Delete { .. }
338        ));
339    }
340
341    #[test]
342    fn an_unknown_operation_is_rejected() {
343        assert!(serde_json::from_str::<Op>(r#"{"op":"flushall"}"#).is_err());
344        assert!(serde_json::from_str::<Op>(r#"{"op":"get"}"#).is_err());
345        assert!(serde_json::from_str::<Op>("not json").is_err());
346    }
347
348    #[test]
349    fn keys_are_prefixed_and_never_empty() {
350        assert_eq!(prefixed("app:", "rates").unwrap(), "app:rates");
351        assert_eq!(prefixed("", " rates ").unwrap(), "rates");
352        // `""` under a prefix *is* the prefix — a real key another app owns.
353        assert!(prefixed("app:", "").is_err());
354        assert!(prefixed("app:", "   ").is_err());
355    }
356}