1#![deny(missing_docs)]
2
3use std::{
11 fmt,
12 sync::{
13 Arc,
14 atomic::{AtomicBool, Ordering},
15 },
16 time::{Duration, Instant},
17};
18
19use async_trait::async_trait;
20use nidus_core::{Container, LifecycleHook, NidusError};
21use nidus_integrations::{IntegrationEvent, IntegrationStatus, IntegrationTelemetry};
22use thiserror::Error;
23use tokio::sync::{Semaphore, SemaphorePermit};
24
25const DEFAULT_CONCURRENCY_LIMIT: usize = 256;
26const MAX_CONCURRENCY_LIMIT: usize = 65_536;
27
28pub type Result<T> = std::result::Result<T, RedisError>;
30
31#[derive(Debug, Error)]
33pub enum RedisError {
34 #[error(transparent)]
36 Redis(#[from] redis::RedisError),
37 #[error(transparent)]
39 Nidus(#[from] NidusError),
40 #[cfg(feature = "nidus-config")]
42 #[error(transparent)]
43 Config(#[from] nidus_config::ConfigError),
44 #[error("{field} must be greater than zero")]
46 InvalidBound {
47 field: &'static str,
49 },
50 #[error("invalid Redis configuration: {message}")]
52 Configuration {
53 message: &'static str,
55 },
56 #[error("Redis provider is shutting down")]
58 ShuttingDown,
59 #[error("Redis operations did not drain before the shutdown timeout")]
61 ShutdownTimeout,
62}
63
64#[derive(Clone, Eq, PartialEq)]
66pub struct RedisConfig {
67 url: String,
68 connection_timeout: Duration,
69 response_timeout: Duration,
70 shutdown_timeout: Duration,
71 reconnect_attempts: usize,
72 reconnect_min_delay: Duration,
73 reconnect_max_delay: Duration,
74 concurrency_limit: usize,
75 pipeline_buffer_size: usize,
76 allow_plaintext_local: bool,
77}
78
79impl RedisConfig {
80 pub fn new(url: impl Into<String>) -> Self {
82 Self {
83 url: url.into(),
84 connection_timeout: Duration::from_secs(5),
85 response_timeout: Duration::from_secs(2),
86 shutdown_timeout: Duration::from_secs(5),
87 reconnect_attempts: 6,
88 reconnect_min_delay: Duration::from_millis(100),
89 reconnect_max_delay: Duration::from_secs(5),
90 concurrency_limit: DEFAULT_CONCURRENCY_LIMIT,
91 pipeline_buffer_size: 1024,
92 allow_plaintext_local: false,
93 }
94 }
95
96 pub fn with_connection_timeout(mut self, timeout: Duration) -> Self {
98 self.connection_timeout = timeout;
99 self
100 }
101
102 pub fn with_response_timeout(mut self, timeout: Duration) -> Self {
104 self.response_timeout = timeout;
105 self
106 }
107
108 pub fn with_shutdown_timeout(mut self, timeout: Duration) -> Self {
110 self.shutdown_timeout = timeout;
111 self
112 }
113
114 pub fn with_reconnect_attempts(mut self, attempts: usize) -> Self {
116 self.reconnect_attempts = attempts;
117 self
118 }
119
120 pub fn with_reconnect_backoff(mut self, minimum: Duration, maximum: Duration) -> Self {
122 self.reconnect_min_delay = minimum;
123 self.reconnect_max_delay = maximum;
124 self
125 }
126
127 pub fn with_concurrency_limit(mut self, limit: usize) -> Self {
129 self.concurrency_limit = limit;
130 self
131 }
132
133 pub fn with_pipeline_buffer_size(mut self, size: usize) -> Self {
135 self.pipeline_buffer_size = size;
136 self
137 }
138
139 pub fn allow_plaintext_for_local_development(mut self) -> Self {
141 self.allow_plaintext_local = true;
142 self
143 }
144
145 pub fn url(&self) -> &str {
147 &self.url
148 }
149
150 pub fn validate(&self) -> Result<()> {
152 for (field, value) in [
153 ("connection_timeout", self.connection_timeout.as_nanos()),
154 ("response_timeout", self.response_timeout.as_nanos()),
155 ("shutdown_timeout", self.shutdown_timeout.as_nanos()),
156 ("reconnect_min_delay", self.reconnect_min_delay.as_nanos()),
157 ("reconnect_max_delay", self.reconnect_max_delay.as_nanos()),
158 ("concurrency_limit", self.concurrency_limit as u128),
159 ("pipeline_buffer_size", self.pipeline_buffer_size as u128),
160 ] {
161 if value == 0 {
162 return Err(RedisError::InvalidBound { field });
163 }
164 }
165 if self.concurrency_limit > MAX_CONCURRENCY_LIMIT {
166 return Err(RedisError::Configuration {
167 message: "Redis concurrency_limit must not exceed 65536",
168 });
169 }
170 if self.reconnect_max_delay < self.reconnect_min_delay {
171 return Err(RedisError::Configuration {
172 message: "Redis reconnect delays must be ordered",
173 });
174 }
175 if self.url.starts_with("redis://") {
176 if !self.allow_plaintext_local || !redis_url_is_loopback(&self.url) {
177 return Err(RedisError::Configuration {
178 message: "plaintext Redis is restricted to explicit loopback development URLs",
179 });
180 }
181 } else if !self.url.starts_with("rediss://") && !self.url.starts_with("redis+unix://") {
182 return Err(RedisError::Configuration {
183 message: "production Redis URLs must use rediss:// or a Unix socket",
184 });
185 }
186 Ok(())
187 }
188
189 #[cfg(feature = "nidus-config")]
191 pub fn from_config_path<I, S>(config: &nidus_config::Config, path: I) -> Result<Self>
192 where
193 I: IntoIterator<Item = S>,
194 S: AsRef<str>,
195 {
196 #[derive(serde::Deserialize)]
197 struct RawConfig {
198 url: String,
199 connection_timeout_ms: Option<u64>,
200 response_timeout_ms: Option<u64>,
201 shutdown_timeout_ms: Option<u64>,
202 reconnect_attempts: Option<usize>,
203 concurrency_limit: Option<usize>,
204 pipeline_buffer_size: Option<usize>,
205 allow_plaintext_local: Option<bool>,
206 }
207
208 let raw: RawConfig = config.get_required_path_typed(path)?;
209 let mut settings = Self::new(raw.url);
210 if let Some(value) = raw.connection_timeout_ms {
211 settings = settings.with_connection_timeout(Duration::from_millis(value));
212 }
213 if let Some(value) = raw.response_timeout_ms {
214 settings = settings.with_response_timeout(Duration::from_millis(value));
215 }
216 if let Some(value) = raw.shutdown_timeout_ms {
217 settings = settings.with_shutdown_timeout(Duration::from_millis(value));
218 }
219 if let Some(value) = raw.reconnect_attempts {
220 settings = settings.with_reconnect_attempts(value);
221 }
222 if let Some(value) = raw.concurrency_limit {
223 settings = settings.with_concurrency_limit(value);
224 }
225 if let Some(value) = raw.pipeline_buffer_size {
226 settings = settings.with_pipeline_buffer_size(value);
227 }
228 if raw.allow_plaintext_local == Some(true) {
229 settings = settings.allow_plaintext_for_local_development();
230 }
231 settings.validate()?;
232 Ok(settings)
233 }
234}
235
236impl fmt::Debug for RedisConfig {
237 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
238 formatter
239 .debug_struct("RedisConfig")
240 .field("url", &"<redacted>")
241 .field("connection_timeout", &self.connection_timeout)
242 .field("response_timeout", &self.response_timeout)
243 .field("shutdown_timeout", &self.shutdown_timeout)
244 .field("reconnect_attempts", &self.reconnect_attempts)
245 .field("reconnect_min_delay", &self.reconnect_min_delay)
246 .field("reconnect_max_delay", &self.reconnect_max_delay)
247 .field("concurrency_limit", &self.concurrency_limit)
248 .field("pipeline_buffer_size", &self.pipeline_buffer_size)
249 .field("allow_plaintext_local", &self.allow_plaintext_local)
250 .finish()
251 }
252}
253
254fn redis_url_is_loopback(url: &str) -> bool {
255 url::Url::parse(url).ok().is_some_and(|url| {
256 url.scheme() == "redis"
257 && match url.host() {
258 Some(url::Host::Domain(host)) => {
259 host.eq_ignore_ascii_case("localhost")
260 || host
261 .parse::<std::net::IpAddr>()
262 .is_ok_and(|address| address.is_loopback())
263 }
264 Some(url::Host::Ipv4(host)) => host.is_loopback(),
265 Some(url::Host::Ipv6(host)) => host.is_loopback(),
266 None => false,
267 }
268 })
269}
270
271#[derive(Clone, Debug)]
273pub struct RedisProviderBuilder {
274 config: RedisConfig,
275 telemetry: IntegrationTelemetry,
276}
277
278impl RedisProviderBuilder {
279 pub fn new(url: impl Into<String>) -> Self {
281 Self {
282 config: RedisConfig::new(url),
283 telemetry: IntegrationTelemetry::new(),
284 }
285 }
286
287 pub fn config(mut self, config: RedisConfig) -> Self {
289 self.config = config;
290 self
291 }
292
293 pub fn telemetry(mut self, telemetry: IntegrationTelemetry) -> Self {
295 self.telemetry = telemetry;
296 self
297 }
298
299 pub fn build_client(&self) -> Result<redis::Client> {
301 self.config.validate()?;
302 Ok(redis::Client::open(self.config.url.as_str())?)
303 }
304
305 pub async fn connect(self) -> Result<RedisProvider> {
307 self.config.validate()?;
308 let started_at = Instant::now();
309 let client = redis::Client::open(self.config.url.as_str())?;
310 let manager_config = redis::aio::ConnectionManagerConfig::new()
311 .set_number_of_retries(self.config.reconnect_attempts)
312 .set_min_delay(self.config.reconnect_min_delay)
313 .set_max_delay(self.config.reconnect_max_delay)
314 .set_response_timeout(Some(self.config.response_timeout))
315 .set_connection_timeout(Some(self.config.connection_timeout))
316 .set_concurrency_limit(self.config.concurrency_limit)
317 .set_pipeline_buffer_size(self.config.pipeline_buffer_size);
318 let result =
319 redis::aio::ConnectionManager::new_with_config(client.clone(), manager_config).await;
320 self.telemetry
321 .record(&IntegrationEvent::new(
322 "nidus-redis",
323 "connect",
324 if result.is_ok() {
325 IntegrationStatus::Success
326 } else {
327 IntegrationStatus::Failure
328 },
329 started_at.elapsed(),
330 ))
331 .await;
332 Ok(RedisProvider {
333 client,
334 connection: result?,
335 in_flight: Arc::new(Semaphore::new(self.config.concurrency_limit)),
336 max_in_flight: self.config.concurrency_limit as u32,
337 shutdown_timeout: self.config.shutdown_timeout,
338 shutting_down: Arc::new(AtomicBool::new(false)),
339 shutdown_complete: Arc::new(AtomicBool::new(false)),
340 telemetry: self.telemetry,
341 })
342 }
343
344 pub async fn register(self, container: &mut Container) -> Result<()> {
346 container.register_singleton(self.connect().await?)?;
347 Ok(())
348 }
349}
350
351#[derive(Clone)]
353pub struct RedisProvider {
354 client: redis::Client,
355 connection: redis::aio::ConnectionManager,
356 in_flight: Arc<Semaphore>,
357 max_in_flight: u32,
358 shutdown_timeout: Duration,
359 shutting_down: Arc<AtomicBool>,
360 shutdown_complete: Arc<AtomicBool>,
361 telemetry: IntegrationTelemetry,
362}
363
364impl fmt::Debug for RedisProvider {
365 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
366 formatter
367 .debug_struct("RedisProvider")
368 .field("client", &"redis::Client(<redacted>)")
369 .field("connection", &"redis::aio::ConnectionManager")
370 .field("shutting_down", &self.shutting_down.load(Ordering::Acquire))
371 .field("available_permits", &self.in_flight.available_permits())
372 .field("telemetry", &self.telemetry)
373 .finish()
374 }
375}
376
377impl RedisProvider {
378 pub fn builder(url: impl Into<String>) -> RedisProviderBuilder {
380 RedisProviderBuilder::new(url)
381 }
382
383 pub fn from_parts(client: redis::Client, connection: redis::aio::ConnectionManager) -> Self {
385 Self {
386 client,
387 connection,
388 in_flight: Arc::new(Semaphore::new(DEFAULT_CONCURRENCY_LIMIT)),
389 max_in_flight: DEFAULT_CONCURRENCY_LIMIT as u32,
390 shutdown_timeout: Duration::from_secs(5),
391 shutting_down: Arc::new(AtomicBool::new(false)),
392 shutdown_complete: Arc::new(AtomicBool::new(false)),
393 telemetry: IntegrationTelemetry::new(),
394 }
395 }
396
397 pub fn client(&self) -> &redis::Client {
399 &self.client
400 }
401
402 pub fn connection_manager(&self) -> &redis::aio::ConnectionManager {
404 &self.connection
405 }
406
407 pub fn connection(&self) -> redis::aio::ConnectionManager {
409 self.connection.clone()
410 }
411
412 pub async fn get(&self, key: &str) -> Result<Option<Vec<u8>>> {
414 let _permit = self.acquire_permit().await?;
415 let started_at = Instant::now();
416 let mut connection = self.connection.clone();
417 let result = redis::cmd("GET")
418 .arg(key)
419 .query_async(&mut connection)
420 .await;
421 self.record("get", result.is_ok(), started_at).await;
422 Ok(result?)
423 }
424
425 pub async fn set(&self, key: &str, value: &[u8], ttl: Option<Duration>) -> Result<()> {
427 if let Some(ttl) = ttl
428 && ttl.is_zero()
429 {
430 return Err(RedisError::InvalidBound { field: "ttl" });
431 }
432 let _permit = self.acquire_permit().await?;
433 let started_at = Instant::now();
434 let mut command = redis::cmd("SET");
435 command.arg(key).arg(value);
436 if let Some(ttl) = ttl {
437 command
438 .arg("PX")
439 .arg(ttl.as_millis().min(u128::from(u64::MAX)) as u64);
440 }
441 let mut connection = self.connection.clone();
442 let result: redis::RedisResult<()> = command.query_async(&mut connection).await;
443 self.record("set", result.is_ok(), started_at).await;
444 Ok(result?)
445 }
446
447 pub async fn delete(&self, key: &str) -> Result<bool> {
449 let _permit = self.acquire_permit().await?;
450 let started_at = Instant::now();
451 let mut connection = self.connection.clone();
452 let result: redis::RedisResult<u64> = redis::cmd("DEL")
453 .arg(key)
454 .query_async(&mut connection)
455 .await;
456 self.record("delete", result.is_ok(), started_at).await;
457 Ok(result? > 0)
458 }
459
460 #[cfg(feature = "health")]
462 pub async fn health_status(&self) -> nidus_http::health::HealthStatus {
463 let Ok(_permit) = self.acquire_permit().await else {
464 return nidus_http::health::HealthStatus::down("redis provider is shutting down");
465 };
466 let started_at = Instant::now();
467 let mut connection = self.connection.clone();
468 let result: redis::RedisResult<String> =
469 redis::cmd("PING").query_async(&mut connection).await;
470 self.record("health", result.as_deref() == Ok("PONG"), started_at)
471 .await;
472 if result.as_deref() == Ok("PONG") {
473 nidus_http::health::HealthStatus::up()
474 } else {
475 nidus_http::health::HealthStatus::down("redis readiness check failed")
476 }
477 }
478
479 #[cfg(feature = "health")]
481 pub fn register_ready_check(
482 self: std::sync::Arc<Self>,
483 registry: nidus_http::health::HealthRegistry,
484 name: impl Into<String>,
485 ) -> nidus_http::health::HealthRegistry {
486 registry.ready_check(name, move || {
487 let provider = std::sync::Arc::clone(&self);
488 async move { provider.health_status().await }
489 })
490 }
491
492 pub async fn shutdown(&self) -> Result<()> {
496 if self.shutdown_complete.load(Ordering::Acquire) {
497 return Ok(());
498 }
499 self.shutting_down.store(true, Ordering::Release);
500 let drained = tokio::time::timeout(
501 self.shutdown_timeout,
502 self.in_flight.acquire_many(self.max_in_flight),
503 )
504 .await
505 .map_err(|_| RedisError::ShutdownTimeout)?
506 .map_err(|_| RedisError::ShuttingDown)?;
507 self.shutdown_complete.store(true, Ordering::Release);
508 self.in_flight.close();
509 drop(drained);
510 Ok(())
511 }
512
513 async fn acquire_permit(&self) -> Result<SemaphorePermit<'_>> {
514 if self.shutting_down.load(Ordering::Acquire) {
515 return Err(RedisError::ShuttingDown);
516 }
517 let permit = self
518 .in_flight
519 .acquire()
520 .await
521 .map_err(|_| RedisError::ShuttingDown)?;
522 if self.shutting_down.load(Ordering::Acquire) {
523 return Err(RedisError::ShuttingDown);
524 }
525 Ok(permit)
526 }
527
528 async fn record(&self, operation: &'static str, success: bool, started_at: Instant) {
529 self.telemetry
530 .record(&IntegrationEvent::new(
531 "nidus-redis",
532 operation,
533 if success {
534 IntegrationStatus::Success
535 } else {
536 IntegrationStatus::Failure
537 },
538 started_at.elapsed(),
539 ))
540 .await;
541 }
542}
543
544#[async_trait]
545impl LifecycleHook for RedisProvider {
546 async fn on_shutdown(&self) -> nidus_core::Result<()> {
547 self.shutdown()
548 .await
549 .map_err(|_| NidusError::ApplicationBuild {
550 message: "Redis operations failed to drain during shutdown".to_owned(),
551 })
552 }
553}