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