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