use std::time::Duration;
use redis::Value;
use rainier_support::Result;
use crate::redis::RedisClient;
pub const BODY: &str = "body";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StreamEntry {
pub id: String,
pub body: Vec<u8>,
}
const PROMOTE_DUE: &str = r"
local due = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
if #due == 0 then return 0 end
for _, body in ipairs(due) do
redis.call('ZREM', KEYS[1], body)
redis.call('XADD', KEYS[2], '*', ARGV[2], body)
end
return #due
";
impl RedisClient {
pub async fn xadd(&self, stream: &str, body: &[u8]) -> Result<String> {
self.connection().query(redis::cmd("XADD").arg(stream).arg("*").arg(BODY).arg(body)).await
}
pub async fn xgroup_create(&self, stream: &str, group: &str) -> Result<()> {
let outcome: Result<()> = self
.connection()
.run(redis::cmd("XGROUP").arg("CREATE").arg(stream).arg(group).arg("0").arg("MKSTREAM"))
.await;
match outcome {
Ok(()) => Ok(()),
Err(e) if already_exists(&e) => Ok(()),
Err(e) => Err(e),
}
}
pub async fn xreadgroup_one(
&self,
stream: &str,
group: &str,
consumer: &str,
) -> Result<Option<StreamEntry>> {
let reply: Value = self
.connection()
.query(
redis::cmd("XREADGROUP")
.arg("GROUP")
.arg(group)
.arg(consumer)
.arg("COUNT")
.arg(1)
.arg("STREAMS")
.arg(stream)
.arg(">"),
)
.await?;
Ok(read_reply(&reply))
}
pub async fn xautoclaim_one(
&self,
stream: &str,
group: &str,
consumer: &str,
min_idle: Duration,
) -> Result<Option<StreamEntry>> {
let reply: Value = self
.connection()
.query(
redis::cmd("XAUTOCLAIM")
.arg(stream)
.arg(group)
.arg(consumer)
.arg(min_idle.as_millis() as u64)
.arg("0-0")
.arg("COUNT")
.arg(1),
)
.await?;
Ok(claim_reply(&reply))
}
pub async fn xack_delete(&self, stream: &str, group: &str, id: &str) -> Result<()> {
self.connection().run(redis::cmd("XACK").arg(stream).arg(group).arg(id)).await?;
self.connection().run(redis::cmd("XDEL").arg(stream).arg(id)).await
}
pub async fn xlen(&self, stream: &str) -> Result<u64> {
self.connection().query(redis::cmd("XLEN").arg(stream)).await
}
pub async fn zadd(&self, key: &str, score: i64, member: &[u8]) -> Result<()> {
self.connection().run(redis::cmd("ZADD").arg(key).arg(score).arg(member)).await
}
pub async fn zcard(&self, key: &str) -> Result<u64> {
self.connection().query(redis::cmd("ZCARD").arg(key)).await
}
pub async fn promote_due(&self, zset: &str, stream: &str, now_ms: i64) -> Result<u64> {
self.connection()
.query(
redis::cmd("EVAL")
.arg(PROMOTE_DUE)
.arg(2)
.arg(zset)
.arg(stream)
.arg(now_ms)
.arg(BODY),
)
.await
}
pub async fn lpush(&self, key: &str, value: &[u8]) -> Result<u64> {
self.connection().query(redis::cmd("LPUSH").arg(key).arg(value)).await
}
pub async fn lrange_all(&self, key: &str) -> Result<Vec<Vec<u8>>> {
self.connection().query(redis::cmd("LRANGE").arg(key).arg(0).arg(-1)).await
}
pub async fn config_get(&self, name: &str) -> Result<Option<String>> {
let reply: Value =
match self.connection().query(redis::cmd("CONFIG").arg("GET").arg(name)).await {
Ok(reply) => reply,
Err(_) => return Ok(None),
};
Ok(config_value(&reply))
}
pub async fn delete_all(&self, keys: &[String]) -> Result<u64> {
if keys.is_empty() {
return Ok(0);
}
let mut command = redis::cmd("DEL");
for key in keys {
command.arg(key);
}
self.connection().query(&command).await
}
}
fn already_exists(error: &rainier_support::Error) -> bool {
let message = error.message();
message.contains("BUSYGROUP") || message.contains("already exists")
}
fn config_value(reply: &Value) -> Option<String> {
match reply {
Value::Array(pairs) => as_string(pairs.get(1)?),
Value::Map(pairs) => pairs.first().and_then(|(_, value)| as_string(value)),
_ => None,
}
}
fn read_reply(reply: &Value) -> Option<StreamEntry> {
let Value::Array(streams) = reply else { return None };
streams.iter().find_map(|stream| {
let Value::Array(parts) = stream else { return None };
entries(parts.get(1)?)
})
}
fn claim_reply(reply: &Value) -> Option<StreamEntry> {
let Value::Array(parts) = reply else { return None };
entries(parts.get(1)?)
}
fn entries(value: &Value) -> Option<StreamEntry> {
let Value::Array(entries) = value else { return None };
entries.iter().find_map(|entry| {
let Value::Array(parts) = entry else { return None };
let id = as_string(parts.first()?)?;
let body = field(parts.get(1)?, BODY)?;
Some(StreamEntry { id, body })
})
}
fn field(value: &Value, name: &str) -> Option<Vec<u8>> {
let Value::Array(pairs) = value else { return None };
pairs.chunks(2).find_map(|pair| match pair {
[key, value] if as_string(key).as_deref() == Some(name) => as_bytes(value),
_ => None,
})
}
fn as_string(value: &Value) -> Option<String> {
match value {
Value::BulkString(bytes) => String::from_utf8(bytes.clone()).ok(),
Value::SimpleString(text) => Some(text.clone()),
_ => None,
}
}
fn as_bytes(value: &Value) -> Option<Vec<u8>> {
match value {
Value::BulkString(bytes) => Some(bytes.clone()),
Value::SimpleString(text) => Some(text.clone().into_bytes()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bulk(text: &str) -> Value {
Value::BulkString(text.as_bytes().to_vec())
}
fn xreadgroup_reply() -> Value {
Value::Array(vec![Value::Array(vec![
bulk("queue:default"),
Value::Array(vec![Value::Array(vec![
bulk("1700000000000-0"),
Value::Array(vec![bulk("body"), bulk(r#"{"id":"j1"}"#)]),
])]),
])])
}
fn xautoclaim_reply() -> Value {
Value::Array(vec![
bulk("0-0"),
Value::Array(vec![Value::Array(vec![
bulk("1700000000001-0"),
Value::Array(vec![bulk("body"), bulk(r#"{"id":"j2"}"#)]),
])]),
Value::Array(vec![]),
])
}
#[test]
fn a_read_reply_yields_its_entry() {
let entry = read_reply(&xreadgroup_reply()).expect("one entry");
assert_eq!(entry.id, "1700000000000-0");
assert_eq!(entry.body, br#"{"id":"j1"}"#.to_vec());
}
#[test]
fn a_claim_reply_is_one_level_shallower_and_still_parses() {
let entry = claim_reply(&xautoclaim_reply()).expect("one entry");
assert_eq!(entry.id, "1700000000001-0");
assert_eq!(entry.body, br#"{"id":"j2"}"#.to_vec());
}
#[test]
fn an_empty_queue_yields_nothing_rather_than_an_error() {
assert_eq!(read_reply(&Value::Nil), None);
assert_eq!(read_reply(&Value::Array(vec![])), None);
let empty_claim =
Value::Array(vec![bulk("0-0"), Value::Array(vec![]), Value::Array(vec![])]);
assert_eq!(claim_reply(&empty_claim), None);
}
#[test]
fn an_entry_missing_its_body_field_is_skipped() {
let reply = Value::Array(vec![Value::Array(vec![
bulk("queue:default"),
Value::Array(vec![Value::Array(vec![
bulk("1-0"),
Value::Array(vec![bulk("something-else"), bulk("{}")]),
])]),
])]);
assert_eq!(read_reply(&reply), None);
}
#[test]
fn a_reply_shape_we_do_not_know_is_none_not_a_panic() {
for reply in [Value::Okay, Value::Int(1), bulk("nonsense")] {
assert_eq!(read_reply(&reply), None);
assert_eq!(claim_reply(&reply), None);
}
}
#[test]
fn a_config_reply_yields_its_value_in_either_protocol() {
let resp2 = Value::Array(vec![bulk("maxmemory-policy"), bulk("noeviction")]);
let resp3 = Value::Map(vec![(bulk("maxmemory-policy"), bulk("allkeys-lru"))]);
assert_eq!(config_value(&resp2).as_deref(), Some("noeviction"));
assert_eq!(config_value(&resp3).as_deref(), Some("allkeys-lru"));
assert_eq!(config_value(&Value::Nil), None);
}
#[test]
fn the_promotion_script_moves_and_removes_in_one_step() {
assert!(PROMOTE_DUE.contains("ZRANGEBYSCORE"));
assert!(PROMOTE_DUE.contains("ZREM"));
assert!(PROMOTE_DUE.contains("XADD"));
}
}