diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 649af8b8..ecec7efa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,7 +103,8 @@ jobs: # HTTP/API layer - consolidated -p cdk-axum, -p cdk-axum --no-default-features, - -p cdk-axum --no-default-features --features swagger, + -p cdk-axum --no-default-features --features redis, + -p cdk-axum --no-default-features --features "redis swagger", # Lightning backends -p cdk-cln, @@ -126,7 +127,7 @@ jobs: --bin cdk-cli --features sqlcipher, --bin cdk-cli --features redb, --bin cdk-mintd, - + --bin cdk-mintd --features redis, --bin cdk-mintd --features sqlcipher, --bin cdk-mintd --no-default-features --features lnd --features sqlite, --bin cdk-mintd --no-default-features --features cln --features postgres, @@ -337,7 +338,7 @@ jobs: -p cdk --no-default-features --features "wallet auth", -p cdk --no-default-features --features "http_subscription", -p cdk-axum, - + -p cdk-axum --no-default-features --features redis, -p cdk-lnbits, -p cdk-fake-wallet, -p cdk-cln, diff --git a/crates/cdk-axum/Cargo.toml b/crates/cdk-axum/Cargo.toml index 5f0a9c93..7ef7e145 100644 --- a/crates/cdk-axum/Cargo.toml +++ b/crates/cdk-axum/Cargo.toml @@ -12,6 +12,7 @@ readme = "README.md" [features] default = ["auth"] +redis = ["dep:redis"] swagger = ["cdk/swagger", "dep:utoipa"] auth = ["cdk/auth"] prometheus = ["dep:cdk-prometheus"] @@ -33,6 +34,9 @@ paste = "1.0.15" serde.workspace = true uuid.workspace = true sha2 = "0.10.8" +redis = { version = "0.31.0", features = [ + "tokio-rustls-comp", +], optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] uuid = { workspace = true, features = ["js"] } diff --git a/crates/cdk-axum/src/cache/backend/mod.rs b/crates/cdk-axum/src/cache/backend/mod.rs index cc0e8ad6..511d156f 100644 --- a/crates/cdk-axum/src/cache/backend/mod.rs +++ b/crates/cdk-axum/src/cache/backend/mod.rs @@ -1,3 +1,7 @@ mod memory; +#[cfg(feature = "redis")] +mod redis; pub use self::memory::InMemoryHttpCache; +#[cfg(feature = "redis")] +pub use self::redis::{Config as RedisConfig, HttpCacheRedis}; diff --git a/crates/cdk-axum/src/cache/backend/redis.rs b/crates/cdk-axum/src/cache/backend/redis.rs new file mode 100644 index 00000000..358350f6 --- /dev/null +++ b/crates/cdk-axum/src/cache/backend/redis.rs @@ -0,0 +1,96 @@ +use std::time::Duration; + +use redis::AsyncCommands; +use serde::{Deserialize, Serialize}; + +use crate::cache::{HttpCacheKey, HttpCacheStorage}; + +/// Redis cache storage for the HTTP cache. +/// +/// This cache storage backend uses Redis to store the cache. +pub struct HttpCacheRedis { + cache_ttl: Duration, + prefix: Option>, + client: redis::Client, +} + +/// Configuration for the Redis cache storage. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Config { + /// Commong key prefix + pub key_prefix: Option, + + /// Connection string to the Redis server. + pub connection_string: String, +} + +impl HttpCacheRedis { + /// Create a new Redis cache. + pub fn new(client: redis::Client) -> Self { + Self { + client, + prefix: None, + cache_ttl: Duration::from_secs(60), + } + } + + /// Set a prefix for the cache keys. + /// + /// This is useful to have all the HTTP cache keys under a common prefix, + /// some sort of namespace, to make management of the database easier. + pub fn set_prefix(mut self, prefix: Vec) -> Self { + self.prefix = Some(prefix); + self + } +} + +#[async_trait::async_trait] +impl HttpCacheStorage for HttpCacheRedis { + fn set_expiration_times(&mut self, cache_ttl: Duration, _cache_tti: Duration) { + self.cache_ttl = cache_ttl; + } + + async fn get(&self, key: &HttpCacheKey) -> Option> { + let mut conn = self + .client + .get_multiplexed_tokio_connection() + .await + .map_err(|err| { + tracing::error!("Failed to get redis connection: {:?}", err); + err + }) + .ok()?; + + let mut db_key = self.prefix.clone().unwrap_or_default(); + db_key.extend(&**key); + + conn.get(db_key) + .await + .map_err(|err| { + tracing::error!("Failed to get value from redis: {:?}", err); + err + }) + .ok()? + } + + async fn set(&self, key: HttpCacheKey, value: Vec) { + let mut db_key = self.prefix.clone().unwrap_or_default(); + db_key.extend(&*key); + + let mut conn = match self.client.get_multiplexed_tokio_connection().await { + Ok(conn) => conn, + Err(err) => { + tracing::error!("Failed to get redis connection: {:?}", err); + return; + } + }; + + let _: Result<(), _> = conn + .set_ex(db_key, value, self.cache_ttl.as_secs()) + .await + .map_err(|err| { + tracing::error!("Failed to set value in redis: {:?}", err); + err + }); + } +} diff --git a/crates/cdk-axum/src/cache/config.rs b/crates/cdk-axum/src/cache/config.rs index c507764b..f5055122 100644 --- a/crates/cdk-axum/src/cache/config.rs +++ b/crates/cdk-axum/src/cache/config.rs @@ -2,6 +2,11 @@ use serde::{Deserialize, Serialize}; pub const ENV_CDK_MINTD_CACHE_BACKEND: &str = "CDK_MINTD_CACHE_BACKEND"; +#[cfg(feature = "redis")] +pub const ENV_CDK_MINTD_CACHE_REDIS_URL: &str = "CDK_MINTD_CACHE_REDIS_URL"; +#[cfg(feature = "redis")] +pub const ENV_CDK_MINTD_CACHE_REDIS_KEY_PREFIX: &str = "CDK_MINTD_CACHE_REDIS_KEY_PREFIX"; + pub const ENV_CDK_MINTD_CACHE_TTI: &str = "CDK_MINTD_CACHE_TTI"; pub const ENV_CDK_MINTD_CACHE_TTL: &str = "CDK_MINTD_CACHE_TTL"; @@ -11,12 +16,27 @@ pub const ENV_CDK_MINTD_CACHE_TTL: &str = "CDK_MINTD_CACHE_TTL"; pub enum Backend { #[default] Memory, + #[cfg(feature = "redis")] + Redis(super::backend::RedisConfig), } impl Backend { pub fn from_env_str(backend_str: &str) -> Option { match backend_str.to_lowercase().as_str() { "memory" => Some(Self::Memory), + #[cfg(feature = "redis")] + "redis" => { + // Get Redis configuration from environment + let connection_string = std::env::var(ENV_CDK_MINTD_CACHE_REDIS_URL) + .unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + + let key_prefix = std::env::var(ENV_CDK_MINTD_CACHE_REDIS_KEY_PREFIX).ok(); + + Some(Self::Redis(super::backend::RedisConfig { + connection_string, + key_prefix, + })) + } _ => None, } } @@ -46,6 +66,20 @@ impl Config { if let Ok(backend_str) = env::var(ENV_CDK_MINTD_CACHE_BACKEND) { if let Some(backend) = Backend::from_env_str(&backend_str) { self.backend = backend; + + // If Redis backend is selected, parse Redis configuration + #[cfg(feature = "redis")] + if matches!(self.backend, Backend::Redis(_)) { + let connection_string = env::var(ENV_CDK_MINTD_CACHE_REDIS_URL) + .unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + + let key_prefix = env::var(ENV_CDK_MINTD_CACHE_REDIS_KEY_PREFIX).ok(); + + self.backend = Backend::Redis(super::backend::RedisConfig { + connection_string, + key_prefix, + }); + } } } diff --git a/crates/cdk-axum/src/cache/mod.rs b/crates/cdk-axum/src/cache/mod.rs index de56d666..f07968d0 100644 --- a/crates/cdk-axum/src/cache/mod.rs +++ b/crates/cdk-axum/src/cache/mod.rs @@ -7,7 +7,7 @@ //! idempotent operations. //! //! This mod also provides common backend implementations as well, such as In -//! Memory (default). +//! Memory (default) and Redis. use std::ops::Deref; use std::sync::Arc; use std::time::Duration; @@ -89,6 +89,23 @@ impl From for HttpCache { Duration::from_secs(config.tti.unwrap_or(DEFAULT_TTI_SECS)), None, ), + #[cfg(feature = "redis")] + config::Backend::Redis(redis_config) => { + let client = redis::Client::open(redis_config.connection_string) + .expect("Failed to create Redis client"); + let storage = HttpCacheRedis::new(client).set_prefix( + redis_config + .key_prefix + .unwrap_or_default() + .as_bytes() + .to_vec(), + ); + Self::new( + Duration::from_secs(config.ttl.unwrap_or(DEFAULT_TTL_SECS)), + Duration::from_secs(config.tti.unwrap_or(DEFAULT_TTI_SECS)), + Some(Box::new(storage)), + ) + } } } } diff --git a/crates/cdk-mintd/Cargo.toml b/crates/cdk-mintd/Cargo.toml index 85f23110..ae83b751 100644 --- a/crates/cdk-mintd/Cargo.toml +++ b/crates/cdk-mintd/Cargo.toml @@ -26,6 +26,7 @@ grpc-processor = ["dep:cdk-payment-processor", "cdk-signatory/grpc"] sqlcipher = ["sqlite", "cdk-sqlite/sqlcipher"] # MSRV is not committed to with swagger enabled swagger = ["cdk-axum/swagger", "dep:utoipa", "dep:utoipa-swagger-ui"] +redis = ["cdk-axum/redis"] auth = ["cdk/auth", "cdk-axum/auth", "cdk-sqlite?/auth", "cdk-postgres?/auth"] prometheus = ["cdk/prometheus", "dep:cdk-prometheus", "cdk-sqlite?/prometheus", "cdk-axum/prometheus"] diff --git a/crates/cdk-mintd/example.config.toml b/crates/cdk-mintd/example.config.toml index 79e0f867..c6510539 100644 --- a/crates/cdk-mintd/example.config.toml +++ b/crates/cdk-mintd/example.config.toml @@ -33,10 +33,13 @@ enabled = false #port = 9090 # [info.http_cache] -# backend type: memory (default) +# memory or redis backend = "memory" ttl = 60 tti = 60 +# `key_prefix` and `connection_string` required for redis +# key_prefix = "mintd" +# connection_string = "redis://localhost" # NOTE: If [mint_management_rpc] is enabled these values will only be used on first start up. # Further changes must be made through the rpc. diff --git a/docker-compose.yaml b/docker-compose.yaml index 9a976894..b318835c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -58,6 +58,9 @@ services: # - CDK_MINTD_DATABASE_URL=postgresql://cdk_user:cdk_password@postgres:5432/cdk_mint # Cache configuration - CDK_MINTD_CACHE_BACKEND=memory + # For Redis cache (requires redis service, enable with: docker-compose --profile redis up): + # - CDK_MINTD_CACHE_REDIS_URL=redis://redis:6379 + # - CDK_MINTD_CACHE_REDIS_KEY_PREFIX=cdk-mintd - CDK_MINTD_PROMETHEUS_ENABLED=true - CDK_MINTD_PROMETHEUS_ADDRESS=0.0.0.0 - CDK_MINTD_PROMETHEUS_PORT=9000 @@ -131,13 +134,34 @@ services: # timeout: 5s # retries: 5 - + # Redis cache service (optional) + # Enable with: docker-compose --profile redis up +# redis: +# image: redis:7-alpine +# container_name: mint_redis +# restart: unless-stopped +# profiles: +# - redis +# ports: +# - "6379:6379" +# volumes: +# - redis_data:/data +# command: redis-server --save 60 1 --loglevel warning +# healthcheck: +# test: ["CMD", "redis-cli", "ping"] +# interval: 10s +# timeout: 3s +# retries: 5 volumes: postgres_data: driver: local ldk_node_data: driver: local +# redis_data: +# driver: local + + networks: cdk: