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}