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}