Skip to main content

brz_memcache/
api.rs

1//! The application-facing memcache contract and CacheService implementation.
2
3use std::{sync::Arc, time::Duration};
4
5use arc_swap::ArcSwapAny;
6use async_trait::async_trait;
7use bytes::Bytes;
8
9use crate::cache_topology::CacheTopology;
10use crate::cacheservice::CacheNamespaceConf;
11use crate::service::Cacheable;
12use crate::value::Value;
13use crate::{Expiration, Protocol, Result};
14/// A value returned by [`Memcache::get`].
15///
16/// Memcached uses `0` for an untagged value on the wire. The public API maps
17/// that common case to `None`, so callers only need to handle flags when their
18/// serialization format actually uses them.
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct CacheEntry {
21    /// The stored bytes, without a copy from the protocol value.
22    pub data: Bytes,
23    /// The stored memcached flags, or `None` when the wire value is `0`.
24    pub flags: Option<u32>,
25}
26
27impl From<Value> for CacheEntry {
28    fn from(value: Value) -> Self {
29        let flags = value.flags();
30        Self {
31            data: value.into_bytes(),
32            flags: (flags != 0).then_some(flags),
33        }
34    }
35}
36
37/// Optional per-write overrides for [`Memcache::set_with`].
38#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
39pub struct SetOptions {
40    /// Override the [`CacheService`] instance's default expiration.
41    pub expiration: Option<Expiration>,
42    /// Memcached flags; `None` is written as the normal untagged value `0`.
43    pub flags: Option<u32>,
44}
45
46impl SetOptions {
47    /// Sets the expiration override for this write.
48    pub fn with_expiration(mut self, expiration: Expiration) -> Self {
49        self.expiration = Some(expiration);
50        self
51    }
52
53    /// Sets the memcached flags for this write.
54    pub fn with_flags(mut self, flags: u32) -> Self {
55        self.flags = Some(flags);
56        self
57    }
58}
59
60/// The small memcache contract consumed by application code.
61#[async_trait]
62pub trait Memcache: Send + Sync {
63    /// Fetches one entry; a cache miss is `Ok(None)`.
64    async fn get(&self, key: &str) -> Result<Option<CacheEntry>>;
65
66    /// Stores bytes using the implementation's default expiration and no
67    /// memcached flags.
68    async fn set(&self, key: &str, value: Bytes) -> Result<bool> {
69        self.set_with(key, value, SetOptions::default()).await
70    }
71
72    /// Stores bytes with optional per-write expiration and flag overrides.
73    async fn set_with(&self, key: &str, value: Bytes, options: SetOptions) -> Result<bool>;
74}
75
76/// Supplies a live, possibly-changing namespace configuration. `CacheService`
77/// does **not** poll: the source detects changes and pushes them by invoking
78/// the `on_update` callback registered via [`CacheServiceConfigSource::subscribe`].
79///
80/// Implement this interface in the application for configuration loading and updates.
81#[async_trait]
82pub trait CacheServiceConfigSource: Send + Sync {
83    /// Loads the initial namespace configuration. Called once at construction.
84    async fn load(&self) -> Result<CacheNamespaceConf>;
85
86    /// Subscribes to subsequent changes. The source invokes `on_update`
87    /// whenever the namespace configuration changes; `CacheService` never
88    /// polls. Dropping the returned [`SubscriptionHandle`] cancels the
89    /// subscription (and releases its polling/listening resources).
90    /// Replay current state when registering to cover changes after `load`,
91    /// then deliver updates in source order. Shared polling may continue while
92    /// other subscribers remain.
93    async fn subscribe(
94        &self,
95        on_update: Arc<dyn Fn(CacheNamespaceConf) + Send + Sync>,
96    ) -> Result<SubscriptionHandle>;
97}
98
99/// Owns one source subscription. Dropping it cancels the subscription and stops
100/// the source's internal polling/listening for this subscriber.
101pub struct SubscriptionHandle {
102    cancel: Option<Box<dyn FnOnce() + Send + Sync>>,
103}
104
105impl std::fmt::Debug for SubscriptionHandle {
106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107        f.debug_struct("SubscriptionHandle").finish_non_exhaustive()
108    }
109}
110
111impl SubscriptionHandle {
112    /// Creates a handle whose `Drop` runs `cancel`.
113    pub fn new(cancel: impl FnOnce() + Send + Sync + 'static) -> Self {
114        Self {
115            cancel: Some(Box::new(cancel)),
116        }
117    }
118
119    /// Creates a no-op handle (for sources that never need cleanup).
120    pub fn empty() -> Self {
121        Self { cancel: None }
122    }
123}
124
125impl Drop for SubscriptionHandle {
126    fn drop(&mut self) {
127        if let Some(cancel) = self.cancel.take() {
128            cancel();
129        }
130    }
131}
132
133/// Instance-level settings for [`CacheService`].
134#[derive(Clone, Copy, Debug, PartialEq, Eq)]
135pub struct CacheServiceOptions {
136    /// Expiration used when [`SetOptions::expiration`] is `None`.
137    pub default_expiration: Expiration,
138    /// Wire protocol used by the topology built by [`CacheService`].
139    ///
140    pub protocol: Protocol,
141    /// Fallback master request timeout when the namespace does not override it.
142    pub master_timeout: Duration,
143    /// Fallback non-master request timeout when the namespace does not override it.
144    pub slave_timeout: Duration,
145    /// TCP connection-establishment timeout.
146    pub connect_timeout: Duration,
147    /// Accumulated request time before moving to the next local replica group.
148    pub replica_quota: Duration,
149    /// Minimum quota charged for a transport-level failure.
150    pub failure_penalty: Duration,
151    /// Whether foreground writes propagate to master-L1 groups.
152    pub update_master_l1: bool,
153}
154
155impl Default for CacheServiceOptions {
156    fn default() -> Self {
157        Self {
158            default_expiration: Expiration::default(),
159            protocol: Protocol::Text,
160            master_timeout: Duration::from_millis(100),
161            slave_timeout: Duration::from_millis(100),
162            connect_timeout: Duration::from_secs(2),
163            replica_quota: Duration::from_secs(2),
164            failure_penalty: Duration::from_millis(500),
165            update_master_l1: true,
166        }
167    }
168}
169
170impl CacheServiceOptions {
171    /// Creates options with an explicit default expiration.
172    pub fn new(default_expiration: Expiration) -> Self {
173        Self {
174            default_expiration,
175            ..Self::default()
176        }
177    }
178
179    /// Selects the wire protocol for the cache-service topology.
180    #[must_use]
181    pub fn with_protocol(mut self, protocol: Protocol) -> Self {
182        self.protocol = protocol;
183        self
184    }
185
186    #[must_use]
187    pub fn with_timeout(mut self, timeout: Duration) -> Self {
188        self.master_timeout = timeout;
189        self.slave_timeout = timeout;
190        self
191    }
192
193    #[must_use]
194    pub fn with_master_timeout(mut self, timeout: Duration) -> Self {
195        self.master_timeout = timeout;
196        self
197    }
198
199    #[must_use]
200    pub fn with_slave_timeout(mut self, timeout: Duration) -> Self {
201        self.slave_timeout = timeout;
202        self
203    }
204
205    #[must_use]
206    pub fn with_connect_timeout(mut self, timeout: Duration) -> Self {
207        self.connect_timeout = timeout;
208        self
209    }
210
211    #[must_use]
212    pub fn with_update_master_l1(mut self, enabled: bool) -> Self {
213        self.update_master_l1 = enabled;
214        self
215    }
216}
217
218/// Sized production/test dispatch stored behind the COW topology snapshot.
219/// The production hot path is concrete; dynamic dispatch only exists in unit
220/// tests that inject a recording backend.
221#[cfg_attr(test, allow(clippy::large_enum_variant))]
222pub(crate) enum BackendSlot {
223    Net(CacheTopology),
224    #[cfg(test)]
225    Test(Arc<dyn Cacheable>),
226}
227
228impl BackendSlot {
229    async fn get(&self, key: &str) -> Result<Option<Value>> {
230        match self {
231            Self::Net(topology) => topology.get(key).await,
232            #[cfg(test)]
233            Self::Test(backend) => backend.get(key).await,
234        }
235    }
236
237    async fn set(&self, key: &str, value: Value, expiration: Expiration) -> Result<bool> {
238        match self {
239            Self::Net(topology) => topology.set(key, value, expiration).await,
240            #[cfg(test)]
241            Self::Test(backend) => backend.set(key, value, expiration).await,
242        }
243    }
244
245    pub(crate) fn topology(&self) -> Option<&CacheTopology> {
246        match self {
247            Self::Net(topology) => Some(topology),
248            #[cfg(test)]
249            Self::Test(_) => None,
250        }
251    }
252}
253
254/// A CacheService-backed [`Memcache`] implementation.
255///
256/// This is the stable application facade. Its production backend mirrors the
257/// reference client master/master-L1/slave/slave-L1 topology over one persistent
258/// `brz-net` session per physical node.
259///
260/// The backend lives behind an `ArcSwapAny` so it can be hot-swapped at
261/// runtime. Construct via [`CacheService::new`] for a fixed (one-shot)
262/// backend, or [`CacheService::new_live`] for a backend that hot-swaps when a
263/// [`CacheServiceConfigSource`] pushes new configuration.
264#[derive(Clone)]
265pub struct CacheService {
266    backend: Arc<ArcSwapAny<Arc<BackendSlot>>>,
267    options: CacheServiceOptions,
268    /// Keeps the live-value driver (the config source subscription + the
269    /// `CacheServiceInner` doing the two-level diff) alive for the hot-swap
270    /// path. `None` for the one-shot construction path.
271    _hold: Option<Arc<dyn std::fmt::Debug + Send + Sync>>,
272}
273
274impl CacheService {
275    /// Builds one unsharded master endpoint using CacheService defaults.
276    ///
277    /// The resulting topology contains exactly one replica group, one shard,
278    /// one physical node, and one persistent TCP connection.
279    pub async fn single(endpoint: impl Into<String>) -> Result<Self> {
280        Self::single_with_options(endpoint, CacheServiceOptions::default()).await
281    }
282
283    /// Builds one unsharded master endpoint with explicit transport options.
284    pub async fn single_with_options(
285        endpoint: impl Into<String>,
286        options: CacheServiceOptions,
287    ) -> Result<Self> {
288        Self::new(CacheNamespaceConf::single_master(endpoint.into()), options).await
289    }
290
291    /// Builds a cache from a fixed (one-shot) namespace configuration. The
292    /// backend never changes — use [`CacheService::new_live`] for a backend
293    /// that hot-swaps on config change.
294    pub async fn new(conf: CacheNamespaceConf, options: CacheServiceOptions) -> Result<Self> {
295        let backend = Self::build_backend(&conf, options, None)?;
296        Ok(Self {
297            backend: Arc::new(ArcSwapAny::new(Arc::new(backend))),
298            options,
299            _hold: None,
300        })
301    }
302
303    /// Builds a cache whose backend hot-swaps when `source` pushes new
304    /// configuration. `source` drives the cache — `CacheService` itself never
305    /// polls. The initial configuration is loaded once at construction.
306    ///
307    /// Use this with an application-owned [`CacheServiceConfigSource`].
308    pub async fn new_live(
309        source: Arc<dyn CacheServiceConfigSource>,
310        options: CacheServiceOptions,
311    ) -> Result<Self> {
312        let initial = source.load().await?;
313        let backend = Self::build_backend(&initial, options, None)?;
314        let backend_slot = Arc::new(ArcSwapAny::new(Arc::new(backend)));
315
316        let inner =
317            crate::service::live::CacheServiceInner::new(initial, backend_slot.clone(), options);
318        let inner_for_cb = inner.clone();
319        let on_update: Arc<dyn Fn(CacheNamespaceConf) + Send + Sync> = Arc::new(move |conf| {
320            if let Err(error) = inner_for_cb.apply(conf) {
321                tracing::warn!(
322                    %error,
323                    "cache-service config update failed; retaining old backend"
324                );
325            }
326        });
327        let handle = source.subscribe(on_update).await?;
328
329        // Hold the source, the subscription handle, and the apply inner so the
330        // whole driver stays alive for the lifetime of this CacheService. The
331        // source must be retained explicitly: it may own the group registry
332        // (and thus the polling task) internally — without a strong reference
333        // held here, a source that is only reachable via its `subscribe`-time
334        // `SubscriptionHandle` could be dropped, stopping the poll early.
335        Ok(Self {
336            backend: backend_slot,
337            options,
338            _hold: Some(Arc::new(LiveHold {
339                _source: source,
340                _handle: handle,
341                _inner: inner,
342            })),
343        })
344    }
345
346    pub(crate) fn build_backend(
347        conf: &CacheNamespaceConf,
348        options: CacheServiceOptions,
349        previous: Option<&CacheTopology>,
350    ) -> Result<BackendSlot> {
351        Ok(BackendSlot::Net(CacheTopology::from_namespace_conf(
352            conf, options, previous,
353        )?))
354    }
355
356    /// Loads the current immutable topology snapshot as a cheap `Arc` clone.
357    fn backend(&self) -> Arc<BackendSlot> {
358        self.backend.load_full()
359    }
360
361    /// Test-only constructor that installs a prebuilt backend directly. Used by
362    /// unit tests that assert get/set delegation without parsing a namespace.
363    #[cfg(test)]
364    pub(crate) fn from_backend(backend: Arc<dyn Cacheable>, options: CacheServiceOptions) -> Self {
365        Self {
366            backend: Arc::new(ArcSwapAny::new(Arc::new(BackendSlot::Test(backend)))),
367            options,
368            _hold: None,
369        }
370    }
371}
372
373/// Keeps a live-value subscription and its applying inner alive while any
374/// `CacheService` clone references them.
375struct LiveHold {
376    _source: Arc<dyn CacheServiceConfigSource>,
377    _handle: SubscriptionHandle,
378    _inner: Arc<crate::service::live::CacheServiceInner>,
379}
380
381impl std::fmt::Debug for LiveHold {
382    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
383        f.debug_struct("LiveHold").finish_non_exhaustive()
384    }
385}
386
387#[async_trait]
388impl Memcache for CacheService {
389    async fn get(&self, key: &str) -> Result<Option<CacheEntry>> {
390        self.backend()
391            .get(key)
392            .await
393            .map(|value| value.map(CacheEntry::from))
394    }
395
396    async fn set_with(&self, key: &str, value: Bytes, options: SetOptions) -> Result<bool> {
397        let expiration = options
398            .expiration
399            .unwrap_or(self.options.default_expiration);
400        let value = Value::new(value, options.flags.unwrap_or_default());
401        self.backend().set(key, value, expiration).await
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use std::collections::HashMap;
408    use std::sync::Mutex;
409
410    use crate::value::CasValue;
411
412    use super::*;
413
414    #[test]
415    fn cache_service_options_default_to_text_protocol() {
416        assert_eq!(CacheServiceOptions::default().protocol, Protocol::Text);
417    }
418
419    #[test]
420    fn cache_service_options_can_select_binary_protocol() {
421        let options = CacheServiceOptions::default().with_protocol(Protocol::Binary);
422
423        assert_eq!(options.protocol, Protocol::Binary);
424    }
425
426    #[derive(Default)]
427    struct RecordingBackend {
428        get_result: Mutex<Option<Value>>,
429        set_call: Mutex<Option<(String, Value, Expiration)>>,
430    }
431
432    #[async_trait]
433    impl Cacheable for RecordingBackend {
434        async fn get(&self, _key: &str) -> Result<Option<Value>> {
435            Ok(self.get_result.lock().unwrap().clone())
436        }
437
438        async fn get_multi(&self, _keys: &[&str]) -> Result<HashMap<String, Value>> {
439            unreachable!("the public API does not expose get_multi")
440        }
441
442        async fn set(&self, key: &str, value: Value, expire: Expiration) -> Result<bool> {
443            *self.set_call.lock().unwrap() = Some((key.to_owned(), value, expire));
444            Ok(true)
445        }
446
447        async fn set_with_noreply(
448            &self,
449            _key: &str,
450            _value: Value,
451            _expire: Expiration,
452        ) -> Result<()> {
453            unreachable!("the public API does not expose set_with_noreply")
454        }
455
456        async fn add(&self, _key: &str, _value: Value, _expire: Expiration) -> Result<bool> {
457            unreachable!("the public API does not expose add")
458        }
459
460        async fn get_cas(&self, _key: &str) -> Result<Option<CasValue>> {
461            unreachable!("the public API does not expose get_cas")
462        }
463
464        async fn cas(&self, _key: &str, _value: &CasValue, _expire: Expiration) -> Result<bool> {
465            unreachable!("the public API does not expose cas")
466        }
467
468        async fn delete(&self, _key: &str) -> Result<bool> {
469            unreachable!("the public API does not expose delete")
470        }
471
472        async fn delete_with_noreply(&self, _key: &str) -> Result<()> {
473            unreachable!("the public API does not expose delete_with_noreply")
474        }
475    }
476
477    #[tokio::test]
478    async fn set_uses_instance_expiration_and_zero_flags_by_default() {
479        let backend = Arc::new(RecordingBackend::default());
480        let cache = CacheService::from_backend(
481            backend.clone(),
482            CacheServiceOptions::new(Expiration::Seconds(90)),
483        );
484
485        assert!(
486            cache
487                .set("user:1", Bytes::from_static(b"value"))
488                .await
489                .unwrap()
490        );
491
492        let call = backend.set_call.lock().unwrap().clone().unwrap();
493        assert_eq!(call.0, "user:1");
494        assert_eq!(call.1.as_bytes(), b"value");
495        assert_eq!(call.1.flags(), 0);
496        assert_eq!(call.2, Expiration::Seconds(90));
497    }
498
499    #[tokio::test]
500    async fn set_with_overrides_expiration_and_flags() {
501        let backend = Arc::new(RecordingBackend::default());
502        let cache = CacheService::from_backend(
503            backend.clone(),
504            CacheServiceOptions::new(Expiration::Seconds(90)),
505        );
506        let options = SetOptions::default()
507            .with_expiration(Expiration::Seconds(5))
508            .with_flags(32);
509
510        cache
511            .set_with("user:2", Bytes::from_static(b"value"), options)
512            .await
513            .unwrap();
514
515        let call = backend.set_call.lock().unwrap().clone().unwrap();
516        assert_eq!(call.1.flags(), 32);
517        assert_eq!(call.2, Expiration::Seconds(5));
518    }
519
520    #[tokio::test]
521    async fn get_maps_wire_zero_flag_to_none_without_copying_data() {
522        let backend = Arc::new(RecordingBackend::default());
523        *backend.get_result.lock().unwrap() = Some(Value::new(Bytes::from_static(b"value"), 0));
524        let cache = CacheService::from_backend(backend, CacheServiceOptions::default());
525
526        let entry = cache.get("user:1").await.unwrap().unwrap();
527
528        assert_eq!(entry.data, Bytes::from_static(b"value"));
529        assert_eq!(entry.flags, None);
530    }
531
532    #[tokio::test]
533    async fn public_contract_is_object_safe() {
534        let backend = Arc::new(RecordingBackend::default());
535        *backend.get_result.lock().unwrap() = Some(Value::new(Bytes::new(), 7));
536        let cache: Arc<dyn Memcache> = Arc::new(CacheService::from_backend(
537            backend,
538            CacheServiceOptions::default(),
539        ));
540
541        assert_eq!(cache.get("key").await.unwrap().unwrap().flags, Some(7));
542    }
543}