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}