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, Debug, PartialEq, Eq)]
135pub struct CacheServiceOptions {
136    /// Business resource name; defaults to each configured endpoint.
137    pub resource_name: Option<Arc<str>>,
138    /// Expiration used when [`SetOptions::expiration`] is `None`.
139    pub default_expiration: Expiration,
140    /// Wire protocol used by the topology built by [`CacheService`].
141    ///
142    pub protocol: Protocol,
143    /// Fallback master request timeout when the namespace does not override it.
144    pub master_timeout: Duration,
145    /// Fallback non-master request timeout when the namespace does not override it.
146    pub slave_timeout: Duration,
147    /// TCP connection-establishment timeout.
148    pub connect_timeout: Duration,
149    /// Accumulated request time before moving to the next local replica group.
150    pub replica_quota: Duration,
151    /// Minimum quota charged for a transport-level failure.
152    pub failure_penalty: Duration,
153    /// Whether foreground writes propagate to master-L1 groups.
154    pub update_master_l1: bool,
155}
156
157impl Default for CacheServiceOptions {
158    fn default() -> Self {
159        Self {
160            resource_name: None,
161            default_expiration: Expiration::default(),
162            protocol: Protocol::Text,
163            master_timeout: Duration::from_millis(100),
164            slave_timeout: Duration::from_millis(100),
165            connect_timeout: Duration::from_secs(2),
166            replica_quota: Duration::from_secs(2),
167            failure_penalty: Duration::from_millis(500),
168            update_master_l1: true,
169        }
170    }
171}
172
173impl CacheServiceOptions {
174    #[must_use]
175    pub fn with_resource_name(mut self, name: impl Into<Arc<str>>) -> Self {
176        self.resource_name = Some(name.into());
177        self
178    }
179
180    /// Creates options with an explicit default expiration.
181    pub fn new(default_expiration: Expiration) -> Self {
182        Self {
183            default_expiration,
184            ..Self::default()
185        }
186    }
187
188    /// Selects the wire protocol for the cache-service topology.
189    #[must_use]
190    pub fn with_protocol(mut self, protocol: Protocol) -> Self {
191        self.protocol = protocol;
192        self
193    }
194
195    #[must_use]
196    pub fn with_timeout(mut self, timeout: Duration) -> Self {
197        self.master_timeout = timeout;
198        self.slave_timeout = timeout;
199        self
200    }
201
202    #[must_use]
203    pub fn with_master_timeout(mut self, timeout: Duration) -> Self {
204        self.master_timeout = timeout;
205        self
206    }
207
208    #[must_use]
209    pub fn with_slave_timeout(mut self, timeout: Duration) -> Self {
210        self.slave_timeout = timeout;
211        self
212    }
213
214    #[must_use]
215    pub fn with_connect_timeout(mut self, timeout: Duration) -> Self {
216        self.connect_timeout = timeout;
217        self
218    }
219
220    #[must_use]
221    pub fn with_update_master_l1(mut self, enabled: bool) -> Self {
222        self.update_master_l1 = enabled;
223        self
224    }
225}
226
227/// Sized production/test dispatch stored behind the COW topology snapshot.
228/// The production hot path is concrete; dynamic dispatch only exists in unit
229/// tests that inject a recording backend.
230#[cfg_attr(test, allow(clippy::large_enum_variant))]
231pub(crate) enum BackendSlot {
232    Net(CacheTopology),
233    #[cfg(test)]
234    Test(Arc<dyn Cacheable>),
235}
236
237impl BackendSlot {
238    async fn get(&self, key: &str) -> Result<Option<Value>> {
239        match self {
240            Self::Net(topology) => topology.get(key).await,
241            #[cfg(test)]
242            Self::Test(backend) => backend.get(key).await,
243        }
244    }
245
246    async fn set(&self, key: &str, value: Value, expiration: Expiration) -> Result<bool> {
247        match self {
248            Self::Net(topology) => topology.set(key, value, expiration).await,
249            #[cfg(test)]
250            Self::Test(backend) => backend.set(key, value, expiration).await,
251        }
252    }
253
254    pub(crate) fn topology(&self) -> Option<&CacheTopology> {
255        match self {
256            Self::Net(topology) => Some(topology),
257            #[cfg(test)]
258            Self::Test(_) => None,
259        }
260    }
261}
262
263/// A CacheService-backed [`Memcache`] implementation.
264///
265/// This is the stable application facade. Its production backend mirrors the
266/// reference client master/master-L1/slave/slave-L1 topology over one persistent
267/// `brz-net` session per physical node.
268///
269/// The backend lives behind an `ArcSwapAny` so it can be hot-swapped at
270/// runtime. Construct via [`CacheService::new`] for a fixed (one-shot)
271/// backend, or [`CacheService::new_live`] for a backend that hot-swaps when a
272/// [`CacheServiceConfigSource`] pushes new configuration.
273#[derive(Clone)]
274pub struct CacheService {
275    backend: Arc<ArcSwapAny<Arc<BackendSlot>>>,
276    options: CacheServiceOptions,
277    /// Keeps the live-value driver (the config source subscription + the
278    /// `CacheServiceInner` doing the two-level diff) alive for the hot-swap
279    /// path. `None` for the one-shot construction path.
280    _hold: Option<Arc<dyn std::fmt::Debug + Send + Sync>>,
281}
282
283impl CacheService {
284    /// Builds one unsharded master endpoint using CacheService defaults.
285    ///
286    /// The resulting topology contains exactly one replica group, one shard,
287    /// one physical node, and one persistent TCP connection.
288    pub async fn single(endpoint: impl Into<String>) -> Result<Self> {
289        Self::single_with_options(endpoint, CacheServiceOptions::default()).await
290    }
291
292    /// Builds one unsharded master endpoint with explicit transport options.
293    pub async fn single_with_options(
294        endpoint: impl Into<String>,
295        options: CacheServiceOptions,
296    ) -> Result<Self> {
297        Self::new(CacheNamespaceConf::single_master(endpoint.into()), options).await
298    }
299
300    /// Builds a cache from a fixed (one-shot) namespace configuration. The
301    /// backend never changes — use [`CacheService::new_live`] for a backend
302    /// that hot-swaps on config change.
303    pub async fn new(conf: CacheNamespaceConf, options: CacheServiceOptions) -> Result<Self> {
304        let backend = Self::build_backend(&conf, &options, None)?;
305        Ok(Self {
306            backend: Arc::new(ArcSwapAny::new(Arc::new(backend))),
307            options,
308            _hold: None,
309        })
310    }
311
312    /// Builds a cache whose backend hot-swaps when `source` pushes new
313    /// configuration. `source` drives the cache — `CacheService` itself never
314    /// polls. The initial configuration is loaded once at construction.
315    ///
316    /// Use this with an application-owned [`CacheServiceConfigSource`].
317    pub async fn new_live(
318        source: Arc<dyn CacheServiceConfigSource>,
319        options: CacheServiceOptions,
320    ) -> Result<Self> {
321        let initial = source.load().await?;
322        let backend = Self::build_backend(&initial, &options, None)?;
323        let backend_slot = Arc::new(ArcSwapAny::new(Arc::new(backend)));
324
325        let inner = crate::service::live::CacheServiceInner::new(
326            initial,
327            backend_slot.clone(),
328            options.clone(),
329        );
330        let inner_for_cb = inner.clone();
331        let on_update: Arc<dyn Fn(CacheNamespaceConf) + Send + Sync> = Arc::new(move |conf| {
332            if let Err(error) = inner_for_cb.apply(conf) {
333                tracing::warn!(
334                    %error,
335                    "cache-service config update failed; retaining old backend"
336                );
337            }
338        });
339        let handle = source.subscribe(on_update).await?;
340
341        // Hold the source, the subscription handle, and the apply inner so the
342        // whole driver stays alive for the lifetime of this CacheService. The
343        // source must be retained explicitly: it may own the group registry
344        // (and thus the polling task) internally — without a strong reference
345        // held here, a source that is only reachable via its `subscribe`-time
346        // `SubscriptionHandle` could be dropped, stopping the poll early.
347        Ok(Self {
348            backend: backend_slot,
349            options,
350            _hold: Some(Arc::new(LiveHold {
351                _source: source,
352                _handle: handle,
353                _inner: inner,
354            })),
355        })
356    }
357
358    pub(crate) fn build_backend(
359        conf: &CacheNamespaceConf,
360        options: &CacheServiceOptions,
361        previous: Option<&CacheTopology>,
362    ) -> Result<BackendSlot> {
363        Ok(BackendSlot::Net(CacheTopology::from_namespace_conf(
364            conf, options, previous,
365        )?))
366    }
367
368    /// Loads the current immutable topology snapshot as a cheap `Arc` clone.
369    fn backend(&self) -> Arc<BackendSlot> {
370        self.backend.load_full()
371    }
372
373    /// Test-only constructor that installs a prebuilt backend directly. Used by
374    /// unit tests that assert get/set delegation without parsing a namespace.
375    #[cfg(test)]
376    pub(crate) fn from_backend(backend: Arc<dyn Cacheable>, options: CacheServiceOptions) -> Self {
377        Self {
378            backend: Arc::new(ArcSwapAny::new(Arc::new(BackendSlot::Test(backend)))),
379            options,
380            _hold: None,
381        }
382    }
383}
384
385/// Keeps a live-value subscription and its applying inner alive while any
386/// `CacheService` clone references them.
387struct LiveHold {
388    _source: Arc<dyn CacheServiceConfigSource>,
389    _handle: SubscriptionHandle,
390    _inner: Arc<crate::service::live::CacheServiceInner>,
391}
392
393impl std::fmt::Debug for LiveHold {
394    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395        f.debug_struct("LiveHold").finish_non_exhaustive()
396    }
397}
398
399#[async_trait]
400impl Memcache for CacheService {
401    async fn get(&self, key: &str) -> Result<Option<CacheEntry>> {
402        self.backend()
403            .get(key)
404            .await
405            .map(|value| value.map(CacheEntry::from))
406    }
407
408    async fn set_with(&self, key: &str, value: Bytes, options: SetOptions) -> Result<bool> {
409        let expiration = options
410            .expiration
411            .unwrap_or(self.options.default_expiration);
412        let value = Value::new(value, options.flags.unwrap_or_default());
413        self.backend().set(key, value, expiration).await
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use std::collections::HashMap;
420    use std::sync::Mutex;
421
422    use crate::value::CasValue;
423
424    use super::*;
425
426    #[test]
427    fn cache_service_options_default_to_text_protocol() {
428        assert_eq!(CacheServiceOptions::default().protocol, Protocol::Text);
429    }
430
431    #[test]
432    fn cache_service_options_can_select_binary_protocol() {
433        let options = CacheServiceOptions::default().with_protocol(Protocol::Binary);
434
435        assert_eq!(options.protocol, Protocol::Binary);
436    }
437
438    #[derive(Default)]
439    struct RecordingBackend {
440        get_result: Mutex<Option<Value>>,
441        set_call: Mutex<Option<(String, Value, Expiration)>>,
442    }
443
444    #[async_trait]
445    impl Cacheable for RecordingBackend {
446        async fn get(&self, _key: &str) -> Result<Option<Value>> {
447            Ok(self.get_result.lock().unwrap().clone())
448        }
449
450        async fn get_multi(&self, _keys: &[&str]) -> Result<HashMap<String, Value>> {
451            unreachable!("the public API does not expose get_multi")
452        }
453
454        async fn set(&self, key: &str, value: Value, expire: Expiration) -> Result<bool> {
455            *self.set_call.lock().unwrap() = Some((key.to_owned(), value, expire));
456            Ok(true)
457        }
458
459        async fn set_with_noreply(
460            &self,
461            _key: &str,
462            _value: Value,
463            _expire: Expiration,
464        ) -> Result<()> {
465            unreachable!("the public API does not expose set_with_noreply")
466        }
467
468        async fn add(&self, _key: &str, _value: Value, _expire: Expiration) -> Result<bool> {
469            unreachable!("the public API does not expose add")
470        }
471
472        async fn get_cas(&self, _key: &str) -> Result<Option<CasValue>> {
473            unreachable!("the public API does not expose get_cas")
474        }
475
476        async fn cas(&self, _key: &str, _value: &CasValue, _expire: Expiration) -> Result<bool> {
477            unreachable!("the public API does not expose cas")
478        }
479
480        async fn delete(&self, _key: &str) -> Result<bool> {
481            unreachable!("the public API does not expose delete")
482        }
483
484        async fn delete_with_noreply(&self, _key: &str) -> Result<()> {
485            unreachable!("the public API does not expose delete_with_noreply")
486        }
487    }
488
489    #[tokio::test]
490    async fn set_uses_instance_expiration_and_zero_flags_by_default() {
491        let backend = Arc::new(RecordingBackend::default());
492        let cache = CacheService::from_backend(
493            backend.clone(),
494            CacheServiceOptions::new(Expiration::Seconds(90)),
495        );
496
497        assert!(
498            cache
499                .set("user:1", Bytes::from_static(b"value"))
500                .await
501                .unwrap()
502        );
503
504        let call = backend.set_call.lock().unwrap().clone().unwrap();
505        assert_eq!(call.0, "user:1");
506        assert_eq!(call.1.as_bytes(), b"value");
507        assert_eq!(call.1.flags(), 0);
508        assert_eq!(call.2, Expiration::Seconds(90));
509    }
510
511    #[tokio::test]
512    async fn set_with_overrides_expiration_and_flags() {
513        let backend = Arc::new(RecordingBackend::default());
514        let cache = CacheService::from_backend(
515            backend.clone(),
516            CacheServiceOptions::new(Expiration::Seconds(90)),
517        );
518        let options = SetOptions::default()
519            .with_expiration(Expiration::Seconds(5))
520            .with_flags(32);
521
522        cache
523            .set_with("user:2", Bytes::from_static(b"value"), options)
524            .await
525            .unwrap();
526
527        let call = backend.set_call.lock().unwrap().clone().unwrap();
528        assert_eq!(call.1.flags(), 32);
529        assert_eq!(call.2, Expiration::Seconds(5));
530    }
531
532    #[tokio::test]
533    async fn get_maps_wire_zero_flag_to_none_without_copying_data() {
534        let backend = Arc::new(RecordingBackend::default());
535        *backend.get_result.lock().unwrap() = Some(Value::new(Bytes::from_static(b"value"), 0));
536        let cache = CacheService::from_backend(backend, CacheServiceOptions::default());
537
538        let entry = cache.get("user:1").await.unwrap().unwrap();
539
540        assert_eq!(entry.data, Bytes::from_static(b"value"));
541        assert_eq!(entry.flags, None);
542    }
543
544    #[tokio::test]
545    async fn public_contract_is_object_safe() {
546        let backend = Arc::new(RecordingBackend::default());
547        *backend.get_result.lock().unwrap() = Some(Value::new(Bytes::new(), 7));
548        let cache: Arc<dyn Memcache> = Arc::new(CacheService::from_backend(
549            backend,
550            CacheServiceOptions::default(),
551        ));
552
553        assert_eq!(cache.get("key").await.unwrap().unwrap().flags, Some(7));
554    }
555}