Skip to main content

ruststream_fred/
broker.rs

1//! The broker ladder: [`RedisBroker`] -> [`ConnectedRedisBroker`] -> [`ClosedRedisBroker`].
2//!
3//! Construction is synchronous and I/O-free: the named constructors only record the topology and
4//! its options. All network work happens in the consuming [`Broker::connect`], and the connected
5//! form owns the live `fred` pool, so subscriptions and publishers exist only once a connection
6//! does.
7
8use std::num::NonZeroUsize;
9use std::sync::Arc;
10#[cfg(feature = "testing")]
11use std::sync::Mutex;
12
13use fred::clients::{Client, Pool};
14use fred::interfaces::{ClientLike, EventInterface, PubsubInterface, StreamsInterface};
15#[cfg(feature = "credential-provider")]
16use fred::types::config::CredentialProvider;
17#[cfg(any(
18    feature = "tls-rustls",
19    feature = "tls-rustls-ring",
20    feature = "tls-native-tls"
21))]
22use fred::types::config::TlsConfig;
23use fred::types::config::{Config, PerformanceConfig, ServerConfig};
24use fred::types::{ClusterHash, CustomCommand, Message, Value, Version};
25use fred::util::redis_keyslot;
26#[cfg(feature = "testing")]
27use ruststream::testing::{Coordinator, InProcess, TestableBroker};
28use ruststream::{AddressedCopies, Broker, ConnectedBroker, DescribeServer, ServerSpec, Subscribe};
29#[cfg(feature = "testing")]
30use ruststream::{OutgoingMessage, RawMessage};
31use tokio::runtime::Handle;
32use tokio::sync::broadcast::Receiver;
33
34use crate::connection::{Closing, Connection, ReadConnection};
35#[cfg(feature = "testing")]
36use crate::convert::{fields_for_publish, parts_from_fields};
37use crate::delay::DelayConfig;
38#[cfg(feature = "testing")]
39use crate::envelope::{frame, unframe};
40#[cfg(feature = "testing")]
41use crate::in_process::{self, Subscription, Write};
42use crate::loopback::{Loopback, Tap};
43use crate::recovery::RecoveryConfig;
44
45use crate::{
46    error::RedisError,
47    list::{ListWire, RedisList, RedisListPublish, RedisListPublisher, RedisListSubscriber},
48    pipeline::{Owner, Rounds},
49    publisher::{RedisDefaultPublisher, RedisPublisher},
50    pubsub::{
51        PubSubMode, PubSubWire, RedisPubSub, RedisPubSubPattern, RedisPubSubPublish,
52        RedisPubSubPublisher, RedisPubSubSubscriber,
53    },
54    route::{Recorded, Route, Routes},
55    stream::{ReadMode, RedisStream},
56    subscriber::RedisSubscriber,
57};
58
59/// Default `fred` connection-pool size when the caller does not set one.
60const DEFAULT_POOL_SIZE: usize = 4;
61
62/// The first Redis release whose `XREADGROUP` takes a `CLAIM` option.
63const CLAIM_MIN_VERSION: Version = Version::new(8, 4, 0);
64
65/// How the broker should connect, recorded synchronously and resolved into a `fred` config at
66/// [`Broker::connect`] time so construction stays I/O- and failure-free.
67#[derive(Debug, Clone)]
68enum Topology {
69    /// A single server, addressed by URL (`redis://host:port`).
70    Standalone(String),
71    /// A Redis Cluster, addressed by one or more `host:port` seed nodes.
72    Cluster(Vec<String>),
73    /// Sentinel-managed replication: the monitored primary's `service` name plus the `host:port`
74    /// of each sentinel.
75    Sentinel { service: String, hosts: Vec<String> },
76    /// A pool supplied already-connected via [`RedisBroker::from_pool`]; `connect` adopts it
77    /// instead of dialing.
78    Preconnected(Pool),
79}
80
81/// Parses a `host:port` address (tolerating a `redis://` / `rediss://` scheme prefix) into the
82/// `(host, port)` pair `fred`'s server-config constructors expect. Falls back to `default_port`
83/// when no port is given.
84fn parse_server(addr: &str, default_port: u16) -> Result<(String, u16), RedisError> {
85    let trimmed = addr
86        .trim()
87        .trim_start_matches("rediss://")
88        .trim_start_matches("redis://");
89    let (host, port) = match trimmed.rsplit_once(':') {
90        Some((host, port)) => {
91            let port = port.parse::<u16>().map_err(|_| {
92                RedisError::Connect(format!("invalid port in redis address `{addr}`").into())
93            })?;
94            (host, port)
95        }
96        None => (trimmed, default_port),
97    };
98    if host.is_empty() {
99        return Err(RedisError::Connect(
100            format!("missing host in redis address `{addr}`").into(),
101        ));
102    }
103    Ok((host.to_owned(), port))
104}
105
106fn parse_servers(addrs: &[String], default_port: u16) -> Result<Vec<(String, u16)>, RedisError> {
107    if addrs.is_empty() {
108        return Err(RedisError::Connect("no redis addresses provided".into()));
109    }
110    addrs
111        .iter()
112        .map(|addr| parse_server(addr, default_port))
113        .collect()
114}
115
116/// Authentication and TLS settings recorded on the broker and folded into the `fred` [`Config`]
117/// at connect time, on every topology. Fields with no value are left untouched, so credentials
118/// supplied through a standalone `redis://user:pass@host` URL survive unless overridden here.
119#[derive(Clone, Default)]
120struct AuthConfig {
121    /// ACL username for the data nodes (`Config.username`).
122    username: Option<String>,
123    /// Password for the data nodes (`Config.password`).
124    password: Option<String>,
125    /// ACL username for authenticating to the sentinels, distinct from the data-node username.
126    #[cfg(feature = "sentinel-auth")]
127    sentinel_username: Option<String>,
128    /// Password for authenticating to the sentinels, distinct from the data-node password.
129    #[cfg(feature = "sentinel-auth")]
130    sentinel_password: Option<String>,
131    /// Explicit TLS configuration (`Config.tls`).
132    #[cfg(any(
133        feature = "tls-rustls",
134        feature = "tls-rustls-ring",
135        feature = "tls-native-tls"
136    ))]
137    tls: Option<TlsConfig>,
138    /// Dynamic/rotating credential provider (`Config.credential_provider`).
139    #[cfg(feature = "credential-provider")]
140    credential_provider: Option<Arc<dyn CredentialProvider>>,
141}
142
143// Redacts secrets: passwords never appear, and TLS / credential-provider show only presence. The
144// usernames are identifiers (not secrets) and are kept to aid debugging.
145impl std::fmt::Debug for AuthConfig {
146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        let mut s = f.debug_struct("AuthConfig");
148        s.field("username", &self.username);
149        s.field("password", &self.password.as_ref().map(|_| "<redacted>"));
150        #[cfg(feature = "sentinel-auth")]
151        {
152            s.field("sentinel_username", &self.sentinel_username);
153            s.field(
154                "sentinel_password",
155                &self.sentinel_password.as_ref().map(|_| "<redacted>"),
156            );
157        }
158        #[cfg(any(
159            feature = "tls-rustls",
160            feature = "tls-rustls-ring",
161            feature = "tls-native-tls"
162        ))]
163        s.field("tls", &self.tls.as_ref().map(|_| "<configured>"));
164        #[cfg(feature = "credential-provider")]
165        s.field(
166            "credential_provider",
167            &self.credential_provider.as_ref().map(|_| "<configured>"),
168        );
169        s.finish()
170    }
171}
172
173/// An unconnected Redis broker: the recorded topology and its options, no I/O performed yet.
174///
175/// Build it with [`standalone`](Self::standalone), [`cluster`](Self::cluster),
176/// [`sentinel`](Self::sentinel), or [`from_pool`](Self::from_pool). All four are synchronous and
177/// perform no I/O, so a Redis service composes with the synchronous `#[ruststream::app]` builder;
178/// the runtime dials once at startup through the consuming [`Broker::connect`], which yields the
179/// [`ConnectedRedisBroker`] every subscription and publisher is reached from.
180///
181/// # Examples
182///
183/// ```
184/// # mod demo {
185/// use ruststream_fred::stream::prelude::*;
186/// use serde::Deserialize;
187///
188/// #[derive(Deserialize)]
189/// struct Order {
190///     id: u64,
191/// }
192///
193/// #[subscriber(RedisStream::new("orders").group("workers"))]
194/// async fn handle(order: &Order) -> HandlerOutcome {
195///     println!("order {}", order.id);
196///     HandlerOutcome::ack()
197/// }
198///
199/// // The broker is a plain value here; the runtime connects it at startup and shuts it down
200/// // when the service stops.
201/// #[ruststream::app]
202/// fn app() -> impl App {
203///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
204///         RedisBroker::standalone("redis://localhost:6379"),
205///         |b| {
206///             b.include(handle);
207///         },
208///     )
209/// }
210/// # }
211/// # fn main() {}
212/// ```
213#[derive(Debug, Clone)]
214#[must_use]
215pub struct RedisBroker {
216    topology: Topology,
217    pool_size: usize,
218    default_group: Option<String>,
219    auth: AuthConfig,
220}
221
222impl RedisBroker {
223    /// Creates a standalone-topology broker that connects to `url` when [`Broker::connect`] runs.
224    pub fn standalone(url: impl Into<String>) -> Self {
225        Self::with_topology(Topology::Standalone(url.into()))
226    }
227
228    /// Creates a Redis Cluster broker from one or more `host:port` seed nodes.
229    ///
230    /// Only one reachable node is needed; `fred` discovers the rest of the cluster on connect.
231    pub fn cluster(nodes: impl IntoIterator<Item = impl Into<String>>) -> Self {
232        Self::with_topology(Topology::Cluster(
233            nodes.into_iter().map(Into::into).collect(),
234        ))
235    }
236
237    /// Creates a Sentinel-backed broker that tracks the primary named `service`, discovering it
238    /// through the given sentinel `host:port` addresses.
239    pub fn sentinel(
240        service: impl Into<String>,
241        sentinels: impl IntoIterator<Item = impl Into<String>>,
242    ) -> Self {
243        Self::with_topology(Topology::Sentinel {
244            service: service.into(),
245            hosts: sentinels.into_iter().map(Into::into).collect(),
246        })
247    }
248
249    /// Wraps an already-connected `fred` pool. Useful for advanced configuration (TLS, cluster,
250    /// sentinel, custom performance and reconnection policies).
251    ///
252    /// [`Broker::connect`] adopts the pool instead of dialing; the config it was built from is
253    /// reused for the dedicated clients Pub/Sub subscriptions need.
254    pub fn from_pool(pool: Pool) -> Self {
255        Self::with_topology(Topology::Preconnected(pool))
256    }
257
258    fn with_topology(topology: Topology) -> Self {
259        Self {
260            topology,
261            pool_size: DEFAULT_POOL_SIZE,
262            default_group: None,
263            auth: AuthConfig::default(),
264        }
265    }
266
267    /// Sets the connection-pool size: the connections publishes, settlements and handler commands
268    /// share. Defaults to 4. Each subscription reads on a connection of its own besides.
269    pub const fn pool(mut self, size: usize) -> Self {
270        self.pool_size = size;
271        self
272    }
273
274    /// Sets a broker-wide default consumer group, enabling the bare-string `#[subscriber("key")]`
275    /// form (Redis Streams always read through a group). Without it a bare-string subscription
276    /// returns [`RedisError::InvalidOptions`]; name the group per subscription with
277    /// [`RedisStream::group`] instead.
278    pub fn default_group(mut self, group: impl Into<String>) -> Self {
279        self.default_group = Some(group.into());
280        self
281    }
282
283    /// Sets the ACL `username` and `password` used to authenticate on connect, applied on every
284    /// topology (standalone, cluster, sentinel).
285    ///
286    /// This maps onto `fred`'s `Config.username` / `Config.password`, so authentication works
287    /// beyond the standalone `redis://user:pass@host` URL, which the bare `cluster` / `sentinel`
288    /// seed lists cannot express. Credentials set here override any in a standalone URL.
289    ///
290    /// For a password-only `AUTH` (the legacy `requirepass`, no ACL user) use
291    /// [`password`](Self::password).
292    ///
293    /// # Examples
294    ///
295    /// ```
296    /// # mod demo {
297    /// use ruststream_fred::stream::prelude::*;
298    /// # use serde::Deserialize;
299    /// # #[derive(Deserialize)]
300    /// # struct Order {
301    /// #     id: u64,
302    /// # }
303    /// #
304    /// # #[subscriber(RedisStream::new("orders").group("workers"))]
305    /// # async fn handle(order: &Order) -> HandlerOutcome {
306    /// #     println!("order {}", order.id);
307    /// #     HandlerOutcome::ack()
308    /// # }
309    ///
310    /// #[ruststream::app]
311    /// fn app() -> impl App {
312    ///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
313    ///         RedisBroker::cluster(["10.0.0.1:6379"]).credentials("worker", "s3cr3t"),
314    ///         |b| {
315    ///             b.include(handle);
316    ///         },
317    ///     )
318    /// }
319    /// # }
320    /// # fn main() {}
321    /// ```
322    pub fn credentials(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
323        self.auth.username = Some(username.into());
324        self.auth.password = Some(password.into());
325        self
326    }
327
328    /// Sets a password-only `AUTH` (no ACL username; the legacy `requirepass` form), on every
329    /// topology. Use [`credentials`](Self::credentials) for an ACL user plus password.
330    ///
331    /// # Examples
332    ///
333    /// ```
334    /// # mod demo {
335    /// use ruststream_fred::stream::prelude::*;
336    /// # use serde::Deserialize;
337    /// # #[derive(Deserialize)]
338    /// # struct Order {
339    /// #     id: u64,
340    /// # }
341    /// #
342    /// # #[subscriber(RedisStream::new("orders").group("workers"))]
343    /// # async fn handle(order: &Order) -> HandlerOutcome {
344    /// #     println!("order {}", order.id);
345    /// #     HandlerOutcome::ack()
346    /// # }
347    ///
348    /// #[ruststream::app]
349    /// fn app() -> impl App {
350    ///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
351    ///         RedisBroker::sentinel("mymaster", ["10.0.0.1:26379"]).password("s3cr3t"),
352    ///         |b| {
353    ///             b.include(handle);
354    ///         },
355    ///     )
356    /// }
357    /// # }
358    /// # fn main() {}
359    /// ```
360    pub fn password(mut self, password: impl Into<String>) -> Self {
361        self.auth.password = Some(password.into());
362        self
363    }
364
365    /// Sets the TLS configuration used on connect, on every topology. Accepts a `fred`
366    /// [`TlsConfig`] or anything convertible into one (for example a `TlsConnector`).
367    ///
368    /// Available behind the `tls-rustls`, `tls-rustls-ring`, or `tls-native-tls` feature; a
369    /// standalone broker can also enable TLS through a `rediss://` / `valkeys://` URL. The
370    /// `fred` re-exports [`TlsConfig`](crate::TlsConfig) / [`TlsConnector`](crate::TlsConnector)
371    /// provide `default_rustls()` / `default_native_tls()` shorthands for system-trust setups.
372    ///
373    /// # Examples
374    ///
375    /// ```
376    /// # #[cfg(feature = "tls-rustls")]
377    /// # mod demo {
378    /// use std::error::Error;
379    ///
380    /// use ruststream_fred::stream::prelude::*;
381    /// # use serde::Deserialize;
382    /// # #[derive(Deserialize)]
383    /// # struct Order {
384    /// #     id: u64,
385    /// # }
386    /// #
387    /// # #[subscriber(RedisStream::new("orders").group("workers"))]
388    /// # async fn handle(order: &Order) -> HandlerOutcome {
389    /// #     println!("order {}", order.id);
390    /// #     HandlerOutcome::ack()
391    /// # }
392    ///
393    /// pub async fn serve() -> Result<(), Box<dyn Error>> {
394    ///     // System trust roots, no client certificate.
395    ///     let tls = TlsConnector::default_rustls()?;
396    ///     RustStream::new(AppInfo::new("orders", "0.1.0"))
397    ///         .with_broker(RedisBroker::cluster(["10.0.0.1:6379"]).tls(tls), |b| {
398    ///             b.include(handle);
399    ///         })
400    ///         .run()
401    ///         .await?;
402    ///     Ok(())
403    /// }
404    /// # }
405    /// # fn main() {}
406    /// ```
407    #[cfg(any(
408        feature = "tls-rustls",
409        feature = "tls-rustls-ring",
410        feature = "tls-native-tls"
411    ))]
412    pub fn tls(mut self, tls: impl Into<TlsConfig>) -> Self {
413        self.auth.tls = Some(tls.into());
414        self
415    }
416
417    /// Sets distinct credentials for authenticating to the sentinel nodes, separate from the
418    /// data-node [`credentials`](Self::credentials). Only meaningful on the sentinel topology.
419    ///
420    /// Available behind the `sentinel-auth` feature.
421    ///
422    /// # Examples
423    ///
424    /// ```
425    /// # mod demo {
426    /// use ruststream_fred::stream::prelude::*;
427    /// # use serde::Deserialize;
428    /// # #[derive(Deserialize)]
429    /// # struct Order {
430    /// #     id: u64,
431    /// # }
432    /// #
433    /// # #[subscriber(RedisStream::new("orders").group("workers"))]
434    /// # async fn handle(order: &Order) -> HandlerOutcome {
435    /// #     println!("order {}", order.id);
436    /// #     HandlerOutcome::ack()
437    /// # }
438    ///
439    /// #[ruststream::app]
440    /// fn app() -> impl App {
441    ///     // The data nodes and the sentinels each check their own pair.
442    ///     let broker = RedisBroker::sentinel("mymaster", ["10.0.0.1:26379"])
443    ///         .credentials("worker", "data-pass")
444    ///         .sentinel_credentials("sentinel-user", "sentinel-pass");
445    ///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
446    ///         broker,
447    ///         |b| {
448    ///             b.include(handle);
449    ///         },
450    ///     )
451    /// }
452    /// # }
453    /// # fn main() {}
454    /// ```
455    #[cfg(feature = "sentinel-auth")]
456    pub fn sentinel_credentials(
457        mut self,
458        username: impl Into<String>,
459        password: impl Into<String>,
460    ) -> Self {
461        self.auth.sentinel_username = Some(username.into());
462        self.auth.sentinel_password = Some(password.into());
463        self
464    }
465
466    /// Sets a password-only credential for authenticating to the sentinel nodes. Use
467    /// [`sentinel_credentials`](Self::sentinel_credentials) for an ACL user plus password.
468    ///
469    /// Available behind the `sentinel-auth` feature.
470    ///
471    /// # Examples
472    ///
473    /// ```
474    /// # mod demo {
475    /// use ruststream_fred::stream::prelude::*;
476    /// # use serde::Deserialize;
477    /// # #[derive(Deserialize)]
478    /// # struct Order {
479    /// #     id: u64,
480    /// # }
481    /// #
482    /// # #[subscriber(RedisStream::new("orders").group("workers"))]
483    /// # async fn handle(order: &Order) -> HandlerOutcome {
484    /// #     println!("order {}", order.id);
485    /// #     HandlerOutcome::ack()
486    /// # }
487    ///
488    /// #[ruststream::app]
489    /// fn app() -> impl App {
490    ///     let broker = RedisBroker::sentinel("mymaster", ["10.0.0.1:26379"])
491    ///         .password("data-pass")
492    ///         .sentinel_password("sentinel-pass");
493    ///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
494    ///         broker,
495    ///         |b| {
496    ///             b.include(handle);
497    ///         },
498    ///     )
499    /// }
500    /// # }
501    /// # fn main() {}
502    /// ```
503    #[cfg(feature = "sentinel-auth")]
504    pub fn sentinel_password(mut self, password: impl Into<String>) -> Self {
505        self.auth.sentinel_password = Some(password.into());
506        self
507    }
508
509    /// Sets a dynamic credential provider that supplies (and can rotate) the username/password on
510    /// each `AUTH` / `HELLO`, for IAM-style auth. Takes precedence over static
511    /// [`credentials`](Self::credentials).
512    ///
513    /// Available behind the `credential-provider` feature.
514    ///
515    /// # Examples
516    ///
517    /// ```
518    /// # mod demo {
519    /// use std::sync::Arc;
520    ///
521    /// use ruststream_fred::stream::prelude::*;
522    /// # use serde::Deserialize;
523    /// # use fred::error::Error as FredError;
524    /// # use fred::types::config::Server;
525    /// # use ruststream_fred::CredentialProvider;
526    /// # #[derive(Debug)]
527    /// # struct IamTokens;
528    /// # #[async_trait::async_trait]
529    /// # impl CredentialProvider for IamTokens {
530    /// #     async fn fetch(
531    /// #         &self,
532    /// #         _server: Option<&Server>,
533    /// #     ) -> Result<(Option<String>, Option<String>), FredError> {
534    /// #         Ok((Some("worker".to_owned()), Some("token".to_owned())))
535    /// #     }
536    /// # }
537    /// # #[derive(Deserialize)]
538    /// # struct Order {
539    /// #     id: u64,
540    /// # }
541    /// #
542    /// # #[subscriber(RedisStream::new("orders").group("workers"))]
543    /// # async fn handle(order: &Order) -> HandlerOutcome {
544    /// #     println!("order {}", order.id);
545    /// #     HandlerOutcome::ack()
546    /// # }
547    ///
548    /// #[ruststream::app]
549    /// fn app() -> impl App {
550    ///     // `IamTokens` fetches a fresh token from the identity service on every `AUTH`.
551    ///     let broker = RedisBroker::standalone("redis://localhost:6379")
552    ///         .credential_provider(Arc::new(IamTokens));
553    ///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
554    ///         broker,
555    ///         |b| {
556    ///             b.include(handle);
557    ///         },
558    ///     )
559    /// }
560    /// # }
561    /// # fn main() {}
562    /// ```
563    #[cfg(feature = "credential-provider")]
564    pub fn credential_provider(mut self, provider: Arc<dyn CredentialProvider>) -> Self {
565        self.auth.credential_provider = Some(provider);
566        self
567    }
568
569    /// Builds the `fred` config for this broker's topology, then folds in the auth/TLS settings.
570    fn build_config(&self) -> Result<Config, RedisError> {
571        let mut config = match &self.topology {
572            Topology::Standalone(url) => {
573                Config::from_url(url).map_err(|err| RedisError::Connect(Box::new(err)))?
574            }
575            Topology::Cluster(nodes) => {
576                let hosts = parse_servers(nodes, 6379)?;
577                Config {
578                    server: ServerConfig::new_clustered(hosts),
579                    ..Config::default()
580                }
581            }
582            Topology::Sentinel { service, hosts } => {
583                let hosts = parse_servers(hosts, 26379)?;
584                Config {
585                    server: ServerConfig::new_sentinel(hosts, service.clone()),
586                    ..Config::default()
587                }
588            }
589            // A caller-supplied pool carries the config it was built from; reusing it keeps the
590            // Pub/Sub path (which dials a dedicated client per subscription) available.
591            Topology::Preconnected(pool) => pool.next().client_config(),
592        };
593        self.apply_auth(&mut config);
594        Ok(config)
595    }
596
597    /// Folds the recorded auth/TLS settings into `config`. Each setting is applied only when set,
598    /// so credentials carried by a standalone URL survive unless explicitly overridden.
599    fn apply_auth(&self, config: &mut Config) {
600        if self.auth.username.is_some() {
601            config.username.clone_from(&self.auth.username);
602        }
603        if self.auth.password.is_some() {
604            config.password.clone_from(&self.auth.password);
605        }
606        #[cfg(any(
607            feature = "tls-rustls",
608            feature = "tls-rustls-ring",
609            feature = "tls-native-tls"
610        ))]
611        if self.auth.tls.is_some() {
612            config.tls.clone_from(&self.auth.tls);
613        }
614        #[cfg(feature = "credential-provider")]
615        if self.auth.credential_provider.is_some() {
616            config
617                .credential_provider
618                .clone_from(&self.auth.credential_provider);
619        }
620        #[cfg(feature = "sentinel-auth")]
621        if let ServerConfig::Sentinel {
622            username, password, ..
623        } = &mut config.server
624        {
625            if self.auth.sentinel_username.is_some() {
626                username.clone_from(&self.auth.sentinel_username);
627            }
628            if self.auth.sentinel_password.is_some() {
629                password.clone_from(&self.auth.sentinel_password);
630            }
631        }
632    }
633}
634
635impl Broker for RedisBroker {
636    type Error = RedisError;
637    type Connected = ConnectedRedisBroker;
638
639    /// Opens (or adopts) the connection pool, consuming the unconnected form.
640    ///
641    /// # Errors
642    ///
643    /// Returns [`RedisError::Connect`] when the recorded topology cannot be turned into a `fred`
644    /// config or the pool cannot reach the server.
645    async fn connect(self) -> Result<Self::Connected, Self::Error> {
646        let config = self.build_config()?;
647        // Read off the resolved config rather than the recorded topology, so a caller-supplied
648        // pool that is itself clustered is treated as the cluster it is.
649        let clustered = config.server.is_clustered();
650        let pool = if let Topology::Preconnected(pool) = &self.topology {
651            pool.clone()
652        } else {
653            let pool = Pool::new(config.clone(), None, None, None, self.pool_size)
654                .map_err(|err| RedisError::Connect(Box::new(err)))?;
655            pool.init()
656                .await
657                .map_err(|err| RedisError::Connect(Box::new(err)))?;
658            pool
659        };
660        Ok(ConnectedRedisBroker {
661            core: Arc::new(RedisCore {
662                connection: Connection::new(pool),
663                closing: Arc::default(),
664                runtime: Handle::current(),
665                config,
666                default_group: self.default_group,
667                clustered,
668                routes: Routes::default(),
669                rounds: Arc::default(),
670                loopback: Loopback::default(),
671                #[cfg(feature = "testing")]
672                opened: Mutex::default(),
673            }),
674        })
675    }
676}
677
678/// The in-process mode: the production connected broker over an in-process Redis, the transport
679/// a test runs the service's own app against.
680///
681/// Every setting of this broker is read the way [`Broker::connect`] reads it: the topology's
682/// address is parsed (a URL `connect` refuses is refused here), the pool keeps its size, the
683/// default group and the cluster flag carry over, and a cluster topology refuses what spans hash
684/// slots. What the model answers is described in the crate's testing overview.
685#[cfg(feature = "testing")]
686impl InProcess for RedisBroker {
687    async fn connect_in_process(self) -> Result<Self::Connected, Self::Error> {
688        let config = self.build_config()?;
689        let clustered = config.server.is_clustered();
690        let pool_size = match &self.topology {
691            Topology::Preconnected(pool) => pool.size(),
692            _ => self.pool_size,
693        };
694        let (pool, config, server) = in_process::connect(&config, pool_size, clustered).await?;
695        Ok(ConnectedRedisBroker {
696            core: Arc::new(RedisCore {
697                connection: Connection::new(pool),
698                closing: Arc::default(),
699                runtime: Handle::current(),
700                config,
701                default_group: self.default_group,
702                clustered,
703                routes: Routes::default(),
704                rounds: Arc::default(),
705                loopback: Loopback::to(server),
706                opened: Mutex::default(),
707            }),
708        })
709    }
710}
711
712#[cfg(feature = "testing")]
713ruststream::register_testable_broker!(RedisBroker);
714
715/// `DescribeServer` reports the host and port a client dials (the first seed for cluster and
716/// sentinel). Nothing else a configured address may carry reaches the description: a broker built
717/// from an already-connected pool describes no host at all.
718impl DescribeServer for RedisBroker {
719    fn describe_server(&self) -> ServerSpec {
720        let host = match &self.topology {
721            Topology::Standalone(url) => describe_address(url, 6379),
722            Topology::Cluster(nodes) => nodes
723                .first()
724                .map_or_else(String::new, |node| describe_address(node, 6379)),
725            Topology::Sentinel { hosts, .. } => hosts
726                .first()
727                .map_or_else(String::new, |host| describe_address(host, 26379)),
728            // A caller-supplied pool carries its address inside `fred`'s own config, which this
729            // description does not reach into.
730            Topology::Preconnected(_) => String::new(),
731        };
732        let mut spec = ServerSpec::new(host, "redis");
733        // An address this broker cannot name is left out of the document rather than published as
734        // an empty string, which a reader takes for a coordinate and tries to dial.
735        if spec.host.as_deref() == Some("") {
736            spec.host = None;
737        }
738        spec
739    }
740}
741
742/// The `host:port` a client dials, with everything else an address may carry stripped: the scheme,
743/// any `user:password@`, the database path and the query.
744///
745/// [`ServerSpec::host_from_url`] does the stripping, so the rule that keeps a credential out of a
746/// published `AsyncAPI` document lives in one place for every broker. What is added here is the
747/// Redis default port, which the core cannot know.
748fn describe_address(addr: &str, default_port: u16) -> String {
749    let host = ServerSpec::host_from_url(addr.trim());
750    if host.is_empty() {
751        return String::new();
752    }
753    if has_port(&host) {
754        host
755    } else {
756        format!("{host}:{default_port}")
757    }
758}
759
760/// Whether an address already names a port, allowing for a bracketed IPv6 literal (`[::1]:6379`).
761fn has_port(host: &str) -> bool {
762    host.rsplit_once(']').map_or_else(
763        || {
764            host.rsplit_once(':')
765                .is_some_and(|(_, port)| !port.is_empty())
766        },
767        |(_, after_bracket)| after_bracket.starts_with(':'),
768    )
769}
770
771/// The live connection shared by the connected broker and every handle derived from it.
772pub(crate) struct RedisCore {
773    /// The pool, and whether [`ConnectedBroker::shutdown`] closed it. The ladder makes owner-side
774    /// misuse a compile error, but publishers and deliveries handed out before the shutdown alias
775    /// the connection and outlive it, so they ask it before issuing a command.
776    connection: Arc<Connection>,
777    /// The closes of the subscriptions' read connections still running, which the shutdown
778    /// waits for before it closes the pool.
779    closing: Arc<Closing>,
780    /// The runtime `connect` ran on. Every task the broker starts for itself is spawned here, so
781    /// a call from a dedicated thread's runtime (a handler under `threads(n)`) leaves no task
782    /// behind that dies when that runtime stops.
783    runtime: Handle,
784    /// The config the pool was built from; Pub/Sub subscriptions dial their dedicated client
785    /// from it.
786    config: Config,
787    default_group: Option<String>,
788    /// Whether the connection is to a cluster, which is where a command spanning two keys needs
789    /// them on one hash slot: a `MULTI` block cannot span slots at all, and a reliable list's
790    /// claim is a `BLMOVE` between its queue and its processing list.
791    clustered: bool,
792    /// How each name this connection's subscriptions read or dead-letter to is written, for the
793    /// default publisher.
794    routes: Routes,
795    /// The rounds of the deliveries in flight on this connection's pipelined subscriptions, which
796    /// a reply or a bound slot publish joins.
797    rounds: Arc<Rounds>,
798    /// The in-process server, on a connection the test harness made in process.
799    loopback: Loopback,
800    /// The subscriptions opened on this connection, which the test harness asks where a publish
801    /// is delivered.
802    #[cfg(feature = "testing")]
803    opened: Mutex<Vec<Opened>>,
804}
805
806/// What one subscription reads, for the harness's routing question.
807#[cfg(feature = "testing")]
808#[derive(Debug, Clone)]
809enum Opened {
810    /// A consumer of `group` on the stream `key`.
811    Stream { key: String, group: String },
812    /// A consumer of the list `key`.
813    List { key: String },
814    /// A subscription to one channel.
815    Channel { name: String, sharded: bool },
816    /// A subscription to every channel a glob matches.
817    Pattern { glob: String },
818}
819
820impl RedisCore {
821    /// The rounds a publish of this connection may join.
822    pub(crate) fn rounds(&self) -> &Arc<Rounds> {
823        &self.rounds
824    }
825
826    /// The route table the default publisher reads.
827    pub(crate) const fn routes(&self) -> &Routes {
828        &self.routes
829    }
830
831    /// Records a subscription's own name and its dead-letter destination with `route`.
832    ///
833    /// # Errors
834    ///
835    /// Returns [`RedisError::InvalidOptions`] when either name is already written another way.
836    pub(crate) fn record_routes(
837        &self,
838        name: &str,
839        dead_letter: Option<&str>,
840        route: &Route,
841    ) -> Result<Recorded<'_>, RedisError> {
842        self.routes.record_subscription(name, dead_letter, route)
843    }
844
845    /// The live pool, or [`RedisError::ShutDown`] once the connection was torn down.
846    pub(crate) fn pool(&self) -> Result<Pool, RedisError> {
847        self.connection.live().cloned()
848    }
849
850    /// The shared connection a subscription hands its deliveries, or [`RedisError::ShutDown`]
851    /// once it was torn down.
852    pub(crate) fn connection(&self) -> Result<Arc<Connection>, RedisError> {
853        self.connection.live()?;
854        Ok(Arc::clone(&self.connection))
855    }
856
857    /// Cluster cannot offer multi-key transactions, because buffered keys may hash to different
858    /// nodes, so its publishers reject `begin_transaction`.
859    pub(crate) const fn transactions_supported(&self) -> bool {
860        !self.clustered
861    }
862
863    pub(crate) const fn clustered(&self) -> bool {
864        self.clustered
865    }
866
867    /// The in-process server, when the connection is one.
868    pub(crate) const fn loopback(&self) -> &Loopback {
869        &self.loopback
870    }
871
872    #[cfg(feature = "testing")]
873    fn opened(&self, subscription: Opened) {
874        self.opened
875            .lock()
876            .expect("redis subscription record poisoned")
877            .push(subscription);
878    }
879
880    /// The version the server reported, or the in-process server's own.
881    #[cfg_attr(
882        not(feature = "testing"),
883        allow(
884            clippy::unused_self,
885            reason = "the connection is read under `testing` only"
886        )
887    )]
888    fn server_version(&self, pool: &Pool) -> Option<Version> {
889        #[cfg(feature = "testing")]
890        if self.loopback.server().is_some() {
891            return Some(in_process::VERSION);
892        }
893        pool.server_version()
894    }
895}
896
897impl std::fmt::Debug for RedisCore {
898    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
899        f.debug_struct("RedisCore")
900            .field("pool", self.connection.pool())
901            .field("closed", &self.connection.is_closed())
902            .finish_non_exhaustive()
903    }
904}
905
906/// The typed witness that [`Broker::connect`] succeeded: it owns the live `fred` pool.
907///
908/// Every subscription and every publisher is reached from here, so "not connected" is not
909/// representable. [`ConnectedBroker::shutdown`] consumes it, which makes a publish or subscribe
910/// after shutdown a compile error for the owner of the handle.
911#[derive(Debug)]
912pub struct ConnectedRedisBroker {
913    core: Arc<RedisCore>,
914}
915
916impl ConnectedRedisBroker {
917    /// Opens a stream subscription described by `def`.
918    ///
919    /// Ensures the consumer group exists (`XGROUP CREATE ... MKSTREAM`, ignoring an
920    /// already-existing group) before returning the subscriber.
921    ///
922    /// # Errors
923    ///
924    /// Returns [`RedisError::ShutDown`] when the connection was already torn down,
925    /// [`RedisError::InvalidOptions`] when `def` names no consumer group, or
926    /// [`RedisError::Subscribe`] when the group cannot be created.
927    pub async fn subscribe(&self, def: RedisStream) -> Result<RedisSubscriber, RedisError> {
928        let connection = self.core.connection()?;
929        let pool = connection.pool();
930        let group = def.group_or_err()?.to_owned();
931        let consumer = def.consumer_or_auto();
932        if let ReadMode::Claiming { .. } = def.mode() {
933            require_claim_support(self.core.server_version(pool).as_ref(), def.key())?;
934        }
935        let recorded = self
936            .core
937            .record_routes(def.key(), def.dead_letter(), &Route::Stream)?;
938        ensure_group(pool, def.key(), &group, def.start().as_id()).await?;
939        let reader = self.read_connection().await?;
940        let delay = def.delay_config().map(|cfg| cfg.on(self.core.loopback()));
941        let tap = self.stream_tap(&def, &group, &consumer, delay.as_ref());
942        recorded.keep();
943        Ok(RedisSubscriber::new(
944            connection,
945            reader,
946            def.key().to_owned(),
947            group,
948            consumer,
949            def.block_or_default(),
950            def.mode(),
951            delay,
952            tap,
953        ))
954    }
955
956    /// Registers a stream subscription with the in-process server, and records it for the
957    /// harness's routing question.
958    #[cfg_attr(
959        not(feature = "testing"),
960        allow(
961            clippy::unused_self,
962            reason = "the connection is read under `testing` only"
963        )
964    )]
965    fn stream_tap(
966        &self,
967        def: &RedisStream,
968        group: &str,
969        consumer: &str,
970        delay: Option<&DelayConfig>,
971    ) -> Tap {
972        #[cfg(feature = "testing")]
973        {
974            self.core.opened(Opened::Stream {
975                key: def.key().to_owned(),
976                group: group.to_owned(),
977            });
978            if let Some(server) = self.core.loopback().server() {
979                let (claim, fresh) = match def.mode() {
980                    ReadMode::Fresh => (None, true),
981                    ReadMode::Reclaim { min_idle } => (Some(min_idle), false),
982                    ReadMode::Claiming { min_idle } => (Some(min_idle), true),
983                };
984                let reader = server.attach_stream(
985                    def.key(),
986                    group,
987                    consumer,
988                    claim,
989                    fresh,
990                    delay.map(DelayConfig::zset_key),
991                );
992                return Tap::registered(Arc::clone(server), reader);
993            }
994            Tap::default()
995        }
996        #[cfg(not(feature = "testing"))]
997        {
998            let _ = (def, group, consumer, delay);
999            Tap::default()
1000        }
1001    }
1002
1003    /// Opens a Pub/Sub subscription on one channel, described by `def`, on a dedicated client.
1004    ///
1005    /// # Errors
1006    ///
1007    /// Returns [`RedisError::ShutDown`] when the connection was already torn down,
1008    /// [`RedisError::Connect`] when the dedicated client cannot connect, or
1009    /// [`RedisError::Subscribe`] when the subscribe command fails.
1010    pub async fn subscribe_pubsub(
1011        &self,
1012        def: RedisPubSub,
1013    ) -> Result<RedisPubSubSubscriber, RedisError> {
1014        Ok(RedisPubSubSubscriber::new(self.open_pubsub(def).await?))
1015    }
1016
1017    /// Subscribes a dedicated client to the channel `def` names and hands back its wire.
1018    pub(crate) async fn open_pubsub(&self, def: RedisPubSub) -> Result<PubSubWire, RedisError> {
1019        let codec = def.codec_handle();
1020        let recorded = self
1021            .core
1022            .record_routes(def.channel(), def.dead_letter(), &def.route())?;
1023        let client = self.new_client(def.buffer_size()).await?;
1024        // Opened before the subscribe, because the messages arrive over a broadcast channel whose
1025        // receiver sees only what is sent after it exists. The connection is dedicated to this one
1026        // subscription, so opening the stream early can pick up nothing else.
1027        let rx = client.message_rx();
1028        let channel = def.channel().to_owned();
1029        let sharded = def.delivery_mode() == PubSubMode::Sharded;
1030        match def.delivery_mode() {
1031            PubSubMode::Classic => {
1032                client
1033                    .subscribe(channel)
1034                    .await
1035                    .map_err(RedisError::subscribe)?;
1036                confirm_subscribed(&client).await?;
1037            }
1038            // `SSUBSCRIBE` is answered by the server, so `fred` waits for it and there is nothing
1039            // left to confirm here.
1040            PubSubMode::Sharded => {
1041                client
1042                    .ssubscribe(channel)
1043                    .await
1044                    .map_err(RedisError::subscribe)?;
1045            }
1046        }
1047        let (rx, tap) = self.pubsub_tap(
1048            rx,
1049            PubSubTarget::Channel {
1050                name: def.channel(),
1051                sharded,
1052            },
1053            def.buffer_size(),
1054        );
1055        let pool = self.core.pool()?;
1056        recorded.keep();
1057        Ok(PubSubWire::new(
1058            def.channel().to_owned(),
1059            client,
1060            rx,
1061            codec,
1062            pool,
1063            tap,
1064            self.runtime().clone(),
1065        ))
1066    }
1067
1068    /// Registers a Pub/Sub subscription with the in-process server, whose messages then arrive
1069    /// on a channel of its own instead of the client's, and records it for the harness's routing
1070    /// question.
1071    #[cfg_attr(
1072        not(feature = "testing"),
1073        allow(
1074            clippy::unused_self,
1075            reason = "the connection is read under `testing` only"
1076        )
1077    )]
1078    fn pubsub_tap(
1079        &self,
1080        rx: Receiver<Message>,
1081        target: PubSubTarget<'_>,
1082        buffer: Option<NonZeroUsize>,
1083    ) -> (Receiver<Message>, Tap) {
1084        #[cfg(feature = "testing")]
1085        {
1086            let (opened, subscription) = match target {
1087                PubSubTarget::Channel { name, sharded } => {
1088                    let bytes = bytes::Bytes::copy_from_slice(name.as_bytes());
1089                    (
1090                        Opened::Channel {
1091                            name: name.to_owned(),
1092                            sharded,
1093                        },
1094                        if sharded {
1095                            Subscription::Sharded(bytes)
1096                        } else {
1097                            Subscription::Channel(bytes)
1098                        },
1099                    )
1100                }
1101                PubSubTarget::Pattern(glob) => (
1102                    Opened::Pattern {
1103                        glob: glob.to_owned(),
1104                    },
1105                    Subscription::Pattern(bytes::Bytes::copy_from_slice(glob.as_bytes())),
1106                ),
1107            };
1108            self.core.opened(opened);
1109            if let Some(server) = self.core.loopback().server() {
1110                let (rx, reader) = server.attach_pubsub(subscription, buffer);
1111                return (rx, Tap::registered(Arc::clone(server), reader));
1112            }
1113            (rx, Tap::default())
1114        }
1115        #[cfg(not(feature = "testing"))]
1116        {
1117            let _ = (target, buffer);
1118            (rx, Tap::default())
1119        }
1120    }
1121
1122    /// Opens a Pub/Sub subscription on a glob (`PSUBSCRIBE`), described by `def`, on a dedicated
1123    /// client.
1124    ///
1125    /// # Errors
1126    ///
1127    /// Returns [`RedisError::ShutDown`] when the connection was already torn down,
1128    /// [`RedisError::Connect`] when the dedicated client cannot connect, or
1129    /// [`RedisError::Subscribe`] when the subscribe command fails.
1130    pub async fn subscribe_pubsub_pattern(
1131        &self,
1132        def: RedisPubSubPattern,
1133    ) -> Result<RedisPubSubSubscriber, RedisError> {
1134        Ok(RedisPubSubSubscriber::new(
1135            self.open_pubsub_pattern(def).await?,
1136        ))
1137    }
1138
1139    /// Subscribes a dedicated client to the glob `def` names and hands back its wire.
1140    pub(crate) async fn open_pubsub_pattern(
1141        &self,
1142        def: RedisPubSubPattern,
1143    ) -> Result<PubSubWire, RedisError> {
1144        let codec = def.codec_handle();
1145        let client = self.new_client(def.buffer_size()).await?;
1146        // Opened before the subscribe, for the reason `subscribe_pubsub` gives.
1147        let rx = client.message_rx();
1148        client
1149            .psubscribe(def.pattern().to_owned())
1150            .await
1151            .map_err(RedisError::subscribe)?;
1152        confirm_subscribed(&client).await?;
1153        let (rx, tap) =
1154            self.pubsub_tap(rx, PubSubTarget::Pattern(def.pattern()), def.buffer_size());
1155        Ok(PubSubWire::new(
1156            def.pattern().to_owned(),
1157            client,
1158            rx,
1159            codec,
1160            self.core.pool()?,
1161            tap,
1162            self.runtime().clone(),
1163        ))
1164    }
1165
1166    /// Opens a list (work-queue) subscription described by `def`.
1167    ///
1168    /// # Errors
1169    ///
1170    /// Returns [`RedisError::ShutDown`] when the connection was already torn down,
1171    /// [`RedisError::InvalidOptions`] when `def` names a recovery ZSET without a `min_idle`, or
1172    /// [`RedisError::Connect`] when the connection the subscription reads on cannot connect.
1173    pub async fn subscribe_list(&self, def: RedisList) -> Result<RedisListSubscriber, RedisError> {
1174        self.open_list(def).await.map(RedisListSubscriber::new)
1175    }
1176
1177    /// Validates `def` against this connection and hands back its wire.
1178    pub(crate) async fn open_list(&self, def: RedisList) -> Result<ListWire, RedisError> {
1179        let connection = self.core.connection()?;
1180        let recovery = def
1181            .recovery_config()?
1182            .map(|cfg| cfg.on(self.core.loopback()));
1183        let reliable = def.is_reliable();
1184        let processing = def.processing_or_default();
1185        if reliable && self.core.clustered() {
1186            require_one_slot(def.key(), &processing)?;
1187        }
1188        self.core
1189            .record_routes(def.key(), def.dead_letter(), &def.route())?
1190            .keep();
1191        let block = def.block_or_default();
1192        let codec = def.codec_handle();
1193        let reader = self.read_connection().await?;
1194        let tap = self.list_tap(def.key(), recovery.as_ref());
1195        Ok(ListWire::new(
1196            connection,
1197            reader,
1198            def.into_key(),
1199            reliable,
1200            processing,
1201            block,
1202            codec,
1203            recovery,
1204            tap,
1205        ))
1206    }
1207
1208    /// Registers a list subscription with the in-process server, and records it for the
1209    /// harness's routing question.
1210    #[cfg_attr(
1211        not(feature = "testing"),
1212        allow(
1213            clippy::unused_self,
1214            reason = "the connection is read under `testing` only"
1215        )
1216    )]
1217    fn list_tap(&self, key: &str, recovery: Option<&RecoveryConfig>) -> Tap {
1218        #[cfg(feature = "testing")]
1219        {
1220            self.core.opened(Opened::List {
1221                key: key.to_owned(),
1222            });
1223            if let Some(server) = self.core.loopback().server() {
1224                let reader = server.attach_list(
1225                    key,
1226                    recovery.map(|cfg| (cfg.zset_key.as_str(), cfg.min_idle)),
1227                );
1228                return Tap::registered(Arc::clone(server), reader);
1229            }
1230            Tap::default()
1231        }
1232        #[cfg(not(feature = "testing"))]
1233        {
1234            let _ = (key, recovery);
1235            Tap::default()
1236        }
1237    }
1238
1239    /// Returns a stream publisher (`XADD`) bound to this connection.
1240    #[must_use]
1241    pub fn publisher(&self) -> RedisPublisher {
1242        RedisPublisher::new(Arc::clone(&self.core))
1243    }
1244
1245    /// Returns the publisher of the broker's default policy, [`RedisDefaultPublish`], which
1246    /// writes each name the way this connection's subscriptions read it.
1247    ///
1248    /// [`RedisDefaultPublish`]: crate::RedisDefaultPublish
1249    #[must_use]
1250    pub fn default_publisher(&self) -> RedisDefaultPublisher {
1251        RedisDefaultPublisher::new(Arc::clone(&self.core))
1252    }
1253
1254    /// Returns a Pub/Sub publisher configured by `publish` (mode and envelope codec).
1255    #[must_use]
1256    pub fn pubsub_publisher(&self, publish: RedisPubSubPublish) -> RedisPubSubPublisher {
1257        RedisPubSubPublisher::new(Arc::clone(&self.core), publish)
1258    }
1259
1260    /// Returns a list publisher (`LPUSH`) configured by `publish` (envelope codec and key TTL).
1261    #[must_use]
1262    pub fn list_publisher(&self, publish: RedisListPublish) -> RedisListPublisher {
1263        RedisListPublisher::new(Arc::clone(&self.core), publish)
1264    }
1265
1266    /// The rounds a publish of this connection may join.
1267    pub(crate) fn rounds(&self) -> &Arc<Rounds> {
1268        self.core.rounds()
1269    }
1270
1271    /// The runtime this connection was opened on, where the broker spawns its own tasks.
1272    pub(crate) fn runtime(&self) -> &Handle {
1273        &self.core.runtime
1274    }
1275
1276    /// What a pipelined subscription's window takes from this connection, or
1277    /// [`RedisError::ShutDown`] once it was torn down.
1278    pub(crate) fn window_owner(&self) -> Result<Owner, RedisError> {
1279        Ok(Owner {
1280            rounds: Arc::clone(self.rounds()),
1281            runtime: self.runtime().clone(),
1282            connection: self.core.connection()?,
1283        })
1284    }
1285
1286    /// Returns a clone of the underlying pool, for advanced operations not covered by the
1287    /// wrapper.
1288    ///
1289    /// # Errors
1290    ///
1291    /// Returns [`RedisError::ShutDown`] once the connection was torn down.
1292    pub fn pool_handle(&self) -> Result<Pool, RedisError> {
1293        self.core.pool()
1294    }
1295
1296    /// The connection a stream or list subscription reads on, apart from the pool its publishes
1297    /// and settlements go out on.
1298    async fn read_connection(&self) -> Result<ReadConnection, RedisError> {
1299        #[cfg(feature = "testing")]
1300        let blocks = self.core.loopback().server().is_none();
1301        #[cfg(not(feature = "testing"))]
1302        let blocks = true;
1303        Ok(ReadConnection::new(
1304            self.new_client(None).await?,
1305            self.runtime().clone(),
1306            Arc::clone(&self.core.closing),
1307            blocks,
1308        ))
1309    }
1310
1311    /// Builds and connects a dedicated `fred` client (used for Pub/Sub, which needs an isolated
1312    /// message stream and channel state per subscriber, and for the reads of a stream or list
1313    /// subscription).
1314    ///
1315    /// `buffer` is how many messages its Pub/Sub channel holds for a reader that has not taken them
1316    /// yet; `None` leaves `fred`'s own setting.
1317    async fn new_client(&self, buffer: Option<NonZeroUsize>) -> Result<Client, RedisError> {
1318        // The dedicated client is a second connection to the same server: refuse to dial one for
1319        // a connection whose owner already shut down.
1320        let _ = self.core.pool()?;
1321        let performance = buffer.map(|buffer| PerformanceConfig {
1322            broadcast_channel_capacity: buffer.get(),
1323            ..PerformanceConfig::default()
1324        });
1325        let client = Client::new(self.core.config.clone(), performance, None, None);
1326        // `init` spawns the task that drives the connection on the runtime it is polled on; it is
1327        // polled on the broker's own runtime, so the connection outlives a caller's runtime. The
1328        // price is one task spawned per subscription opened.
1329        let connecting = client.clone();
1330        self.core
1331            .runtime
1332            .spawn(async move { connecting.init().await })
1333            .await
1334            .map_err(|err| RedisError::Connect(Box::new(err)))?
1335            .map_err(|err| RedisError::Connect(Box::new(err)))?;
1336        Ok(client)
1337    }
1338}
1339
1340impl ConnectedBroker for ConnectedRedisBroker {
1341    type Error = RedisError;
1342    type Closed = ClosedRedisBroker;
1343
1344    /// Closes every pooled connection and marks the shared connection dead, so publishers handed
1345    /// out earlier report [`RedisError::ShutDown`] instead of running against a closed pool.
1346    ///
1347    /// # Errors
1348    ///
1349    /// Returns [`RedisError::Connect`] when the `QUIT` roundtrip fails.
1350    async fn shutdown(self) -> Result<Self::Closed, Self::Error> {
1351        self.core.connection.close();
1352        self.core.closing.finish().await;
1353        let connections_closed = self.core.connection.pool().clients().len();
1354        self.core
1355            .connection
1356            .pool()
1357            .quit()
1358            .await
1359            .map_err(|err| RedisError::Connect(Box::new(err)))?;
1360        // The in-process server goes down with the connection, after the `QUIT` it answers.
1361        #[cfg(feature = "testing")]
1362        if let Some(server) = self.core.loopback().server() {
1363            server.close();
1364        }
1365        Ok(ClosedRedisBroker { connections_closed })
1366    }
1367}
1368
1369/// What a Pub/Sub subscription listens to, as its registration names it.
1370#[cfg_attr(
1371    not(feature = "testing"),
1372    allow(dead_code, reason = "read by the in-process registration only")
1373)]
1374#[derive(Clone, Copy)]
1375enum PubSubTarget<'a> {
1376    Channel { name: &'a str, sharded: bool },
1377    Pattern(&'a str),
1378}
1379
1380/// The harness's view of the connection: the in-process server it injects into and reads the
1381/// writes of, and, in both modes, where a publish is delivered.
1382///
1383/// # Panics
1384///
1385/// `inject` and `published` panic on a broker connected with `connect`: the harness drives only
1386/// the connection `connect_in_process` produced, and a live connection has no log to read and no
1387/// synchronous way to take a message.
1388#[cfg(feature = "testing")]
1389impl TestableBroker for ConnectedRedisBroker {
1390    fn install_coordinator(&self, coordinator: Coordinator) {
1391        if let Some(server) = self.core.loopback().server() {
1392            server.install(coordinator);
1393        }
1394    }
1395
1396    /// Writes the message the way the default publisher writes its name: an `XADD`, or the
1397    /// `LPUSH` or `PUBLISH` of the subscription that reads it, in that subscription's framing. A
1398    /// name only pattern subscriptions read goes out with a raw `PUBLISH`, as the producers they
1399    /// listen to publish it: an `XADD` would reach none of them.
1400    fn inject(&self, message: OutgoingMessage<'_>) {
1401        let server = self.in_process("inject");
1402        let name = message.name();
1403        let (payload, headers) = (message.payload(), message.headers());
1404        let written = match self.core.routes().route(name) {
1405            Route::Stream if self.read_by_patterns_alone(name) => {
1406                server.inject_publish(name, frame(None, payload, headers), false)
1407            }
1408            Route::Stream => {
1409                server.inject_stream(name, fields_for_publish(payload.to_vec(), headers))
1410            }
1411            Route::List { envelope } => {
1412                server.inject_list(name, frame(envelope.as_ref(), payload, headers))
1413            }
1414            Route::Channel { mode, envelope } => server.inject_publish(
1415                name,
1416                frame(envelope.as_ref(), payload, headers),
1417                mode == PubSubMode::Sharded,
1418            ),
1419        };
1420        if let Err(err) = written {
1421            panic!("the injected message to {name:?} is not one Redis takes: {err}");
1422        }
1423    }
1424
1425    /// Every write to `name`, decoded the way the subscription reading it decodes: an entry's
1426    /// fields, or a list element or a channel message in its subscription's framing. A requeue's
1427    /// copy and a delayed entry the sweep adds back are writes too.
1428    fn published(&self, name: &str) -> Vec<RawMessage> {
1429        let server = self.in_process("published");
1430        let envelope = match self.core.routes().route(name) {
1431            Route::Stream => None,
1432            Route::List { envelope } | Route::Channel { envelope, .. } => envelope,
1433        };
1434        server
1435            .published(name)
1436            .into_iter()
1437            .map(|write| {
1438                let (payload, headers) = match write {
1439                    Write::Fields(fields) => parts_from_fields(
1440                        fields
1441                            .into_iter()
1442                            .map(|(field, value)| {
1443                                (String::from_utf8_lossy(&field).into_owned(), value.to_vec())
1444                            })
1445                            .collect(),
1446                    ),
1447                    Write::Body(body) => unframe(envelope.as_ref(), &body),
1448                };
1449                RawMessage::new(name.to_owned(), payload).with_headers(headers)
1450            })
1451            .collect()
1452    }
1453
1454    /// Redis's routing: a stream entry is delivered once to every consumer group reading the
1455    /// stream (to one consumer of each), a list element to one consumer, and a channel message to
1456    /// every subscription of the channel and every pattern subscription whose glob matches it. A
1457    /// name no subscription reads exactly is reached by the pattern subscriptions alone.
1458    ///
1459    /// Subscriptions sharing a name are told apart by nothing but the name, so a publish is owed
1460    /// by the first position of each name as many times as that name receives it. In process
1461    /// that is where it goes: a group's new entries go to its earliest open subscription.
1462    fn routes(&self, destination: &str, subscriptions: &[&str]) -> Vec<usize> {
1463        let opened = self
1464            .core
1465            .opened
1466            .lock()
1467            .expect("redis subscription record poisoned")
1468            .clone();
1469        let mut owed = Vec::new();
1470        let mut owe = |name: &str, times: usize| {
1471            if let Some(at) = subscriptions.iter().position(|held| *held == name) {
1472                owed.extend(std::iter::repeat_n(at, times));
1473            }
1474        };
1475        let groups: std::collections::BTreeSet<&str> = opened
1476            .iter()
1477            .filter_map(|sub| match sub {
1478                Opened::Stream { key, group } if key == destination => Some(group.as_str()),
1479                _ => None,
1480            })
1481            .collect();
1482        let patterns = match self.core.routes().route(destination) {
1483            Route::Stream => {
1484                owe(destination, groups.len());
1485                groups.is_empty()
1486            }
1487            Route::List { .. } => {
1488                let read = opened
1489                    .iter()
1490                    .any(|sub| matches!(sub, Opened::List { key } if key == destination));
1491                owe(destination, usize::from(read));
1492                false
1493            }
1494            Route::Channel { mode, .. } => {
1495                let sharded = mode == PubSubMode::Sharded;
1496                let reached = opened
1497                    .iter()
1498                    .filter(|sub| {
1499                        matches!(sub, Opened::Channel { name, sharded: held }
1500                            if name == destination && *held == sharded)
1501                    })
1502                    .count();
1503                owe(destination, reached);
1504                !sharded
1505            }
1506        };
1507        if patterns {
1508            for sub in &opened {
1509                if let Opened::Pattern { glob } = sub
1510                    && in_process::glob_matches(glob.as_bytes(), destination.as_bytes())
1511                {
1512                    owe(glob, 1);
1513                }
1514            }
1515        }
1516        owed
1517    }
1518}
1519
1520#[cfg(feature = "testing")]
1521impl ConnectedRedisBroker {
1522    /// Whether `name` is read by a pattern subscription and by no stream group: what the harness
1523    /// then publishes to the channel instead of adding to a stream.
1524    fn read_by_patterns_alone(&self, name: &str) -> bool {
1525        let opened = self
1526            .core
1527            .opened
1528            .lock()
1529            .expect("redis subscription record poisoned");
1530        let grouped = opened
1531            .iter()
1532            .any(|sub| matches!(sub, Opened::Stream { key, .. } if key == name));
1533        !grouped
1534            && opened.iter().any(|sub| {
1535                matches!(sub, Opened::Pattern { glob }
1536                    if in_process::glob_matches(glob.as_bytes(), name.as_bytes()))
1537            })
1538    }
1539
1540    /// The in-process server, which is all the harness injects into and reads.
1541    fn in_process(&self, what: &str) -> &Arc<in_process::Server> {
1542        self.core.loopback().server().unwrap_or_else(|| {
1543            panic!(
1544                "TestableBroker::{what} reached a broker connected with `connect`; the harness \
1545                 drives the connection `connect_in_process` produces"
1546            )
1547        })
1548    }
1549}
1550
1551/// The terminal witness returned by shutting down a [`ConnectedRedisBroker`].
1552///
1553/// It has no publish or subscribe surface; it carries the teardown diagnostics as plain data.
1554#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1555pub struct ClosedRedisBroker {
1556    connections_closed: usize,
1557}
1558
1559impl ClosedRedisBroker {
1560    /// How many pooled connections the teardown closed.
1561    #[must_use]
1562    pub const fn connections_closed(&self) -> usize {
1563        self.connections_closed
1564    }
1565}
1566
1567// By-name subscription capability for the bare string `#[subscriber("key")]` form. Redis Streams
1568// always read through a consumer group, so this requires a broker-wide default group.
1569#[allow(
1570    clippy::use_self,
1571    reason = "the type name disambiguates the inherent subscribe from this trait method"
1572)]
1573impl Subscribe for ConnectedRedisBroker {
1574    type Subscriber = RedisSubscriber;
1575    /// A bare name opens a consumer group over that stream key, and an `XADD` there is read by the
1576    /// group again, so the name is both ends and a retry copy needs nothing from the mount site.
1577    type Copies = AddressedCopies;
1578
1579    async fn subscribe(&self, name: &str) -> Result<Self::Subscriber, Self::Error> {
1580        let group = self
1581            .core
1582            .default_group
1583            .clone()
1584            .ok_or_else(|| missing_default_group(name, "RedisBroker"))?;
1585        ConnectedRedisBroker::subscribe(self, RedisStream::new(name).group(group)).await
1586    }
1587}
1588
1589/// The refusal of a bare-string subscription on a broker with no default group: Redis Streams read
1590/// through a consumer group, and a bare name names none. `broker` is the type whose
1591/// `default_group` setting supplies one.
1592pub(crate) fn missing_default_group(name: &str, broker: &str) -> RedisError {
1593    RedisError::InvalidOptions(format!(
1594        "bare-string subscription on `{name}` needs a broker-wide default group: call \
1595         {broker}::default_group(name), or subscribe with RedisStream::new(name).group(group)"
1596    ))
1597}
1598
1599/// Refuses a [`RedisStream::claiming`] subscription the connected server cannot serve.
1600///
1601/// The version is the one the `fred` client read from `INFO server` during its handshake, so the
1602/// check costs no round trip and runs once, where the subscription resolves against the connected
1603/// broker. Without it the mistake surfaces on the first read, as a bare syntax error from a server
1604/// that does not know the option.
1605///
1606/// A server that reports no version is refused the same way: the option either exists or the
1607/// service does not start, and a guess is what the check is here to avoid.
1608fn require_claim_support(version: Option<&Version>, key: &str) -> Result<(), RedisError> {
1609    match version {
1610        Some(version) if *version >= CLAIM_MIN_VERSION => Ok(()),
1611        Some(version) => Err(RedisError::ServerTooOld(format!(
1612            "RedisStream::claiming on {key:?} needs Redis {CLAIM_MIN_VERSION} or later \
1613             (XREADGROUP CLAIM); the connected server is {version}"
1614        ))),
1615        None => Err(RedisError::ServerTooOld(format!(
1616            "RedisStream::claiming on {key:?} needs Redis {CLAIM_MIN_VERSION} or later \
1617             (XREADGROUP CLAIM); the connected server reports no version"
1618        ))),
1619    }
1620}
1621
1622/// Waits for the server to apply the `SUBSCRIBE` or `PSUBSCRIBE` the client just issued.
1623///
1624/// `fred` answers both the moment the frame is queued: Redis confirms them out of band, on the
1625/// message stream, and the client drops that confirmation without surfacing it. A publish issued
1626/// the instant `subscribe(..)` returned therefore reaches a server that has not routed the channel
1627/// here yet, and Pub/Sub keeps nothing for a subscriber who is not there: the message is gone,
1628/// with no error on either side. That is a lost delivery on a subscription the caller was told was
1629/// open, so it is worth the round trip this costs once per subscription.
1630///
1631/// Redis serves one connection's commands in order, so a reply to a command sent after the
1632/// subscribe cannot come back before the subscribe was applied. The `PING` carries the hash `fred`
1633/// pins the legacy Pub/Sub commands to, so on a cluster it is answered by the node the subscribe
1634/// reached rather than by another one.
1635async fn confirm_subscribed(client: &Client) -> Result<(), RedisError> {
1636    let node = ClusterHash::Custom(redis_keyslot(client.id().as_bytes()));
1637    let _: Value = client
1638        .custom(CustomCommand::new("PING", node, false), Vec::<Value>::new())
1639        .await
1640        .map_err(RedisError::subscribe)?;
1641    Ok(())
1642}
1643
1644/// Refuses a reliable list on a cluster whose two keys live on different nodes.
1645///
1646/// A claim is a `BLMOVE` from the queue to the processing list, and Redis refuses a command that
1647/// spans hash slots. Nothing says so until the first delivery, so the subscription is refused
1648/// where it is opened and the message names the hash tag that puts both keys on one node.
1649fn require_one_slot(key: &str, processing: &str) -> Result<(), RedisError> {
1650    if redis_keyslot(key.as_bytes()) == redis_keyslot(processing.as_bytes()) {
1651        return Ok(());
1652    }
1653    Err(RedisError::InvalidOptions(format!(
1654        "a reliable list on `{key}` cannot run on a cluster while its processing list \
1655         `{processing}` hashes to another slot: a claim moves the entry between the two in one \
1656         command. Put both on one slot with a hash tag, as in RedisList::new(\"{{{key}}}\"), which \
1657         the default processing key follows"
1658    )))
1659}
1660
1661/// Creates the consumer group, treating an already-existing group as success.
1662async fn ensure_group(
1663    pool: &Pool,
1664    key: &str,
1665    group: &str,
1666    start_id: &str,
1667) -> Result<(), RedisError> {
1668    let result: Result<String, fred::error::Error> =
1669        pool.xgroup_create(key, group, start_id, true).await;
1670    match result {
1671        Ok(_) => Ok(()),
1672        // BUSYGROUP: the group already exists, which is the steady-state case.
1673        Err(err) if err.details().contains("BUSYGROUP") => Ok(()),
1674        Err(err) => Err(RedisError::subscribe(err)),
1675    }
1676}
1677
1678#[cfg(test)]
1679mod tests {
1680    use super::*;
1681
1682    /// The refusal names the subscription, the mode and both versions, so the operator reads
1683    /// what to change without reaching for the source.
1684    #[test]
1685    fn a_claiming_subscription_names_both_versions_when_it_refuses() {
1686        let err = require_claim_support(Some(&Version::new(7, 4, 2)), "orders")
1687            .expect_err("a server below 8.4.0 must be refused");
1688        assert_eq!(
1689            err.to_string(),
1690            "RedisStream::claiming on \"orders\" needs Redis 8.4.0 or later (XREADGROUP CLAIM); \
1691             the connected server is 7.4.2"
1692        );
1693    }
1694
1695    /// A server that reports no version is refused too: the option either exists or the service
1696    /// does not start.
1697    #[test]
1698    fn a_server_with_no_version_is_refused() {
1699        let err = require_claim_support(None, "orders")
1700            .expect_err("a server reporting no version must be refused");
1701        assert!(
1702            err.to_string().contains("reports no version"),
1703            "the refusal has to say the version is missing: {err}"
1704        );
1705    }
1706
1707    /// 8.4.0 is the floor, not a version to be above, and a later release keeps the option.
1708    #[test]
1709    fn the_floor_release_and_later_are_accepted() {
1710        for version in [
1711            Version::new(8, 4, 0),
1712            Version::new(8, 4, 6),
1713            Version::new(9, 0, 0),
1714        ] {
1715            assert!(
1716                require_claim_support(Some(&version), "orders").is_ok(),
1717                "{version} has XREADGROUP CLAIM"
1718            );
1719        }
1720        for version in [Version::new(8, 3, 9), Version::new(7, 4, 2)] {
1721            assert!(
1722                require_claim_support(Some(&version), "orders").is_err(),
1723                "{version} does not have XREADGROUP CLAIM"
1724            );
1725        }
1726    }
1727
1728    #[test]
1729    fn describe_server_reports_redis() {
1730        let broker = RedisBroker::standalone("redis://localhost:6379");
1731        let spec = broker.describe_server();
1732        assert_eq!(spec.protocol, "redis");
1733        assert_eq!(spec.host.as_deref(), Some("localhost:6379"));
1734    }
1735
1736    /// Every address shape this crate accepts reduces to the host and the port a client dials.
1737    #[test]
1738    fn a_description_is_the_host_and_port_of_every_address_shape() {
1739        let cases = [
1740            ("redis://localhost:6379", "localhost:6379"),
1741            ("rediss://redis.example.com:6380", "redis.example.com:6380"),
1742            ("redis://localhost", "localhost:6379"),
1743            ("redis://localhost:6379/3", "localhost:6379"),
1744            ("redis://localhost:6379/?timeout=5s", "localhost:6379"),
1745            (
1746                "valkeys://valkey.example.com:6380",
1747                "valkey.example.com:6380",
1748            ),
1749            ("10.0.0.1:6379", "10.0.0.1:6379"),
1750            ("  redis://localhost:6379  ", "localhost:6379"),
1751            ("redis://[::1]:6379", "[::1]:6379"),
1752            ("redis://[::1]", "[::1]:6379"),
1753        ];
1754
1755        for (url, expected) in cases {
1756            let spec = RedisBroker::standalone(url).describe_server();
1757            assert_eq!(
1758                spec.host.as_deref(),
1759                Some(expected),
1760                "the description of {url:?} is wrong"
1761            );
1762        }
1763
1764        let cluster = RedisBroker::cluster(["10.0.0.1", "10.0.0.2:7001"]).describe_server();
1765        assert_eq!(cluster.host.as_deref(), Some("10.0.0.1:6379"));
1766
1767        let sentinel = RedisBroker::sentinel("mymaster", ["10.0.0.9"]).describe_server();
1768        assert_eq!(sentinel.host.as_deref(), Some("10.0.0.9:26379"));
1769    }
1770
1771    /// The description ends up in the generated `AsyncAPI` document, which teams publish, so a URL
1772    /// carrying credentials must leave neither the user name nor the password behind. The password
1773    /// here holds an `@` of its own, the case a split on the first separator gets wrong.
1774    #[test]
1775    fn a_description_never_carries_the_credentials_of_a_url() {
1776        let cases = [
1777            (
1778                RedisBroker::standalone("redis://alice:p@ss@redis.example.com:6379/0"),
1779                "redis.example.com:6379",
1780            ),
1781            (
1782                RedisBroker::cluster(["redis://alice:p@ss@10.0.0.1:7000"]),
1783                "10.0.0.1:7000",
1784            ),
1785            (
1786                RedisBroker::sentinel("mymaster", ["redis://alice:p@ss@10.0.0.9:26379"]),
1787                "10.0.0.9:26379",
1788            ),
1789        ];
1790
1791        for (broker, expected) in cases {
1792            let host = broker.describe_server().host.expect("a described host");
1793            assert_eq!(host, expected, "the description kept more than the address");
1794            assert!(
1795                !host.contains('@'),
1796                "{host:?} still carries a userinfo part"
1797            );
1798            assert!(
1799                !host.contains("alice"),
1800                "{host:?} still carries the user name"
1801            );
1802            assert!(
1803                !host.contains("p@ss"),
1804                "{host:?} still carries the password"
1805            );
1806        }
1807    }
1808
1809    // Credentials must reach the fred config on every topology, not just the standalone URL.
1810    #[test]
1811    fn credentials_apply_to_all_topologies() {
1812        let brokers = [
1813            RedisBroker::standalone("redis://localhost:6379").credentials("alice", "s3cr3t"),
1814            RedisBroker::cluster(["127.0.0.1:7000"]).credentials("alice", "s3cr3t"),
1815            RedisBroker::sentinel("mymaster", ["127.0.0.1:26379"]).credentials("alice", "s3cr3t"),
1816        ];
1817        for broker in brokers {
1818            let config = broker.build_config().expect("config builds");
1819            assert_eq!(config.username.as_deref(), Some("alice"));
1820            assert_eq!(config.password.as_deref(), Some("s3cr3t"));
1821        }
1822    }
1823
1824    #[test]
1825    fn password_only_sets_password_without_username() {
1826        let config = RedisBroker::cluster(["127.0.0.1:7000"])
1827            .password("requirepass")
1828            .build_config()
1829            .expect("config builds");
1830        assert_eq!(config.username, None);
1831        assert_eq!(config.password.as_deref(), Some("requirepass"));
1832    }
1833
1834    // Programmatic credentials win over a standalone URL's userinfo.
1835    #[test]
1836    fn programmatic_credentials_override_standalone_url() {
1837        let config = RedisBroker::standalone("redis://urluser:urlpass@localhost:6379")
1838            .credentials("acluser", "aclpass")
1839            .build_config()
1840            .expect("config builds");
1841        assert_eq!(config.username.as_deref(), Some("acluser"));
1842        assert_eq!(config.password.as_deref(), Some("aclpass"));
1843    }
1844
1845    // Without an override the URL's credentials are left untouched.
1846    #[test]
1847    fn url_credentials_preserved_without_override() {
1848        let config = RedisBroker::standalone("redis://urluser:urlpass@localhost:6379")
1849            .build_config()
1850            .expect("config builds");
1851        assert_eq!(config.username.as_deref(), Some("urluser"));
1852        assert_eq!(config.password.as_deref(), Some("urlpass"));
1853    }
1854
1855    #[test]
1856    fn debug_redacts_password() {
1857        let broker =
1858            RedisBroker::standalone("redis://localhost:6379").credentials("alice", "s3cr3t");
1859        let rendered = format!("{broker:?}");
1860        assert!(
1861            !rendered.contains("s3cr3t"),
1862            "password must not appear in Debug output: {rendered}"
1863        );
1864        // The username is an identifier, not a secret, and is kept for debugging.
1865        assert!(
1866            rendered.contains("alice"),
1867            "expected username in: {rendered}"
1868        );
1869    }
1870
1871    #[cfg(feature = "sentinel-auth")]
1872    #[test]
1873    fn sentinel_credentials_apply_to_sentinel_server() {
1874        let config = RedisBroker::sentinel("mymaster", ["127.0.0.1:26379"])
1875            .credentials("datauser", "datapass")
1876            .sentinel_credentials("sentineluser", "sentinelpass")
1877            .build_config()
1878            .expect("config builds");
1879        // Data-node credentials sit on the top-level config.
1880        assert_eq!(config.username.as_deref(), Some("datauser"));
1881        let ServerConfig::Sentinel {
1882            username, password, ..
1883        } = &config.server
1884        else {
1885            panic!("expected a sentinel server config");
1886        };
1887        assert_eq!(username.as_deref(), Some("sentineluser"));
1888        assert_eq!(password.as_deref(), Some("sentinelpass"));
1889    }
1890
1891    #[cfg(feature = "credential-provider")]
1892    #[derive(Debug)]
1893    struct StaticCredentials;
1894
1895    #[cfg(feature = "credential-provider")]
1896    #[async_trait::async_trait]
1897    impl CredentialProvider for StaticCredentials {
1898        async fn fetch(
1899            &self,
1900            _server: Option<&fred::types::config::Server>,
1901        ) -> Result<(Option<String>, Option<String>), fred::error::Error> {
1902            Ok((Some("rotating".into()), Some("token".into())))
1903        }
1904    }
1905
1906    #[cfg(feature = "credential-provider")]
1907    #[test]
1908    fn credential_provider_is_applied() {
1909        let provider: Arc<dyn CredentialProvider> = Arc::new(StaticCredentials);
1910        let config = RedisBroker::cluster(["127.0.0.1:7000"])
1911            .credential_provider(provider)
1912            .build_config()
1913            .expect("config builds");
1914        assert!(config.credential_provider.is_some());
1915    }
1916}