Skip to main content

kevy_cluster_rw/
lib.rs

1//! kevy-cluster-rw — read/write-split cluster client for kevy.
2//!
3//! Splits each client command between a **primary** connection (for
4//! writes — keyspace-mutating verbs) and a fleet of **replica**
5//! connections (for reads — round-robin across them, fallback to the
6//! primary when no replica is configured). A per-command
7//! `consistent: bool` knob (`READCONSISTENT` semantics) forces a read
8//! to the primary for callers that need fresh data.
9//!
10//! v1.18 model: the operator supplies the primary address + a list of
11//! replica addresses to [`ReadWriteClient::connect`]; the client
12//! holds one `RespClient` per node. Server-side classification is
13//! intentionally not consulted (no implicit `CLUSTER SLOTS` walk);
14//! this keeps the client correct under both `[cluster] enabled` and
15//! standalone deployments.
16//!
17//! Per-command read/write classification lives in [`is_write_verb`]
18//! — a small static table mirroring `kevy::cmd`'s server-side rule
19//! (duplicated here on purpose: this crate is downstream of
20//! `kevy-resp-client` only, so it never depends on the server crate).
21#![forbid(unsafe_code)]
22#![warn(missing_docs)]
23
24use std::collections::HashMap;
25use std::io;
26
27use kevy_resp::Reply;
28use kevy_resp_client::RespClient;
29
30/// Read/write-split cluster client. Owns one `RespClient` to the
31/// primary node + one per replica node. Round-robins reads across
32/// the replica fleet (fallback to primary on empty fleet or
33/// `consistent = true`).
34pub struct ReadWriteClient {
35    primary: RespClient,
36    replicas: Vec<RespClient>,
37    /// Wrap-around counter for read load-balancing across replicas.
38    /// Even distribution under steady-state; the rare write-immediately-
39    /// after-write pattern that pins to a single replica is acceptable
40    /// for v1.18 (no fairness guarantee).
41    rr_counter: usize,
42    /// v3-cluster Phase 3 scope cache: `host:port` → live
43    /// `RespClient`. Populated on demand when a write returns
44    /// `-MISDIRECTED writer is <host:port>` — the client opens a
45    /// new connection, caches it, and retries against the named
46    /// writer.
47    scope_writers: HashMap<String, RespClient>,
48    /// Per-key target cache: key bytes → `host:port` of the writer
49    /// most recently confirmed by the server. Subsequent writes for
50    /// the same key skip the primary round-trip + go straight to
51    /// the cached writer. Bounded at 4096 entries; the oldest get
52    /// dropped wholesale when exceeded (the cache is rebuilt by
53    /// the next `-MISDIRECTED` reply, so we don't need an LRU).
54    /// Prefix-shape caching is a follow-up — it requires the
55    /// server to include the prefix in the MISDIRECTED reply.
56    scope_key_targets: HashMap<Vec<u8>, String>,
57}
58
59/// Cap for [`ReadWriteClient::scope_key_targets`] before bulk-evict.
60const SCOPE_KEY_CACHE_CAP: usize = 4096;
61
62/// Max retries the client attempts after a `-QUIESCED` reply
63/// (T3.15). Total worst-case wait ≈
64/// `QUIESCE_RETRY_MIN_MS * (2^N - 1)` with N = budget; with
65/// 5 ms / 80 ms / budget = 7 that's ~635 ms ceiling — enough
66/// to ride out a typical KB-sized scope's quiesce window.
67const QUIESCE_RETRY_BUDGET: usize = 7;
68const QUIESCE_RETRY_MIN_MS: u64 = 5;
69const QUIESCE_RETRY_MAX_MS: u64 = 80;
70
71impl ReadWriteClient {
72    /// Open one connection to the primary and one per replica.
73    ///
74    /// `primary` is the address of the kevy node running with
75    /// `[replication] role = "primary"` (or `standalone` — both
76    /// behave the same from the client's perspective). `replicas`
77    /// lists the addresses of kevy nodes running with
78    /// `[replication] role = "replica"`; v1.18 assumes they all
79    /// share the keyspace of the primary (operator-enforced — kevy
80    /// has no automatic discovery in v1.18).
81    pub fn connect(primary: (&str, u16), replicas: &[(&str, u16)]) -> io::Result<Self> {
82        let primary_conn = RespClient::connect(primary.0, primary.1)?;
83        let mut replica_conns = Vec::with_capacity(replicas.len());
84        for (host, port) in replicas {
85            replica_conns.push(RespClient::connect(host, *port)?);
86        }
87        Ok(Self {
88            primary: primary_conn,
89            replicas: replica_conns,
90            rr_counter: 0,
91            scope_writers: HashMap::new(),
92            scope_key_targets: HashMap::new(),
93        })
94    }
95
96    /// Number of replica connections.
97    pub fn replica_count(&self) -> usize {
98        self.replicas.len()
99    }
100
101    /// Route a write command (or any command that must hit the
102    /// primary) to the primary connection. If the server replies
103    /// `-MISDIRECTED writer is <host:port>` (Phase 3 / v1.21 scoped
104    /// multi-writer), the client transparently opens a connection
105    /// to the named writer (caching it), caches the key→writer
106    /// mapping for follow-up writes on the same key, and retries
107    /// **once**. A second `-MISDIRECTED` from the retry surfaces as
108    /// an error.
109    pub fn request_write(&mut self, args: &[Vec<u8>]) -> io::Result<Reply> {
110        // Fast path: a prior MISDIRECTED for this key cached its
111        // writer's address — skip the primary round-trip.
112        if let Some(key) = args.get(1)
113            && let Some(addr) = self.scope_key_targets.get(key.as_slice()).cloned()
114        {
115            return self.request_via_writer(&addr, args);
116        }
117        self.request_write_with_quiesce_retry(args)
118    }
119
120    /// Send `args` to the primary; on `-QUIESCED` (T3.15), back off
121    /// and retry up to `QUIESCE_RETRY_BUDGET` times against the same
122    /// primary. The migration is in-flight; the cluster member that
123    /// answered will start returning `-MISDIRECTED` once the
124    /// migration commits, and the client follows via the existing
125    /// MISDIRECTED branch. Surfaces the final `-QUIESCED` reply as
126    /// a regular `Reply::Error` after exhausting retries — the
127    /// caller decides whether to back off further or fail.
128    fn request_write_with_quiesce_retry(&mut self, args: &[Vec<u8>]) -> io::Result<Reply> {
129        let mut backoff = std::time::Duration::from_millis(QUIESCE_RETRY_MIN_MS);
130        for _ in 0..QUIESCE_RETRY_BUDGET {
131            let reply = self.primary.request(args)?;
132            if let Some(target_addr) = parse_misdirected(&reply) {
133                if let Some(key) = args.get(1) {
134                    self.remember_key_target(key, &target_addr);
135                }
136                return self.request_via_writer(&target_addr, args);
137            }
138            if parse_quiesced(&reply).is_some() {
139                // Migration in flight — back off and retry. Do NOT
140                // cache the QUIESCED `<to-addr>`: the migration may
141                // abort and the original writer would resume.
142                std::thread::sleep(backoff);
143                backoff = (backoff * 2).min(std::time::Duration::from_millis(QUIESCE_RETRY_MAX_MS));
144                continue;
145            }
146            return Ok(reply);
147        }
148        // Exhausted the retry budget. Final attempt; whatever it
149        // returns (likely another -QUIESCED) bubbles to the caller.
150        self.primary.request(args)
151    }
152
153    /// Open + cache a connection to `addr` (`"host:port"`) and send
154    /// `args`. A second `-MISDIRECTED` from this hop is **not**
155    /// followed — the client would be in a redirect loop and the
156    /// caller deserves to see the error rather than burn round-trips.
157    fn request_via_writer(&mut self, addr: &str, args: &[Vec<u8>]) -> io::Result<Reply> {
158        if !self.scope_writers.contains_key(addr) {
159            let (host, port) = split_host_port(addr).ok_or_else(|| {
160                io::Error::new(
161                    io::ErrorKind::InvalidData,
162                    format!("server returned MISDIRECTED with malformed target {addr:?}"),
163                )
164            })?;
165            let conn = RespClient::connect(host, port)?;
166            self.scope_writers.insert(addr.to_string(), conn);
167        }
168        let conn = self
169            .scope_writers
170            .get_mut(addr)
171            .expect("just inserted above");
172        conn.request(args)
173    }
174
175    fn remember_key_target(&mut self, key: &[u8], addr: &str) {
176        if self.scope_key_targets.len() >= SCOPE_KEY_CACHE_CAP {
177            // Bulk-evict — next MISDIRECTED will reseed. The
178            // cache is an optimisation; correctness still holds.
179            self.scope_key_targets.clear();
180        }
181        self.scope_key_targets.insert(key.to_vec(), addr.to_string());
182    }
183
184    /// Route a read command to a replica (round-robin); fallback to
185    /// the primary when no replica is configured or when
186    /// `consistent` is `true`.
187    pub fn request_read(&mut self, args: &[Vec<u8>], consistent: bool) -> io::Result<Reply> {
188        if consistent || self.replicas.is_empty() {
189            return self.primary.request(args);
190        }
191        let idx = self.rr_counter % self.replicas.len();
192        self.rr_counter = self.rr_counter.wrapping_add(1);
193        self.replicas[idx].request(args)
194    }
195
196    /// Auto-routed command. Classifies `args[0]` via [`is_write_verb`]
197    /// and dispatches to either [`Self::request_write`] or
198    /// [`Self::request_read`] (`consistent = false`). Convenience for
199    /// callers that don't want to make the read/write decision
200    /// explicit.
201    pub fn request(&mut self, args: &[Vec<u8>]) -> io::Result<Reply> {
202        let Some(verb) = args.first() else {
203            return self.primary.request(args);
204        };
205        if is_write_verb(verb) {
206            self.request_write(args)
207        } else {
208            self.request_read(args, false)
209        }
210    }
211}
212
213/// `true` when `verb` is a keyspace-mutating command and so must run
214/// against the primary. Otherwise the command is read-side and a
215/// replica can serve it.
216///
217/// The table mirrors `kevy::cmd::is_write_verb` (server-side) — kept
218/// in sync by review. Verbs not listed here (including PING / ECHO /
219/// CLUSTER / CLIENT / HELLO) are read-side or keyspace-neutral.
220pub fn is_write_verb(verb: &[u8]) -> bool {
221    let mut buf = [0u8; 32];
222    let upper = ascii_upper(verb, &mut buf);
223    matches!(
224        upper,
225        // strings + counters
226        b"SET" | b"SETNX" | b"SETEX" | b"PSETEX" | b"MSET" | b"MSETNX"
227        | b"APPEND" | b"INCR" | b"INCRBY" | b"INCRBYFLOAT"
228        | b"DECR" | b"DECRBY" | b"GETSET" | b"GETDEL"
229        | b"SETRANGE"
230        // generic keyspace
231        | b"DEL" | b"UNLINK" | b"EXPIRE" | b"EXPIREAT" | b"PEXPIRE" | b"PEXPIREAT"
232        | b"PERSIST" | b"RENAME" | b"RENAMENX" | b"TYPE" // TYPE is read but cheap to misclassify
233        | b"COPY" | b"OBJECT"
234        // hash
235        | b"HSET" | b"HSETNX" | b"HMSET" | b"HDEL" | b"HINCRBY" | b"HINCRBYFLOAT"
236        // list
237        | b"LPUSH" | b"RPUSH" | b"LPUSHX" | b"RPUSHX" | b"LPOP" | b"RPOP"
238        | b"LREM" | b"LTRIM" | b"LSET" | b"LINSERT" | b"RPOPLPUSH" | b"LMOVE"
239        | b"BLPOP" | b"BRPOP" | b"BLMOVE"
240        // set
241        | b"SADD" | b"SREM" | b"SPOP" | b"SMOVE" | b"SINTERSTORE" | b"SUNIONSTORE" | b"SDIFFSTORE"
242        // zset
243        | b"ZADD" | b"ZREM" | b"ZINCRBY" | b"ZPOPMIN" | b"ZPOPMAX"
244        | b"ZREMRANGEBYRANK" | b"ZREMRANGEBYSCORE" | b"ZREMRANGEBYLEX"
245        // stream
246        | b"XADD" | b"XDEL" | b"XTRIM" | b"XGROUP" | b"XACK" | b"XCLAIM" | b"XAUTOCLAIM"
247        // server admin (mutates state)
248        | b"FLUSHDB" | b"FLUSHALL" | b"CONFIG" | b"SAVE" | b"BGSAVE" | b"BGREWRITEAOF"
249        | b"REPLICAOF" | b"SLAVEOF"
250        // pub/sub PUBLISH technically mutates subscriber state — route to primary
251        // so the publisher's "delivered count" reflects the primary's registry
252        | b"PUBLISH" | b"SPUBLISH"
253        // txn
254        | b"MULTI" | b"EXEC" | b"DISCARD" | b"WATCH" | b"UNWATCH"
255    )
256}
257
258fn ascii_upper<'a>(s: &[u8], buf: &'a mut [u8; 32]) -> &'a [u8] {
259    let n = s.len().min(32);
260    for i in 0..n {
261        buf[i] = s[i].to_ascii_uppercase();
262    }
263    &buf[..n]
264}
265
266/// Detect a Phase 3 `-MISDIRECTED writer is <host:port>` reply and
267/// extract the host-port target. `None` for any other Reply (incl.
268/// non-MISDIRECTED `-ERR ...` strings) — caller propagates those
269/// unchanged.
270fn parse_misdirected(reply: &Reply) -> Option<String> {
271    let Reply::Error(bytes) = reply else { return None };
272    // Expect `MISDIRECTED writer is <addr>` — kevy server's
273    // `scope_integration::encode_misdirected` shape.
274    const PREFIX: &[u8] = b"MISDIRECTED writer is ";
275    if !bytes.starts_with(PREFIX) {
276        return None;
277    }
278    let addr = std::str::from_utf8(&bytes[PREFIX.len()..]).ok()?;
279    let addr = addr.trim_end_matches(['\r', '\n']);
280    if addr.is_empty() {
281        return None;
282    }
283    Some(addr.to_string())
284}
285
286/// T3.15: detect a `-QUIESCED migrating to <host:port>` reply and
287/// extract the target. Same shape rationale as
288/// [`parse_misdirected`]; clients use this to know "back off + retry
289/// against the original writer until the migration commits".
290fn parse_quiesced(reply: &Reply) -> Option<String> {
291    let Reply::Error(bytes) = reply else { return None };
292    const PREFIX: &[u8] = b"QUIESCED migrating to ";
293    if !bytes.starts_with(PREFIX) {
294        return None;
295    }
296    let addr = std::str::from_utf8(&bytes[PREFIX.len()..]).ok()?;
297    let addr = addr.trim_end_matches(['\r', '\n']);
298    if addr.is_empty() {
299        return None;
300    }
301    Some(addr.to_string())
302}
303
304/// Parse `"host:port"`. Rejects empty host or non-u16 port.
305fn split_host_port(addr: &str) -> Option<(&str, u16)> {
306    let colon = addr.rfind(':')?;
307    let host = &addr[..colon];
308    if host.is_empty() {
309        return None;
310    }
311    let port: u16 = addr[colon + 1..].parse().ok()?;
312    Some((host, port))
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    #[test]
320    fn writes_classified_correctly() {
321        for verb in [&b"SET"[..], b"DEL", b"LPUSH", b"HSET", b"ZADD", b"XADD", b"FLUSHDB", b"REPLICAOF"] {
322            assert!(is_write_verb(verb), "{:?} should be write", std::str::from_utf8(verb));
323        }
324    }
325
326    #[test]
327    fn reads_classified_correctly() {
328        for verb in [&b"GET"[..], b"HGET", b"LRANGE", b"SMEMBERS", b"ZSCORE", b"XRANGE", b"PING", b"INFO"] {
329            assert!(!is_write_verb(verb), "{:?} should be read", std::str::from_utf8(verb));
330        }
331    }
332
333    #[test]
334    fn classification_is_case_insensitive() {
335        assert!(is_write_verb(b"set"));
336        assert!(is_write_verb(b"Set"));
337        assert!(is_write_verb(b"SET"));
338        assert!(!is_write_verb(b"get"));
339        assert!(!is_write_verb(b"Get"));
340    }
341
342    #[test]
343    fn long_verb_doesnt_panic_on_classification() {
344        // Verbs longer than 32 bytes (silly but legal RESP) are
345        // truncated by the upper-buf — they fall through to the
346        // catch-all read classification.
347        assert!(!is_write_verb(&[b'X'; 64]));
348    }
349
350    // ---- scope MISDIRECTED parser (T3.9) ----
351
352    #[test]
353    fn parse_misdirected_basic() {
354        let r = Reply::Error(b"MISDIRECTED writer is 10.0.0.1:6004".to_vec());
355        assert_eq!(parse_misdirected(&r).as_deref(), Some("10.0.0.1:6004"));
356    }
357
358    #[test]
359    fn parse_misdirected_strips_trailing_crlf() {
360        // Some encoders leave `\r\n` in the Error payload; parser
361        // tolerates both shapes.
362        let r = Reply::Error(b"MISDIRECTED writer is 10.0.0.1:6004\r\n".to_vec());
363        assert_eq!(parse_misdirected(&r).as_deref(), Some("10.0.0.1:6004"));
364    }
365
366    #[test]
367    fn parse_misdirected_rejects_unrelated_error() {
368        let r = Reply::Error(b"ERR something else".to_vec());
369        assert!(parse_misdirected(&r).is_none());
370        // Non-Error replies are also rejected.
371        let r = Reply::Simple(b"OK".to_vec());
372        assert!(parse_misdirected(&r).is_none());
373    }
374
375    #[test]
376    fn split_host_port_dotted_v4_and_dns() {
377        assert_eq!(split_host_port("10.0.0.1:6004"), Some(("10.0.0.1", 6004)));
378        assert_eq!(split_host_port("db.local:6105"), Some(("db.local", 6105)));
379    }
380
381    #[test]
382    fn parse_quiesced_basic() {
383        let r = Reply::Error(b"QUIESCED migrating to 10.0.0.1:6004".to_vec());
384        assert_eq!(parse_quiesced(&r).as_deref(), Some("10.0.0.1:6004"));
385    }
386
387    #[test]
388    fn parse_quiesced_strips_trailing_crlf() {
389        let r = Reply::Error(b"QUIESCED migrating to 10.0.0.1:6004\r\n".to_vec());
390        assert_eq!(parse_quiesced(&r).as_deref(), Some("10.0.0.1:6004"));
391    }
392
393    #[test]
394    fn parse_quiesced_rejects_unrelated_error() {
395        let r = Reply::Error(b"MISDIRECTED writer is 10.0.0.1:6004".to_vec());
396        assert!(parse_quiesced(&r).is_none());
397        let r = Reply::Simple(b"OK".to_vec());
398        assert!(parse_quiesced(&r).is_none());
399    }
400
401    #[test]
402    fn split_host_port_rejects_bad_inputs() {
403        assert!(split_host_port("nohost:").is_none());
404        assert!(split_host_port(":6004").is_none());
405        assert!(split_host_port("no-colon").is_none());
406        assert!(split_host_port("host:99999").is_none()); // u16 overflow
407    }
408}