Skip to main content

cachekit/backend/
memcached.rs

1//! Memcached backend via [`rust-memcache`](https://crates.io/crates/memcache)
2//! (r2d2-pooled, per-socket read/write timeouts, ASCII protocol).
3//!
4//! ## Why the ASCII protocol
5//!
6//! The connection URL pins `protocol=ascii` (the same protocol cachekit-py's
7//! pymemcache speaks) deliberately: rust-memcache's binary-protocol parser
8//! does not correlate responses to requests (opcode/opaque unchecked), so a
9//! connection desynced by a timed-out operation could serve a *late* response
10//! as the answer to the next command — worst case a wrong value for a
11//! different key. The ASCII `get` validates the echoed key, and a desynced
12//! connection fails its next pool-checkout ping and is discarded. Every
13//! operation additionally runs under a hard async time budget (see
14//! [`crate::backend::memcached::MemcachedBackendBuilder::timeout`]) so a wedged server surfaces as a
15//! `Timeout` error instead of hanging callers.
16//!
17//! ## TTL capability — the honest parity picture
18//!
19//! cachekit-py's Memcached backend implements `refresh_ttl` (wrapping the
20//! memcached `touch` command) but **not** `get_ttl` — the classic memcached
21//! protocol has no command to read a key's remaining TTL. That makes py's
22//! Memcached *not* a `TTLInspectableBackend`; its `refresh_ttl` is a bare,
23//! directly-callable method outside the protocol.
24//!
25//! This backend mirrors that exactly: no [`TtlInspectable`] impl (the trait
26//! requires the unreadable `ttl()`), and a bare inherent
27//! [`refresh_ttl`](crate::backend::memcached::MemcachedBackend::refresh_ttl) wrapping `touch`. Rust
28//! *could* read TTLs via the meta protocol (`mg <key> t`, memcached >= 1.6),
29//! but shipping a capability py cannot match would make TTL-driven behaviour
30//! diverge between SDKs on the same cluster. Revisit only when cachekit-py
31//! gains meta-protocol support.
32//!
33//! [`TtlInspectable`]: crate::backend::TtlInspectable
34//!
35//! ## Why rust-memcache
36//!
37//! The previously used `async-memcached 0.6` ships `toxiproxy_rust` (a fault
38//! injection test proxy) as a non-optional *runtime* dependency, dragging
39//! openssl/native-tls/hyper-0.14 into a rustls-only SDK — so it was
40//! rejected. rust-memcache with `default-features = false` has a
41//! lean tree, a connection pool (one hung connection doesn't wedge the
42//! backend), per-socket timeouts, and `touch`.
43
44use std::collections::HashMap;
45use std::sync::Arc;
46use std::time::Duration;
47
48use memcache::{Client, CommandError, MemcacheError, ServerError};
49
50use async_trait::async_trait;
51
52use crate::backend::{run_blocking, Backend, HealthStatus};
53use crate::error::{BackendError, BackendErrorKind};
54
55/// Memcached rejects TTLs above 30 days *as durations* — anything larger is
56/// silently reinterpreted as an absolute unix timestamp, expiring the entry
57/// in the past. Clamp instead, matching cachekit-py's `MAX_MEMCACHED_TTL`.
58const MAX_MEMCACHED_TTL_SECS: u64 = 30 * 24 * 60 * 60;
59
60/// Memcached's default server-side item-size limit (`-I` flag).
61const DEFAULT_MAX_ITEM_SIZE_BYTES: usize = 1024 * 1024;
62
63/// Memcached's hard key-length limit (bytes).
64const MAX_KEY_LEN: usize = 250;
65
66// py-parity defaults (cachekit-py MemcachedBackendConfig).
67const DEFAULT_OP_TIMEOUT: Duration = Duration::from_secs(1);
68const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
69const DEFAULT_POOL_SIZE: u32 = 10;
70
71// ── Key validation (CWE-93) ───────────────────────────────────────────────────
72
73/// Reject keys the memcached ASCII protocol cannot represent safely.
74///
75/// The ASCII protocol is `\r\n`-delimited and space-separated: a key
76/// containing control bytes or spaces injects protocol commands (CWE-93 —
77/// `flush_all`, cross-key `set`/`delete`). This backend speaks ASCII, so the
78/// guard is load-bearing. Non-ASCII bytes (> 0x7e) are rejected too:
79/// cachekit-py's pymemcache refuses them (`allow_unicode_keys=False`
80/// default), so allowing them here would create keys py cannot address —
81/// breaking cross-SDK key identity.
82fn validate_key(key: &str) -> Result<(), BackendError> {
83    if key.is_empty() {
84        return Err(BackendError::permanent("memcached key must not be empty"));
85    }
86    if key.len() > MAX_KEY_LEN {
87        return Err(BackendError::permanent(format!(
88            "memcached key is {} bytes (limit {MAX_KEY_LEN})",
89            key.len()
90        )));
91    }
92    if key.bytes().any(|b| b <= 0x20 || b >= 0x7f) {
93        return Err(BackendError::permanent(
94            "memcached key contains whitespace, control, or non-ASCII bytes — not \
95             representable in the memcached ASCII protocol (and rejected by cachekit-py, \
96             breaking cross-SDK key identity)",
97        ));
98    }
99    Ok(())
100}
101
102// ── Error mapping ─────────────────────────────────────────────────────────────
103
104fn memcached_err(e: MemcacheError) -> BackendError {
105    let kind = match &e {
106        MemcacheError::IOError(io) => match io.kind() {
107            // SO_RCVTIMEO/SO_SNDTIMEO expiry surfaces as WouldBlock on unix.
108            std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => {
109                BackendErrorKind::Timeout
110            }
111            _ => BackendErrorKind::Transient,
112        },
113        // Pool exhaustion / connection acquisition — retryable.
114        MemcacheError::PoolError(_) => BackendErrorKind::Transient,
115        // Oversized items must NOT be retried — the same payload fails
116        // forever (Transient here means infinite retry
117        // loops upstream). The ASCII protocol reports it as a SERVER_ERROR
118        // string ("SERVER_ERROR object too large for cache", verified live);
119        // the binary protocol as CommandError::ValueTooLarge.
120        MemcacheError::ServerError(ServerError::Error(msg)) if msg.contains("object too large") => {
121            BackendErrorKind::Permanent
122        }
123        // Other SERVER_ERROR strings are retryable (py maps
124        // MemcacheServerError → TRANSIENT); malformed responses are protocol
125        // violations — not.
126        MemcacheError::ServerError(ServerError::Error(_)) => BackendErrorKind::Transient,
127        MemcacheError::ServerError(_) => BackendErrorKind::Permanent,
128        MemcacheError::CommandError(CommandError::ValueTooLarge) => BackendErrorKind::Permanent,
129        MemcacheError::ClientError(_)
130        | MemcacheError::CommandError(_)
131        | MemcacheError::ParseError(_)
132        | MemcacheError::BadURL(_) => BackendErrorKind::Permanent,
133    };
134
135    BackendError {
136        kind,
137        message: e.to_string(),
138        source: Some(Box::new(e)),
139    }
140}
141
142/// Wire-level expiration: 0 = never expires; sub-second TTLs round up to 1s
143/// and anything above 30 days clamps (see [`MAX_MEMCACHED_TTL_SECS`]).
144fn expiration_secs(ttl: Option<Duration>) -> u32 {
145    match ttl {
146        None => 0,
147        Some(d) => {
148            let secs = d.as_secs().clamp(1, MAX_MEMCACHED_TTL_SECS);
149            // 30 days fits u32 by construction.
150            u32::try_from(secs).unwrap_or(u32::MAX)
151        }
152    }
153}
154
155// ── MemcachedBackend ──────────────────────────────────────────────────────────
156
157/// Memcached backend powered by [`rust-memcache`](https://crates.io/crates/memcache).
158///
159/// Build with [`builder`](MemcachedBackend::builder); the terminal
160/// [`connect`](MemcachedBackendBuilder::connect) is async and verifies the
161/// server is reachable (unlike [`RedisBackend`]'s lazy build-then-connect
162/// split).
163///
164/// [`RedisBackend`]: crate::backend::redis::RedisBackend
165pub struct MemcachedBackend {
166    // ponytail: single server behind an r2d2 pool — py's HashClient shards
167    // across servers; add multi-server + consistent hashing when needed.
168    client: Arc<Client>,
169    max_item_size_bytes: usize,
170    /// Hard async ceiling per operation. Socket timeouts bound each I/O call,
171    /// but r2d2's checkout loop can retry pings against a wedged-but-accepting
172    /// server past any single socket timeout — this budget bounds the caller.
173    op_budget: Duration,
174}
175
176impl std::fmt::Debug for MemcachedBackend {
177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178        f.debug_struct("MemcachedBackend").finish_non_exhaustive()
179    }
180}
181
182impl MemcachedBackend {
183    /// Start building a [`MemcachedBackend`].
184    pub fn builder() -> MemcachedBackendBuilder {
185        MemcachedBackendBuilder::default()
186    }
187
188    /// Run a pooled-client call on the blocking pool under the op budget.
189    ///
190    /// A wedged-but-accepting server can keep r2d2's checkout loop pinging
191    /// (each ping bounded by the socket timeout) past any single I/O
192    /// deadline; the budget converts that into an immediate `Timeout` error
193    /// for the caller. The abandoned blocking task exits on its next socket
194    /// timeout. Under `unsync` the call runs inline (no budget — there is no
195    /// executor to time it out against; the socket timeouts still bound each
196    /// I/O call).
197    #[cfg(not(feature = "unsync"))]
198    async fn run_op<T: Send + 'static>(
199        &self,
200        f: impl FnOnce() -> Result<T, BackendError> + Send + 'static,
201    ) -> Result<T, BackendError> {
202        match tokio::time::timeout(self.op_budget, run_blocking(f)).await {
203            Ok(result) => result,
204            Err(_) => Err(BackendError::timeout(format!(
205                "memcached operation exceeded its {}ms budget (server hung or pool checkout \
206                 retrying); the in-flight call was abandoned",
207                self.op_budget.as_millis()
208            ))),
209        }
210    }
211
212    /// `unsync` variant — see above.
213    #[cfg(feature = "unsync")]
214    async fn run_op<T>(
215        &self,
216        f: impl FnOnce() -> Result<T, BackendError>,
217    ) -> Result<T, BackendError> {
218        run_blocking(f).await
219    }
220
221    /// Refresh a key's TTL via the memcached `touch` command.
222    ///
223    /// Mirrors cachekit-py's `refresh_ttl`: a bare method, **not** part of
224    /// [`TtlInspectable`](crate::backend::TtlInspectable) — memcached cannot
225    /// *read* TTLs, so the trait (which requires `ttl()`) cannot be
226    /// implemented, and TTL-refresh-on-read features will not auto-engage on
227    /// this backend in any SDK. `None` makes the entry permanent (py's
228    /// `refresh_ttl(key, 0)`); TTLs clamp to the 30-day ceiling like
229    /// [`set`](Backend::set).
230    ///
231    /// Returns `true` if the key existed and its expiry was updated.
232    pub async fn refresh_ttl(
233        &self,
234        key: &str,
235        ttl: Option<Duration>,
236    ) -> Result<bool, BackendError> {
237        validate_key(key)?;
238        let client = Arc::clone(&self.client);
239        let key = key.to_owned();
240        let expire = expiration_secs(ttl);
241        self.run_op(move || client.touch(&key, expire).map_err(memcached_err))
242            .await
243    }
244}
245
246// ── Backend impl ──────────────────────────────────────────────────────────────
247
248#[cfg(not(target_arch = "wasm32"))]
249#[cfg_attr(not(feature = "unsync"), async_trait)]
250#[cfg_attr(feature = "unsync", async_trait(?Send))]
251impl Backend for MemcachedBackend {
252    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, BackendError> {
253        validate_key(key)?;
254        let client = Arc::clone(&self.client);
255        let key = key.to_owned();
256        self.run_op(move || client.get::<Vec<u8>>(&key).map_err(memcached_err))
257            .await
258    }
259
260    async fn set(
261        &self,
262        key: &str,
263        value: Vec<u8>,
264        ttl: Option<Duration>,
265    ) -> Result<(), BackendError> {
266        validate_key(key)?;
267        // Fail loudly on oversized items instead of waiting for the server
268        // to reject them: callers should compress, shard, or switch backends
269        // (mirrors cachekit-py's client-side guard). The server-side
270        // rejection maps to a Permanent error either way (see memcached_err).
271        if self.max_item_size_bytes > 0 && value.len() > self.max_item_size_bytes {
272            return Err(BackendError::permanent(format!(
273                "value is {} bytes, exceeding the memcached max item size of {} bytes; \
274                 enable compression, use a larger-payload backend (Redis/SaaS/File), or \
275                 raise both the server's -I limit and the builder's max_item_size_bytes",
276                value.len(),
277                self.max_item_size_bytes,
278            )));
279        }
280
281        let client = Arc::clone(&self.client);
282        let key = key.to_owned();
283        let expire = expiration_secs(ttl);
284        self.run_op(move || {
285            client
286                .set(&key, value.as_slice(), expire)
287                .map_err(memcached_err)
288        })
289        .await
290    }
291
292    async fn delete(&self, key: &str) -> Result<bool, BackendError> {
293        validate_key(key)?;
294        let client = Arc::clone(&self.client);
295        let key = key.to_owned();
296        self.run_op(move || client.delete(&key).map_err(memcached_err))
297            .await
298    }
299
300    async fn exists(&self, key: &str) -> Result<bool, BackendError> {
301        // Memcached has no EXISTS command; a get is the probe (same trade-off
302        // as cachekit-py — the value crosses the wire and is discarded).
303        validate_key(key)?;
304        let client = Arc::clone(&self.client);
305        let key = key.to_owned();
306        self.run_op(move || {
307            Ok(client
308                .get::<Vec<u8>>(&key)
309                .map_err(memcached_err)?
310                .is_some())
311        })
312        .await
313    }
314
315    async fn health(&self) -> Result<HealthStatus, BackendError> {
316        let start = std::time::Instant::now();
317        let client = Arc::clone(&self.client);
318        let versions = self
319            .run_op(move || client.version().map_err(memcached_err))
320            .await?;
321        let latency = start.elapsed();
322
323        let mut details = HashMap::new();
324        if let Some((_, version)) = versions.first() {
325            details.insert("version".to_string(), version.clone());
326        }
327        Ok(HealthStatus {
328            is_healthy: true,
329            latency_ms: latency.as_secs_f64() * 1000.0,
330            backend_type: "memcached".to_string(),
331            details,
332        })
333    }
334}
335
336// ── Builder ───────────────────────────────────────────────────────────────────
337
338/// Builder for [`MemcachedBackend`].
339///
340/// Timeout and pool defaults mirror cachekit-py's `MemcachedBackendConfig`
341/// (1s op timeout, 2s connect timeout, pool of 10).
342#[derive(Default)]
343#[must_use]
344pub struct MemcachedBackendBuilder {
345    url: Option<String>,
346    max_item_size_bytes: Option<usize>,
347    timeout: Option<Duration>,
348    connect_timeout: Option<Duration>,
349    pool_size: Option<u32>,
350}
351
352impl MemcachedBackendBuilder {
353    /// Set the memcached server address (required).
354    ///
355    /// Accepts `memcache://host:port`, `tcp://host:port`, or bare
356    /// `host:port`.
357    pub fn url(mut self, url: impl Into<String>) -> Self {
358        self.url = Some(url.into());
359        self
360    }
361
362    /// Reject values larger than this before sending to memcached
363    /// (default 1 MiB — the server's own `-I` default; `0` disables the
364    /// check). Raise this only if the server's `-I` limit is raised too.
365    pub fn max_item_size_bytes(mut self, bytes: usize) -> Self {
366        self.max_item_size_bytes = Some(bytes);
367        self
368    }
369
370    /// Socket read/write timeout per operation (default 1s). A hung server
371    /// errors that operation instead of wedging the backend.
372    pub fn timeout(mut self, timeout: Duration) -> Self {
373        self.timeout = Some(timeout);
374        self
375    }
376
377    /// TCP connect timeout (default 2s).
378    pub fn connect_timeout(mut self, timeout: Duration) -> Self {
379        self.connect_timeout = Some(timeout);
380        self
381    }
382
383    /// Connection pool size (default 10).
384    pub fn pool_size(mut self, size: u32) -> Self {
385        self.pool_size = Some(size.max(1));
386        self
387    }
388
389    /// Normalize accepted URL spellings to rust-memcache's `memcache://`.
390    fn normalized_url(url: &str) -> String {
391        if let Some(rest) = url.strip_prefix("tcp://") {
392            return format!("memcache://{rest}");
393        }
394        if url.contains("://") {
395            return url.to_string();
396        }
397        format!("memcache://{url}")
398    }
399
400    /// Pin the connection parameters rust-memcache reads from the URL.
401    ///
402    /// - `protocol=ascii`: see the module docs — the binary parser does not
403    ///   correlate responses to requests, so a timed-out op could desync a
404    ///   pooled connection into serving wrong values.
405    /// - `timeout=<secs>`: the ONLY spelling that applies socket read/write
406    ///   timeouts to **every** pooled connection at creation.
407    ///   `ClientBuilder::with_read_timeout` reaches exactly one checked-out
408    ///   connection and none of the replacements r2d2 creates later (a hung
409    ///   server wedged the backend despite configured timeouts).
410    ///
411    /// Explicit user-supplied `protocol=`/`timeout=` params win.
412    fn with_connection_params(url: &str, timeout: Duration) -> String {
413        let mut url = url.to_string();
414        let mut sep = if url.contains('?') { '&' } else { '?' };
415        // Match whole param names: a bare `contains("timeout=")` would also
416        // match a user-supplied `connect_timeout=` and silently skip pinning
417        // the load-bearing socket timeout.
418        let has_param = |u: &str, name: &str| {
419            u.contains(&format!("?{name}=")) || u.contains(&format!("&{name}="))
420        };
421        if !has_param(&url, "protocol") {
422            url.push(sep);
423            url.push_str("protocol=ascii");
424            sep = '&';
425        }
426        if !has_param(&url, "timeout") {
427            url.push(sep);
428            url.push_str(&format!("timeout={}", timeout.as_secs_f64()));
429        }
430        url
431    }
432
433    /// Connect to memcached and construct a [`MemcachedBackend`].
434    ///
435    /// Establishes the pool and pings the server (`version`), so a bad
436    /// address fails here rather than on the first cache operation.
437    ///
438    /// # Errors
439    ///
440    /// Returns a config error if `url` was not provided or is invalid, or a
441    /// backend error if the connection cannot be established.
442    pub async fn connect(self) -> Result<MemcachedBackend, crate::error::CachekitError> {
443        use crate::error::CachekitError;
444
445        let url = self
446            .url
447            .filter(|u| !u.is_empty())
448            .ok_or_else(|| CachekitError::Config("url is required".to_string()))?;
449
450        let timeout = self.timeout.unwrap_or(DEFAULT_OP_TIMEOUT);
451        let connect_timeout = self.connect_timeout.unwrap_or(DEFAULT_CONNECT_TIMEOUT);
452        let pool_size = self.pool_size.unwrap_or(DEFAULT_POOL_SIZE);
453        let url = Self::with_connection_params(&Self::normalized_url(&url), timeout);
454
455        // Budget: pool checkout may burn one socket timeout per ping retry
456        // plus a connect; 3× socket timeout + connect covers the healthy
457        // path with slack while still failing a wedged server fast.
458        let op_budget = timeout * 3 + connect_timeout;
459
460        let build = move || {
461            let client = Client::builder()
462                .add_server(url)
463                .map_err(memcached_err)?
464                .with_max_pool_size(pool_size)
465                // min_idle = 0 is load-bearing, not a tuning preference:
466                // with eager replenishment (the default), a checkout whose
467                // test-ping times out gets a fresh eagerly-created connection
468                // on every loop iteration and r2d2's deadline is never
469                // consulted — a wedged-but-accepting server live-loops the
470                // checkout forever (reproduced). With 0, a
471                // failed test leaves no idle connection, the loop parks on
472                // the condvar, and the connection_timeout deadline fires.
473                .with_min_idle_conns(0)
474                .with_connection_timeout(connect_timeout)
475                // BOTH timeout spellings are required. build() unconditionally
476                // applies the builder values to the connection it touches —
477                // leaving these unset would CLEAR the URL-applied socket
478                // timeout on that (LIFO-hot) connection. The URL param covers
479                // every connection r2d2 creates later; these cover the one
480                // build() strips (re-verified and reproduced).
481                .with_read_timeout(timeout)
482                .with_write_timeout(timeout)
483                .build()
484                .map_err(memcached_err)?;
485            // Eager reachability check — connect() promises a live server.
486            // Socket timeouts (via the URL) bound this even against a
487            // wedged-but-accepting server.
488            client.version().map_err(memcached_err)?;
489            Ok(client)
490        };
491        // Budget the connect too: pool construction + ping must not hang.
492        #[cfg(not(feature = "unsync"))]
493        let client = {
494            let connect_budget = (connect_timeout + timeout) * pool_size.min(4) + timeout;
495            match tokio::time::timeout(connect_budget, run_blocking(build)).await {
496                Ok(result) => result?,
497                Err(_) => {
498                    return Err(BackendError::timeout(format!(
499                        "memcached connect exceeded its {}ms budget",
500                        connect_budget.as_millis()
501                    ))
502                    .into())
503                }
504            }
505        };
506        #[cfg(feature = "unsync")]
507        let client = run_blocking(build).await?;
508
509        Ok(MemcachedBackend {
510            client: Arc::new(client),
511            max_item_size_bytes: self
512                .max_item_size_bytes
513                .unwrap_or(DEFAULT_MAX_ITEM_SIZE_BYTES),
514            op_budget,
515        })
516    }
517}
518
519#[cfg(test)]
520#[allow(clippy::expect_used, clippy::unwrap_used)] // test-only: failures should panic loudly
521mod tests {
522    use memcache::ClientError;
523
524    use super::*;
525
526    #[test]
527    fn error_mapping_classifies_by_retryability() {
528        let refused = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
529        assert_eq!(
530            memcached_err(MemcacheError::IOError(refused)).kind,
531            BackendErrorKind::Transient
532        );
533
534        let timed_out = std::io::Error::new(std::io::ErrorKind::TimedOut, "slow");
535        assert_eq!(
536            memcached_err(MemcacheError::IOError(timed_out)).kind,
537            BackendErrorKind::Timeout
538        );
539
540        // SO_RCVTIMEO expiry surfaces as WouldBlock on unix sockets.
541        let would_block = std::io::Error::new(std::io::ErrorKind::WouldBlock, "recv timeout");
542        assert_eq!(
543            memcached_err(MemcacheError::IOError(would_block)).kind,
544            BackendErrorKind::Timeout
545        );
546
547        assert_eq!(
548            memcached_err(MemcacheError::ServerError(ServerError::Error(
549                "SERVER_ERROR out of memory".into()
550            )))
551            .kind,
552            BackendErrorKind::Transient
553        );
554
555        // ASCII-protocol oversize rejection (exact live server string) must
556        // be Permanent — the same payload can never succeed on retry.
557        assert_eq!(
558            memcached_err(MemcacheError::ServerError(ServerError::Error(
559                "SERVER_ERROR object too large for cache".into()
560            )))
561            .kind,
562            BackendErrorKind::Permanent
563        );
564
565        // Oversized items must be Permanent — Transient would mean upstream
566        // retry loops resending a payload that can never succeed.
567        assert_eq!(
568            memcached_err(MemcacheError::CommandError(CommandError::ValueTooLarge)).kind,
569            BackendErrorKind::Permanent
570        );
571
572        assert_eq!(
573            memcached_err(MemcacheError::ClientError(ClientError::KeyTooLong)).kind,
574            BackendErrorKind::Permanent
575        );
576    }
577
578    #[test]
579    fn key_validation_rejects_protocol_metacharacters() {
580        // CWE-93: CRLF and space are ASCII-protocol framing bytes.
581        assert!(validate_key("evil\r\nflush_all\r\n").is_err());
582        assert!(validate_key("evil\nkey").is_err());
583        assert!(validate_key("evil key").is_err());
584        assert!(validate_key("evil\tkey").is_err());
585        assert!(validate_key("evil\x00key").is_err());
586        assert!(validate_key("evil\x7fkey").is_err());
587        assert!(validate_key("").is_err());
588        assert!(validate_key(&"k".repeat(251)).is_err());
589
590        // Non-ASCII: pymemcache rejects it (allow_unicode_keys=False), so a
591        // key writable from rs would be unaddressable from py.
592        assert!(validate_key("café").is_err());
593        assert!(validate_key("键").is_err());
594
595        // The canonical cache-key format passes.
596        assert!(validate_key("ns:app:func:m.f:args:abc123:v1").is_ok());
597        assert!(validate_key(&"k".repeat(250)).is_ok());
598    }
599
600    #[test]
601    fn expiration_clamps_to_memcached_ceiling() {
602        assert_eq!(expiration_secs(None), 0);
603        assert_eq!(expiration_secs(Some(Duration::from_millis(10))), 1);
604        assert_eq!(expiration_secs(Some(Duration::from_secs(60))), 60);
605        // > 30 days would be read as an absolute unix timestamp — clamp.
606        let clamped = expiration_secs(Some(Duration::from_secs(MAX_MEMCACHED_TTL_SECS + 1)));
607        assert_eq!(u64::from(clamped), MAX_MEMCACHED_TTL_SECS);
608    }
609
610    #[test]
611    fn url_normalization() {
612        assert_eq!(
613            MemcachedBackendBuilder::normalized_url("tcp://h:11211"),
614            "memcache://h:11211"
615        );
616        assert_eq!(
617            MemcachedBackendBuilder::normalized_url("h:11211"),
618            "memcache://h:11211"
619        );
620        assert_eq!(
621            MemcachedBackendBuilder::normalized_url("memcache://h:11211?protocol=ascii"),
622            "memcache://h:11211?protocol=ascii"
623        );
624    }
625
626    #[test]
627    fn connection_params_pin_ascii_and_per_connection_timeout() {
628        // `timeout=` on the URL is the only spelling that reaches EVERY
629        // pooled connection; `protocol=ascii` is deliberate.
630        assert_eq!(
631            MemcachedBackendBuilder::with_connection_params(
632                "memcache://h:11211",
633                Duration::from_secs(1)
634            ),
635            "memcache://h:11211?protocol=ascii&timeout=1"
636        );
637        // Explicit user params win — nothing is duplicated.
638        assert_eq!(
639            MemcachedBackendBuilder::with_connection_params(
640                "memcache://h:11211?protocol=binary&timeout=5",
641                Duration::from_secs(1)
642            ),
643            "memcache://h:11211?protocol=binary&timeout=5"
644        );
645        assert_eq!(
646            MemcachedBackendBuilder::with_connection_params(
647                "memcache://h:11211?tcp_nodelay=true",
648                Duration::from_millis(1500)
649            ),
650            "memcache://h:11211?tcp_nodelay=true&protocol=ascii&timeout=1.5"
651        );
652        // `connect_timeout=` must NOT be mistaken for `timeout=` — the
653        // load-bearing socket timeout still gets pinned.
654        assert_eq!(
655            MemcachedBackendBuilder::with_connection_params(
656                "memcache://h:11211?connect_timeout=5",
657                Duration::from_secs(1)
658            ),
659            "memcache://h:11211?connect_timeout=5&protocol=ascii&timeout=1"
660        );
661    }
662}