Skip to main content

ringline_memcache/
sharded.rs

1//! Ketama-sharded client for ringline-memcache.
2//!
3//! Routes commands to independent Memcache instances using consistent hashing
4//! (ketama). Each server has a pool of connections with round-robin dispatch
5//! and lazy reconnection.
6//!
7//! # Example
8//!
9//! ```no_run
10//! use ringline_memcache::{ShardedClient, ShardedConfig};
11//!
12//! async fn example() -> Result<(), ringline_memcache::Error> {
13//!     let config = ShardedConfig {
14//!         servers: vec![
15//!             "127.0.0.1:11211".parse().unwrap(),
16//!             "127.0.0.1:11212".parse().unwrap(),
17//!         ],
18//!         pool_size: 2,
19//!         connect_timeout_ms: 1000,
20//!         tls_server_name: None,
21//!     };
22//!     let mut sharded = ShardedClient::new(config);
23//!     sharded.connect_all().await?;
24//!     sharded.set("hello", "world").await?;
25//!     let val = sharded.get("hello").await?;
26//!     assert_eq!(val.unwrap().data.as_ref(), b"world");
27//!     sharded.close_all();
28//!     Ok(())
29//! }
30//! ```
31
32use std::net::SocketAddr;
33
34use memcache_proto::ResponseBytes as McResponseBytes;
35use ringline::ConnCtx;
36
37use crate::{
38    Client, Error, GetValue, Value, check_error_bytes, encode_add, encode_request, encode_set,
39};
40use memcache_proto::Request as McRequest;
41
42/// Configuration for a ketama-sharded client.
43pub struct ShardedConfig {
44    /// List of independent Memcache server addresses.
45    pub servers: Vec<SocketAddr>,
46    /// Number of connections per server (default: 1).
47    pub pool_size: usize,
48    /// Connect timeout in milliseconds. 0 means no timeout.
49    pub connect_timeout_ms: u64,
50    /// TLS server name (SNI) for outbound connections. `None` means plain TCP.
51    pub tls_server_name: Option<String>,
52}
53
54enum ShardConn {
55    Connected(ConnCtx),
56    Disconnected,
57}
58
59struct Shard {
60    addr: SocketAddr,
61    conns: Vec<ShardConn>,
62    next: usize,
63}
64
65/// A ketama-sharded Memcache client.
66///
67/// Commands are routed to independent Memcache instances using consistent
68/// hashing. Each server has a pool of connections with round-robin
69/// dispatch and lazy reconnection.
70pub struct ShardedClient {
71    shards: Vec<Shard>,
72    ring: ketama::Ring,
73    connect_timeout_ms: u64,
74    tls_server_name: Option<String>,
75}
76
77impl ShardedClient {
78    /// Create a new sharded client. All connections start disconnected.
79    pub fn new(config: ShardedConfig) -> Self {
80        let pool_size = config.pool_size.max(1);
81
82        let server_ids: Vec<String> = config.servers.iter().map(|a| a.to_string()).collect();
83        let ring = ketama::Ring::build(&server_ids.iter().map(|s| s.as_str()).collect::<Vec<_>>());
84
85        let shards = config
86            .servers
87            .iter()
88            .map(|&addr| {
89                let mut conns = Vec::with_capacity(pool_size);
90                for _ in 0..pool_size {
91                    conns.push(ShardConn::Disconnected);
92                }
93                Shard {
94                    addr,
95                    conns,
96                    next: 0,
97                }
98            })
99            .collect();
100
101        Self {
102            shards,
103            ring,
104            connect_timeout_ms: config.connect_timeout_ms,
105            tls_server_name: config.tls_server_name,
106        }
107    }
108
109    /// Eagerly connect all connections on all shards.
110    pub async fn connect_all(&mut self) -> Result<(), Error> {
111        let opts = self.connect_opts();
112        for shard in &mut self.shards {
113            for slot in &mut shard.conns {
114                let conn = do_connect(shard.addr, &opts).await?;
115                *slot = ShardConn::Connected(conn);
116            }
117        }
118        Ok(())
119    }
120
121    /// Close all connections on all shards.
122    pub fn close_all(&mut self) {
123        for shard in &mut self.shards {
124            for slot in &mut shard.conns {
125                if let ShardConn::Connected(c) = slot {
126                    c.close();
127                }
128                *slot = ShardConn::Disconnected;
129            }
130        }
131    }
132
133    /// Number of shards (servers).
134    pub fn shard_count(&self) -> usize {
135        self.shards.len()
136    }
137
138    fn connect_opts(&self) -> ConnectOpts {
139        ConnectOpts {
140            connect_timeout_ms: self.connect_timeout_ms,
141            tls_server_name: self.tls_server_name.clone(),
142        }
143    }
144
145    /// Get a [`Client`] for a specific shard by index (for node-level commands).
146    ///
147    /// The returned `Client` borrows a slot's connection but the
148    /// [`ShardedClient`] no longer observes its outcome — if the caller
149    /// hits [`Error::ConnectionClosed`] on that client, no slot is marked
150    /// dead, and subsequent calls (including via [`ShardedClient::get`])
151    /// may keep hitting the same broken slot until reconnected
152    /// explicitly. Prefer routed commands when possible.
153    pub async fn shard_client(&mut self, index: usize) -> Result<Client, Error> {
154        let opts = self.connect_opts();
155        let shard = &mut self.shards[index];
156        let conn = get_conn(shard, &opts).await?;
157        Ok(Client::new(conn))
158    }
159
160    // -- Core routing --------------------------------------------------------
161
162    /// Route an encoded command to the shard owning `key`.
163    ///
164    /// Transport failures (synchronous `send` errors, or `ConnectionClosed`
165    /// from the response read) mark the offending slot dead and fall
166    /// through to the next slot in the shard; only after every slot has
167    /// been tried does the call resolve to [`Error::AllConnectionsFailed`].
168    /// Server-level errors (`ERROR` / `CLIENT_ERROR` / `SERVER_ERROR`) and
169    /// parse failures are NOT retried — the connection is still healthy
170    /// from the kernel's perspective, and retrying a doomed command would
171    /// just amplify the failure across the pool.
172    async fn route_command(
173        &mut self,
174        key: &[u8],
175        encoded: &[u8],
176    ) -> Result<McResponseBytes, Error> {
177        let opts = self.connect_opts();
178        let shard_idx = self.ring.route(key);
179        let shard = &mut self.shards[shard_idx];
180        let size = shard.conns.len();
181
182        for attempt in 0..size {
183            let idx = (shard.next + attempt) % size;
184            let conn = match &shard.conns[idx] {
185                ShardConn::Connected(c) => *c,
186                ShardConn::Disconnected => match do_connect(shard.addr, &opts).await {
187                    Ok(c) => {
188                        shard.conns[idx] = ShardConn::Connected(c);
189                        c
190                    }
191                    Err(_) => continue,
192                },
193            };
194
195            if conn.send(encoded).is_err() {
196                // Synchronous send failure (EPIPE, ECONNRESET, etc.) — the
197                // conn is dead. Previously this branch returned `Err(Io)`
198                // immediately, bypassing the rest of the pool. Mark the
199                // slot dead and try the next slot, matching the
200                // `ConnectionClosed` branch below.
201                shard.conns[idx] = ShardConn::Disconnected;
202                conn.close();
203                continue;
204            }
205            match Client::new(conn).read_response().await {
206                Ok(response) => {
207                    shard.next = (idx + 1) % size;
208                    check_error_bytes(&response)?;
209                    return Ok(response);
210                }
211                Err(Error::ConnectionClosed) => {
212                    shard.conns[idx] = ShardConn::Disconnected;
213                    continue;
214                }
215                Err(e) => return Err(e),
216            }
217        }
218
219        Err(Error::AllConnectionsFailed)
220    }
221
222    /// Verify all keys route to the same shard. Returns the common shard index.
223    fn require_same_shard(&self, keys: &[&[u8]]) -> Result<usize, Error> {
224        let first = self.ring.route(keys[0]);
225        for key in &keys[1..] {
226            if self.ring.route(key) != first {
227                return Err(Error::Memcache(
228                    "keys in request don't route to the same shard".into(),
229                ));
230            }
231        }
232        Ok(first)
233    }
234
235    // -- Commands ------------------------------------------------------------
236
237    /// Get the value of a key. Returns `None` on cache miss.
238    pub async fn get(&mut self, key: impl AsRef<[u8]>) -> Result<Option<Value>, Error> {
239        let key = key.as_ref();
240        let encoded = encode_request(&memcache_proto::Request::get(key))?;
241        let response = self.route_command(key, &encoded).await?;
242        match response {
243            McResponseBytes::Values(mut values) => {
244                if values.is_empty() {
245                    Ok(None)
246                } else {
247                    let v = values.swap_remove(0);
248                    Ok(Some(Value {
249                        data: v.data,
250                        flags: v.flags,
251                    }))
252                }
253            }
254            _ => Err(Error::UnexpectedResponse),
255        }
256    }
257
258    /// Get values for multiple keys. All keys must route to the same shard.
259    /// Returns only hits, each with its key and CAS token.
260    pub async fn gets(&mut self, keys: &[&[u8]]) -> Result<Vec<GetValue>, Error> {
261        if keys.is_empty() {
262            return Ok(Vec::new());
263        }
264        self.require_same_shard(keys)?;
265        let encoded = encode_request(&McRequest::gets(keys))?;
266        let response = self.route_command(keys[0], &encoded).await?;
267        match response {
268            McResponseBytes::Values(values) => Ok(values
269                .into_iter()
270                .map(|v| GetValue {
271                    key: v.key,
272                    data: v.data,
273                    flags: v.flags,
274                    cas: v.cas,
275                })
276                .collect()),
277            _ => Err(Error::UnexpectedResponse),
278        }
279    }
280
281    /// Set a key-value pair with default flags (0) and no expiration.
282    pub async fn set(
283        &mut self,
284        key: impl AsRef<[u8]>,
285        value: impl AsRef<[u8]>,
286    ) -> Result<(), Error> {
287        self.set_with_options(key, value, 0, 0).await
288    }
289
290    /// Set a key-value pair with custom flags and expiration time.
291    pub async fn set_with_options(
292        &mut self,
293        key: impl AsRef<[u8]>,
294        value: impl AsRef<[u8]>,
295        flags: u32,
296        exptime: u32,
297    ) -> Result<(), Error> {
298        let key = key.as_ref();
299        let value = value.as_ref();
300        let encoded = encode_set(key, value, flags, exptime)?;
301        let response = self.route_command(key, &encoded).await?;
302        match response {
303            McResponseBytes::Stored => Ok(()),
304            _ => Err(Error::UnexpectedResponse),
305        }
306    }
307
308    /// Store a key only if it does not already exist (ADD command).
309    /// Returns `true` if stored, `false` if the key already exists.
310    pub async fn add(
311        &mut self,
312        key: impl AsRef<[u8]>,
313        value: impl AsRef<[u8]>,
314    ) -> Result<bool, Error> {
315        let key = key.as_ref();
316        let value = value.as_ref();
317        let encoded = encode_add(key, value)?;
318        let response = self.route_command(key, &encoded).await?;
319        match response {
320            McResponseBytes::Stored => Ok(true),
321            McResponseBytes::NotStored => Ok(false),
322            _ => Err(Error::UnexpectedResponse),
323        }
324    }
325
326    /// Store a key only if it already exists (REPLACE command).
327    /// Returns `true` if stored, `false` if the key does not exist.
328    pub async fn replace(
329        &mut self,
330        key: impl AsRef<[u8]>,
331        value: impl AsRef<[u8]>,
332    ) -> Result<bool, Error> {
333        let key = key.as_ref();
334        let value = value.as_ref();
335        let encoded = encode_request(&McRequest::Replace {
336            key,
337            value,
338            flags: 0,
339            exptime: 0,
340        })?;
341        let response = self.route_command(key, &encoded).await?;
342        match response {
343            McResponseBytes::Stored => Ok(true),
344            McResponseBytes::NotStored => Ok(false),
345            _ => Err(Error::UnexpectedResponse),
346        }
347    }
348
349    /// Increment a numeric value by delta. Returns the new value after incrementing.
350    /// Returns `None` if the key does not exist.
351    pub async fn incr(&mut self, key: impl AsRef<[u8]>, delta: u64) -> Result<Option<u64>, Error> {
352        let key = key.as_ref();
353        let encoded = encode_request(&McRequest::incr(key, delta))?;
354        let response = self.route_command(key, &encoded).await?;
355        match response {
356            McResponseBytes::Numeric(val) => Ok(Some(val)),
357            McResponseBytes::NotFound => Ok(None),
358            _ => Err(Error::UnexpectedResponse),
359        }
360    }
361
362    /// Decrement a numeric value by delta. Returns the new value after decrementing.
363    /// Returns `None` if the key does not exist.
364    pub async fn decr(&mut self, key: impl AsRef<[u8]>, delta: u64) -> Result<Option<u64>, Error> {
365        let key = key.as_ref();
366        let encoded = encode_request(&McRequest::decr(key, delta))?;
367        let response = self.route_command(key, &encoded).await?;
368        match response {
369            McResponseBytes::Numeric(val) => Ok(Some(val)),
370            McResponseBytes::NotFound => Ok(None),
371            _ => Err(Error::UnexpectedResponse),
372        }
373    }
374
375    /// Append data to an existing item's value.
376    /// Returns `true` if stored, `false` if the key does not exist.
377    pub async fn append(
378        &mut self,
379        key: impl AsRef<[u8]>,
380        value: impl AsRef<[u8]>,
381    ) -> Result<bool, Error> {
382        let key = key.as_ref();
383        let value = value.as_ref();
384        let encoded = encode_request(&McRequest::append(key, value))?;
385        let response = self.route_command(key, &encoded).await?;
386        match response {
387            McResponseBytes::Stored => Ok(true),
388            McResponseBytes::NotStored => Ok(false),
389            _ => Err(Error::UnexpectedResponse),
390        }
391    }
392
393    /// Prepend data to an existing item's value.
394    /// Returns `true` if stored, `false` if the key does not exist.
395    pub async fn prepend(
396        &mut self,
397        key: impl AsRef<[u8]>,
398        value: impl AsRef<[u8]>,
399    ) -> Result<bool, Error> {
400        let key = key.as_ref();
401        let value = value.as_ref();
402        let encoded = encode_request(&McRequest::prepend(key, value))?;
403        let response = self.route_command(key, &encoded).await?;
404        match response {
405            McResponseBytes::Stored => Ok(true),
406            McResponseBytes::NotStored => Ok(false),
407            _ => Err(Error::UnexpectedResponse),
408        }
409    }
410
411    /// Compare-and-swap: store the value only if the CAS token matches.
412    /// Returns `Ok(true)` if stored, `Ok(false)` if the CAS token didn't match (EXISTS),
413    /// or `Err` if the key was not found or another error occurred.
414    pub async fn cas(
415        &mut self,
416        key: impl AsRef<[u8]>,
417        value: impl AsRef<[u8]>,
418        cas_unique: u64,
419    ) -> Result<bool, Error> {
420        let key = key.as_ref();
421        let value = value.as_ref();
422        let encoded = encode_request(&McRequest::cas(key, value, cas_unique))?;
423        let response = self.route_command(key, &encoded).await?;
424        match response {
425            McResponseBytes::Stored => Ok(true),
426            McResponseBytes::Exists => Ok(false),
427            McResponseBytes::NotFound => Err(Error::Memcache("NOT_FOUND".into())),
428            _ => Err(Error::UnexpectedResponse),
429        }
430    }
431
432    /// Delete a key. Returns `true` if deleted, `false` if not found.
433    pub async fn delete(&mut self, key: impl AsRef<[u8]>) -> Result<bool, Error> {
434        let key = key.as_ref();
435        let encoded = encode_request(&McRequest::delete(key))?;
436        let response = self.route_command(key, &encoded).await?;
437        match response {
438            McResponseBytes::Deleted => Ok(true),
439            McResponseBytes::NotFound => Ok(false),
440            _ => Err(Error::UnexpectedResponse),
441        }
442    }
443
444    /// Flush all items on all shards.
445    ///
446    /// On a shard whose currently selected slot returns
447    /// [`Error::ConnectionClosed`], the slot is marked disconnected and
448    /// the next slot is tried — the previous implementation called
449    /// `Client::new(conn).flush_all()` on a conn borrowed from
450    /// `get_conn` and never propagated the broken-conn signal back, so a
451    /// dead slot would remain `Connected` and every subsequent
452    /// `flush_all` (or any other op) would land on the same dead slot.
453    pub async fn flush_all(&mut self) -> Result<(), Error> {
454        let opts = self.connect_opts();
455        for shard in &mut self.shards {
456            flush_all_on_shard(shard, &opts).await?;
457        }
458        Ok(())
459    }
460
461    /// Get the version string from any connected shard.
462    ///
463    /// As with [`Self::flush_all`], a [`Error::ConnectionClosed`] from
464    /// the inner call marks the slot disconnected and falls through to
465    /// the next slot / shard rather than leaving a dead slot wedged in
466    /// the `Connected` state.
467    pub async fn version(&mut self) -> Result<Box<str>, Error> {
468        let opts = self.connect_opts();
469        for shard in &mut self.shards {
470            match version_on_shard(shard, &opts).await {
471                Ok(v) => return Ok(v),
472                Err(Error::AllConnectionsFailed) => continue,
473                Err(e) => return Err(e),
474            }
475        }
476        Err(Error::AllConnectionsFailed)
477    }
478}
479
480/// Run `flush_all` on `shard`, retrying on the next slot when a
481/// [`Error::ConnectionClosed`] surfaces and marking the broken slot
482/// disconnected so future calls reconnect.
483async fn flush_all_on_shard(shard: &mut Shard, opts: &ConnectOpts) -> Result<(), Error> {
484    let size = shard.conns.len();
485    for _ in 0..size {
486        let idx = shard.next;
487        shard.next = (shard.next + 1) % size;
488        let conn = match &shard.conns[idx] {
489            ShardConn::Connected(c) => *c,
490            ShardConn::Disconnected => match do_connect(shard.addr, opts).await {
491                Ok(c) => {
492                    shard.conns[idx] = ShardConn::Connected(c);
493                    c
494                }
495                Err(_) => continue,
496            },
497        };
498        match Client::new(conn).flush_all().await {
499            Ok(()) => return Ok(()),
500            Err(Error::ConnectionClosed) => {
501                shard.conns[idx] = ShardConn::Disconnected;
502                conn.close();
503                continue;
504            }
505            Err(e) => return Err(e),
506        }
507    }
508    Err(Error::AllConnectionsFailed)
509}
510
511/// Run `version` on `shard`, marking broken slots disconnected.
512async fn version_on_shard(shard: &mut Shard, opts: &ConnectOpts) -> Result<Box<str>, Error> {
513    let size = shard.conns.len();
514    for _ in 0..size {
515        let idx = shard.next;
516        shard.next = (shard.next + 1) % size;
517        let conn = match &shard.conns[idx] {
518            ShardConn::Connected(c) => *c,
519            ShardConn::Disconnected => match do_connect(shard.addr, opts).await {
520                Ok(c) => {
521                    shard.conns[idx] = ShardConn::Connected(c);
522                    c
523                }
524                Err(_) => continue,
525            },
526        };
527        match Client::new(conn).version().await {
528            Ok(v) => return Ok(v),
529            Err(Error::ConnectionClosed) => {
530                shard.conns[idx] = ShardConn::Disconnected;
531                conn.close();
532                continue;
533            }
534            Err(e) => return Err(e),
535        }
536    }
537    Err(Error::AllConnectionsFailed)
538}
539
540/// Connection options cloned from config to avoid borrow conflicts.
541#[derive(Clone)]
542struct ConnectOpts {
543    connect_timeout_ms: u64,
544    tls_server_name: Option<String>,
545}
546
547/// Get a ConnCtx from a shard, lazily reconnecting if needed.
548async fn get_conn(shard: &mut Shard, opts: &ConnectOpts) -> Result<ConnCtx, Error> {
549    let size = shard.conns.len();
550    for _ in 0..size {
551        let idx = shard.next;
552        shard.next = (shard.next + 1) % size;
553
554        match &shard.conns[idx] {
555            ShardConn::Connected(c) => return Ok(*c),
556            ShardConn::Disconnected => {
557                if let Ok(conn) = do_connect(shard.addr, opts).await {
558                    shard.conns[idx] = ShardConn::Connected(conn);
559                    return Ok(conn);
560                }
561            }
562        }
563    }
564    Err(Error::AllConnectionsFailed)
565}
566
567async fn do_connect(addr: SocketAddr, opts: &ConnectOpts) -> Result<ConnCtx, Error> {
568    let conn = if let Some(ref sni) = opts.tls_server_name {
569        let fut = if opts.connect_timeout_ms > 0 {
570            ringline::connect_tls_with_timeout(addr, sni, opts.connect_timeout_ms)?
571        } else {
572            ringline::connect_tls(addr, sni)?
573        };
574        fut.await?
575    } else {
576        let fut = if opts.connect_timeout_ms > 0 {
577            ringline::connect_with_timeout(addr, opts.connect_timeout_ms)?
578        } else {
579            ringline::connect(addr)?
580        };
581        fut.await?
582    };
583
584    Ok(conn)
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590
591    #[test]
592    fn test_single_server_always_routes_to_zero() {
593        let config = ShardedConfig {
594            servers: vec!["127.0.0.1:11211".parse().unwrap()],
595            pool_size: 1,
596            connect_timeout_ms: 0,
597            tls_server_name: None,
598        };
599        let client = ShardedClient::new(config);
600        assert_eq!(client.ring.route(b"any-key"), 0);
601        assert_eq!(client.ring.route(b"another-key"), 0);
602        assert_eq!(client.ring.route(b""), 0);
603    }
604
605    #[test]
606    fn test_deterministic_routing() {
607        let config = ShardedConfig {
608            servers: vec![
609                "127.0.0.1:11211".parse().unwrap(),
610                "127.0.0.1:11212".parse().unwrap(),
611                "127.0.0.1:11213".parse().unwrap(),
612            ],
613            pool_size: 1,
614            connect_timeout_ms: 0,
615            tls_server_name: None,
616        };
617        let client = ShardedClient::new(config);
618
619        let a = client.ring.route(b"test-key");
620        let b = client.ring.route(b"test-key");
621        assert_eq!(a, b);
622
623        let c = client.ring.route(b"other-key");
624        let d = client.ring.route(b"other-key");
625        assert_eq!(c, d);
626    }
627
628    #[test]
629    fn test_config_defaults() {
630        let config = ShardedConfig {
631            servers: vec![
632                "127.0.0.1:11211".parse().unwrap(),
633                "127.0.0.1:11212".parse().unwrap(),
634            ],
635            pool_size: 4,
636            connect_timeout_ms: 500,
637            tls_server_name: None,
638        };
639        let client = ShardedClient::new(config);
640        assert_eq!(client.shard_count(), 2);
641        assert_eq!(client.ring.node_count(), 2);
642        assert_eq!(client.shards[0].conns.len(), 4);
643        assert_eq!(client.shards[1].conns.len(), 4);
644    }
645
646    #[test]
647    fn test_pool_size_minimum() {
648        let config = ShardedConfig {
649            servers: vec!["127.0.0.1:11211".parse().unwrap()],
650            pool_size: 0,
651            connect_timeout_ms: 0,
652            tls_server_name: None,
653        };
654        let client = ShardedClient::new(config);
655        assert_eq!(client.shards[0].conns.len(), 1);
656    }
657
658    #[test]
659    fn test_require_same_shard_matching() {
660        let config = ShardedConfig {
661            servers: vec![
662                "127.0.0.1:11211".parse().unwrap(),
663                "127.0.0.1:11212".parse().unwrap(),
664            ],
665            pool_size: 1,
666            connect_timeout_ms: 0,
667            tls_server_name: None,
668        };
669        let client = ShardedClient::new(config);
670        let keys: &[&[u8]] = &[b"same-key", b"same-key"];
671        assert!(client.require_same_shard(keys).is_ok());
672    }
673
674    #[test]
675    fn test_require_same_shard_single_key() {
676        let config = ShardedConfig {
677            servers: vec![
678                "127.0.0.1:11211".parse().unwrap(),
679                "127.0.0.1:11212".parse().unwrap(),
680            ],
681            pool_size: 1,
682            connect_timeout_ms: 0,
683            tls_server_name: None,
684        };
685        let client = ShardedClient::new(config);
686        let keys: &[&[u8]] = &[b"anykey"];
687        assert!(client.require_same_shard(keys).is_ok());
688    }
689}