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}