Skip to main content

glide/client/
mod.rs

1// Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0
2//! The async GLIDE clients.
3//!
4//! [`GlideClient`] connects to a standalone deployment; [`GlideClusterClient`]
5//! connects to a cluster. Both wrap the shared `glide_core::client::Client` and
6//! implement [`CommandExecutor`], so all command family traits apply to them.
7
8use crate::cmd::Cmd;
9use crate::config::{GlideClientConfiguration, GlideClusterClientConfiguration};
10use crate::error::GlideError;
11use crate::executor::CommandExecutor;
12use crate::pipeline::dispatch_pipeline;
13use crate::pipeline_options::PipelineOptions;
14use crate::routes::Route;
15use crate::value::FromValkeyValue;
16use crate::{ValkeyFuture, ValkeyResult, ValkeyValue};
17use async_trait::async_trait;
18use bytes::Bytes;
19use glide_core::client::{Client as CoreClient, FINISHED_SCAN_CURSOR};
20use glide_core::cluster_scan_container::{get_cluster_scan_cursor, remove_scan_state_cursor};
21use redis::cluster_routing::RoutingInfo;
22use redis::{ClusterScanArgs, PushInfo, PushKind, ScanStateRC, Value};
23use std::sync::Arc;
24use tokio::sync::Mutex as AsyncMutex;
25use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
26
27mod pipeline;
28pub use pipeline::PipelineExt;
29
30/// The kind of a received Pub/Sub message.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum PubSubMessageKind {
33    /// A message on an exactly-subscribed channel (`SUBSCRIBE`).
34    Message,
35    /// A message on a pattern-subscribed channel (`PSUBSCRIBE`).
36    PMessage,
37    /// A message on a shard-subscribed channel (`SSUBSCRIBE`).
38    SMessage,
39}
40
41/// A message received on a Pub/Sub subscription.
42///
43/// Mirrors Python's `CoreCommands.PubSubMsg`.
44#[derive(Debug, Clone)]
45pub struct PubSubMessage {
46    /// The kind of subscription that produced the message.
47    pub kind: PubSubMessageKind,
48    /// The channel the message was published to.
49    pub channel: Bytes,
50    /// The message payload.
51    pub payload: Bytes,
52    /// The pattern that matched (only for [`PubSubMessageKind::PMessage`]).
53    pub pattern: Option<Bytes>,
54}
55
56/// A shared receiver of Pub/Sub push messages from `glide-core`.
57///
58/// Semantics worth knowing:
59/// - The channel is **unbounded**: a fast publisher with a slow/absent consumer
60///   grows memory without bound, so callers should drain promptly.
61/// - Access is guarded by a `tokio::Mutex`, making this a **single-consumer**
62///   model — concurrent `get_pubsub_message` / `try_get_pubsub_message` callers
63///   serialize on the lock (the async `Mutex` is held across `.await`, which is
64///   correct for `tokio::Mutex`).
65type PushRx = Arc<AsyncMutex<UnboundedReceiver<PushInfo>>>;
66
67/// Build the optional push channel for a client that has subscriptions.
68fn make_push_channel(
69    has_subscriptions: bool,
70) -> (Option<UnboundedSender<PushInfo>>, Option<PushRx>) {
71    if has_subscriptions {
72        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
73        (Some(tx), Some(Arc::new(AsyncMutex::new(rx))))
74    } else {
75        (None, None)
76    }
77}
78
79/// The client's push receiver, or the error both clients report when Pub/Sub
80/// was not configured.
81fn pubsub_rx(rx: &Option<PushRx>) -> ValkeyResult<&PushRx> {
82    rx.as_ref()
83        .ok_or_else(|| GlideError::Request("client has no configured pub/sub subscriptions".into()))
84}
85
86/// Shared implementation of `get_pubsub_message`: wait for the next Pub/Sub
87/// *message* push, skipping non-message pushes (subscribe/unsubscribe
88/// confirmations, invalidations, disconnects).
89async fn recv_pubsub_message(rx: &Option<PushRx>) -> ValkeyResult<PubSubMessage> {
90    let mut guard = pubsub_rx(rx)?.lock().await;
91    loop {
92        match guard.recv().await {
93            Some(push) => {
94                if let Some(msg) = push_to_message(push) {
95                    return Ok(msg);
96                }
97            }
98            None => return Err(GlideError::Request("pub/sub channel closed".into())),
99        }
100    }
101}
102
103/// Shared implementation of `try_get_pubsub_message`: non-blocking variant of
104/// [`recv_pubsub_message`]; returns `None` when no message is available.
105async fn try_recv_pubsub_message(rx: &Option<PushRx>) -> ValkeyResult<Option<PubSubMessage>> {
106    let mut guard = pubsub_rx(rx)?.lock().await;
107    loop {
108        match guard.try_recv() {
109            Ok(push) => {
110                if let Some(msg) = push_to_message(push) {
111                    return Ok(Some(msg));
112                }
113            }
114            Err(tokio::sync::mpsc::error::TryRecvError::Empty) => return Ok(None),
115            Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
116                return Err(GlideError::Request("pub/sub channel closed".into()));
117            }
118        }
119    }
120}
121
122/// Convert a raw `PushInfo` into a [`PubSubMessage`], returning `None` for
123/// non-message push kinds (subscribe/unsubscribe confirmations, invalidations,
124/// disconnects).
125fn push_to_message(push: PushInfo) -> Option<PubSubMessage> {
126    let (kind, has_pattern) = match push.kind {
127        PushKind::Message => (PubSubMessageKind::Message, false),
128        PushKind::SMessage => (PubSubMessageKind::SMessage, false),
129        PushKind::PMessage => (PubSubMessageKind::PMessage, true),
130        _ => return None,
131    };
132
133    let mut data = push.data.into_iter();
134    let message = (|| {
135        let mut next = || match data.next()? {
136            Value::BulkString(b) => Some(b),
137            _ => None,
138        };
139        let pattern = if has_pattern { Some(next()?) } else { None };
140        let channel = next()?;
141        let payload = next()?;
142        Some(PubSubMessage {
143            kind,
144            channel,
145            payload,
146            pattern,
147        })
148    })();
149
150    if message.is_none() {
151        glide_logger::log_warn(
152            "pubsub",
153            format!("skipping malformed {kind:?} push: missing or non-string channel/payload"),
154        );
155    }
156    message
157}
158
159/// Executes a [`crate::Pipeline`] and returns command responses.
160async fn run_pipeline(
161    core: &CoreClient,
162    pipeline: &crate::pipeline::Pipeline,
163    routing: Option<RoutingInfo>,
164    raise_on_error: bool,
165    options: &PipelineOptions,
166) -> ValkeyResult<Vec<ValkeyValue>> {
167    let reply = dispatch_pipeline(core, pipeline, routing, raise_on_error, options).await?;
168    match reply {
169        ValkeyValue::Array(items) => Ok(items),
170        ValkeyValue::Nil => Ok(Vec::new()),
171        other => unreachable!("unexpected pipeline reply from Valkey: {other:?}"),
172    }
173}
174
175/// A cursor for an in-progress cluster `SCAN`.
176///
177/// Start a new scan with [`ClusterScanCursor::new`]. After each
178/// [`GlideClusterClient::cluster_scan`] call, use the returned cursor for the
179/// next iteration until [`ClusterScanCursor::is_finished`] returns `true`.
180#[derive(Debug, PartialEq, Eq)]
181pub struct ClusterScanCursor(String);
182
183impl ClusterScanCursor {
184    /// The id of an initial cursor.
185    const INITIAL_CURSOR: &'static str = "";
186
187    /// Create a cursor for a new cluster scan.
188    pub fn new() -> Self {
189        ClusterScanCursor(Self::INITIAL_CURSOR.to_owned())
190    }
191
192    /// The cursor id.
193    pub fn id(&self) -> &str {
194        &self.0
195    }
196
197    /// Whether the scan has not started.
198    fn is_initial(&self) -> bool {
199        self.0 == Self::INITIAL_CURSOR
200    }
201
202    /// Whether the scan has completed.
203    pub fn is_finished(&self) -> bool {
204        self.0 == FINISHED_SCAN_CURSOR
205    }
206}
207
208impl Default for ClusterScanCursor {
209    fn default() -> Self {
210        Self::new()
211    }
212}
213
214impl Drop for ClusterScanCursor {
215    fn drop(&mut self) {
216        // Initial and finished cursors have no scan state to clean up.
217        if !self.is_initial() && !self.is_finished() {
218            remove_scan_state_cursor(std::mem::take(&mut self.0));
219        }
220    }
221}
222
223/// An async client for a **standalone** Valkey/Redis deployment.
224///
225/// Mirrors Python `GlideClient`. Cheaply cloneable — clones share the same
226/// underlying connection pool.
227#[derive(Clone)]
228pub struct GlideClient {
229    inner: CoreClient,
230    pubsub_rx: Option<PushRx>,
231}
232
233impl GlideClient {
234    /// Connect using the given standalone configuration.
235    pub async fn connect(config: GlideClientConfiguration) -> ValkeyResult<Self> {
236        let request = config.to_request();
237        let has_subs = config
238            .pubsub_subscriptions
239            .as_ref()
240            .is_some_and(|s| !s.is_empty());
241        let (sender, pubsub_rx) = make_push_channel(has_subs || config.force_pubsub_channel);
242        let inner = CoreClient::new(request, sender)
243            .await
244            .map_err(GlideError::from_connection_error)?;
245        Ok(GlideClient { inner, pubsub_rx })
246    }
247
248    /// Wait for the next Pub/Sub message on this client's configured
249    /// subscriptions (`get_pubsub_message`). Subscribe/unsubscribe confirmations
250    /// and other non-message pushes are skipped.
251    ///
252    /// Returns an error if the client was not configured with subscriptions.
253    pub async fn get_pubsub_message(&self) -> ValkeyResult<PubSubMessage> {
254        recv_pubsub_message(&self.pubsub_rx).await
255    }
256
257    /// Try to get the next Pub/Sub message without blocking
258    /// (`try_get_pubsub_message`). Returns `None` if no message is currently
259    /// available.
260    pub async fn try_get_pubsub_message(&self) -> ValkeyResult<Option<PubSubMessage>> {
261        try_recv_pubsub_message(&self.pubsub_rx).await
262    }
263
264    /// Execute a [`crate::pipeline::Pipeline`] with GLIDE execution options
265    /// (per-call timeout, pipeline retry policy) and return the raw per-command
266    /// replies. Build with [`crate::pipe()`]; `.atomic()` pipelines run as a
267    /// `MULTI`/`EXEC` transaction. For plain typed execution prefer
268    /// [`PipelineExt::query_async`]. When `raise_on_error` is `true`, the
269    /// first errored command aborts with an error; otherwise error replies are
270    /// returned inline.
271    pub async fn exec(
272        &self,
273        pipeline: &crate::pipeline::Pipeline,
274        raise_on_error: bool,
275        options: &PipelineOptions,
276    ) -> ValkeyResult<Vec<ValkeyValue>> {
277        run_pipeline(&self.inner, pipeline, None, raise_on_error, options).await
278    }
279
280    /// Update the password used by this client to authenticate with the server,
281    /// without changing the server-side password (`update_connection_password`).
282    ///
283    /// The new `password` is stored and used for all future (re)connections. Pass
284    /// `None` to clear a previously-set password (revert to no authentication).
285    ///
286    /// When `immediate_auth` is `true`, an `AUTH` is issued on the live
287    /// connection right away so the change takes effect without waiting for a
288    /// reconnect; the call errors if that `AUTH` is rejected. When `false`, the
289    /// password is only applied on the next reconnection.
290    ///
291    /// Mirrors Python's `update_connection_password`.
292    pub async fn update_connection_password(
293        &self,
294        password: Option<String>,
295        immediate_auth: bool,
296    ) -> ValkeyResult<()> {
297        // `Client` is Clone (Arc inside) and the core method needs `&mut self`,
298        // so we operate on a cheap clone — same pattern as `execute_command`.
299        let mut client = self.inner.clone();
300        client
301            .update_connection_password(password, immediate_auth)
302            .await
303            .map_err(GlideError::from_redis_error)?;
304        Ok(())
305    }
306}
307
308#[async_trait]
309impl CommandExecutor for GlideClient {
310    async fn execute_command(
311        &self,
312        mut cmd: Cmd,
313        route: Option<Route>,
314    ) -> ValkeyResult<ValkeyValue> {
315        let routing = route.map(|r| r.to_routing_info(Some(cmd.as_redis())));
316        // `Client` is Clone (Arc inside) and `send_command` needs `&mut self`,
317        // so we operate on a cheap clone — exactly what every wrapper does.
318        let mut client = self.inner.clone();
319        let value = client
320            .send_command(cmd.as_redis_mut(), routing)
321            .await
322            .map_err(GlideError::from_redis_error)?;
323        ValkeyValue::from_redis(value)
324    }
325}
326
327/// An async client for a **cluster** Valkey/Redis deployment.
328///
329/// Mirrors Python `GlideClusterClient`. Commands are routed automatically by the
330/// core based on their keys; use the `*_with_route` helpers (or
331/// [`crate::CustomCommand::custom_command_with_route`]) to override routing.
332#[derive(Clone)]
333pub struct GlideClusterClient {
334    inner: CoreClient,
335    pubsub_rx: Option<PushRx>,
336}
337
338impl GlideClusterClient {
339    /// Connect using the given cluster configuration.
340    pub async fn connect(config: GlideClusterClientConfiguration) -> ValkeyResult<Self> {
341        let request = config.to_request();
342        let has_subs = config
343            .pubsub_subscriptions
344            .as_ref()
345            .is_some_and(|s| !s.is_empty());
346        let (sender, pubsub_rx) = make_push_channel(has_subs || config.force_pubsub_channel);
347        let inner = CoreClient::new(request, sender)
348            .await
349            .map_err(GlideError::from_connection_error)?;
350        Ok(GlideClusterClient { inner, pubsub_rx })
351    }
352
353    /// Wait for the next Pub/Sub message (including shard messages) on this
354    /// client's configured subscriptions.
355    pub async fn get_pubsub_message(&self) -> ValkeyResult<PubSubMessage> {
356        recv_pubsub_message(&self.pubsub_rx).await
357    }
358
359    /// Try to get the next Pub/Sub message without blocking. Returns `None` if
360    /// no message is currently available.
361    pub async fn try_get_pubsub_message(&self) -> ValkeyResult<Option<PubSubMessage>> {
362        try_recv_pubsub_message(&self.pubsub_rx).await
363    }
364
365    /// Execute a raw command with an explicit route.
366    pub async fn route_command(&self, mut cmd: Cmd, route: Route) -> ValkeyResult<ValkeyValue> {
367        let routing = route.to_routing_info(Some(cmd.as_redis()));
368        let mut client = self.inner.clone();
369        let value = client
370            .send_command(cmd.as_redis_mut(), Some(routing))
371            .await
372            .map_err(GlideError::from_redis_error)?;
373        ValkeyValue::from_redis(value)
374    }
375
376    /// Execute a [`crate::Pipeline`] with GLIDE execution options,
377    /// optionally routed. See [`crate::GlideClient::exec`].
378    pub async fn exec(
379        &self,
380        pipeline: &crate::pipeline::Pipeline,
381        raise_on_error: bool,
382        route: Option<Route>,
383        options: &PipelineOptions,
384    ) -> ValkeyResult<Vec<ValkeyValue>> {
385        let routing = route.map(|r| r.to_routing_info(None));
386        run_pipeline(&self.inner, pipeline, routing, raise_on_error, options).await
387    }
388
389    /// Update the password used by this client to authenticate with the cluster,
390    /// without changing the server-side password (`update_connection_password`).
391    ///
392    /// The new `password` is stored and used for all future (re)connections to
393    /// every node. Pass `None` to clear a previously-set password. When
394    /// `immediate_auth` is `true`, an `AUTH` is issued right away and the call
395    /// errors if rejected; when `false`, it applies on the next reconnection.
396    ///
397    /// Mirrors Python's `update_connection_password`.
398    pub async fn update_connection_password(
399        &self,
400        password: Option<String>,
401        immediate_auth: bool,
402    ) -> ValkeyResult<()> {
403        let mut client = self.inner.clone();
404        client
405            .update_connection_password(password, immediate_auth)
406            .await
407            .map_err(GlideError::from_redis_error)?;
408        Ok(())
409    }
410
411    /// Incrementally iterate the entire keyspace of a cluster (`SCAN` for
412    /// cluster). Returns the next [`ClusterScanCursor`] and the batch of keys
413    /// found. Iteration is complete when the returned cursor's
414    /// [`ClusterScanCursor::is_finished`] is `true`.
415    ///
416    /// Unlike standalone `SCAN`, the cluster scan is coordinated by `glide-core`
417    /// across all shards using an opaque cursor.
418    pub async fn cluster_scan(
419        &self,
420        cursor: &ClusterScanCursor,
421        match_pattern: Option<&[u8]>,
422        count: Option<u32>,
423        object_type: Option<crate::commands::options::ObjectType>,
424    ) -> ValkeyResult<(ClusterScanCursor, Vec<Bytes>)> {
425        // TODO #7161: Update to use new glide_core::Client methods.
426        let scan_state = if cursor.is_initial() {
427            ScanStateRC::new()
428        } else {
429            get_cluster_scan_cursor(cursor.0.clone()).map_err(GlideError::from_redis_error)?
430        };
431
432        let mut builder = ClusterScanArgs::builder();
433        if let Some(p) = match_pattern {
434            builder = builder.with_match_pattern(p.to_vec());
435        }
436        if let Some(c) = count {
437            builder = builder.with_count(c);
438        }
439        if let Some(t) = object_type {
440            builder = builder.with_object_type(t.to_redis());
441        }
442        let args = builder.build();
443
444        let mut client = self.inner.clone();
445        let reply = client
446            .cluster_scan(&scan_state, args)
447            .await
448            .map_err(GlideError::from_redis_error)?;
449
450        // Reply shape: [cursor_id_or_"finished", [keys...]].
451        let items = match ValkeyValue::from_redis(reply)? {
452            ValkeyValue::Array(items) => items,
453            other => {
454                return Err(GlideError::Request(format!(
455                    "unexpected cluster scan reply: {other:?}"
456                )));
457            }
458        };
459        let [cursor_val, keys_val] = <[ValkeyValue; 2]>::try_from(items).map_err(|items| {
460            GlideError::Request(format!("unexpected cluster scan reply arity: {items:?}"))
461        })?;
462        let next = ClusterScanCursor(String::from_owned_valkey_value(cursor_val)?);
463        let keys = match keys_val {
464            ValkeyValue::Array(elems) => elems
465                .into_iter()
466                .map(Bytes::from_owned_valkey_value)
467                .collect::<ValkeyResult<Vec<_>>>()?,
468            ValkeyValue::Nil => Vec::new(),
469            other => vec![Bytes::from_owned_valkey_value(other)?],
470        };
471        Ok((next, keys))
472    }
473}
474
475#[async_trait]
476impl CommandExecutor for GlideClusterClient {
477    async fn execute_command(
478        &self,
479        mut cmd: Cmd,
480        route: Option<Route>,
481    ) -> ValkeyResult<ValkeyValue> {
482        let routing = route.map(|r| r.to_routing_info(Some(cmd.as_redis())));
483        let mut client = self.inner.clone();
484        let value = client
485            .send_command(cmd.as_redis_mut(), routing)
486            .await
487            .map_err(GlideError::from_redis_error)?;
488        ValkeyValue::from_redis(value)
489    }
490}
491
492// ---- Command dispatch -------------------------------------------------------
493
494macro_rules! impl_async_command {
495    ($ty:ty) => {
496        #[::sealed::sealed]
497        impl crate::commands::core::CommandDispatch for $ty {
498            fn glide_dispatch_command<'a>(&'a self, mut cmd: Cmd) -> ValkeyFuture<'a, ValkeyValue> {
499                let mut client = self.inner.clone();
500                Box::pin(async move {
501                    let value = client
502                        .send_command(cmd.as_redis_mut(), None)
503                        .await
504                        .map_err(GlideError::from_redis_error)?;
505                    ValkeyValue::from_redis(value)
506                })
507            }
508        }
509    };
510}
511
512impl_async_command!(GlideClient);
513impl_async_command!(GlideClusterClient);
514
515// ---- Script dispatch --------------------------------------------------------
516
517macro_rules! impl_script_invoke {
518    ($ty:ty) => {
519        #[::sealed::sealed]
520        impl crate::script::ScriptInvoke for $ty {
521            fn glide_invoke_script<'a>(
522                &'a self,
523                hash: &'a str,
524                keys: &'a [Vec<u8>],
525                args: &'a [Vec<u8>],
526            ) -> ValkeyFuture<'a, ValkeyValue> {
527                let mut client = self.inner.clone();
528                Box::pin(async move {
529                    let key_refs: Vec<&[u8]> = keys.iter().map(Vec::as_slice).collect();
530                    let arg_refs: Vec<&[u8]> = args.iter().map(Vec::as_slice).collect();
531                    let value = client
532                        .invoke_script(hash, &key_refs, &arg_refs, None)
533                        .await
534                        .map_err(GlideError::from_redis_error)?;
535                    ValkeyValue::from_redis(value)
536                })
537            }
538        }
539    };
540}
541
542impl_script_invoke!(GlideClient);
543impl_script_invoke!(GlideClusterClient);
544
545// ---- Pipeline dispatch ------------------------------------------------------
546
547macro_rules! impl_pipeline_dispatch {
548    ($ty:ty) => {
549        #[::sealed::sealed]
550        impl pipeline::PipelineDispatch for $ty {
551            fn glide_dispatch_pipeline<'a>(
552                &self,
553                pipeline: &'a crate::pipeline::Pipeline,
554            ) -> ValkeyFuture<'a, ValkeyValue> {
555                // Clone the core handle (Arc inside) before the async move so
556                // the returned future does not borrow `self`.
557                let core = self.inner.clone();
558                Box::pin(async move {
559                    let opts = &PipelineOptions::default();
560                    dispatch_pipeline(&core, pipeline, None, true, opts).await
561                })
562            }
563        }
564    };
565}
566
567impl_pipeline_dispatch!(GlideClient);
568impl_pipeline_dispatch!(GlideClusterClient);
569
570// ---- Tests ------------------------------------------------------------------
571
572#[cfg(test)]
573mod push_tests {
574    use super::*;
575
576    fn bulk(s: &str) -> Value {
577        Value::BulkString(Bytes::copy_from_slice(s.as_bytes()))
578    }
579
580    #[test]
581    fn message_push_parses_channel_and_payload() {
582        let push = PushInfo {
583            kind: PushKind::Message,
584            data: vec![bulk("chan"), bulk("hello")],
585        };
586        let msg = push_to_message(push).expect("message should parse");
587        assert_eq!(msg.kind, PubSubMessageKind::Message);
588        assert_eq!(msg.channel, Bytes::from_static(b"chan"));
589        assert_eq!(msg.payload, Bytes::from_static(b"hello"));
590        assert_eq!(msg.pattern, None);
591    }
592
593    #[test]
594    fn pmessage_push_parses_pattern() {
595        let push = PushInfo {
596            kind: PushKind::PMessage,
597            data: vec![bulk("ch.*"), bulk("chan"), bulk("hello")],
598        };
599        let msg = push_to_message(push).expect("pmessage should parse");
600        assert_eq!(msg.kind, PubSubMessageKind::PMessage);
601        assert_eq!(msg.pattern, Some(Bytes::from_static(b"ch.*")));
602        assert_eq!(msg.channel, Bytes::from_static(b"chan"));
603        assert_eq!(msg.payload, Bytes::from_static(b"hello"));
604    }
605
606    #[test]
607    fn non_message_kind_is_skipped() {
608        let push = PushInfo {
609            kind: PushKind::Subscribe,
610            data: vec![bulk("chan"), Value::Int(1)],
611        };
612        assert!(push_to_message(push).is_none());
613    }
614
615    #[test]
616    fn non_string_payload_is_skipped_not_fabricated() {
617        // A non-string payload element must not be stringified into the payload;
618        // the malformed push is skipped (and logged).
619        let push = PushInfo {
620            kind: PushKind::Message,
621            data: vec![bulk("chan"), Value::Int(5)],
622        };
623        assert!(push_to_message(push).is_none());
624    }
625
626    #[test]
627    fn short_message_push_is_skipped() {
628        let push = PushInfo {
629            kind: PushKind::Message,
630            data: vec![bulk("chan")],
631        };
632        assert!(push_to_message(push).is_none());
633    }
634}
635
636#[cfg(test)]
637mod cluster_scan_cursor_tests {
638    use super::*;
639    use glide_core::cluster_scan_container::{get_cluster_scan_cursor, insert_cluster_scan_cursor};
640
641    #[test]
642    fn is_initial() {
643        // Maps from cursor to the expected value.
644        let cases = [
645            (ClusterScanCursor::new(), true),
646            (ClusterScanCursor::default(), true),
647            (ClusterScanCursor("id".to_owned()), false),
648            (ClusterScanCursor(FINISHED_SCAN_CURSOR.to_owned()), false),
649        ];
650
651        for (cursor, expected) in &cases {
652            assert_eq!(cursor.is_initial(), *expected);
653        }
654    }
655
656    #[test]
657    fn is_finished() {
658        // Maps from cursor to the expected value.
659        let cases = [
660            (ClusterScanCursor::new(), false),
661            (ClusterScanCursor::default(), false),
662            (ClusterScanCursor("id".to_owned()), false),
663            (ClusterScanCursor(FINISHED_SCAN_CURSOR.to_owned()), true),
664        ];
665
666        for (cursor, expected) in &cases {
667            assert_eq!(cursor.is_finished(), *expected);
668        }
669    }
670
671    #[test]
672    fn drop_releases_container_entry() {
673        // Initial cursor: owns no container entry, so its drop is a no-op.
674        let cursor = ClusterScanCursor::new();
675        let id = cursor.id().to_owned();
676        assert!(get_cluster_scan_cursor(id.clone()).is_err());
677
678        drop(cursor);
679        assert!(get_cluster_scan_cursor(id).is_err());
680
681        // Intermediate cursor: owns a container entry that drop must remove.
682        let id = insert_cluster_scan_cursor(ScanStateRC::new());
683        assert!(get_cluster_scan_cursor(id.clone()).is_ok());
684
685        drop(ClusterScanCursor(id.clone()));
686        assert!(get_cluster_scan_cursor(id).is_err());
687
688        // Finished cursor: owns no container entry, so its drop is a no-op.
689        let cursor = ClusterScanCursor(FINISHED_SCAN_CURSOR.to_owned());
690        let id = cursor.id().to_owned();
691        assert!(get_cluster_scan_cursor(id.clone()).is_err());
692
693        drop(cursor);
694        assert!(get_cluster_scan_cursor(id).is_err());
695    }
696}