redis.rs 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. //! Topology-aware Redis: one enum, all three topologies first-class.
  2. //!
  3. //! [`RedisConn`] is selected by `RedisConfig::topology` and implements
  4. //! `redis::aio::ConnectionLike`, so every later store talks to one type:
  5. //! - `Single` → `aio::ConnectionManager` (`retries=6`, `min_delay=100ms`,
  6. //! `exponent_base=2.0`, `max_delay=5s`, `response_timeout=2s`,
  7. //! `connection_timeout=3s`, taken from the config's retry knobs).
  8. //! - `Sentinel` → retained [`SentinelClient`] + cached data connection with
  9. //! the hand-built failover resolver in [`super::sentinel`].
  10. //! - `Cluster` → `ClusterClient::get_async_connection` (native slot refresh),
  11. //! with the config's response/connection timeouts.
  12. //!
  13. //! [`connect_redis`] fails startup closed: after connecting it pings the
  14. //! deployment and runs the config's no-eviction/AOF assertion helpers, so a
  15. //! wrong `maxmemory-policy` (or disabled AOF where required) is a startup
  16. //! error, never a silent durability hole.
  17. use std::fmt;
  18. use redis::{
  19. AsyncConnectionConfig, Client, ConnectionInfo, IntoConnectionInfo, RedisFuture, Value,
  20. aio::{ConnectionLike, ConnectionManager, ConnectionManagerConfig},
  21. cluster::ClusterClientBuilder,
  22. cluster_async::ClusterConnection,
  23. };
  24. use secrecy::ExposeSecret;
  25. use super::{StateError, sentinel::SentinelConn};
  26. use crate::config::{RedisConfig, RedisTopology};
  27. /// Topology-aware Redis connection (never a concrete connection type).
  28. #[derive(Clone)]
  29. pub enum RedisConn {
  30. /// Single node behind an auto-reconnecting manager.
  31. Single(ConnectionManager),
  32. /// Sentinel-managed replication with failover re-resolution.
  33. Sentinel(SentinelConn),
  34. /// Native cluster connection with slot refresh.
  35. Cluster(ClusterConnection),
  36. }
  37. impl fmt::Debug for RedisConn {
  38. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
  39. f.debug_tuple("RedisConn")
  40. .field(&self.topology_name())
  41. .finish()
  42. }
  43. }
  44. impl RedisConn {
  45. /// Topology name for logs and health output (never a secret).
  46. #[must_use]
  47. pub fn topology_name(&self) -> &'static str {
  48. match self {
  49. Self::Single(_) => "single",
  50. Self::Sentinel(_) => "sentinel",
  51. Self::Cluster(_) => "cluster",
  52. }
  53. }
  54. }
  55. impl ConnectionLike for RedisConn {
  56. fn req_packed_command<'a>(&'a mut self, cmd: &'a redis::Cmd) -> RedisFuture<'a, Value> {
  57. match self {
  58. Self::Single(manager) => manager.req_packed_command(cmd),
  59. Self::Sentinel(sentinel) => Box::pin(sentinel.req_packed(cmd)),
  60. Self::Cluster(cluster) => cluster.req_packed_command(cmd),
  61. }
  62. }
  63. fn req_packed_commands<'a>(
  64. &'a mut self,
  65. pipeline: &'a redis::Pipeline,
  66. offset: usize,
  67. count: usize,
  68. ) -> RedisFuture<'a, Vec<Value>> {
  69. match self {
  70. Self::Single(manager) => manager.req_packed_commands(pipeline, offset, count),
  71. Self::Sentinel(sentinel) => Box::pin(sentinel.req_packed_pipeline(pipeline, offset, count)),
  72. Self::Cluster(cluster) => cluster.req_packed_commands(pipeline, offset, count),
  73. }
  74. }
  75. fn get_db(&self) -> i64 {
  76. match self {
  77. Self::Single(manager) => manager.get_db(),
  78. Self::Sentinel(_) => 0,
  79. Self::Cluster(cluster) => cluster.get_db(),
  80. }
  81. }
  82. }
  83. /// Apply `cfg.password` (mounted secret file) to a bare `redis://` URL.
  84. ///
  85. /// The URL is parsed into a [`ConnectionInfo`] first and the password is set
  86. /// programmatically, so the secret never passes through URL string
  87. /// interpolation: any password bytes are safe (no percent-encoding needed),
  88. /// and a `Some` password is always applied even when the URL already carries
  89. /// userinfo without a password.
  90. fn single_connection_info(
  91. url: &secrecy::SecretString,
  92. password: Option<&secrecy::SecretString>,
  93. ) -> Result<ConnectionInfo, redis::RedisError> {
  94. let mut info: ConnectionInfo = url.expose_secret().into_connection_info()?;
  95. if let Some(password) = password {
  96. let redis_settings = info
  97. .redis_settings()
  98. .clone()
  99. .set_password(password.expose_secret());
  100. info = info.set_redis_settings(redis_settings);
  101. }
  102. Ok(info)
  103. }
  104. async fn connect_single(
  105. config: &RedisConfig,
  106. url: &secrecy::SecretString,
  107. ) -> Result<RedisConn, StateError> {
  108. let info = single_connection_info(url, config.password.as_ref()).map_err(StateError::Redis)?;
  109. let client = Client::open(info).map_err(StateError::Redis)?;
  110. let manager_config = ConnectionManagerConfig::new()
  111. .set_number_of_retries(config.retry_budget as usize)
  112. .set_min_delay(config.retry_min_delay)
  113. .set_max_delay(config.retry_max_delay)
  114. .set_exponent_base(2.0)
  115. .set_response_timeout(Some(config.response_timeout))
  116. .set_connection_timeout(Some(config.connect_timeout));
  117. let manager = ConnectionManager::new_with_config(client, manager_config)
  118. .await
  119. .map_err(StateError::Redis)?;
  120. Ok(RedisConn::Single(manager))
  121. }
  122. async fn connect_cluster(config: &RedisConfig, seeds: &[String]) -> Result<RedisConn, StateError> {
  123. if seeds.is_empty() {
  124. return Err(StateError::Redis(redis::RedisError::from((
  125. redis::ErrorKind::InvalidClientConfig,
  126. "cluster topology requires seed nodes",
  127. ))));
  128. }
  129. let auth = match &config.topology {
  130. RedisTopology::Cluster { auth, .. } => auth.as_ref().or(config.password.as_ref()),
  131. _ => config.password.as_ref(),
  132. };
  133. let tls = match &config.topology {
  134. RedisTopology::Cluster { tls, .. } => *tls,
  135. _ => false,
  136. };
  137. let urls: Vec<String> = seeds
  138. .iter()
  139. .map(|seed| {
  140. if seed.contains("://") {
  141. seed.clone()
  142. } else {
  143. format!("redis://{seed}")
  144. }
  145. })
  146. .collect();
  147. let mut builder = ClusterClientBuilder::new(urls);
  148. if let Some(password) = auth {
  149. builder = builder.password(password.expose_secret());
  150. }
  151. if tls {
  152. builder = builder.tls(redis::TlsMode::Secure);
  153. }
  154. let client = builder
  155. .retries(config.retry_budget)
  156. .connection_timeout(config.connect_timeout)
  157. .response_timeout(config.response_timeout)
  158. .build()
  159. .map_err(StateError::Redis)?;
  160. let conn = client
  161. .get_async_connection()
  162. .await
  163. .map_err(StateError::Redis)?;
  164. Ok(RedisConn::Cluster(conn))
  165. }
  166. /// Read one `CONFIG GET <key>` value from the deployment.
  167. async fn config_get(conn: &mut RedisConn, key: &str) -> Result<Option<String>, StateError> {
  168. let pair: Vec<String> = redis::cmd("CONFIG")
  169. .arg("GET")
  170. .arg(key)
  171. .query_async(conn)
  172. .await
  173. .map_err(StateError::Redis)?;
  174. Ok(pair.into_iter().nth(1))
  175. }
  176. /// Run the config's Redis durability/eviction startup assertions.
  177. async fn assert_contract(conn: &mut RedisConn, config: &RedisConfig) -> Result<(), StateError> {
  178. let observed = config_get(conn, "maxmemory-policy").await?;
  179. config
  180. .assert_noeviction_policy(observed.as_deref().unwrap_or("unknown"))
  181. .map_err(StateError::Config)?;
  182. let appendonly = config_get(conn, "appendonly").await?;
  183. config
  184. .assert_aof_persistence(appendonly.as_deref() == Some("yes"))
  185. .map_err(StateError::Config)?;
  186. Ok(())
  187. }
  188. /// Connect Redis for the configured topology, then prove the deployment is
  189. /// reachable (PING) and contract-compliant (eviction/AOF assertions).
  190. pub async fn connect_redis(config: &RedisConfig) -> Result<RedisConn, StateError> {
  191. let mut conn = match &config.topology {
  192. RedisTopology::Single { url } => connect_single(config, url).await?,
  193. RedisTopology::Sentinel { .. } => RedisConn::Sentinel(SentinelConn::connect(config).await?),
  194. RedisTopology::Cluster { seeds, .. } => connect_cluster(config, seeds).await?,
  195. };
  196. let pong: String = redis::cmd("PING")
  197. .query_async(&mut conn)
  198. .await
  199. .map_err(StateError::Redis)?;
  200. config
  201. .assert_topology_reachable(pong == "PONG")
  202. .map_err(StateError::Config)?;
  203. assert_contract(&mut conn, config).await?;
  204. Ok(conn)
  205. }
  206. /// Async-connection timeouts shared by the data paths.
  207. pub fn data_timeouts(config: &RedisConfig) -> AsyncConnectionConfig {
  208. AsyncConnectionConfig::new()
  209. .set_connection_timeout(Some(config.connect_timeout))
  210. .set_response_timeout(Some(config.response_timeout))
  211. }