rs-zero 0.2.8

Rust-first microservice framework inspired by go-zero engineering practices
Documentation
#![cfg(feature = "cache-redis")]

use std::time::Duration;

use rs_zero::{
    cache_redis::{RedisBreakerConfig, RedisCacheConfig, RedisClusterConfig},
    lock::{DistributedLock, LockError, RedisDistributedLock, RedisLockConfig},
};

#[test]
fn redis_lock_defaults_require_ttl_and_namespace() {
    let config = RedisLockConfig {
        namespace: "locks".to_string(),
        ..RedisLockConfig::default()
    };
    config.validate().expect("valid config");

    let invalid = RedisLockConfig {
        namespace: " ".to_string(),
        ..config
    };
    assert!(matches!(
        invalid.validate().expect_err("namespace required"),
        LockError::InvalidConfig(message) if message.contains("namespace")
    ));
}

#[tokio::test]
async fn redis_lock_rejects_zero_ttl_before_redis_io() {
    let lock = RedisDistributedLock::new(RedisLockConfig::default()).expect("lock backend");

    let error = lock
        .acquire("unit:{zero-ttl}", Duration::ZERO)
        .await
        .expect_err("zero ttl");

    assert!(matches!(error, LockError::InvalidConfig(message) if message.contains("ttl")));
}

#[tokio::test]
async fn redis_cluster_lock_requires_hash_tag_before_redis_io() {
    let lock = RedisDistributedLock::new(RedisLockConfig {
        redis: RedisCacheConfig {
            url: "redis://127.0.0.1:6379".to_string(),
            cluster: RedisClusterConfig {
                enabled: true,
                ..RedisClusterConfig::default()
            },
            ..RedisCacheConfig::default()
        },
        ..RedisLockConfig::default()
    })
    .expect("lock backend");

    let error = lock
        .acquire("order:123", Duration::from_secs(1))
        .await
        .expect_err("missing hash tag");

    assert!(matches!(error, LockError::InvalidConfig(message) if message.contains("hash tag")));
}

#[tokio::test]
async fn redis_lock_breaker_fast_fails_after_backend_error() {
    let lock = RedisDistributedLock::new(RedisLockConfig {
        redis: RedisCacheConfig {
            url: "redis://127.0.0.1:1".to_string(),
            connect_timeout: Duration::from_millis(50),
            command_timeout: Duration::from_millis(50),
            ..RedisCacheConfig::default()
        },
        breaker: RedisBreakerConfig::fast_failure(1, Duration::from_secs(60)),
        ..RedisLockConfig::default()
    })
    .expect("lock backend");

    let first = lock
        .try_acquire("unit:{breaker}", Duration::from_secs(1))
        .await
        .expect_err("connection should fail");
    assert!(first.to_string().contains("connection") || first.to_string().contains("redis"));

    let second = lock
        .try_acquire("unit:{breaker}", Duration::from_secs(1))
        .await
        .expect_err("breaker should reject");
    assert!(matches!(second, LockError::BreakerOpen(_)));
}

#[cfg(feature = "observability")]
#[test]
fn redis_lock_metrics_use_low_cardinality_command_labels() {
    let metrics = rs_zero::observability::MetricsRegistry::new();
    metrics.record_redis_command(
        rs_zero::observability::RedisMetricLabels::new("LOCK_ACQUIRE", "primary", "success"),
        Duration::from_millis(1),
    );

    let text = metrics.render_prometheus();

    assert!(text.contains("command=\"LOCK_ACQUIRE\",shard=\"primary\",result=\"success\""));
    assert!(!text.contains("order:123"));
    assert!(!text.contains("redis://"));
}