cratefield_runtime_native/ports/kv.rs
1//! `KeyValue` over Redis, plus the shared-connection convenience both
2//! Redis ports are wired through (`redis_from_env`). The honest
3//! differences from a Workers KV namespace are listed on [`RedisKv`].
4
5use std::sync::Arc;
6use std::time::Duration;
7
8use async_trait::async_trait;
9use cratefield_core::{Config, KeyValue, KvError};
10use redis::aio::ConnectionManager;
11
12use crate::ports::rate_limit::RedisRateLimiter;
13
14/// `KeyValue` over Redis (one shared `ConnectionManager`, cloned per
15/// call — a cheap channel copy, not a new connection).
16///
17/// # Differences from the Workers KV binding (documented, not papered
18/// over)
19///
20/// | | Workers KV | `RedisKv` |
21/// |---|---|---|
22/// | Consistency | eventually consistent; a write can take up to ~60 s to propagate to other colos, and reads may hit a stale per-colo cache | strongly consistent single Redis: a read after a write sees the write |
23/// | Failure mode | KV outages degrade per colo; values survive Redis-down | Redis is a single point of failure and of latency for the port |
24/// | TTL | minimum 60 s | any duration, millisecond granularity (`PX`) |
25/// | Value size | 25 MiB per value | 512 MiB per string, but the port is meant for small values |
26/// | Key space | the binding's own namespace | one Redis database shared with the rate limiter — keys are prefixed `kv:` here to keep the two apart |
27///
28/// The port has no list/scan method, so KV's list operations have no
29/// counterpart to mismatch.
30pub struct RedisKv(ConnectionManager);
31
32impl RedisKv {
33 pub fn new(conn: ConnectionManager) -> Self {
34 Self(conn)
35 }
36}
37
38fn op(err: &redis::RedisError) -> KvError {
39 KvError::Operation(err.to_string())
40}
41
42#[async_trait]
43impl KeyValue for RedisKv {
44 async fn get(&self, key: &str) -> Result<Option<String>, KvError> {
45 let mut conn = self.0.clone();
46 redis::cmd("GET")
47 .arg(format!("kv:{key}"))
48 .query_async(&mut conn)
49 .await
50 .map_err(|err| op(&err))
51 }
52
53 async fn put(&self, key: &str, value: &str, ttl: Option<Duration>) -> Result<(), KvError> {
54 let mut conn = self.0.clone();
55 let mut cmd = redis::cmd("SET");
56 cmd.arg(format!("kv:{key}")).arg(value);
57 if let Some(ttl) = ttl {
58 // PX (milliseconds) rather than EX (seconds): the port takes
59 // a Duration and the port's granularity survives intact.
60 let millis = i64::try_from(ttl.as_millis()).unwrap_or(i64::MAX).max(1);
61 cmd.arg("PX").arg(millis);
62 }
63 cmd.query_async::<()>(&mut conn)
64 .await
65 .map_err(|err| op(&err))
66 }
67
68 async fn delete(&self, key: &str) -> Result<(), KvError> {
69 let mut conn = self.0.clone();
70 redis::cmd("DEL")
71 .arg(format!("kv:{key}"))
72 .query_async::<i64>(&mut conn)
73 .await
74 .map_err(|err| op(&err))?;
75 Ok(())
76 }
77}
78
79/// Both Redis ports over one shared connection, what a venture gets from
80/// `REDIS_URL` when it is set.
81pub struct RedisBundle {
82 pub rate_limiter: Arc<RedisRateLimiter>,
83 pub kv: Arc<RedisKv>,
84}
85
86#[derive(Debug, thiserror::Error)]
87pub enum RedisPortError {
88 /// `REDIS_URL` is not a valid Redis URL, or the manager could not
89 /// establish its first connection.
90 #[error("redis port setup failed: {0}")]
91 Connection(String),
92}
93
94/// Wires both Redis ports from config: `REDIS_URL` opens a client and
95/// one reconnecting [`ConnectionManager`] shared (cloned) by the two
96/// adapters; `RATE_LIMIT_MAX` (default `100`) and
97/// `RATE_LIMIT_PERIOD_SECS` (default `60`) configure the limiter's
98/// window. `Ok(None)` when `REDIS_URL` is unset — the ports stay absent
99/// and a module that requires one fails `Harness::build` by name.
100///
101/// # Errors
102///
103/// [`RedisPortError::Connection`] when the URL cannot be parsed or the
104/// first connection fails (fail fast at startup, not on the first
105/// request).
106pub async fn redis_from_env(config: &dyn Config) -> Result<Option<RedisBundle>, RedisPortError> {
107 let Some(url) = config.get("REDIS_URL") else {
108 return Ok(None);
109 };
110 let client = redis::Client::open(url.as_str())
111 .map_err(|err| RedisPortError::Connection(err.to_string()))?;
112 let manager = client
113 .get_connection_manager()
114 .await
115 .map_err(|err| RedisPortError::Connection(err.to_string()))?;
116 let rate_limiter = Arc::new(RedisRateLimiter::from_env(manager.clone(), config));
117 let kv = Arc::new(RedisKv::new(manager));
118 Ok(Some(RedisBundle { rate_limiter, kv }))
119}