Files
Aether/crates/aether-runtime/state/src/redis/stream.rs
T
elky ecc16673eb fix: harden concurrency limits and high-RPM runtime paths
Bound request, stream, queue, and shutdown resource lifetimes. Reduce scheduler and Redis hot-path work and isolate database maintenance. Include regression coverage, load probes, and concurrency audit results.
2026-09-10 08:14:58 +08:00

1176 lines
39 KiB
Rust

use std::collections::{BTreeMap, HashMap};
use std::future::Future;
use redis::Value as RedisValue;
use redis::{from_owned_redis_value, from_redis_value};
use crate::error::{redis_error, RedisResultExt};
use crate::redis::{
run_lane_with_timeout, RedisClientConfig, RedisClientFactory, RedisConnectionLane,
RedisConnectionRouter, RedisKeyspace,
};
use crate::{
validate_runtime_queue_transfer, DataLayerError, RuntimeQueueStats, RuntimeQueueTransferOutcome,
};
const DEAD_LETTER_TRANSFER_SCRIPT: &str = include_str!("dead_letter_transfer.lua");
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RedisStreamName(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RedisConsumerGroup(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RedisConsumerName(pub String);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RedisStreamEntry {
pub id: String,
pub fields: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RedisStreamReclaimResult {
pub next_start_id: String,
pub entries: Vec<RedisStreamEntry>,
pub deleted_ids: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RedisStreamReclaimConfig {
pub min_idle_ms: u64,
pub count: usize,
}
impl Default for RedisStreamReclaimConfig {
fn default() -> Self {
Self {
min_idle_ms: 60_000,
count: 32,
}
}
}
impl RedisStreamReclaimConfig {
pub fn validate(&self) -> Result<(), DataLayerError> {
if self.min_idle_ms == 0 {
return Err(DataLayerError::InvalidConfiguration(
"redis stream reclaim min_idle_ms must be positive".to_string(),
));
}
if self.count == 0 {
return Err(DataLayerError::InvalidConfiguration(
"redis stream reclaim count must be positive".to_string(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RedisStreamRunnerConfig {
pub command_timeout_ms: Option<u64>,
pub read_block_ms: Option<u64>,
pub read_count: usize,
}
impl Default for RedisStreamRunnerConfig {
fn default() -> Self {
Self {
command_timeout_ms: Some(2_000),
read_block_ms: Some(1_000),
read_count: 32,
}
}
}
impl RedisStreamRunnerConfig {
pub fn validate(&self) -> Result<(), DataLayerError> {
if matches!(self.command_timeout_ms, Some(0)) {
return Err(DataLayerError::InvalidConfiguration(
"redis stream command_timeout_ms must be positive".to_string(),
));
}
if matches!(self.read_block_ms, Some(0)) {
return Err(DataLayerError::InvalidConfiguration(
"redis stream read_block_ms must be positive".to_string(),
));
}
if let (Some(command_timeout_ms), Some(read_block_ms)) =
(self.command_timeout_ms, self.read_block_ms)
{
if command_timeout_ms <= read_block_ms {
return Err(DataLayerError::InvalidConfiguration(
"redis stream command_timeout_ms must be greater than read_block_ms"
.to_string(),
));
}
}
if self.read_count == 0 {
return Err(DataLayerError::InvalidConfiguration(
"redis stream read_count must be positive".to_string(),
));
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct RedisStreamRunner {
connections: RedisConnectionRouter,
keyspace: RedisKeyspace,
config: RedisStreamRunnerConfig,
}
impl RedisStreamRunner {
pub(crate) fn new(
connections: RedisConnectionRouter,
keyspace: RedisKeyspace,
config: RedisStreamRunnerConfig,
) -> Result<Self, DataLayerError> {
config.validate()?;
Ok(Self {
connections,
keyspace,
config,
})
}
pub async fn from_config(
config: RedisClientConfig,
runner_config: RedisStreamRunnerConfig,
) -> 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) -> RedisStreamRunnerConfig {
self.config
}
pub(crate) fn with_config(
&self,
config: RedisStreamRunnerConfig,
) -> Result<Self, DataLayerError> {
Self::new(self.connections.clone(), self.keyspace.clone(), config)
}
pub async fn ensure_consumer_group(
&self,
stream: &RedisStreamName,
group: &RedisConsumerGroup,
start_id: &str,
) -> Result<(), DataLayerError> {
validate_stream_name(stream)?;
validate_group(group)?;
validate_stream_position(start_id)?;
self.run_with_timeout(
RedisConnectionLane::Stream,
"redis stream ensure consumer group",
async {
let mut connection = self.connections.connection(RedisConnectionLane::Stream);
let result = redis::cmd("XGROUP")
.arg("CREATE")
.arg(&stream.0)
.arg(&group.0)
.arg(start_id)
.arg("MKSTREAM")
.query_async::<String>(&mut connection)
.await;
match result {
Ok(_) => Ok(()),
Err(err) if err.code() == Some("BUSYGROUP") => Ok(()),
Err(err) => Err(redis_error(err)),
}
},
)
.await
}
pub async fn append_fields(
&self,
stream: &RedisStreamName,
fields: &BTreeMap<String, String>,
) -> Result<String, DataLayerError> {
self.append_fields_with_maxlen(stream, fields, None).await
}
pub async fn append_fields_with_maxlen(
&self,
stream: &RedisStreamName,
fields: &BTreeMap<String, String>,
maxlen: Option<usize>,
) -> Result<String, DataLayerError> {
validate_stream_name(stream)?;
if fields.is_empty() {
return Err(DataLayerError::InvalidInput(
"redis stream fields cannot be empty".to_string(),
));
}
self.run_with_timeout(RedisConnectionLane::Stream, "redis stream append", async {
let mut connection = self.connections.connection(RedisConnectionLane::Stream);
let mut command = redis::cmd("XADD");
command.arg(&stream.0);
if let Some(maxlen) = maxlen.filter(|value| *value > 0) {
command.arg("MAXLEN").arg("~").arg(maxlen);
}
command.arg("*");
for (key, value) in fields {
command.arg(key).arg(value);
}
command
.query_async::<String>(&mut connection)
.await
.map_redis_err()
})
.await
}
pub async fn append_json(
&self,
stream: &RedisStreamName,
field: &str,
payload: &serde_json::Value,
) -> Result<String, DataLayerError> {
if field.trim().is_empty() {
return Err(DataLayerError::InvalidInput(
"redis stream json field cannot be empty".to_string(),
));
}
let mut fields = BTreeMap::new();
fields.insert(
field.to_string(),
serde_json::to_string(payload).map_err(|err| {
DataLayerError::UnexpectedValue(format!(
"failed to serialize redis stream payload: {err}"
))
})?,
);
self.append_fields(stream, &fields).await
}
/// Atomically archives a pending entry and removes it from its source group and stream.
/// Requires Redis 7+ for ACL preflight. Both stream keys must share a slot on Redis Cluster.
pub async fn try_transfer_pending_to_stream(
&self,
source: &str,
group: &str,
entry_id: &str,
destination: &str,
destination_fields: &BTreeMap<String, String>,
) -> Result<RuntimeQueueTransferOutcome, DataLayerError> {
validate_runtime_queue_transfer(source, group, entry_id, destination, destination_fields)?;
self.run_with_timeout(
RedisConnectionLane::BlockingStream,
"redis stream pending transfer",
async {
let mut lease = self.connections.blocking_stream_connection().await?;
let mut command = redis::cmd("EVAL");
command
.arg(DEAD_LETTER_TRANSFER_SCRIPT)
.arg(2)
.arg(source)
.arg(destination)
.arg(group)
.arg(entry_id);
for (field, value) in destination_fields {
command.arg(field).arg(value);
}
let outcome = parse_transfer_result(lease.query(&command).await?)?;
lease.recycle();
Ok(outcome)
},
)
.await
}
pub async fn read_group(
&self,
stream: &RedisStreamName,
group: &RedisConsumerGroup,
consumer: &RedisConsumerName,
) -> Result<Vec<RedisStreamEntry>, DataLayerError> {
validate_stream_name(stream)?;
validate_group(group)?;
validate_consumer(consumer)?;
let lane = if self.config.read_block_ms.is_some() {
RedisConnectionLane::BlockingStream
} else {
RedisConnectionLane::Stream
};
self.run_with_timeout(lane, "redis stream read group", async {
let mut command = redis::cmd("XREADGROUP");
command
.arg("GROUP")
.arg(&group.0)
.arg(&consumer.0)
.arg("COUNT")
.arg(self.config.read_count);
if let Some(block_ms) = self.config.read_block_ms {
command.arg("BLOCK").arg(block_ms);
}
command.arg("STREAMS").arg(&stream.0).arg(">");
if lane == RedisConnectionLane::BlockingStream {
let mut lease = self.connections.blocking_stream_connection().await?;
let entries = parse_stream_read_entries(lease.query(&command).await?)?;
lease.recycle();
return Ok(entries);
}
let mut connection = self.connections.connection(lane);
let reply = command
.query_async::<RedisValue>(&mut connection)
.await
.map_redis_err()?;
parse_stream_read_entries(reply)
})
.await
}
pub async fn ack(
&self,
stream: &RedisStreamName,
group: &RedisConsumerGroup,
ids: &[String],
) -> Result<usize, DataLayerError> {
validate_stream_name(stream)?;
validate_group(group)?;
if ids.is_empty() {
return Ok(0);
}
self.run_with_timeout(RedisConnectionLane::Stream, "redis stream ack", async {
let mut connection = self.connections.connection(RedisConnectionLane::Stream);
let mut command = redis::cmd("XACK");
command.arg(&stream.0).arg(&group.0);
for id in ids {
command.arg(id);
}
command
.query_async::<usize>(&mut connection)
.await
.map_redis_err()
})
.await
}
pub async fn delete(
&self,
stream: &RedisStreamName,
ids: &[String],
) -> Result<usize, DataLayerError> {
validate_stream_name(stream)?;
if ids.is_empty() {
return Ok(0);
}
self.run_with_timeout(RedisConnectionLane::Stream, "redis stream delete", async {
let mut connection = self.connections.connection(RedisConnectionLane::Stream);
let mut command = redis::cmd("XDEL");
command.arg(&stream.0);
for id in ids {
command.arg(id);
}
command
.query_async::<usize>(&mut connection)
.await
.map_redis_err()
})
.await
}
pub async fn claim_stale(
&self,
stream: &RedisStreamName,
group: &RedisConsumerGroup,
consumer: &RedisConsumerName,
start_id: &str,
config: RedisStreamReclaimConfig,
) -> Result<RedisStreamReclaimResult, DataLayerError> {
validate_stream_name(stream)?;
validate_group(group)?;
validate_consumer(consumer)?;
validate_stream_position(start_id)?;
config.validate()?;
self.run_with_timeout(
RedisConnectionLane::BlockingStream,
"redis stream reclaim",
async {
let mut lease = self.connections.blocking_stream_connection().await?;
let mut command = redis::cmd("XAUTOCLAIM");
command
.arg(&stream.0)
.arg(&group.0)
.arg(&consumer.0)
.arg(config.min_idle_ms)
.arg(start_id)
.arg("COUNT")
.arg(config.count);
let result = parse_reclaim_result(lease.query(&command).await?)?;
lease.recycle();
Ok(result)
},
)
.await
}
pub async fn stats(
&self,
stream: &RedisStreamName,
group: Option<&RedisConsumerGroup>,
) -> Result<RuntimeQueueStats, DataLayerError> {
validate_stream_name(stream)?;
if let Some(group) = group {
validate_group(group)?;
}
let stream_length = self
.run_with_timeout(RedisConnectionLane::Stream, "redis stream xlen", async {
let mut connection = self.connections.connection(RedisConnectionLane::Stream);
redis::cmd("XLEN")
.arg(&stream.0)
.query_async::<u64>(&mut connection)
.await
.map_redis_err()
})
.await?;
let Some(group) = group else {
return Ok(RuntimeQueueStats {
stream_length,
..RuntimeQueueStats::default()
});
};
let groups = self
.run_with_timeout(
RedisConnectionLane::Stream,
"redis stream xinfo groups",
async {
let mut connection = self.connections.connection(RedisConnectionLane::Stream);
let reply = match redis::cmd("XINFO")
.arg("GROUPS")
.arg(&stream.0)
.query_async::<RedisValue>(&mut connection)
.await
{
Ok(reply) => reply,
Err(err) if redis_stream_stats_missing_stream(&err) => {
return Ok(RedisXInfoGroupStats::default());
}
Err(err) => return Err(redis_error(err)),
};
parse_xinfo_group_stats(reply, &group.0)
},
)
.await?;
let oldest_pending_idle_ms = self
.run_with_timeout(
RedisConnectionLane::Stream,
"redis stream xpending summary",
async {
let mut connection = self.connections.connection(RedisConnectionLane::Stream);
let reply = match redis::cmd("XPENDING")
.arg(&stream.0)
.arg(&group.0)
.query_async::<RedisValue>(&mut connection)
.await
{
Ok(reply) => reply,
Err(err) if redis_stream_stats_missing_stream_or_group(&err) => {
return Ok(None);
}
Err(err) => return Err(redis_error(err)),
};
parse_xpending_oldest_idle_ms(reply)
},
)
.await?;
Ok(RuntimeQueueStats {
stream_length,
group_pending: groups.pending.unwrap_or_default(),
group_lag: groups.lag,
oldest_pending_idle_ms,
})
}
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 redis_stream_stats_missing_stream(error: &redis::RedisError) -> bool {
error.code() == Some("ERR") && error.to_string().contains("no such key")
}
fn redis_stream_stats_missing_stream_or_group(error: &redis::RedisError) -> bool {
let message = error.to_string();
redis_stream_stats_missing_stream(error)
|| error.code() == Some("NOGROUP")
|| (message.contains("NOGROUP") && message.contains("consumer group"))
}
fn validate_stream_name(stream: &RedisStreamName) -> Result<(), DataLayerError> {
if stream.0.trim().is_empty() {
return Err(DataLayerError::InvalidInput(
"redis stream name cannot be empty".to_string(),
));
}
Ok(())
}
fn validate_group(group: &RedisConsumerGroup) -> Result<(), DataLayerError> {
if group.0.trim().is_empty() {
return Err(DataLayerError::InvalidInput(
"redis consumer group cannot be empty".to_string(),
));
}
Ok(())
}
fn validate_consumer(consumer: &RedisConsumerName) -> Result<(), DataLayerError> {
if consumer.0.trim().is_empty() {
return Err(DataLayerError::InvalidInput(
"redis consumer name cannot be empty".to_string(),
));
}
Ok(())
}
fn validate_stream_position(position: &str) -> Result<(), DataLayerError> {
if position.trim().is_empty() {
return Err(DataLayerError::InvalidInput(
"redis stream position cannot be empty".to_string(),
));
}
Ok(())
}
fn parse_stream_read_entries(value: RedisValue) -> Result<Vec<RedisStreamEntry>, DataLayerError> {
if matches!(value, RedisValue::Nil) {
return Ok(Vec::new());
}
// StreamReadReply in redis 0.28 falls back to borrowed conversion even for an
// owned input. Its underlying containers support moving every payload buffer.
type StreamReadRows = Vec<HashMap<String, Vec<HashMap<String, HashMap<String, RedisValue>>>>>;
let rows = from_owned_redis_value::<StreamReadRows>(value).map_err(redis_error)?;
Ok(rows
.into_iter()
.flat_map(HashMap::into_values)
.flatten()
.flat_map(HashMap::into_iter)
.map(|(id, fields)| RedisStreamEntry {
id,
fields: fields
.into_iter()
.filter_map(|(field, value)| {
from_owned_redis_value::<String>(value)
.ok()
.map(|text| (field, text))
})
.collect(),
})
.collect())
}
fn parse_transfer_result(value: RedisValue) -> Result<RuntimeQueueTransferOutcome, DataLayerError> {
if !matches!(&value, RedisValue::Array(parts) if parts.len() == 4) {
return Err(DataLayerError::UnexpectedValue(
"redis stream pending transfer returned an invalid result shape".to_string(),
));
}
let (transferred, destination_id, acked, deleted) =
from_owned_redis_value::<(i64, String, usize, usize)>(value).map_err(redis_error)?;
match transferred {
0 if destination_id.is_empty() && acked == 0 && deleted == 0 => {
Ok(RuntimeQueueTransferOutcome::NotPending)
}
1 if !destination_id.is_empty() && acked == 1 && deleted <= 1 => {
Ok(RuntimeQueueTransferOutcome::Transferred {
destination_id,
acked,
deleted,
})
}
_ => Err(DataLayerError::UnexpectedValue(
"redis stream pending transfer returned inconsistent outcome fields".to_string(),
)),
}
}
fn parse_reclaim_result(value: RedisValue) -> Result<RedisStreamReclaimResult, DataLayerError> {
let RedisValue::Array(parts) = value else {
return Err(DataLayerError::UnexpectedValue(
"redis xautoclaim returned non-array payload".to_string(),
));
};
if parts.len() < 2 || parts.len() > 3 {
return Err(DataLayerError::UnexpectedValue(format!(
"redis xautoclaim returned {} top-level fields, expected 2 or 3",
parts.len()
)));
}
let mut parts = parts.into_iter();
let next_start_id = parse_owned_string_value(
parts.next().expect("validated reclaim result length"),
"redis xautoclaim next_start_id",
)?;
let entries = parse_reclaim_entries(parts.next().expect("validated reclaim result length"))?;
let deleted_ids = match parts.next() {
Some(value) => parse_string_array(value, "redis xautoclaim deleted_ids")?,
None => Vec::new(),
};
Ok(RedisStreamReclaimResult {
next_start_id,
entries,
deleted_ids,
})
}
fn parse_reclaim_entries(value: RedisValue) -> Result<Vec<RedisStreamEntry>, DataLayerError> {
match value {
RedisValue::Array(entries) => entries.into_iter().map(parse_reclaim_entry).collect(),
RedisValue::Nil => Ok(Vec::new()),
_ => Err(DataLayerError::UnexpectedValue(
"redis xautoclaim entries payload was not an array".to_string(),
)),
}
}
fn parse_reclaim_entry(value: RedisValue) -> Result<RedisStreamEntry, DataLayerError> {
let RedisValue::Array(parts) = value else {
return Err(DataLayerError::UnexpectedValue(
"redis xautoclaim entry was not an array".to_string(),
));
};
if parts.len() != 2 {
return Err(DataLayerError::UnexpectedValue(format!(
"redis xautoclaim entry had {} fields, expected 2",
parts.len()
)));
}
let mut parts = parts.into_iter();
let id = parse_owned_string_value(
parts.next().expect("validated reclaim entry length"),
"redis xautoclaim entry id",
)?;
let fields = parse_string_map(
parts.next().expect("validated reclaim entry length"),
"redis xautoclaim entry fields",
)?;
Ok(RedisStreamEntry { id, fields })
}
fn parse_string_map(
value: RedisValue,
context: &str,
) -> Result<BTreeMap<String, String>, DataLayerError> {
match value {
RedisValue::Array(values) => {
if values.len() % 2 != 0 {
return Err(DataLayerError::UnexpectedValue(format!(
"{context} expected an even number of field elements, got {}",
values.len()
)));
}
let mut fields = BTreeMap::new();
let mut values = values.into_iter();
while let Some(key) = values.next() {
let key = parse_owned_string_value(key, context)?;
let value = parse_owned_string_value(
values.next().expect("validated even number of fields"),
context,
)?;
fields.insert(key, value);
}
Ok(fields)
}
RedisValue::Map(entries) => entries
.into_iter()
.map(|(key, value)| {
Ok((
parse_owned_string_value(key, context)?,
parse_owned_string_value(value, context)?,
))
})
.collect(),
RedisValue::Nil => Ok(BTreeMap::new()),
_ => Err(DataLayerError::UnexpectedValue(format!(
"{context} expected a redis array/map payload"
))),
}
}
fn parse_string_array(value: RedisValue, context: &str) -> Result<Vec<String>, DataLayerError> {
match value {
RedisValue::Array(values) => values
.into_iter()
.map(|value| parse_owned_string_value(value, context))
.collect(),
RedisValue::Nil => Ok(Vec::new()),
_ => Err(DataLayerError::UnexpectedValue(format!(
"{context} expected a redis array payload"
))),
}
}
fn parse_string_value(value: &RedisValue, context: &str) -> Result<String, DataLayerError> {
from_redis_value::<String>(value).map_err(|err| {
DataLayerError::UnexpectedValue(format!(
"{context} was not a string-compatible redis value: {err}"
))
})
}
fn parse_owned_string_value(value: RedisValue, context: &str) -> Result<String, DataLayerError> {
from_owned_redis_value::<String>(value).map_err(|err| {
DataLayerError::UnexpectedValue(format!(
"{context} was not a string-compatible redis value: {err}"
))
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
struct RedisXInfoGroupStats {
pending: Option<u64>,
lag: Option<u64>,
}
fn parse_xinfo_group_stats(
value: RedisValue,
group_name: &str,
) -> Result<RedisXInfoGroupStats, DataLayerError> {
let RedisValue::Array(groups) = value else {
return Err(DataLayerError::UnexpectedValue(
"redis xinfo groups returned non-array payload".to_string(),
));
};
for group in groups {
let fields = parse_info_fields(&group, "redis xinfo group")?;
if fields
.get("name")
.is_some_and(|value| value.as_str() == group_name)
{
return Ok(RedisXInfoGroupStats {
pending: fields
.get("pending")
.and_then(|value| value.parse::<u64>().ok()),
lag: fields
.get("lag")
.and_then(|value| value.parse::<u64>().ok()),
});
}
}
Ok(RedisXInfoGroupStats::default())
}
fn parse_xpending_oldest_idle_ms(value: RedisValue) -> Result<Option<u64>, DataLayerError> {
let RedisValue::Array(parts) = value else {
return Err(DataLayerError::UnexpectedValue(
"redis xpending summary returned non-array payload".to_string(),
));
};
let Some(total) = parts
.first()
.and_then(|value| parse_u64_value(value, "redis xpending pending count").ok())
else {
return Ok(None);
};
if total == 0 {
return Ok(None);
}
let Some(consumers) = parts.get(3) else {
return Ok(None);
};
let RedisValue::Array(consumers) = consumers else {
return Ok(None);
};
let mut oldest: Option<u64> = None;
for consumer in consumers {
let RedisValue::Array(fields) = consumer else {
continue;
};
let Some(idle_ms) = fields
.get(2)
.and_then(|value| parse_u64_value(value, "redis xpending consumer idle").ok())
else {
continue;
};
oldest = Some(oldest.map_or(idle_ms, |current| current.max(idle_ms)));
}
Ok(oldest)
}
fn parse_info_fields(
value: &RedisValue,
context: &str,
) -> Result<BTreeMap<String, String>, DataLayerError> {
match value {
RedisValue::Array(values) => {
if values.len() % 2 != 0 {
return Err(DataLayerError::UnexpectedValue(format!(
"{context} expected an even number of field elements, got {}",
values.len()
)));
}
let mut fields = BTreeMap::new();
for pair in values.chunks(2) {
if matches!(pair[1], RedisValue::Nil) {
continue;
}
fields.insert(
parse_string_value(&pair[0], context)?,
parse_string_value(&pair[1], context)?,
);
}
Ok(fields)
}
RedisValue::Map(entries) => entries
.iter()
.map(|(key, value)| {
Ok((
parse_string_value(key, context)?,
parse_string_value(value, context)?,
))
})
.collect(),
RedisValue::Nil => Ok(BTreeMap::new()),
_ => Err(DataLayerError::UnexpectedValue(format!(
"{context} expected a redis array/map payload"
))),
}
}
fn parse_u64_value(value: &RedisValue, context: &str) -> Result<u64, DataLayerError> {
from_redis_value::<u64>(value).map_err(|err| {
DataLayerError::UnexpectedValue(format!(
"{context} was not an unsigned integer-compatible redis value: {err}"
))
})
}
#[cfg(test)]
#[path = "stream_owned_reply_tests.rs"]
mod owned_reply_tests;
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::{
parse_reclaim_result, parse_stream_read_entries, parse_transfer_result,
parse_xinfo_group_stats, parse_xpending_oldest_idle_ms, redis_stream_stats_missing_stream,
redis_stream_stats_missing_stream_or_group, validate_consumer, validate_group,
validate_stream_name, validate_stream_position, RedisConsumerName, RedisStreamName,
RedisStreamReclaimConfig, RedisStreamReclaimResult, RedisStreamRunnerConfig,
};
use redis::Value as RedisValue;
#[test]
fn pending_transfer_result_requires_complete_consistent_outcome() {
assert!(matches!(
parse_transfer_result(RedisValue::Array(vec![
RedisValue::Int(0),
RedisValue::BulkString(Vec::new()),
RedisValue::Int(0),
RedisValue::Int(0),
])),
Ok(crate::RuntimeQueueTransferOutcome::NotPending)
));
for values in [
Vec::new(),
vec![RedisValue::Int(0)],
vec![
RedisValue::Int(0),
RedisValue::BulkString(b"1-0".to_vec()),
RedisValue::Int(0),
RedisValue::Int(0),
],
vec![
RedisValue::Int(1),
RedisValue::BulkString(b"1-0".to_vec()),
RedisValue::Int(0),
RedisValue::Int(1),
],
vec![
RedisValue::Int(1),
RedisValue::BulkString(b"1-0".to_vec()),
RedisValue::Int(1),
RedisValue::Int(2),
],
] {
assert!(parse_transfer_result(RedisValue::Array(values)).is_err());
}
}
#[test]
fn validates_stream_runner_config() {
assert!(RedisStreamRunnerConfig {
command_timeout_ms: Some(0),
..RedisStreamRunnerConfig::default()
}
.validate()
.is_err());
assert!(RedisStreamRunnerConfig {
read_block_ms: Some(0),
..RedisStreamRunnerConfig::default()
}
.validate()
.is_err());
assert!(RedisStreamRunnerConfig {
command_timeout_ms: Some(1_000),
read_block_ms: Some(1_000),
..RedisStreamRunnerConfig::default()
}
.validate()
.is_err());
assert!(RedisStreamRunnerConfig {
read_count: 0,
..RedisStreamRunnerConfig::default()
}
.validate()
.is_err());
}
#[test]
fn validates_reclaim_config() {
assert!(RedisStreamReclaimConfig {
min_idle_ms: 0,
..RedisStreamReclaimConfig::default()
}
.validate()
.is_err());
assert!(RedisStreamReclaimConfig {
count: 0,
..RedisStreamReclaimConfig::default()
}
.validate()
.is_err());
}
#[test]
fn runner_reuses_client_and_keyspace() {
RedisStreamRunnerConfig::default()
.validate()
.expect("default stream config should be valid");
}
#[test]
fn rejects_invalid_inputs_before_network() {
assert!(validate_stream_name(&RedisStreamName(String::new())).is_err());
assert!(validate_group(&super::RedisConsumerGroup(String::new())).is_err());
assert!(validate_consumer(&RedisConsumerName(String::new())).is_err());
assert!(validate_stream_position("").is_err());
}
#[test]
fn parses_empty_blocking_read_as_no_entries() {
let parsed = parse_stream_read_entries(RedisValue::Nil)
.expect("nil stream read reply should be empty");
assert!(parsed.is_empty());
}
#[test]
fn parses_reclaim_result_with_deleted_ids() {
let parsed = parse_reclaim_result(RedisValue::Array(vec![
RedisValue::BulkString(b"0-0".to_vec()),
RedisValue::Array(vec![RedisValue::Array(vec![
RedisValue::BulkString(b"1710000000000-0".to_vec()),
RedisValue::Array(vec![
RedisValue::BulkString(b"payload".to_vec()),
RedisValue::BulkString(br#"{"ok":true}"#.to_vec()),
RedisValue::BulkString(b"kind".to_vec()),
RedisValue::BulkString(b"shadow".to_vec()),
]),
])]),
RedisValue::Array(vec![RedisValue::BulkString(b"1709999999999-0".to_vec())]),
]))
.expect("reclaim result should parse");
assert_eq!(
parsed,
RedisStreamReclaimResult {
next_start_id: "0-0".to_string(),
entries: vec![super::RedisStreamEntry {
id: "1710000000000-0".to_string(),
fields: BTreeMap::from([
("kind".to_string(), "shadow".to_string()),
("payload".to_string(), r#"{"ok":true}"#.to_string()),
]),
}],
deleted_ids: vec!["1709999999999-0".to_string()],
}
);
}
#[test]
fn parses_reclaim_result_without_deleted_ids() {
let parsed = parse_reclaim_result(RedisValue::Array(vec![
RedisValue::BulkString(b"0-0".to_vec()),
RedisValue::Array(vec![]),
]))
.expect("reclaim result should parse");
assert_eq!(
parsed,
RedisStreamReclaimResult {
next_start_id: "0-0".to_string(),
entries: Vec::new(),
deleted_ids: Vec::new(),
}
);
}
#[test]
fn parses_xinfo_group_stats() {
let parsed = parse_xinfo_group_stats(
RedisValue::Array(vec![RedisValue::Array(vec![
RedisValue::BulkString(b"name".to_vec()),
RedisValue::BulkString(b"usage_consumers".to_vec()),
RedisValue::BulkString(b"consumers".to_vec()),
RedisValue::Int(4),
RedisValue::BulkString(b"pending".to_vec()),
RedisValue::Int(7),
RedisValue::BulkString(b"last-delivered-id".to_vec()),
RedisValue::BulkString(b"1710000000000-0".to_vec()),
RedisValue::BulkString(b"entries-read".to_vec()),
RedisValue::Int(12),
RedisValue::BulkString(b"lag".to_vec()),
RedisValue::Int(3),
])]),
"usage_consumers",
)
.expect("xinfo groups should parse");
assert_eq!(parsed.pending, Some(7));
assert_eq!(parsed.lag, Some(3));
}
#[test]
fn parses_xinfo_group_stats_with_nil_lag() {
let parsed = parse_xinfo_group_stats(
RedisValue::Array(vec![RedisValue::Array(vec![
RedisValue::BulkString(b"name".to_vec()),
RedisValue::BulkString(b"usage_consumers".to_vec()),
RedisValue::BulkString(b"consumers".to_vec()),
RedisValue::Int(4),
RedisValue::BulkString(b"pending".to_vec()),
RedisValue::Int(7),
RedisValue::BulkString(b"last-delivered-id".to_vec()),
RedisValue::BulkString(b"1710000000000-0".to_vec()),
RedisValue::BulkString(b"entries-read".to_vec()),
RedisValue::Int(12),
RedisValue::BulkString(b"lag".to_vec()),
RedisValue::Nil,
])]),
"usage_consumers",
)
.expect("xinfo groups should parse nil lag");
assert_eq!(parsed.pending, Some(7));
assert_eq!(parsed.lag, None);
}
#[test]
fn parses_xpending_oldest_idle_ms_from_summary() {
let parsed = parse_xpending_oldest_idle_ms(RedisValue::Array(vec![
RedisValue::Int(7),
RedisValue::BulkString(b"1710000000000-0".to_vec()),
RedisValue::BulkString(b"1710000000006-0".to_vec()),
RedisValue::Array(vec![
RedisValue::Array(vec![
RedisValue::BulkString(b"consumer-a".to_vec()),
RedisValue::Int(4),
RedisValue::Int(1200),
]),
RedisValue::Array(vec![
RedisValue::BulkString(b"consumer-b".to_vec()),
RedisValue::Int(3),
RedisValue::Int(3400),
]),
]),
]))
.expect("xpending should parse");
assert_eq!(parsed, Some(3400));
}
#[test]
fn parses_empty_xpending_as_none() {
let parsed = parse_xpending_oldest_idle_ms(RedisValue::Array(vec![
RedisValue::Int(0),
RedisValue::Nil,
RedisValue::Nil,
RedisValue::Array(vec![]),
]))
.expect("empty xpending should parse");
assert_eq!(parsed, None);
}
#[test]
fn classifies_missing_stream_stats_errors() {
let missing_stream = redis::RedisError::from((
redis::ErrorKind::ResponseError,
"ERR",
"no such key".to_string(),
));
let missing_group = redis::RedisError::from((
redis::ErrorKind::ResponseError,
"ERR",
"NOGROUP No such key 'usage:events' or consumer group 'usage_consumers'".to_string(),
));
let other_error = redis::RedisError::from((
redis::ErrorKind::ResponseError,
"ERR",
"wrong type".to_string(),
));
assert!(redis_stream_stats_missing_stream(&missing_stream));
assert!(redis_stream_stats_missing_stream_or_group(&missing_stream));
assert!(!redis_stream_stats_missing_stream(&missing_group));
assert!(redis_stream_stats_missing_stream_or_group(&missing_group));
assert!(!redis_stream_stats_missing_stream(&other_error));
assert!(!redis_stream_stats_missing_stream_or_group(&other_error));
}
}