mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 09:57:47 +08:00
refactor(workspace): enforce layered crate boundaries
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
pub use aether_data_contracts::DataLayerError;
|
||||
|
||||
pub(crate) fn redis_error(error: impl std::fmt::Display) -> DataLayerError {
|
||||
DataLayerError::redis(error)
|
||||
}
|
||||
|
||||
pub(crate) trait RedisResultExt<T> {
|
||||
fn map_redis_err(self) -> Result<T, DataLayerError>;
|
||||
}
|
||||
|
||||
impl<T> RedisResultExt<T> for Result<T, redis::RedisError> {
|
||||
fn map_redis_err(self) -> Result<T, DataLayerError> {
|
||||
self.map_err(redis_error)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,550 @@
|
||||
use crate::error::RedisResultExt;
|
||||
use crate::redis::RedisKeyspace;
|
||||
use crate::DataLayerError;
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tracing::info;
|
||||
|
||||
pub(crate) type RedisClient = redis::Client;
|
||||
pub(crate) type RedisManagedConnection = redis::aio::ConnectionManager;
|
||||
|
||||
const DEFAULT_STREAM_LANES: usize = 4;
|
||||
const DEFAULT_BLOCKING_STREAM_LANES_FALLBACK: usize = 4;
|
||||
const DEFAULT_BLOCKING_STREAM_LANES_CAP: usize = 16;
|
||||
const MAX_BLOCKING_STREAM_LANES_CAP: usize = 64;
|
||||
pub(crate) const REDIS_COMMAND_LATENCY_BUCKETS_MS: [u64; 12] =
|
||||
[1, 5, 10, 25, 50, 100, 250, 500, 1_000, 2_500, 5_000, 10_000];
|
||||
const REDIS_COMMAND_LATENCY_BUCKET_COUNT: usize = REDIS_COMMAND_LATENCY_BUCKETS_MS.len() + 1;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
pub struct RedisClientConfig {
|
||||
pub url: String,
|
||||
pub key_prefix: Option<String>,
|
||||
}
|
||||
|
||||
impl RedisClientConfig {
|
||||
pub fn validate(&self) -> Result<(), DataLayerError> {
|
||||
let raw = self.url.trim();
|
||||
if raw.is_empty() {
|
||||
return Err(DataLayerError::InvalidConfiguration(
|
||||
"redis url cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
url::Url::parse(raw).map_err(|err| {
|
||||
DataLayerError::InvalidConfiguration(format!("invalid redis url: {err}"))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn keyspace(&self) -> RedisKeyspace {
|
||||
RedisKeyspace::new(self.key_prefix.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct RedisClientFactory {
|
||||
config: RedisClientConfig,
|
||||
}
|
||||
|
||||
impl RedisClientFactory {
|
||||
pub(crate) fn new(config: RedisClientConfig) -> Result<Self, DataLayerError> {
|
||||
config.validate()?;
|
||||
Ok(Self { config })
|
||||
}
|
||||
|
||||
pub(crate) fn config(&self) -> &RedisClientConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
pub(crate) fn connect_lazy(&self) -> Result<RedisClient, DataLayerError> {
|
||||
RedisClient::open(self.config.url.clone()).map_redis_err()
|
||||
}
|
||||
|
||||
pub(crate) async fn connect_router(
|
||||
&self,
|
||||
command_timeout_ms: Option<u64>,
|
||||
) -> Result<RedisConnectionRouter, DataLayerError> {
|
||||
self.connect_router_with_blocking_stream_lanes(command_timeout_ms, None)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn connect_router_with_blocking_stream_lanes(
|
||||
&self,
|
||||
command_timeout_ms: Option<u64>,
|
||||
blocking_stream_lanes: Option<usize>,
|
||||
) -> Result<RedisConnectionRouter, DataLayerError> {
|
||||
RedisConnectionRouter::connect(
|
||||
self.connect_lazy()?,
|
||||
command_timeout_ms,
|
||||
blocking_stream_lanes,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum RedisConnectionLane {
|
||||
Fast,
|
||||
Stream,
|
||||
BlockingStream,
|
||||
Admin,
|
||||
}
|
||||
|
||||
impl RedisConnectionLane {
|
||||
pub(crate) const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Fast => "fast",
|
||||
Self::Stream => "stream",
|
||||
Self::BlockingStream => "blocking_stream",
|
||||
Self::Admin => "admin",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct RedisConnectionRouter {
|
||||
fast: RedisManagedConnection,
|
||||
stream: Arc<Vec<RedisManagedConnection>>,
|
||||
stream_next: Arc<AtomicUsize>,
|
||||
blocking_stream: Arc<Vec<RedisManagedConnection>>,
|
||||
blocking_stream_next: Arc<AtomicUsize>,
|
||||
admin: RedisManagedConnection,
|
||||
metrics: Arc<RedisConnectionMetrics>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RedisConnectionRouter {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("RedisConnectionRouter")
|
||||
.field("lanes", &["fast", "stream", "blocking_stream", "admin"])
|
||||
.field("stream_lanes", &self.stream.len())
|
||||
.field("blocking_stream_lanes", &self.blocking_stream.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl RedisConnectionRouter {
|
||||
pub(crate) async fn connect(
|
||||
client: RedisClient,
|
||||
command_timeout_ms: Option<u64>,
|
||||
blocking_stream_lanes: Option<usize>,
|
||||
) -> Result<Self, DataLayerError> {
|
||||
let fast = connect_lane(
|
||||
&client,
|
||||
connection_manager_config(command_timeout_ms),
|
||||
RedisConnectionLane::Fast,
|
||||
command_timeout_ms,
|
||||
)
|
||||
.await?;
|
||||
let stream = connect_stream_lanes(&client, command_timeout_ms).await?;
|
||||
let blocking_stream =
|
||||
connect_blocking_stream_lanes(&client, command_timeout_ms, blocking_stream_lanes)
|
||||
.await?;
|
||||
let admin = connect_lane(
|
||||
&client,
|
||||
connection_manager_config(command_timeout_ms),
|
||||
RedisConnectionLane::Admin,
|
||||
command_timeout_ms,
|
||||
)
|
||||
.await?;
|
||||
let stream_lanes = stream.len();
|
||||
let blocking_stream_lanes = blocking_stream.len();
|
||||
info!(
|
||||
redis_lanes = "fast,stream,blocking_stream,admin",
|
||||
redis_stream_lanes = stream_lanes,
|
||||
redis_blocking_stream_lanes = blocking_stream_lanes,
|
||||
"runtime redis connection lanes initialized"
|
||||
);
|
||||
Ok(Self {
|
||||
fast,
|
||||
stream: Arc::new(stream),
|
||||
stream_next: Arc::new(AtomicUsize::new(0)),
|
||||
blocking_stream: Arc::new(blocking_stream),
|
||||
blocking_stream_next: Arc::new(AtomicUsize::new(0)),
|
||||
admin,
|
||||
metrics: Arc::new(RedisConnectionMetrics::default()),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn connection(&self, lane: RedisConnectionLane) -> RedisManagedConnection {
|
||||
match lane {
|
||||
RedisConnectionLane::Fast => self.fast.clone(),
|
||||
RedisConnectionLane::Stream => {
|
||||
let index = next_lane_index(&self.stream_next, self.stream.len());
|
||||
self.stream[index].clone()
|
||||
}
|
||||
RedisConnectionLane::BlockingStream => {
|
||||
let index = next_lane_index(&self.blocking_stream_next, self.blocking_stream.len());
|
||||
self.blocking_stream[index].clone()
|
||||
}
|
||||
RedisConnectionLane::Admin => self.admin.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_error(&self, lane: RedisConnectionLane) {
|
||||
self.metrics
|
||||
.for_lane(lane)
|
||||
.errors
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn record_timeout(&self, lane: RedisConnectionLane) {
|
||||
self.metrics
|
||||
.for_lane(lane)
|
||||
.timeouts
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn record_latency(&self, lane: RedisConnectionLane, elapsed: Duration) {
|
||||
self.metrics.for_lane(lane).record_latency(elapsed);
|
||||
}
|
||||
|
||||
pub(crate) fn lane_diagnostics(&self) -> Vec<RedisLaneDiagnostics> {
|
||||
[
|
||||
RedisConnectionLane::Fast,
|
||||
RedisConnectionLane::Stream,
|
||||
RedisConnectionLane::BlockingStream,
|
||||
RedisConnectionLane::Admin,
|
||||
]
|
||||
.into_iter()
|
||||
.map(|lane| {
|
||||
let metrics = self.metrics.for_lane(lane);
|
||||
RedisLaneDiagnostics {
|
||||
lane: lane.as_str(),
|
||||
command_errors: metrics.errors.load(Ordering::Relaxed),
|
||||
command_timeouts: metrics.timeouts.load(Ordering::Relaxed),
|
||||
command_count: metrics.command_count.load(Ordering::Relaxed),
|
||||
command_latency_total_ms: metrics.latency_total_ms.load(Ordering::Relaxed),
|
||||
command_latency_max_ms: metrics.latency_max_ms.load(Ordering::Relaxed),
|
||||
command_latency_buckets: metrics.latency_buckets(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct RedisLaneDiagnostics {
|
||||
pub lane: &'static str,
|
||||
pub command_errors: u64,
|
||||
pub command_timeouts: u64,
|
||||
pub command_count: u64,
|
||||
pub command_latency_total_ms: u64,
|
||||
pub command_latency_max_ms: u64,
|
||||
pub command_latency_buckets: Vec<RedisCommandLatencyBucket>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct RedisCommandLatencyBucket {
|
||||
pub le_ms: Option<u64>,
|
||||
pub count: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RedisConnectionMetrics {
|
||||
fast: RedisLaneMetrics,
|
||||
stream: RedisLaneMetrics,
|
||||
blocking_stream: RedisLaneMetrics,
|
||||
admin: RedisLaneMetrics,
|
||||
}
|
||||
|
||||
impl RedisConnectionMetrics {
|
||||
fn for_lane(&self, lane: RedisConnectionLane) -> &RedisLaneMetrics {
|
||||
match lane {
|
||||
RedisConnectionLane::Fast => &self.fast,
|
||||
RedisConnectionLane::Stream => &self.stream,
|
||||
RedisConnectionLane::BlockingStream => &self.blocking_stream,
|
||||
RedisConnectionLane::Admin => &self.admin,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RedisLaneMetrics {
|
||||
errors: AtomicU64,
|
||||
timeouts: AtomicU64,
|
||||
command_count: AtomicU64,
|
||||
latency_total_ms: AtomicU64,
|
||||
latency_max_ms: AtomicU64,
|
||||
latency_bucket_counts: [AtomicU64; REDIS_COMMAND_LATENCY_BUCKET_COUNT],
|
||||
}
|
||||
|
||||
impl Default for RedisLaneMetrics {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
errors: AtomicU64::new(0),
|
||||
timeouts: AtomicU64::new(0),
|
||||
command_count: AtomicU64::new(0),
|
||||
latency_total_ms: AtomicU64::new(0),
|
||||
latency_max_ms: AtomicU64::new(0),
|
||||
latency_bucket_counts: std::array::from_fn(|_| AtomicU64::new(0)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RedisLaneMetrics {
|
||||
fn record_latency(&self, elapsed: Duration) {
|
||||
let elapsed_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
|
||||
self.command_count.fetch_add(1, Ordering::Relaxed);
|
||||
self.latency_total_ms
|
||||
.fetch_add(elapsed_ms, Ordering::Relaxed);
|
||||
update_atomic_max(&self.latency_max_ms, elapsed_ms);
|
||||
|
||||
let bucket_index = REDIS_COMMAND_LATENCY_BUCKETS_MS
|
||||
.iter()
|
||||
.position(|upper_bound_ms| elapsed_ms <= *upper_bound_ms)
|
||||
.unwrap_or(REDIS_COMMAND_LATENCY_BUCKETS_MS.len());
|
||||
self.latency_bucket_counts[bucket_index].fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn latency_buckets(&self) -> Vec<RedisCommandLatencyBucket> {
|
||||
let mut cumulative = 0u64;
|
||||
let mut buckets = Vec::with_capacity(REDIS_COMMAND_LATENCY_BUCKET_COUNT);
|
||||
for (index, upper_bound_ms) in REDIS_COMMAND_LATENCY_BUCKETS_MS.iter().enumerate() {
|
||||
cumulative = cumulative
|
||||
.saturating_add(self.latency_bucket_counts[index].load(Ordering::Relaxed));
|
||||
buckets.push(RedisCommandLatencyBucket {
|
||||
le_ms: Some(*upper_bound_ms),
|
||||
count: cumulative,
|
||||
});
|
||||
}
|
||||
cumulative = cumulative.saturating_add(
|
||||
self.latency_bucket_counts[REDIS_COMMAND_LATENCY_BUCKETS_MS.len()]
|
||||
.load(Ordering::Relaxed),
|
||||
);
|
||||
buckets.push(RedisCommandLatencyBucket {
|
||||
le_ms: None,
|
||||
count: cumulative,
|
||||
});
|
||||
buckets
|
||||
}
|
||||
}
|
||||
|
||||
fn update_atomic_max(target: &AtomicU64, value: u64) {
|
||||
let mut current = target.load(Ordering::Relaxed);
|
||||
while value > current {
|
||||
match target.compare_exchange_weak(current, value, Ordering::Relaxed, Ordering::Relaxed) {
|
||||
Ok(_) => break,
|
||||
Err(next) => current = next,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn connection_manager_config(
|
||||
command_timeout_ms: Option<u64>,
|
||||
) -> redis::aio::ConnectionManagerConfig {
|
||||
let mut config = redis::aio::ConnectionManagerConfig::new();
|
||||
if let Some(timeout_ms) = command_timeout_ms {
|
||||
config = config.set_connection_timeout(Duration::from_millis(timeout_ms));
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
async fn connect_stream_lanes(
|
||||
client: &RedisClient,
|
||||
command_timeout_ms: Option<u64>,
|
||||
) -> Result<Vec<RedisManagedConnection>, DataLayerError> {
|
||||
let lane_count = stream_lane_count();
|
||||
let mut lanes = Vec::with_capacity(lane_count);
|
||||
for _ in 0..lane_count {
|
||||
lanes.push(
|
||||
connect_lane(
|
||||
client,
|
||||
connection_manager_config(command_timeout_ms),
|
||||
RedisConnectionLane::Stream,
|
||||
command_timeout_ms,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
Ok(lanes)
|
||||
}
|
||||
|
||||
const fn stream_lane_count() -> usize {
|
||||
DEFAULT_STREAM_LANES
|
||||
}
|
||||
|
||||
fn next_lane_index(next: &AtomicUsize, lane_count: usize) -> usize {
|
||||
debug_assert!(lane_count > 0, "redis connection lane must not be empty");
|
||||
next.fetch_add(1, Ordering::Relaxed) % lane_count
|
||||
}
|
||||
|
||||
async fn connect_blocking_stream_lanes(
|
||||
client: &RedisClient,
|
||||
command_timeout_ms: Option<u64>,
|
||||
requested_lanes: Option<usize>,
|
||||
) -> Result<Vec<RedisManagedConnection>, DataLayerError> {
|
||||
let lane_count = blocking_stream_lane_count(requested_lanes)?;
|
||||
let mut lanes = Vec::with_capacity(lane_count);
|
||||
for _ in 0..lane_count {
|
||||
lanes.push(
|
||||
connect_lane(
|
||||
client,
|
||||
connection_manager_config(command_timeout_ms),
|
||||
RedisConnectionLane::BlockingStream,
|
||||
command_timeout_ms,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
Ok(lanes)
|
||||
}
|
||||
|
||||
fn blocking_stream_lane_count(requested_lanes: Option<usize>) -> Result<usize, DataLayerError> {
|
||||
if matches!(requested_lanes, Some(0)) {
|
||||
return Err(DataLayerError::InvalidConfiguration(
|
||||
"runtime redis blocking_stream_lanes must be positive".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let default_lanes = default_blocking_stream_lane_count();
|
||||
Ok(requested_lanes
|
||||
.map(|lanes| lanes.max(default_lanes))
|
||||
.unwrap_or(default_lanes)
|
||||
.clamp(1, MAX_BLOCKING_STREAM_LANES_CAP))
|
||||
}
|
||||
|
||||
fn default_blocking_stream_lane_count() -> usize {
|
||||
std::thread::available_parallelism()
|
||||
.map(|value| value.get())
|
||||
.unwrap_or(DEFAULT_BLOCKING_STREAM_LANES_FALLBACK)
|
||||
.clamp(
|
||||
DEFAULT_BLOCKING_STREAM_LANES_FALLBACK,
|
||||
DEFAULT_BLOCKING_STREAM_LANES_CAP,
|
||||
)
|
||||
}
|
||||
|
||||
async fn connect_lane(
|
||||
client: &RedisClient,
|
||||
config: redis::aio::ConnectionManagerConfig,
|
||||
lane: RedisConnectionLane,
|
||||
command_timeout_ms: Option<u64>,
|
||||
) -> Result<RedisManagedConnection, DataLayerError> {
|
||||
let connect = client.get_connection_manager_with_config(config);
|
||||
let result = if let Some(timeout_ms) = command_timeout_ms {
|
||||
match tokio::time::timeout(Duration::from_millis(timeout_ms), connect).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
return Err(DataLayerError::TimedOut(format!(
|
||||
"runtime redis {} lane connection exceeded {}ms timeout",
|
||||
lane.as_str(),
|
||||
timeout_ms
|
||||
)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
connect.await
|
||||
};
|
||||
result.map_err(|err| {
|
||||
DataLayerError::Redis(format!(
|
||||
"failed to initialize runtime redis {} lane: {err}",
|
||||
lane.as_str()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
blocking_stream_lane_count, default_blocking_stream_lane_count, next_lane_index,
|
||||
stream_lane_count, RedisClientConfig, RedisClientFactory, RedisLaneMetrics,
|
||||
DEFAULT_STREAM_LANES, MAX_BLOCKING_STREAM_LANES_CAP, REDIS_COMMAND_LATENCY_BUCKETS_MS,
|
||||
};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn factory_builds_lazy_client_from_valid_config() {
|
||||
let config = RedisClientConfig {
|
||||
url: "redis://127.0.0.1/0".to_string(),
|
||||
key_prefix: Some("aether".to_string()),
|
||||
};
|
||||
let factory = RedisClientFactory::new(config.clone()).expect("factory should build");
|
||||
|
||||
assert_eq!(factory.config(), &config);
|
||||
let _client = factory
|
||||
.connect_lazy()
|
||||
.expect("lazy redis client should build");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_stream_lane_count_uses_requested_as_floor() {
|
||||
let default_lanes = default_blocking_stream_lane_count();
|
||||
|
||||
assert_eq!(
|
||||
blocking_stream_lane_count(None).expect("default lanes"),
|
||||
default_lanes
|
||||
);
|
||||
assert_eq!(
|
||||
blocking_stream_lane_count(Some(1)).expect("requested below default"),
|
||||
default_lanes
|
||||
);
|
||||
assert_eq!(
|
||||
blocking_stream_lane_count(Some(default_lanes + 1)).expect("requested above default"),
|
||||
default_lanes + 1
|
||||
);
|
||||
assert_eq!(
|
||||
blocking_stream_lane_count(Some(MAX_BLOCKING_STREAM_LANES_CAP + 1))
|
||||
.expect("requested above cap"),
|
||||
MAX_BLOCKING_STREAM_LANES_CAP
|
||||
);
|
||||
assert!(blocking_stream_lane_count(Some(0)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_lane_count_uses_fixed_default() {
|
||||
assert_eq!(stream_lane_count(), DEFAULT_STREAM_LANES);
|
||||
assert_eq!(stream_lane_count(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lane_index_round_robins_across_all_connections() {
|
||||
let next = AtomicUsize::new(0);
|
||||
|
||||
let indexes = (0..10)
|
||||
.map(|_| next_lane_index(&next, stream_lane_count()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(indexes, vec![0, 1, 2, 3, 0, 1, 2, 3, 0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lane_index_round_robin_survives_counter_wraparound() {
|
||||
let next = AtomicUsize::new(usize::MAX - 1);
|
||||
|
||||
let indexes = (0..3)
|
||||
.map(|_| next_lane_index(&next, stream_lane_count()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(indexes, vec![2, 3, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lane_metrics_record_cumulative_latency_buckets() {
|
||||
let metrics = RedisLaneMetrics::default();
|
||||
|
||||
metrics.record_latency(Duration::from_millis(0));
|
||||
metrics.record_latency(Duration::from_millis(12));
|
||||
metrics.record_latency(Duration::from_millis(12_345));
|
||||
|
||||
assert_eq!(metrics.command_count.load(Ordering::Relaxed), 3);
|
||||
assert_eq!(metrics.latency_total_ms.load(Ordering::Relaxed), 12_357);
|
||||
assert_eq!(metrics.latency_max_ms.load(Ordering::Relaxed), 12_345);
|
||||
|
||||
let buckets = metrics.latency_buckets();
|
||||
let le_1 = buckets
|
||||
.iter()
|
||||
.find(|bucket| bucket.le_ms == Some(1))
|
||||
.expect("1ms bucket");
|
||||
let le_25 = buckets
|
||||
.iter()
|
||||
.find(|bucket| bucket.le_ms == Some(25))
|
||||
.expect("25ms bucket");
|
||||
let plus_inf = buckets.last().expect("+Inf bucket");
|
||||
|
||||
assert_eq!(buckets.len(), REDIS_COMMAND_LATENCY_BUCKETS_MS.len() + 1);
|
||||
assert_eq!(le_1.count, 1);
|
||||
assert_eq!(le_25.count, 2);
|
||||
assert_eq!(plus_inf.le_ms, None);
|
||||
assert_eq!(plus_inf.count, 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
use std::future::Future;
|
||||
|
||||
use crate::error::RedisResultExt;
|
||||
use crate::redis::{
|
||||
run_lane_with_timeout, RedisClientConfig, RedisClientFactory, RedisConnectionLane,
|
||||
RedisConnectionRouter, RedisKeyspace,
|
||||
};
|
||||
use crate::DataLayerError;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RedisKvRunnerConfig {
|
||||
pub command_timeout_ms: Option<u64>,
|
||||
pub default_ttl_seconds: u64,
|
||||
}
|
||||
|
||||
impl Default for RedisKvRunnerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
command_timeout_ms: Some(1_000),
|
||||
default_ttl_seconds: 300,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RedisKvRunnerConfig {
|
||||
pub fn validate(&self) -> Result<(), DataLayerError> {
|
||||
if let Some(timeout) = self.command_timeout_ms {
|
||||
if timeout == 0 {
|
||||
return Err(DataLayerError::InvalidConfiguration(
|
||||
"redis kv command_timeout_ms must be positive".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if self.default_ttl_seconds == 0 {
|
||||
return Err(DataLayerError::InvalidConfiguration(
|
||||
"redis kv default_ttl_seconds must be positive".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RedisKvRunner {
|
||||
connections: RedisConnectionRouter,
|
||||
keyspace: RedisKeyspace,
|
||||
config: RedisKvRunnerConfig,
|
||||
}
|
||||
|
||||
impl RedisKvRunner {
|
||||
pub(crate) fn new(
|
||||
connections: RedisConnectionRouter,
|
||||
keyspace: RedisKeyspace,
|
||||
config: RedisKvRunnerConfig,
|
||||
) -> Result<Self, DataLayerError> {
|
||||
config.validate()?;
|
||||
Ok(Self {
|
||||
connections,
|
||||
keyspace,
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn from_config(
|
||||
config: RedisClientConfig,
|
||||
runner_config: RedisKvRunnerConfig,
|
||||
) -> Result<Self, DataLayerError> {
|
||||
let factory = RedisClientFactory::new(config)?;
|
||||
let keyspace = factory.config().keyspace();
|
||||
let connections = factory
|
||||
.connect_router(runner_config.command_timeout_ms)
|
||||
.await?;
|
||||
Self::new(connections, keyspace, runner_config)
|
||||
}
|
||||
|
||||
pub fn keyspace(&self) -> &RedisKeyspace {
|
||||
&self.keyspace
|
||||
}
|
||||
|
||||
pub fn config(&self) -> RedisKvRunnerConfig {
|
||||
self.config
|
||||
}
|
||||
|
||||
pub async fn setex(
|
||||
&self,
|
||||
key: &str,
|
||||
value: &str,
|
||||
ttl_seconds: Option<u64>,
|
||||
) -> Result<String, DataLayerError> {
|
||||
let resolved_ttl = ttl_seconds.unwrap_or(self.config.default_ttl_seconds);
|
||||
let namespaced_key = self.keyspace.key(key);
|
||||
self.run_with_timeout(RedisConnectionLane::Fast, "redis kv setex", async {
|
||||
let mut connection = self.connections.connection(RedisConnectionLane::Fast);
|
||||
redis::cmd("SETEX")
|
||||
.arg(&namespaced_key)
|
||||
.arg(resolved_ttl)
|
||||
.arg(value)
|
||||
.query_async(&mut connection)
|
||||
.await
|
||||
.map_redis_err()
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get(&self, key: &str) -> Result<Option<String>, DataLayerError> {
|
||||
let namespaced_key = self.keyspace.key(key);
|
||||
self.run_with_timeout(RedisConnectionLane::Fast, "redis kv get", async {
|
||||
let mut connection = self.connections.connection(RedisConnectionLane::Fast);
|
||||
redis::cmd("GET")
|
||||
.arg(&namespaced_key)
|
||||
.query_async(&mut connection)
|
||||
.await
|
||||
.map_redis_err()
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn getdel(&self, key: &str) -> Result<Option<String>, DataLayerError> {
|
||||
let namespaced_key = self.keyspace.key(key);
|
||||
self.run_with_timeout(RedisConnectionLane::Fast, "redis kv getdel", async {
|
||||
let mut connection = self.connections.connection(RedisConnectionLane::Fast);
|
||||
redis::cmd("GETDEL")
|
||||
.arg(&namespaced_key)
|
||||
.query_async(&mut connection)
|
||||
.await
|
||||
.map_redis_err()
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn exists(&self, key: &str) -> Result<bool, DataLayerError> {
|
||||
let namespaced_key = self.keyspace.key(key);
|
||||
let exists = self
|
||||
.run_with_timeout(RedisConnectionLane::Fast, "redis kv exists", async {
|
||||
let mut connection = self.connections.connection(RedisConnectionLane::Fast);
|
||||
redis::cmd("EXISTS")
|
||||
.arg(&namespaced_key)
|
||||
.query_async::<i64>(&mut connection)
|
||||
.await
|
||||
.map_redis_err()
|
||||
})
|
||||
.await?;
|
||||
Ok(exists > 0)
|
||||
}
|
||||
|
||||
pub async fn del(&self, key: &str) -> Result<i64, DataLayerError> {
|
||||
let namespaced_key = self.keyspace.key(key);
|
||||
self.run_with_timeout(RedisConnectionLane::Fast, "redis kv del", async {
|
||||
let mut connection = self.connections.connection(RedisConnectionLane::Fast);
|
||||
redis::cmd("DEL")
|
||||
.arg(&namespaced_key)
|
||||
.query_async(&mut connection)
|
||||
.await
|
||||
.map_redis_err()
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn run_with_timeout<T, F>(
|
||||
&self,
|
||||
lane: RedisConnectionLane,
|
||||
operation: &'static str,
|
||||
future: F,
|
||||
) -> Result<T, DataLayerError>
|
||||
where
|
||||
F: Future<Output = Result<T, DataLayerError>>,
|
||||
{
|
||||
run_lane_with_timeout(
|
||||
&self.connections,
|
||||
lane,
|
||||
self.config.command_timeout_ms,
|
||||
operation,
|
||||
future,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::RedisKvRunnerConfig;
|
||||
|
||||
#[test]
|
||||
fn validates_default_config() {
|
||||
RedisKvRunnerConfig::default()
|
||||
.validate()
|
||||
.expect("default kv config should be valid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_default_ttl() {
|
||||
let config = RedisKvRunnerConfig {
|
||||
command_timeout_ms: Some(100),
|
||||
default_ttl_seconds: 0,
|
||||
};
|
||||
assert!(config.validate().is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
use std::future::Future;
|
||||
|
||||
use crate::error::RedisResultExt;
|
||||
use crate::redis::{
|
||||
run_lane_with_timeout, RedisClientConfig, RedisClientFactory, RedisConnectionLane,
|
||||
RedisConnectionRouter, RedisKeyspace,
|
||||
};
|
||||
use crate::DataLayerError;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct RedisLockKey(pub String);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RedisLockLease {
|
||||
pub key: RedisLockKey,
|
||||
pub owner: String,
|
||||
pub token: String,
|
||||
pub fencing_token: u64,
|
||||
pub ttl_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RedisLockRunnerConfig {
|
||||
pub command_timeout_ms: Option<u64>,
|
||||
pub default_ttl_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for RedisLockRunnerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
command_timeout_ms: Some(1_000),
|
||||
default_ttl_ms: 15_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RedisLockRunnerConfig {
|
||||
pub fn validate(&self) -> Result<(), DataLayerError> {
|
||||
if matches!(self.command_timeout_ms, Some(0)) {
|
||||
return Err(DataLayerError::InvalidConfiguration(
|
||||
"redis lock command_timeout_ms must be positive".to_string(),
|
||||
));
|
||||
}
|
||||
if self.default_ttl_ms == 0 {
|
||||
return Err(DataLayerError::InvalidConfiguration(
|
||||
"redis lock default_ttl_ms must be positive".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RedisLockRunner {
|
||||
connections: RedisConnectionRouter,
|
||||
keyspace: RedisKeyspace,
|
||||
config: RedisLockRunnerConfig,
|
||||
}
|
||||
|
||||
impl RedisLockRunner {
|
||||
pub(crate) fn new(
|
||||
connections: RedisConnectionRouter,
|
||||
keyspace: RedisKeyspace,
|
||||
config: RedisLockRunnerConfig,
|
||||
) -> Result<Self, DataLayerError> {
|
||||
config.validate()?;
|
||||
Ok(Self {
|
||||
connections,
|
||||
keyspace,
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn from_config(
|
||||
config: RedisClientConfig,
|
||||
runner_config: RedisLockRunnerConfig,
|
||||
) -> Result<Self, DataLayerError> {
|
||||
let factory = RedisClientFactory::new(config)?;
|
||||
let keyspace = factory.config().keyspace();
|
||||
let connections = factory
|
||||
.connect_router(runner_config.command_timeout_ms)
|
||||
.await?;
|
||||
Self::new(connections, keyspace, runner_config)
|
||||
}
|
||||
|
||||
pub fn keyspace(&self) -> &RedisKeyspace {
|
||||
&self.keyspace
|
||||
}
|
||||
|
||||
pub fn config(&self) -> RedisLockRunnerConfig {
|
||||
self.config
|
||||
}
|
||||
|
||||
pub async fn try_acquire(
|
||||
&self,
|
||||
key: &RedisLockKey,
|
||||
owner: &str,
|
||||
ttl_ms: Option<u64>,
|
||||
) -> Result<Option<RedisLockLease>, DataLayerError> {
|
||||
validate_owner(owner)?;
|
||||
validate_key(key)?;
|
||||
let ttl_ms = self.resolve_ttl_ms(ttl_ms)?;
|
||||
let token = format!("{owner}:{}", Uuid::new_v4());
|
||||
let fencing_key = format!("{}:fencing", key.0);
|
||||
|
||||
self.run_with_timeout(RedisConnectionLane::Fast, "redis lock acquire", async {
|
||||
let mut connection = self.connections.connection(RedisConnectionLane::Fast);
|
||||
let fencing_token = redis::Script::new(
|
||||
"local acquired = redis.call('set', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2]) \n\
|
||||
if not acquired then return 0 end \n\
|
||||
return redis.call('incr', KEYS[2])",
|
||||
)
|
||||
.key(&key.0)
|
||||
.key(&fencing_key)
|
||||
.arg(&token)
|
||||
.arg(ttl_ms)
|
||||
.invoke_async::<i64>(&mut connection)
|
||||
.await
|
||||
.map_redis_err()?;
|
||||
|
||||
Ok((fencing_token > 0).then(|| RedisLockLease {
|
||||
key: key.clone(),
|
||||
owner: owner.to_string(),
|
||||
token,
|
||||
fencing_token: u64::try_from(fencing_token).unwrap_or(u64::MAX),
|
||||
ttl_ms,
|
||||
}))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn release(&self, lease: &RedisLockLease) -> Result<bool, DataLayerError> {
|
||||
validate_lease(lease)?;
|
||||
self.run_with_timeout(RedisConnectionLane::Fast, "redis lock release", async {
|
||||
let mut connection = self.connections.connection(RedisConnectionLane::Fast);
|
||||
let deleted = redis::Script::new(
|
||||
"if redis.call('get', KEYS[1]) == ARGV[1] then \
|
||||
return redis.call('del', KEYS[1]) \
|
||||
else \
|
||||
return 0 \
|
||||
end",
|
||||
)
|
||||
.key(&lease.key.0)
|
||||
.arg(&lease.token)
|
||||
.invoke_async::<i32>(&mut connection)
|
||||
.await
|
||||
.map_redis_err()?;
|
||||
Ok(deleted > 0)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn renew(
|
||||
&self,
|
||||
lease: &RedisLockLease,
|
||||
ttl_ms: Option<u64>,
|
||||
) -> Result<bool, DataLayerError> {
|
||||
validate_lease(lease)?;
|
||||
let ttl_ms = self.resolve_ttl_ms(ttl_ms)?;
|
||||
|
||||
self.run_with_timeout(RedisConnectionLane::Fast, "redis lock renew", async {
|
||||
let mut connection = self.connections.connection(RedisConnectionLane::Fast);
|
||||
let renewed = redis::Script::new(
|
||||
"if redis.call('get', KEYS[1]) == ARGV[1] then \
|
||||
return redis.call('pexpire', KEYS[1], ARGV[2]) \
|
||||
else \
|
||||
return 0 \
|
||||
end",
|
||||
)
|
||||
.key(&lease.key.0)
|
||||
.arg(&lease.token)
|
||||
.arg(ttl_ms)
|
||||
.invoke_async::<i32>(&mut connection)
|
||||
.await
|
||||
.map_redis_err()?;
|
||||
Ok(renewed > 0)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn run_with_timeout<T, F>(
|
||||
&self,
|
||||
lane: RedisConnectionLane,
|
||||
operation: &'static str,
|
||||
future: F,
|
||||
) -> Result<T, DataLayerError>
|
||||
where
|
||||
F: Future<Output = Result<T, DataLayerError>>,
|
||||
{
|
||||
run_lane_with_timeout(
|
||||
&self.connections,
|
||||
lane,
|
||||
self.config.command_timeout_ms,
|
||||
operation,
|
||||
future,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn resolve_ttl_ms(&self, ttl_ms: Option<u64>) -> Result<u64, DataLayerError> {
|
||||
let ttl_ms = ttl_ms.unwrap_or(self.config.default_ttl_ms);
|
||||
if ttl_ms == 0 {
|
||||
return Err(DataLayerError::InvalidInput(
|
||||
"redis lock ttl_ms must be positive".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(ttl_ms)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_owner(owner: &str) -> Result<(), DataLayerError> {
|
||||
if owner.trim().is_empty() {
|
||||
return Err(DataLayerError::InvalidInput(
|
||||
"redis lock owner cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_key(key: &RedisLockKey) -> Result<(), DataLayerError> {
|
||||
if key.0.trim().is_empty() {
|
||||
return Err(DataLayerError::InvalidInput(
|
||||
"redis lock key cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_lease(lease: &RedisLockLease) -> Result<(), DataLayerError> {
|
||||
validate_key(&lease.key)?;
|
||||
validate_owner(&lease.owner)?;
|
||||
if lease.token.trim().is_empty() {
|
||||
return Err(DataLayerError::InvalidInput(
|
||||
"redis lock token cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if lease.fencing_token == 0 {
|
||||
return Err(DataLayerError::InvalidInput(
|
||||
"redis lock fencing_token must be positive".to_string(),
|
||||
));
|
||||
}
|
||||
if lease.ttl_ms == 0 {
|
||||
return Err(DataLayerError::InvalidInput(
|
||||
"redis lock ttl_ms must be positive".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
validate_key, validate_lease, validate_owner, RedisLockKey, RedisLockLease,
|
||||
RedisLockRunnerConfig,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn validates_runner_config() {
|
||||
assert!(RedisLockRunnerConfig {
|
||||
command_timeout_ms: Some(0),
|
||||
..RedisLockRunnerConfig::default()
|
||||
}
|
||||
.validate()
|
||||
.is_err());
|
||||
assert!(RedisLockRunnerConfig {
|
||||
default_ttl_ms: 0,
|
||||
..RedisLockRunnerConfig::default()
|
||||
}
|
||||
.validate()
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runner_reuses_client_and_keyspace() {
|
||||
RedisLockRunnerConfig::default()
|
||||
.validate()
|
||||
.expect("default lock config should be valid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_owner_or_lease_before_network() {
|
||||
assert!(validate_owner("").is_err());
|
||||
assert!(validate_key(&RedisLockKey(String::new())).is_err());
|
||||
assert!(validate_lease(&RedisLockLease {
|
||||
key: RedisLockKey("aether:lock:poller".to_string()),
|
||||
owner: "worker-1".to_string(),
|
||||
token: String::new(),
|
||||
fencing_token: 1,
|
||||
ttl_ms: 1_000,
|
||||
})
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
mod client;
|
||||
mod kv;
|
||||
mod lock;
|
||||
mod namespace;
|
||||
mod runtime;
|
||||
mod stream;
|
||||
|
||||
pub use client::{RedisClientConfig, RedisLaneDiagnostics};
|
||||
pub use kv::{RedisKvRunner, RedisKvRunnerConfig};
|
||||
pub use lock::{RedisLockKey, RedisLockLease, RedisLockRunner, RedisLockRunnerConfig};
|
||||
pub use namespace::RedisKeyspace;
|
||||
pub use runtime::RedisRuntimeDiagnostics;
|
||||
pub use stream::{
|
||||
RedisConsumerGroup, RedisConsumerName, RedisStreamEntry, RedisStreamName,
|
||||
RedisStreamReclaimConfig, RedisStreamReclaimResult, RedisStreamRunner, RedisStreamRunnerConfig,
|
||||
};
|
||||
|
||||
pub(crate) type RedisCmd = redis::Cmd;
|
||||
pub(crate) type RedisScript = redis::Script;
|
||||
|
||||
pub(crate) use client::{RedisClientFactory, RedisConnectionLane, RedisConnectionRouter};
|
||||
pub(crate) use runtime::RedisRuntimeRunner;
|
||||
|
||||
pub(crate) fn cmd(name: &str) -> RedisCmd {
|
||||
redis::cmd(name)
|
||||
}
|
||||
|
||||
pub(crate) fn script(source: &str) -> RedisScript {
|
||||
redis::Script::new(source)
|
||||
}
|
||||
|
||||
pub(crate) async fn run_lane_with_timeout<T, F>(
|
||||
connections: &RedisConnectionRouter,
|
||||
lane: RedisConnectionLane,
|
||||
timeout_ms: Option<u64>,
|
||||
operation: &'static str,
|
||||
future: F,
|
||||
) -> Result<T, crate::DataLayerError>
|
||||
where
|
||||
F: std::future::Future<Output = Result<T, crate::DataLayerError>>,
|
||||
{
|
||||
let started = std::time::Instant::now();
|
||||
let result = if let Some(timeout_ms) = timeout_ms {
|
||||
match tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), future).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
connections.record_timeout(lane);
|
||||
connections.record_latency(lane, started.elapsed());
|
||||
return Err(crate::DataLayerError::TimedOut(format!(
|
||||
"{operation} exceeded {timeout_ms}ms timeout"
|
||||
)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
future.await
|
||||
};
|
||||
connections.record_latency(lane, started.elapsed());
|
||||
if result.is_err() {
|
||||
connections.record_error(lane);
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
use aether_cache::CacheKeyNamespace;
|
||||
|
||||
use crate::redis::{RedisLockKey, RedisStreamName};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RedisKeyspace {
|
||||
namespace: CacheKeyNamespace,
|
||||
}
|
||||
|
||||
impl RedisKeyspace {
|
||||
pub fn new(prefix: Option<&str>) -> Self {
|
||||
let normalized = prefix.unwrap_or_default().trim().trim_matches(':');
|
||||
Self {
|
||||
namespace: CacheKeyNamespace::new(normalized),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn key(&self, raw_key: &str) -> String {
|
||||
self.namespace.key(raw_key)
|
||||
}
|
||||
|
||||
pub fn lock_key(&self, raw_key: &str) -> RedisLockKey {
|
||||
RedisLockKey(self.namespace.child("lock").key(raw_key))
|
||||
}
|
||||
|
||||
pub fn stream_name(&self, raw_name: &str) -> RedisStreamName {
|
||||
RedisStreamName(self.namespace.child("stream").key(raw_name))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::RedisKeyspace;
|
||||
|
||||
#[test]
|
||||
fn composes_prefixed_lock_and_stream_names() {
|
||||
let keyspace = RedisKeyspace::new(Some("aether"));
|
||||
|
||||
assert_eq!(keyspace.key("auth:user"), "aether:auth:user");
|
||||
assert_eq!(keyspace.lock_key("poller").0, "aether:lock:poller");
|
||||
assert_eq!(keyspace.stream_name("audit").0, "aether:stream:audit");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,749 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::error::RedisResultExt;
|
||||
use crate::redis::{
|
||||
cmd, run_lane_with_timeout, script, RedisCmd, RedisConnectionLane, RedisConnectionRouter,
|
||||
RedisKeyspace, RedisLaneDiagnostics,
|
||||
};
|
||||
use crate::{
|
||||
DataLayerError, RateLimitCheck, RateLimitInput, RateLimitScope, RuntimeSemaphoreError,
|
||||
};
|
||||
|
||||
const RATE_LIMIT_CHECK_AND_CONSUME_SCRIPT: &str = r#"
|
||||
local user_key = KEYS[1]
|
||||
local key_key = KEYS[2]
|
||||
local user_limit = tonumber(ARGV[1])
|
||||
local key_limit = tonumber(ARGV[2])
|
||||
local ttl = tonumber(ARGV[3])
|
||||
|
||||
local user_count = 0
|
||||
if user_limit > 0 then
|
||||
user_count = tonumber(redis.call('GET', user_key) or '0')
|
||||
if user_count >= user_limit then
|
||||
return {0, 1, user_limit, 0}
|
||||
end
|
||||
end
|
||||
|
||||
local key_count = 0
|
||||
if key_limit > 0 then
|
||||
key_count = tonumber(redis.call('GET', key_key) or '0')
|
||||
if key_count >= key_limit then
|
||||
return {0, 2, key_limit, 0}
|
||||
end
|
||||
end
|
||||
|
||||
local remaining = -1
|
||||
if user_limit > 0 then
|
||||
user_count = redis.call('INCR', user_key)
|
||||
redis.call('EXPIRE', user_key, ttl)
|
||||
remaining = user_limit - user_count
|
||||
end
|
||||
|
||||
if key_limit > 0 then
|
||||
key_count = redis.call('INCR', key_key)
|
||||
redis.call('EXPIRE', key_key, ttl)
|
||||
local key_remaining = key_limit - key_count
|
||||
if remaining == -1 or key_remaining < remaining then
|
||||
remaining = key_remaining
|
||||
end
|
||||
end
|
||||
|
||||
return {1, 0, 0, remaining}
|
||||
"#;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct RedisRuntimeDiagnostics {
|
||||
pub connected_clients: Option<u64>,
|
||||
pub blocked_clients: Option<u64>,
|
||||
pub total_connections_received: Option<u64>,
|
||||
pub rejected_connections: Option<u64>,
|
||||
pub total_commands_processed: Option<u64>,
|
||||
pub instantaneous_ops_per_sec: Option<u64>,
|
||||
pub total_error_replies: Option<u64>,
|
||||
pub expired_keys: Option<u64>,
|
||||
pub evicted_keys: Option<u64>,
|
||||
pub keyspace_hits: Option<u64>,
|
||||
pub keyspace_misses: Option<u64>,
|
||||
pub used_memory_bytes: Option<u64>,
|
||||
pub maxmemory_bytes: Option<u64>,
|
||||
pub memory_fragmentation_ratio_basis_points: Option<u64>,
|
||||
pub lanes: Vec<RedisLaneDiagnostics>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct RedisRuntimeRunner {
|
||||
connections: RedisConnectionRouter,
|
||||
keyspace: RedisKeyspace,
|
||||
command_timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl RedisRuntimeRunner {
|
||||
pub(crate) fn new(
|
||||
connections: RedisConnectionRouter,
|
||||
keyspace: RedisKeyspace,
|
||||
command_timeout_ms: Option<u64>,
|
||||
) -> Self {
|
||||
Self {
|
||||
connections,
|
||||
keyspace,
|
||||
command_timeout_ms,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn ping(&self) -> Result<(), DataLayerError> {
|
||||
let pong = self
|
||||
.query_string(RedisConnectionLane::Fast, "runtime redis ping", cmd("PING"))
|
||||
.await?;
|
||||
if pong.eq_ignore_ascii_case("PONG") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(DataLayerError::UnexpectedValue(format!(
|
||||
"unexpected runtime redis ping response {pong}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn diagnostics(&self) -> Result<RedisRuntimeDiagnostics, DataLayerError> {
|
||||
let info = self
|
||||
.query_string(
|
||||
RedisConnectionLane::Admin,
|
||||
"runtime redis diagnostics",
|
||||
cmd("INFO"),
|
||||
)
|
||||
.await?;
|
||||
Ok(parse_diagnostics(
|
||||
&info,
|
||||
self.connections.lane_diagnostics(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn kv_set_plain(
|
||||
&self,
|
||||
key: &str,
|
||||
value: String,
|
||||
) -> Result<(), DataLayerError> {
|
||||
let namespaced_key = self.keyspace.key(key);
|
||||
let mut command = cmd("SET");
|
||||
command.arg(namespaced_key).arg(value);
|
||||
self.query_string(RedisConnectionLane::Fast, "runtime kv set", command)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn kv_set_with_ttl(
|
||||
&self,
|
||||
key: &str,
|
||||
value: String,
|
||||
ttl: Duration,
|
||||
) -> Result<(), DataLayerError> {
|
||||
let namespaced_key = self.keyspace.key(key);
|
||||
let mut command = cmd("PSETEX");
|
||||
command
|
||||
.arg(namespaced_key)
|
||||
.arg(u64::try_from(ttl.as_millis().max(1)).unwrap_or(u64::MAX))
|
||||
.arg(value);
|
||||
self.query_string(RedisConnectionLane::Fast, "runtime kv set ttl", command)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn kv_get_many(
|
||||
&self,
|
||||
keys: &[String],
|
||||
) -> Result<Vec<Option<String>>, DataLayerError> {
|
||||
let namespaced = keys
|
||||
.iter()
|
||||
.map(|key| self.keyspace.key(key))
|
||||
.collect::<Vec<_>>();
|
||||
let mut command = cmd("MGET");
|
||||
command.arg(&namespaced);
|
||||
self.query(RedisConnectionLane::Fast, "runtime kv mget", command)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn kv_delete_many(&self, keys: &[String]) -> Result<usize, DataLayerError> {
|
||||
let prefix = self.keyspace.key("");
|
||||
let namespaced = keys
|
||||
.iter()
|
||||
.map(|key| {
|
||||
if key_belongs_to_prefix(key, &prefix) {
|
||||
key.clone()
|
||||
} else {
|
||||
self.keyspace.key(key)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut command = cmd("DEL");
|
||||
command.arg(&namespaced);
|
||||
let deleted = self
|
||||
.query_i64(
|
||||
RedisConnectionLane::Admin,
|
||||
"runtime kv delete many",
|
||||
command,
|
||||
)
|
||||
.await?;
|
||||
Ok(usize::try_from(deleted).unwrap_or(0))
|
||||
}
|
||||
|
||||
pub(crate) async fn kv_ttl_seconds(&self, key: &str) -> Result<Option<i64>, DataLayerError> {
|
||||
let namespaced_key = self.keyspace.key(key);
|
||||
let mut command = cmd("TTL");
|
||||
command.arg(&namespaced_key);
|
||||
let ttl = self
|
||||
.query_i64(RedisConnectionLane::Fast, "runtime kv ttl", command)
|
||||
.await?;
|
||||
Ok((ttl >= -1).then_some(ttl))
|
||||
}
|
||||
|
||||
pub(crate) async fn scan_keys(
|
||||
&self,
|
||||
pattern: &str,
|
||||
count: usize,
|
||||
) -> Result<Vec<String>, DataLayerError> {
|
||||
let pattern = self.keyspace.key(pattern);
|
||||
run_lane_with_timeout(
|
||||
&self.connections,
|
||||
RedisConnectionLane::Admin,
|
||||
self.command_timeout_ms,
|
||||
"runtime scan keys",
|
||||
async {
|
||||
let mut connection = self.connections.connection(RedisConnectionLane::Admin);
|
||||
let mut cursor = 0u64;
|
||||
let mut keys = Vec::new();
|
||||
loop {
|
||||
let (next_cursor, mut batch) = cmd("SCAN")
|
||||
.arg(cursor)
|
||||
.arg("MATCH")
|
||||
.arg(&pattern)
|
||||
.arg("COUNT")
|
||||
.arg(count.max(1))
|
||||
.query_async::<(u64, Vec<String>)>(&mut connection)
|
||||
.await
|
||||
.map_redis_err()?;
|
||||
keys.append(&mut batch);
|
||||
if next_cursor == 0 {
|
||||
break;
|
||||
}
|
||||
cursor = next_cursor;
|
||||
}
|
||||
keys.sort();
|
||||
Ok(keys)
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn check_and_consume_rate_limit(
|
||||
&self,
|
||||
input: RateLimitInput<'_>,
|
||||
) -> Result<RateLimitCheck, DataLayerError> {
|
||||
let user_key = self.keyspace.key(input.user_key);
|
||||
let key_key = self.keyspace.key(input.key_key);
|
||||
let raw = run_lane_with_timeout(
|
||||
&self.connections,
|
||||
RedisConnectionLane::Fast,
|
||||
self.command_timeout_ms,
|
||||
"runtime rate limit check",
|
||||
async {
|
||||
let mut connection = self.connections.connection(RedisConnectionLane::Fast);
|
||||
script(RATE_LIMIT_CHECK_AND_CONSUME_SCRIPT)
|
||||
.key(user_key)
|
||||
.key(key_key)
|
||||
.arg(i64::from(input.user_limit))
|
||||
.arg(i64::from(input.key_limit))
|
||||
.arg(i64::try_from(input.ttl_seconds.max(1)).unwrap_or(i64::MAX))
|
||||
.invoke_async::<Vec<i64>>(&mut connection)
|
||||
.await
|
||||
.map_redis_err()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if raw.first().copied().unwrap_or_default() == 1 {
|
||||
return Ok(RateLimitCheck::Allowed {
|
||||
remaining: raw
|
||||
.get(3)
|
||||
.copied()
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
let scope = match raw.get(1).copied().unwrap_or_default() {
|
||||
2 => RateLimitScope::Key,
|
||||
_ => RateLimitScope::User,
|
||||
};
|
||||
let limit = raw
|
||||
.get(2)
|
||||
.copied()
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.unwrap_or(match scope {
|
||||
RateLimitScope::User => input.user_limit,
|
||||
RateLimitScope::Key => input.key_limit,
|
||||
});
|
||||
Ok(RateLimitCheck::Rejected { scope, limit })
|
||||
}
|
||||
|
||||
pub(crate) async fn set_add(&self, key: &str, member: &str) -> Result<bool, DataLayerError> {
|
||||
let key = self.keyspace.key(key);
|
||||
let mut command = cmd("SADD");
|
||||
command.arg(&key).arg(member);
|
||||
Ok(self
|
||||
.query_i64(RedisConnectionLane::Fast, "runtime set add", command)
|
||||
.await?
|
||||
> 0)
|
||||
}
|
||||
|
||||
pub(crate) async fn set_remove(&self, key: &str, member: &str) -> Result<bool, DataLayerError> {
|
||||
let key = self.keyspace.key(key);
|
||||
let mut command = cmd("SREM");
|
||||
command.arg(&key).arg(member);
|
||||
Ok(self
|
||||
.query_i64(RedisConnectionLane::Fast, "runtime set remove", command)
|
||||
.await?
|
||||
> 0)
|
||||
}
|
||||
|
||||
pub(crate) async fn set_members(&self, key: &str) -> Result<Vec<String>, DataLayerError> {
|
||||
let key = self.keyspace.key(key);
|
||||
let mut command = cmd("SMEMBERS");
|
||||
command.arg(&key);
|
||||
let mut values = self
|
||||
.query::<Vec<String>>(RedisConnectionLane::Admin, "runtime set members", command)
|
||||
.await?;
|
||||
values.sort();
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
pub(crate) async fn set_len(&self, key: &str) -> Result<usize, DataLayerError> {
|
||||
let key = self.keyspace.key(key);
|
||||
let mut command = cmd("SCARD");
|
||||
command.arg(&key);
|
||||
let len = self
|
||||
.query_i64(RedisConnectionLane::Fast, "runtime set len", command)
|
||||
.await?;
|
||||
Ok(usize::try_from(len).unwrap_or(0))
|
||||
}
|
||||
|
||||
pub(crate) async fn score_set(
|
||||
&self,
|
||||
key: &str,
|
||||
member: &str,
|
||||
score: f64,
|
||||
) -> Result<(), DataLayerError> {
|
||||
let key = self.keyspace.key(key);
|
||||
let mut command = cmd("ZADD");
|
||||
command.arg(&key).arg(score).arg(member);
|
||||
self.query_i64(RedisConnectionLane::Fast, "runtime score set", command)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn score_many(
|
||||
&self,
|
||||
key: &str,
|
||||
members: &[String],
|
||||
) -> Result<Vec<Option<f64>>, DataLayerError> {
|
||||
let key = self.keyspace.key(key);
|
||||
let mut command = cmd("ZMSCORE");
|
||||
command.arg(&key);
|
||||
for member in members {
|
||||
command.arg(member);
|
||||
}
|
||||
self.query(RedisConnectionLane::Fast, "runtime score many", command)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn score_range_by_min(
|
||||
&self,
|
||||
key: &str,
|
||||
min_score: f64,
|
||||
) -> Result<Vec<String>, DataLayerError> {
|
||||
let key = self.keyspace.key(key);
|
||||
let mut command = cmd("ZRANGEBYSCORE");
|
||||
command.arg(&key).arg(min_score).arg("+inf");
|
||||
self.query(RedisConnectionLane::Admin, "runtime score range", command)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn score_remove_by_score(
|
||||
&self,
|
||||
key: &str,
|
||||
max_score: f64,
|
||||
) -> Result<usize, DataLayerError> {
|
||||
let key = self.keyspace.key(key);
|
||||
let mut command = cmd("ZREMRANGEBYSCORE");
|
||||
command.arg(&key).arg("-inf").arg(max_score);
|
||||
let removed = self
|
||||
.query_i64(RedisConnectionLane::Admin, "runtime score trim", command)
|
||||
.await?;
|
||||
Ok(usize::try_from(removed).unwrap_or(0))
|
||||
}
|
||||
|
||||
pub(crate) async fn score_remove(
|
||||
&self,
|
||||
key: &str,
|
||||
member: &str,
|
||||
) -> Result<bool, DataLayerError> {
|
||||
let key = self.keyspace.key(key);
|
||||
let mut command = cmd("ZREM");
|
||||
command.arg(&key).arg(member);
|
||||
Ok(self
|
||||
.query_i64(RedisConnectionLane::Fast, "runtime score remove", command)
|
||||
.await?
|
||||
> 0)
|
||||
}
|
||||
|
||||
pub(crate) async fn score_remove_by_rank(
|
||||
&self,
|
||||
key: &str,
|
||||
start: i64,
|
||||
stop: i64,
|
||||
) -> Result<usize, DataLayerError> {
|
||||
let key = self.keyspace.key(key);
|
||||
let mut command = cmd("ZREMRANGEBYRANK");
|
||||
command.arg(&key).arg(start).arg(stop);
|
||||
let removed = self
|
||||
.query_i64(
|
||||
RedisConnectionLane::Admin,
|
||||
"runtime score rank trim",
|
||||
command,
|
||||
)
|
||||
.await?;
|
||||
Ok(usize::try_from(removed).unwrap_or(0))
|
||||
}
|
||||
|
||||
pub(crate) async fn score_len(&self, key: &str) -> Result<usize, DataLayerError> {
|
||||
let key = self.keyspace.key(key);
|
||||
let mut command = cmd("ZCARD");
|
||||
command.arg(&key);
|
||||
let len = self
|
||||
.query_i64(RedisConnectionLane::Fast, "runtime score len", command)
|
||||
.await?;
|
||||
Ok(usize::try_from(len).unwrap_or(0))
|
||||
}
|
||||
|
||||
pub(crate) async fn key_expire(
|
||||
&self,
|
||||
key: &str,
|
||||
ttl: Duration,
|
||||
) -> Result<bool, DataLayerError> {
|
||||
let key = self.keyspace.key(key);
|
||||
let mut command = cmd("PEXPIRE");
|
||||
command
|
||||
.arg(&key)
|
||||
.arg(u64::try_from(ttl.as_millis()).unwrap_or(u64::MAX));
|
||||
Ok(self
|
||||
.query_i64(RedisConnectionLane::Fast, "runtime key expire", command)
|
||||
.await?
|
||||
> 0)
|
||||
}
|
||||
|
||||
pub(crate) async fn semaphore_try_acquire(
|
||||
&self,
|
||||
gate: &'static str,
|
||||
limit: usize,
|
||||
key: &str,
|
||||
token: &str,
|
||||
lease_ttl_ms: u64,
|
||||
timeout_ms: Option<u64>,
|
||||
) -> Result<(i64, i64), RuntimeSemaphoreError> {
|
||||
let now_ms = crate::unix_time_ms();
|
||||
let expires_at_ms = now_ms.saturating_add(lease_ttl_ms);
|
||||
let key = self.keyspace.key(key);
|
||||
let timeout_ms = timeout_ms.or(self.command_timeout_ms);
|
||||
run_lane_with_timeout(
|
||||
&self.connections,
|
||||
RedisConnectionLane::Fast,
|
||||
timeout_ms,
|
||||
"runtime semaphore acquire",
|
||||
async {
|
||||
let mut connection = self.connections.connection(RedisConnectionLane::Fast);
|
||||
script(
|
||||
"redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1]); \
|
||||
local count = redis.call('ZCARD', KEYS[1]); \
|
||||
if count >= tonumber(ARGV[3]) then \
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[5]); \
|
||||
return {0, count}; \
|
||||
end; \
|
||||
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[4]); \
|
||||
count = redis.call('ZCARD', KEYS[1]); \
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[5]); \
|
||||
return {1, count};",
|
||||
)
|
||||
.key(&key)
|
||||
.arg(now_ms as i64)
|
||||
.arg(expires_at_ms as i64)
|
||||
.arg(limit as i64)
|
||||
.arg(token)
|
||||
.arg(lease_ttl_ms as i64)
|
||||
.invoke_async::<(i64, i64)>(&mut connection)
|
||||
.await
|
||||
.map_redis_err()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|err| RuntimeSemaphoreError::Unavailable {
|
||||
gate,
|
||||
limit,
|
||||
message: format!("acquire failed: {err}"),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn semaphore_renew(
|
||||
&self,
|
||||
gate: &'static str,
|
||||
limit: usize,
|
||||
key: &str,
|
||||
token: &str,
|
||||
lease_ttl_ms: u64,
|
||||
timeout_ms: Option<u64>,
|
||||
) -> Result<i64, RuntimeSemaphoreError> {
|
||||
let now_ms = crate::unix_time_ms();
|
||||
let expires_at_ms = now_ms.saturating_add(lease_ttl_ms);
|
||||
let key = self.keyspace.key(key);
|
||||
let timeout_ms = timeout_ms.or(self.command_timeout_ms);
|
||||
run_lane_with_timeout(
|
||||
&self.connections,
|
||||
RedisConnectionLane::Fast,
|
||||
timeout_ms,
|
||||
"runtime semaphore renew",
|
||||
async {
|
||||
let mut connection = self.connections.connection(RedisConnectionLane::Fast);
|
||||
script(
|
||||
"redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1]); \
|
||||
local score = redis.call('ZSCORE', KEYS[1], ARGV[2]); \
|
||||
if not score then return 0; end; \
|
||||
redis.call('ZADD', KEYS[1], 'XX', ARGV[3], ARGV[2]); \
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[4]); \
|
||||
return 1;",
|
||||
)
|
||||
.key(&key)
|
||||
.arg(now_ms as i64)
|
||||
.arg(token)
|
||||
.arg(expires_at_ms as i64)
|
||||
.arg(lease_ttl_ms as i64)
|
||||
.invoke_async::<i64>(&mut connection)
|
||||
.await
|
||||
.map_redis_err()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|err| RuntimeSemaphoreError::Unavailable {
|
||||
gate,
|
||||
limit,
|
||||
message: format!("renew failed: {err}"),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn semaphore_release(
|
||||
&self,
|
||||
gate: &'static str,
|
||||
limit: usize,
|
||||
key: &str,
|
||||
token: &str,
|
||||
timeout_ms: Option<u64>,
|
||||
) -> Result<(), RuntimeSemaphoreError> {
|
||||
let key = self.keyspace.key(key);
|
||||
let timeout_ms = timeout_ms.or(self.command_timeout_ms);
|
||||
run_lane_with_timeout(
|
||||
&self.connections,
|
||||
RedisConnectionLane::Fast,
|
||||
timeout_ms,
|
||||
"runtime semaphore release",
|
||||
async {
|
||||
let mut connection = self.connections.connection(RedisConnectionLane::Fast);
|
||||
script(
|
||||
"local removed = redis.call('ZREM', KEYS[1], ARGV[1]); \
|
||||
if removed > 0 and redis.call('ZCARD', KEYS[1]) == 0 then \
|
||||
redis.call('DEL', KEYS[1]); \
|
||||
end; \
|
||||
return removed;",
|
||||
)
|
||||
.key(&key)
|
||||
.arg(token)
|
||||
.invoke_async::<i64>(&mut connection)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_redis_err()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|err| RuntimeSemaphoreError::Unavailable {
|
||||
gate,
|
||||
limit,
|
||||
message: format!("release failed: {err}"),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn semaphore_live_count(
|
||||
&self,
|
||||
gate: &'static str,
|
||||
limit: usize,
|
||||
key: &str,
|
||||
timeout_ms: Option<u64>,
|
||||
) -> Result<usize, RuntimeSemaphoreError> {
|
||||
let now_ms = crate::unix_time_ms();
|
||||
let key = self.keyspace.key(key);
|
||||
let timeout_ms = timeout_ms.or(self.command_timeout_ms);
|
||||
run_lane_with_timeout(
|
||||
&self.connections,
|
||||
RedisConnectionLane::Fast,
|
||||
timeout_ms,
|
||||
"runtime semaphore snapshot",
|
||||
async {
|
||||
let mut connection = self.connections.connection(RedisConnectionLane::Fast);
|
||||
script(
|
||||
"redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1]); \
|
||||
return redis.call('ZCARD', KEYS[1]);",
|
||||
)
|
||||
.key(&key)
|
||||
.arg(now_ms as i64)
|
||||
.invoke_async::<i64>(&mut connection)
|
||||
.await
|
||||
.map(|value| value.max(0) as usize)
|
||||
.map_redis_err()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|err| RuntimeSemaphoreError::Unavailable {
|
||||
gate,
|
||||
limit,
|
||||
message: format!("snapshot failed: {err}"),
|
||||
})
|
||||
}
|
||||
|
||||
async fn query<T>(
|
||||
&self,
|
||||
lane: RedisConnectionLane,
|
||||
operation: &'static str,
|
||||
command: RedisCmd,
|
||||
) -> Result<T, DataLayerError>
|
||||
where
|
||||
T: redis::FromRedisValue,
|
||||
{
|
||||
run_lane_with_timeout(
|
||||
&self.connections,
|
||||
lane,
|
||||
self.command_timeout_ms,
|
||||
operation,
|
||||
async {
|
||||
let mut connection = self.connections.connection(lane);
|
||||
command
|
||||
.query_async::<T>(&mut connection)
|
||||
.await
|
||||
.map_redis_err()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn query_i64(
|
||||
&self,
|
||||
lane: RedisConnectionLane,
|
||||
operation: &'static str,
|
||||
command: RedisCmd,
|
||||
) -> Result<i64, DataLayerError> {
|
||||
self.query(lane, operation, command).await
|
||||
}
|
||||
|
||||
async fn query_string(
|
||||
&self,
|
||||
lane: RedisConnectionLane,
|
||||
operation: &'static str,
|
||||
command: RedisCmd,
|
||||
) -> Result<String, DataLayerError> {
|
||||
self.query(lane, operation, command).await
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_diagnostics(info: &str, lanes: Vec<RedisLaneDiagnostics>) -> RedisRuntimeDiagnostics {
|
||||
RedisRuntimeDiagnostics {
|
||||
connected_clients: parse_info_u64(info, "connected_clients"),
|
||||
blocked_clients: parse_info_u64(info, "blocked_clients"),
|
||||
total_connections_received: parse_info_u64(info, "total_connections_received"),
|
||||
rejected_connections: parse_info_u64(info, "rejected_connections"),
|
||||
total_commands_processed: parse_info_u64(info, "total_commands_processed"),
|
||||
instantaneous_ops_per_sec: parse_info_u64(info, "instantaneous_ops_per_sec"),
|
||||
total_error_replies: parse_info_u64(info, "total_error_replies"),
|
||||
expired_keys: parse_info_u64(info, "expired_keys"),
|
||||
evicted_keys: parse_info_u64(info, "evicted_keys"),
|
||||
keyspace_hits: parse_info_u64(info, "keyspace_hits"),
|
||||
keyspace_misses: parse_info_u64(info, "keyspace_misses"),
|
||||
used_memory_bytes: parse_info_u64(info, "used_memory"),
|
||||
maxmemory_bytes: parse_info_u64(info, "maxmemory"),
|
||||
memory_fragmentation_ratio_basis_points: parse_info_f64_basis_points(
|
||||
info,
|
||||
"mem_fragmentation_ratio",
|
||||
),
|
||||
lanes,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_info_u64(info: &str, key: &str) -> Option<u64> {
|
||||
info.lines().find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
(name == key)
|
||||
.then(|| value.trim().parse::<u64>().ok())
|
||||
.flatten()
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_info_f64_basis_points(info: &str, key: &str) -> Option<u64> {
|
||||
info.lines().find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
if name != key {
|
||||
return None;
|
||||
}
|
||||
let parsed = value.trim().parse::<f64>().ok()?;
|
||||
(parsed.is_finite() && parsed >= 0.0).then(|| (parsed * 10_000.0).round() as u64)
|
||||
})
|
||||
}
|
||||
|
||||
fn key_belongs_to_prefix(key: &str, prefix: &str) -> bool {
|
||||
prefix.is_empty()
|
||||
|| key == prefix
|
||||
|| key
|
||||
.strip_prefix(prefix)
|
||||
.is_some_and(|rest| rest.starts_with(':'))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{key_belongs_to_prefix, parse_diagnostics, RedisRuntimeDiagnostics};
|
||||
|
||||
#[test]
|
||||
fn parses_runtime_diagnostics_from_info() {
|
||||
let parsed = parse_diagnostics(
|
||||
"# Clients\r\nconnected_clients:5\r\nblocked_clients:2\r\n# Memory\r\nused_memory:1048576\r\nmaxmemory:8388608\r\nmem_fragmentation_ratio:1.25\r\n# Stats\r\ntotal_connections_received:42\r\nrejected_connections:0\r\ntotal_commands_processed:99\r\ninstantaneous_ops_per_sec:7\r\ntotal_error_replies:1\r\nexpired_keys:3\r\nevicted_keys:4\r\nkeyspace_hits:10\r\nkeyspace_misses:2\r\n",
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
RedisRuntimeDiagnostics {
|
||||
connected_clients: Some(5),
|
||||
blocked_clients: Some(2),
|
||||
total_connections_received: Some(42),
|
||||
rejected_connections: Some(0),
|
||||
total_commands_processed: Some(99),
|
||||
instantaneous_ops_per_sec: Some(7),
|
||||
total_error_replies: Some(1),
|
||||
expired_keys: Some(3),
|
||||
evicted_keys: Some(4),
|
||||
keyspace_hits: Some(10),
|
||||
keyspace_misses: Some(2),
|
||||
used_memory_bytes: Some(1_048_576),
|
||||
maxmemory_bytes: Some(8_388_608),
|
||||
memory_fragmentation_ratio_basis_points: Some(12_500),
|
||||
lanes: Vec::new(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_namespaced_key_prefix_on_boundary() {
|
||||
assert!(key_belongs_to_prefix("aether:cache:item", "aether"));
|
||||
assert!(key_belongs_to_prefix("aether", "aether"));
|
||||
assert!(key_belongs_to_prefix("raw:key", ""));
|
||||
assert!(!key_belongs_to_prefix("aetherish:cache:item", "aether"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user