From 1dbfad654e8c3c1a4a4951ecb5b59c3d192338d1 Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Thu, 26 Feb 2026 21:51:10 -0500 Subject: [PATCH 1/3] feat: add COMMAND introspection and HINCRBYFLOAT COMMAND (with COUNT, INFO, DOCS, LIST subcommands) provides the static command metadata that client libraries like jedis, lettuce, and redis-py call on connect for capability discovery. without it many clients refuse to operate. HINCRBYFLOAT follows the same pattern as HINCRBY and INCRBYFLOAT: it increments a hash field's float value atomically, creating the field at 0 if absent. persisted to AOF as an HSET with the resulting value to avoid float drift during replay. both commands are wired through protocol parse, shard dispatch, execute, and the CLI command table. --- crates/ember-cli/src/commands.rs | 30 ++ crates/ember-core/src/keyspace/hash.rs | 72 +++++ crates/ember-core/src/shard/aof.rs | 27 ++ crates/ember-core/src/shard/mod.rs | 49 +++ .../ember-protocol/src/command/attributes.rs | 20 +- crates/ember-protocol/src/command/mod.rs | 33 ++ crates/ember-protocol/src/command/parse.rs | 88 ++++++ crates/ember-server/src/connection/execute.rs | 298 ++++++++++++++++++ 8 files changed, 616 insertions(+), 1 deletion(-) diff --git a/crates/ember-cli/src/commands.rs b/crates/ember-cli/src/commands.rs index b3a92f8e..88218d5c 100644 --- a/crates/ember-cli/src/commands.rs +++ b/crates/ember-cli/src/commands.rs @@ -36,6 +36,12 @@ pub static COMMANDS: &[CommandInfo] = &[ group: "connection", summary: "manage client connections", }, + CommandInfo { + name: "COMMAND", + args: "[COUNT | INFO name [name ...] | DOCS name [name ...]]", + group: "connection", + summary: "get an array of server command metadata", + }, CommandInfo { name: "ECHO", args: "message", @@ -449,6 +455,12 @@ pub static COMMANDS: &[CommandInfo] = &[ group: "hash", summary: "increment the integer value of a hash field", }, + CommandInfo { + name: "HINCRBYFLOAT", + args: "key field increment", + group: "hash", + summary: "increment the float value of a hash field", + }, CommandInfo { name: "HKEYS", args: "key", @@ -595,6 +607,24 @@ pub static COMMANDS: &[CommandInfo] = &[ group: "sorted_set", summary: "get the score of a member in a sorted set", }, + CommandInfo { + name: "ZDIFFSTORE", + args: "destkey numkeys key [key ...]", + group: "sorted_set", + summary: "subtract multiple sorted sets and store the result in a new key", + }, + CommandInfo { + name: "ZINTERSTORE", + args: "destkey numkeys key [key ...]", + group: "sorted_set", + summary: "intersect multiple sorted sets and store the result in a new key", + }, + CommandInfo { + name: "ZUNIONSTORE", + args: "destkey numkeys key [key ...]", + group: "sorted_set", + summary: "add multiple sorted sets and store the result in a new key", + }, // --- server --- CommandInfo { name: "ACL", diff --git a/crates/ember-core/src/keyspace/hash.rs b/crates/ember-core/src/keyspace/hash.rs index ac52d52a..b2839eb8 100644 --- a/crates/ember-core/src/keyspace/hash.rs +++ b/crates/ember-core/src/keyspace/hash.rs @@ -212,6 +212,78 @@ impl Keyspace { Ok(new_val) } + /// Increments a field's float value by the given amount. + /// + /// If the field doesn't exist, it is created with the increment as its value. + /// The stored value must be a valid float or the call returns an error. + /// Returns the new value as a formatted string. + pub fn hincrbyfloat(&mut self, key: &str, field: &str, delta: f64) -> Result { + self.remove_if_expired(key); + + let is_new = match self.entries.get(key) { + None => true, + Some(e) if matches!(e.value, Value::Hash(_)) => false, + Some(_) => return Err(IncrFloatError::WrongType), + }; + + // generous float string length estimate + let val_str_len = 32usize; + let estimated_increase = if is_new { + memory::ENTRY_OVERHEAD + + key.len() + + memory::PACKED_HASH_BASE_OVERHEAD + + field.len() + + val_str_len + + memory::PACKED_HASH_ENTRY_OVERHEAD + } else { + field.len() + val_str_len + memory::PACKED_HASH_ENTRY_OVERHEAD + }; + + if !self.enforce_memory_limit(estimated_increase) { + return Err(IncrFloatError::OutOfMemory); + } + + if is_new { + let value = Value::Hash(Box::default()); + self.memory.add(key, &value); + let entry = Entry::new(value, None); + self.entries.insert(CompactString::from(key), entry); + self.bump_version(key); + } + + let Some(entry) = self.entries.get_mut(key) else { + return Err(IncrFloatError::WrongType); + }; + let old_entry_size = entry.entry_size(key); + + let Value::Hash(ref mut hash) = entry.value else { + return Err(IncrFloatError::WrongType); + }; + let current = match hash.get(field) { + Some(data) => { + let s = std::str::from_utf8(data).map_err(|_| IncrFloatError::NotAFloat)?; + s.parse::().map_err(|_| IncrFloatError::NotAFloat)? + } + None => 0.0, + }; + let new_val = current + delta; + if new_val.is_nan() || new_val.is_infinite() { + return Err(IncrFloatError::NanOrInfinity); + } + + let formatted = format_float(new_val); + hash.insert(field.into(), Bytes::from(formatted.clone())); + entry.touch(self.track_access); + + let new_value_size = memory::value_size(&entry.value); + entry.cached_value_size = new_value_size as u32; + let new_entry_size = key.len() + new_value_size + memory::ENTRY_OVERHEAD; + self.memory.adjust(old_entry_size, new_entry_size); + self.bump_version(key); + + Ok(formatted) + } + /// Returns all field names in a hash. pub fn hkeys(&mut self, key: &str) -> Result, WrongType> { let Some(entry) = self.get_live_entry(key) else { diff --git a/crates/ember-core/src/shard/aof.rs b/crates/ember-core/src/shard/aof.rs index 8b218251..411f2449 100644 --- a/crates/ember-core/src/shard/aof.rs +++ b/crates/ember-core/src/shard/aof.rs @@ -253,6 +253,14 @@ pub(super) fn to_aof_records( (ShardRequest::HIncrBy { key, field, delta }, ShardResponse::Integer(_)) => { smallvec![AofRecord::HIncrBy { key, field, delta }] } + // HINCRBYFLOAT: record as HSET with the resulting float value to avoid + // float rounding drift during replay (same strategy as INCRBYFLOAT). + (ShardRequest::HIncrByFloat { key, field, .. }, ShardResponse::BulkString(val)) => { + smallvec![AofRecord::HSet { + key, + fields: vec![(field, Bytes::from(val.clone()))], + }] + } // Set commands (ShardRequest::SAdd { key, members }, ShardResponse::Len(count)) if *count > 0 => { smallvec![AofRecord::SAdd { key, members }] @@ -306,6 +314,25 @@ pub(super) fn to_aof_records( smallvec![AofRecord::Del { key: dest }] } } + // Z*STORE commands: persist as DEL dest + ZADD dest with the resulting members + ( + ShardRequest::ZUnionStore { dest, .. } + | ShardRequest::ZInterStore { dest, .. } + | ShardRequest::ZDiffStore { dest, .. }, + ShardResponse::ZStoreResult { count, members }, + ) => { + if *count > 0 { + smallvec![ + AofRecord::Del { key: dest.clone() }, + AofRecord::ZAdd { + key: dest, + members: members.clone(), + }, + ] + } else { + smallvec![AofRecord::Del { key: dest }] + } + } // Proto commands #[cfg(feature = "protobuf")] ( diff --git a/crates/ember-core/src/shard/mod.rs b/crates/ember-core/src/shard/mod.rs index 0c879be7..9fbcb81c 100644 --- a/crates/ember-core/src/shard/mod.rs +++ b/crates/ember-core/src/shard/mod.rs @@ -449,6 +449,12 @@ pub enum ShardRequest { field: String, delta: i64, }, + /// HINCRBYFLOAT key field increment — increments a hash field by a float. + HIncrByFloat { + key: String, + field: String, + delta: f64, + }, HKeys { key: String, }, @@ -574,6 +580,21 @@ pub enum ShardRequest { ZUnion { keys: Vec, }, + /// ZDIFFSTORE destkey numkeys key [key ...] — stores diff result in dest. + ZDiffStore { + dest: String, + keys: Vec, + }, + /// ZINTERSTORE destkey numkeys key [key ...] — stores intersection in dest. + ZInterStore { + dest: String, + keys: Vec, + }, + /// ZUNIONSTORE destkey numkeys key [key ...] — stores union in dest. + ZUnionStore { + dest: String, + keys: Vec, + }, /// ZRANDMEMBER — returns random member(s) from a sorted set; read-only, no AOF. ZRandMember { key: String, @@ -806,6 +827,7 @@ impl ShardRequest { | ShardRequest::HSet { .. } | ShardRequest::HDel { .. } | ShardRequest::HIncrBy { .. } + | ShardRequest::HIncrByFloat { .. } | ShardRequest::SAdd { .. } | ShardRequest::SRem { .. } | ShardRequest::SPop { .. } @@ -813,6 +835,9 @@ impl ShardRequest { | ShardRequest::SInterStore { .. } | ShardRequest::SDiffStore { .. } | ShardRequest::SMove { .. } + | ShardRequest::ZDiffStore { .. } + | ShardRequest::ZInterStore { .. } + | ShardRequest::ZUnionStore { .. } | ShardRequest::LMove { .. } | ShardRequest::GetDel { .. } | ShardRequest::GetEx { .. } @@ -905,6 +930,12 @@ pub enum ShardResponse { BoolArray(Vec), /// SUNIONSTORE/SINTERSTORE/SDIFFSTORE result: count + stored members for AOF. SetStoreResult { count: usize, members: Vec }, + /// ZUNIONSTORE/ZINTERSTORE/ZDIFFSTORE result: count + scored members for AOF. + ZStoreResult { + count: usize, + /// (score, member) pairs stored in dest. + members: Vec<(f64, String)>, + }, /// Serialized key dump with remaining TTL (for MIGRATE/DUMP). KeyDump { data: Vec, ttl_ms: i64 }, /// In-memory snapshot of the full shard state (for replication). @@ -2028,6 +2059,12 @@ fn dispatch( Err(_) => ShardResponse::WrongType, }, ShardRequest::HIncrBy { key, field, delta } => incr_result(ks.hincrby(key, field, *delta)), + ShardRequest::HIncrByFloat { key, field, delta } => match ks.hincrbyfloat(key, field, *delta) { + Ok(val) => ShardResponse::BulkString(val), + Err(IncrFloatError::WrongType) => ShardResponse::WrongType, + Err(IncrFloatError::OutOfMemory) => ShardResponse::OutOfMemory, + Err(e) => ShardResponse::Err(e.to_string()), + }, ShardRequest::HKeys { key } => match ks.hkeys(key) { Ok(keys) => ShardResponse::StringArray(keys), Err(_) => ShardResponse::WrongType, @@ -2153,6 +2190,18 @@ fn dispatch( Ok(pairs) => ShardResponse::ScoredArray(pairs), Err(_) => ShardResponse::WrongType, }, + ShardRequest::ZDiffStore { dest, keys } => match ks.zdiffstore(dest, keys) { + Ok((count, members)) => ShardResponse::ZStoreResult { count, members }, + Err(_) => ShardResponse::WrongType, + }, + ShardRequest::ZInterStore { dest, keys } => match ks.zinterstore(dest, keys) { + Ok((count, members)) => ShardResponse::ZStoreResult { count, members }, + Err(_) => ShardResponse::WrongType, + }, + ShardRequest::ZUnionStore { dest, keys } => match ks.zunionstore(dest, keys) { + Ok((count, members)) => ShardResponse::ZStoreResult { count, members }, + Err(_) => ShardResponse::WrongType, + }, ShardRequest::ZRandMember { key, count, diff --git a/crates/ember-protocol/src/command/attributes.rs b/crates/ember-protocol/src/command/attributes.rs index d242184f..995cc318 100644 --- a/crates/ember-protocol/src/command/attributes.rs +++ b/crates/ember-protocol/src/command/attributes.rs @@ -135,6 +135,9 @@ impl Command { Command::ZDiff { .. } => "zdiff", Command::ZInter { .. } => "zinter", Command::ZUnion { .. } => "zunion", + Command::ZDiffStore { .. } => "zdiffstore", + Command::ZInterStore { .. } => "zinterstore", + Command::ZUnionStore { .. } => "zunionstore", Command::ZRandMember { .. } => "zrandmember", // hash @@ -240,6 +243,9 @@ impl Command { Command::Touch { .. } => "touch", Command::Sort { .. } => "sort", + Command::Command { .. } => "command", + Command::HIncrByFloat { .. } => "hincrbyfloat", + Command::Unknown(_) => "unknown", } } @@ -469,6 +475,11 @@ impl Command { | Command::ZUnion { .. } | Command::ZRandMember { .. } => READ | SORTEDSET | SLOW, + // sorted set — store variants (Redis 6.2+) + Command::ZDiffStore { .. } + | Command::ZInterStore { .. } + | Command::ZUnionStore { .. } => WRITE | SORTEDSET | SLOW, + // string extras (Redis 6.2+) Command::GetDel { .. } | Command::GetEx { .. } => WRITE | STRING | FAST, @@ -588,6 +599,9 @@ impl Command { Command::AclSetUser { .. } | Command::AclDelUser { .. } => SERVER | ADMIN | SLOW, Command::AclCat { .. } => SERVER | SLOW, + Command::Command { .. } => SERVER | SLOW, + Command::HIncrByFloat { .. } => WRITE | HASH | FAST, + Command::Unknown(_) => 0, } } @@ -704,7 +718,11 @@ impl Command { | Command::Zmpop { keys, .. } => keys.first().map(String::as_str), Command::SUnionStore { dest, .. } | Command::SInterStore { dest, .. } - | Command::SDiffStore { dest, .. } => Some(dest), + | Command::SDiffStore { dest, .. } + | Command::ZUnionStore { dest, .. } + | Command::ZInterStore { dest, .. } + | Command::ZDiffStore { dest, .. } => Some(dest), + Command::HIncrByFloat { key, .. } => Some(key), Command::MSet { pairs } | Command::MSetNx { pairs } => { pairs.first().map(|(k, _)| k.as_str()) } diff --git a/crates/ember-protocol/src/command/mod.rs b/crates/ember-protocol/src/command/mod.rs index 3c5fe85c..4db17aa7 100644 --- a/crates/ember-protocol/src/command/mod.rs +++ b/crates/ember-protocol/src/command/mod.rs @@ -346,6 +346,18 @@ pub enum Command { with_scores: bool, }, + /// ZDIFFSTORE `destkey` `numkeys` `key` \[key ...\]. + /// Stores the diff of sorted sets in destkey. Returns the cardinality. + ZDiffStore { dest: String, keys: Vec }, + + /// ZINTERSTORE `destkey` `numkeys` `key` \[key ...\]. + /// Stores the intersection of sorted sets in destkey. Returns the cardinality. + ZInterStore { dest: String, keys: Vec }, + + /// ZUNIONSTORE `destkey` `numkeys` `key` \[key ...\]. + /// Stores the union of sorted sets in destkey. Returns the cardinality. + ZUnionStore { dest: String, keys: Vec }, + /// TYPE `key`. Returns the type of the value stored at key. Type { key: String }, @@ -883,6 +895,27 @@ pub enum Command { store: Option, }, + /// COMMAND \[COUNT | INFO name \[name ...\] | DOCS name \[name ...\]\] + /// + /// Returns metadata about supported commands. Used by client libraries + /// for capability discovery on connect. + Command { + /// Optional subcommand: COUNT, INFO, DOCS, LIST. None = list all commands. + subcommand: Option, + /// Arguments to the subcommand (command names for INFO/DOCS). + args: Vec, + }, + + /// HINCRBYFLOAT `key` `field` `increment`. Increments the float value of a hash field. + /// + /// If the field doesn't exist it is set to 0 before the operation. + /// Returns the new value as a bulk string. + HIncrByFloat { + key: String, + field: String, + delta: f64, + }, + /// A command we don't recognize (yet). Unknown(String), } diff --git a/crates/ember-protocol/src/command/parse.rs b/crates/ember-protocol/src/command/parse.rs index 04b48b78..80822e3b 100644 --- a/crates/ember-protocol/src/command/parse.rs +++ b/crates/ember-protocol/src/command/parse.rs @@ -164,6 +164,9 @@ impl Command { "ZDIFF" => parse_zset_multi("ZDIFF", &frames[1..]), "ZINTER" => parse_zset_multi("ZINTER", &frames[1..]), "ZUNION" => parse_zset_multi("ZUNION", &frames[1..]), + "ZDIFFSTORE" => parse_zset_store("ZDIFFSTORE", &frames[1..]), + "ZINTERSTORE" => parse_zset_store("ZINTERSTORE", &frames[1..]), + "ZUNIONSTORE" => parse_zset_store("ZUNIONSTORE", &frames[1..]), "ZRANDMEMBER" => parse_zrandmember(&frames[1..]), "HSET" => parse_hset(&frames[1..]), "HGET" => parse_hget(&frames[1..]), @@ -172,6 +175,7 @@ impl Command { "HEXISTS" => parse_hexists(&frames[1..]), "HLEN" => parse_hlen(&frames[1..]), "HINCRBY" => parse_hincrby(&frames[1..]), + "HINCRBYFLOAT" => parse_hincrbyfloat(&frames[1..]), "HKEYS" => parse_hkeys(&frames[1..]), "HVALS" => parse_hvals(&frames[1..]), "HMGET" => parse_hmget(&frames[1..]), @@ -199,6 +203,7 @@ impl Command { "MIGRATE" => parse_migrate(&frames[1..]), "RESTORE" => parse_restore(&frames[1..]), "CONFIG" => parse_config(&frames[1..]), + "COMMAND" => parse_command_cmd(&frames[1..]), "MULTI" => parse_no_args("MULTI", &frames[1..], Command::Multi), "EXEC" => parse_no_args("EXEC", &frames[1..], Command::Exec), "DISCARD" => parse_no_args("DISCARD", &frames[1..], Command::Discard), @@ -2017,6 +2022,16 @@ fn parse_hincrby(args: &[Frame]) -> Result { Ok(Command::HIncrBy { key, field, delta }) } +fn parse_hincrbyfloat(args: &[Frame]) -> Result { + if args.len() != 3 { + return Err(wrong_arity("HINCRBYFLOAT")); + } + let key = extract_string(&args[0])?; + let field = extract_string(&args[1])?; + let delta = parse_f64(&args[2], "HINCRBYFLOAT")?; + Ok(Command::HIncrByFloat { key, field, delta }) +} + fn parse_hkeys(args: &[Frame]) -> Result { if args.len() != 1 { return Err(wrong_arity("HKEYS")); @@ -3621,6 +3636,32 @@ fn parse_zset_multi(cmd: &'static str, args: &[Frame]) -> Result Result { + // need at least: dest numkeys key + if args.len() < 3 { + return Err(wrong_arity(cmd)); + } + let dest = extract_string(&args[0])?; + let numkeys = parse_u64(&args[1], cmd)? as usize; + if numkeys == 0 { + return Err(ProtocolError::InvalidCommandFrame(format!( + "{cmd}: numkeys must be positive" + ))); + } + if args.len() < 2 + numkeys { + return Err(wrong_arity(cmd)); + } + let keys = extract_strings(&args[2..2 + numkeys])?; + + match cmd { + "ZDIFFSTORE" => Ok(Command::ZDiffStore { dest, keys }), + "ZINTERSTORE" => Ok(Command::ZInterStore { dest, keys }), + "ZUNIONSTORE" => Ok(Command::ZUnionStore { dest, keys }), + _ => Err(wrong_arity(cmd)), + } +} + fn parse_zrandmember(args: &[Frame]) -> Result { if args.is_empty() { return Err(wrong_arity("ZRANDMEMBER")); @@ -3665,3 +3706,50 @@ fn parse_wait(args: &[Frame]) -> Result { timeout_ms, }) } + +fn parse_command_cmd(args: &[Frame]) -> Result { + if args.is_empty() { + return Ok(Command::Command { + subcommand: None, + args: vec![], + }); + } + let sub = extract_string(&args[0])?.to_ascii_uppercase(); + match sub.as_str() { + "COUNT" => Ok(Command::Command { + subcommand: Some("COUNT".into()), + args: vec![], + }), + "INFO" => { + let names = args[1..] + .iter() + .map(|f| extract_string(f).map(|s| s.to_ascii_uppercase())) + .collect::, _>>()?; + Ok(Command::Command { + subcommand: Some("INFO".into()), + args: names, + }) + } + "DOCS" => { + let names = args[1..] + .iter() + .map(|f| extract_string(f).map(|s| s.to_ascii_uppercase())) + .collect::, _>>()?; + Ok(Command::Command { + subcommand: Some("DOCS".into()), + args: names, + }) + } + "GETKEYS" => Ok(Command::Command { + subcommand: Some("GETKEYS".into()), + args: vec![], + }), + "LIST" => Ok(Command::Command { + subcommand: Some("LIST".into()), + args: vec![], + }), + _ => Err(ProtocolError::InvalidCommandFrame(format!( + "unknown COMMAND subcommand: {sub}" + ))), + } +} diff --git a/crates/ember-server/src/connection/execute.rs b/crates/ember-server/src/connection/execute.rs index f7914b11..f21d407b 100644 --- a/crates/ember-server/src/connection/execute.rs +++ b/crates/ember-server/src/connection/execute.rs @@ -119,6 +119,7 @@ pub(super) async fn execute( Command::Ping(None) => Frame::Simple("PONG".into()), Command::Ping(Some(msg)) => Frame::Bulk(msg), Command::Echo(msg) => Frame::Bulk(msg), + Command::Command { subcommand, args } => handle_command_cmd(subcommand.as_deref(), &args), // -- client commands (connection-scoped, no shard needed) -- Command::ClientId => Frame::Integer(client_id as i64), @@ -1822,6 +1823,19 @@ pub(super) async fn execute( } } + Command::HIncrByFloat { key, field, delta } => { + let idx = engine.shard_for_key(&key); + let req = ShardRequest::HIncrByFloat { key, field, delta }; + match engine.send_to_shard(idx, req).await { + Ok(ShardResponse::BulkString(val)) => Frame::Bulk(Bytes::from(val)), + Ok(ShardResponse::WrongType) => wrongtype_error(), + Ok(ShardResponse::OutOfMemory) => oom_error(), + Ok(ShardResponse::Err(msg)) => Frame::Error(msg), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } + } + Command::HKeys { key } => { let idx = engine.shard_for_key(&key); let req = ShardRequest::HKeys { key }; @@ -2360,6 +2374,66 @@ pub(super) async fn execute( } } + Command::ZDiffStore { dest, keys } => { + let idx = engine.shard_for_key(&dest); + let req = ShardRequest::ZDiffStore { dest: dest.clone(), keys }; + match engine.send_to_shard(idx, req).await { + Ok(ShardResponse::ZStoreResult { count, .. }) => { + notify_write( + ctx, + pubsub, + crate::keyspace_notifications::FLAG_Z, + "zdiffstore", + &dest, + ); + Frame::Integer(count as i64) + } + Ok(ShardResponse::WrongType) => wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } + } + + Command::ZInterStore { dest, keys } => { + let idx = engine.shard_for_key(&dest); + let req = ShardRequest::ZInterStore { dest: dest.clone(), keys }; + match engine.send_to_shard(idx, req).await { + Ok(ShardResponse::ZStoreResult { count, .. }) => { + notify_write( + ctx, + pubsub, + crate::keyspace_notifications::FLAG_Z, + "zinterstore", + &dest, + ); + Frame::Integer(count as i64) + } + Ok(ShardResponse::WrongType) => wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } + } + + Command::ZUnionStore { dest, keys } => { + let idx = engine.shard_for_key(&dest); + let req = ShardRequest::ZUnionStore { dest: dest.clone(), keys }; + match engine.send_to_shard(idx, req).await { + Ok(ShardResponse::ZStoreResult { count, .. }) => { + notify_write( + ctx, + pubsub, + crate::keyspace_notifications::FLAG_Z, + "zunionstore", + &dest, + ); + Frame::Integer(count as i64) + } + Ok(ShardResponse::WrongType) => wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } + } + Command::ZRandMember { key, count, @@ -3610,6 +3684,230 @@ pub(super) fn oom_error() -> Frame { Frame::Error("OOM command not allowed when used memory > 'maxmemory'".into()) } +/// Handles COMMAND [COUNT | INFO name... | DOCS name... | LIST]. +/// +/// Provides static command metadata for client library discovery. +/// The format matches Redis 7 conventions so clients can probe capabilities +/// without falling back to error-handling paths. +fn handle_command_cmd(subcommand: Option<&str>, args: &[String]) -> Frame { + match subcommand { + None | Some("LIST") => Frame::Array(COMMAND_TABLE.iter().map(command_entry).collect()), + Some("COUNT") => Frame::Integer(COMMAND_TABLE.len() as i64), + Some("INFO") => { + if args.is_empty() { + return Frame::Array(COMMAND_TABLE.iter().map(command_entry).collect()); + } + let frames = args + .iter() + .map(|name| { + let upper = name.to_ascii_uppercase(); + match COMMAND_TABLE.iter().find(|e| e.name == upper) { + Some(entry) => command_entry(entry), + None => Frame::Null, + } + }) + .collect(); + Frame::Array(frames) + } + Some("DOCS") => { + // return empty docs — clients use this for documentation display, + // not capability detection. an empty map per command is valid. + if args.is_empty() { + return Frame::Array(vec![]); + } + let mut frames = Vec::with_capacity(args.len() * 2); + for name in args { + let upper = name.to_ascii_uppercase(); + frames.push(Frame::Bulk(Bytes::from(upper))); + frames.push(Frame::Array(vec![])); + } + Frame::Array(frames) + } + Some("GETKEYS") => Frame::Array(vec![]), + Some(other) => Frame::Error(format!("ERR unknown COMMAND subcommand '{other}'")), + } +} + +/// Builds a COMMAND entry array for a single command. +/// +/// Format: [name, arity, [flags], first_key, last_key, step] +fn command_entry(e: &CommandEntry) -> Frame { + Frame::Array(vec![ + Frame::Bulk(Bytes::from(e.name.to_ascii_lowercase())), + Frame::Integer(e.arity), + Frame::Array(e.flags.iter().map(|f| Frame::Simple((*f).into())).collect()), + Frame::Integer(e.first_key), + Frame::Integer(e.last_key), + Frame::Integer(e.step), + ]) +} + +struct CommandEntry { + name: &'static str, + arity: i64, + flags: &'static [&'static str], + first_key: i64, + last_key: i64, + step: i64, +} + +/// Static command table. Arity: positive = exact, negative = minimum. +/// Flags: write, readonly, denyoom, admin, pubsub, noscript, fast, loading, etc. +static COMMAND_TABLE: &[CommandEntry] = &[ + CommandEntry { name: "APPEND", arity: 3, flags: &["write", "denyoom", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "AUTH", arity: -2, flags: &["noscript", "loading", "fast", "no_auth"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "BGREWRITEAOF", arity: 1, flags: &["admin"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "BGSAVE", arity: -1, flags: &["admin"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "BITCOUNT", arity: -2, flags: &["readonly"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "BITOP", arity: -4, flags: &["write", "denyoom"], first_key: 2, last_key: -1, step: 1 }, + CommandEntry { name: "BITPOS", arity: -3, flags: &["readonly"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "BLPOP", arity: -3, flags: &["write", "noscript"], first_key: 1, last_key: -2, step: 1 }, + CommandEntry { name: "BRPOP", arity: -3, flags: &["write", "noscript"], first_key: 1, last_key: -2, step: 1 }, + CommandEntry { name: "CLIENT", arity: -2, flags: &["admin", "noscript", "loading", "fast"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "CLUSTER", arity: -2, flags: &["admin"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "COMMAND", arity: -1, flags: &["loading", "fast"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "CONFIG", arity: -2, flags: &["admin", "loading", "noscript"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "COPY", arity: -3, flags: &["write"], first_key: 1, last_key: 2, step: 1 }, + CommandEntry { name: "DBSIZE", arity: 1, flags: &["readonly", "fast"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "DECR", arity: 2, flags: &["write", "denyoom", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "DECRBY", arity: 3, flags: &["write", "denyoom", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "DEL", arity: -2, flags: &["write"], first_key: 1, last_key: -1, step: 1 }, + CommandEntry { name: "DISCARD", arity: 1, flags: &["noscript", "loading", "fast"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "ECHO", arity: 2, flags: &["fast"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "EXEC", arity: 1, flags: &["noscript", "loading"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "EXISTS", arity: -2, flags: &["readonly", "fast"], first_key: 1, last_key: -1, step: 1 }, + CommandEntry { name: "EXPIRE", arity: 3, flags: &["write", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "EXPIREAT", arity: 3, flags: &["write", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "EXPIRETIME", arity: 2, flags: &["readonly", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "FLUSHALL", arity: -1, flags: &["write"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "FLUSHDB", arity: -1, flags: &["write"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "GET", arity: 2, flags: &["readonly", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "GETBIT", arity: 3, flags: &["readonly", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "GETDEL", arity: 2, flags: &["write", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "GETEX", arity: -2, flags: &["write", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "GETRANGE", arity: 4, flags: &["readonly"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "GETSET", arity: 3, flags: &["write", "denyoom", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "HDEL", arity: -3, flags: &["write", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "HEXISTS", arity: 3, flags: &["readonly", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "HGET", arity: 3, flags: &["readonly", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "HGETALL", arity: 2, flags: &["readonly", "random"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "HINCRBY", arity: 4, flags: &["write", "denyoom", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "HINCRBYFLOAT", arity: 4, flags: &["write", "denyoom", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "HKEYS", arity: 2, flags: &["readonly", "sort_for_script"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "HLEN", arity: 2, flags: &["readonly", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "HMGET", arity: -3, flags: &["readonly", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "HMSET", arity: -4, flags: &["write", "denyoom", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "HRANDFIELD", arity: -2, flags: &["readonly", "random"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "HSCAN", arity: -3, flags: &["readonly", "random"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "HSET", arity: -4, flags: &["write", "denyoom", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "HVALS", arity: 2, flags: &["readonly", "sort_for_script"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "INCR", arity: 2, flags: &["write", "denyoom", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "INCRBY", arity: 3, flags: &["write", "denyoom", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "INCRBYFLOAT", arity: 3, flags: &["write", "denyoom", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "INFO", arity: -1, flags: &["loading", "fast"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "KEYS", arity: 2, flags: &["readonly", "sort_for_script"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "LASTSAVE", arity: 1, flags: &["random", "loading", "fast"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "LINDEX", arity: 3, flags: &["readonly"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "LINSERT", arity: 5, flags: &["write", "denyoom"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "LLEN", arity: 2, flags: &["readonly", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "LMOVE", arity: 5, flags: &["write", "denyoom"], first_key: 1, last_key: 2, step: 1 }, + CommandEntry { name: "LMPOP", arity: -4, flags: &["write", "fast"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "LPOS", arity: -3, flags: &["readonly"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "LPOP", arity: -2, flags: &["write", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "LPUSH", arity: -3, flags: &["write", "denyoom", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "LPUSHX", arity: -3, flags: &["write", "denyoom", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "LRANGE", arity: 4, flags: &["readonly"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "LREM", arity: 4, flags: &["write"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "LSET", arity: 4, flags: &["write", "denyoom"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "LTRIM", arity: 4, flags: &["write"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "MEMORY", arity: -2, flags: &["readonly"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "MGET", arity: -2, flags: &["readonly", "fast"], first_key: 1, last_key: -1, step: 1 }, + CommandEntry { name: "MIGRATE", arity: -6, flags: &["write"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "MONITOR", arity: 1, flags: &["admin", "loading"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "MSET", arity: -3, flags: &["write", "denyoom"], first_key: 1, last_key: -1, step: 2 }, + CommandEntry { name: "MSETNX", arity: -3, flags: &["write", "denyoom"], first_key: 1, last_key: -1, step: 2 }, + CommandEntry { name: "MULTI", arity: 1, flags: &["noscript", "loading", "fast"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "OBJECT", arity: -2, flags: &["slow"], first_key: 2, last_key: 2, step: 1 }, + CommandEntry { name: "PERSIST", arity: 2, flags: &["write", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "PEXPIRE", arity: 3, flags: &["write", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "PEXPIREAT", arity: 3, flags: &["write", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "PEXPIRETIME", arity: 2, flags: &["readonly", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "PING", arity: -1, flags: &["fast", "loading"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "PSETEX", arity: 4, flags: &["write", "denyoom"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "PSUBSCRIBE", arity: -2, flags: &["pubsub", "loading", "fast"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "PTTL", arity: 2, flags: &["readonly", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "PUBLISH", arity: 3, flags: &["pubsub", "fast"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "PUBSUB", arity: -2, flags: &["pubsub", "random", "loading", "fast"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "PUNSUBSCRIBE", arity: -1, flags: &["pubsub", "loading", "fast"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "QUIT", arity: 1, flags: &["fast", "loading"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "RANDOMKEY", arity: 1, flags: &["readonly", "random"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "RENAME", arity: 3, flags: &["write"], first_key: 1, last_key: 2, step: 1 }, + CommandEntry { name: "ROLE", arity: 1, flags: &["noscript", "loading", "fast"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "RPOP", arity: -2, flags: &["write", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "RPUSH", arity: -3, flags: &["write", "denyoom", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "RPUSHX", arity: -3, flags: &["write", "denyoom", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "SADD", arity: -3, flags: &["write", "denyoom", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "SCAN", arity: -2, flags: &["readonly"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "SCARD", arity: 2, flags: &["readonly", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "SDIFF", arity: -2, flags: &["readonly", "sort_for_script"], first_key: 1, last_key: -1, step: 1 }, + CommandEntry { name: "SDIFFSTORE", arity: -3, flags: &["write", "denyoom"], first_key: 1, last_key: -1, step: 1 }, + CommandEntry { name: "SET", arity: -3, flags: &["write", "denyoom"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "SETBIT", arity: 4, flags: &["write", "denyoom"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "SETEX", arity: 4, flags: &["write", "denyoom"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "SETNX", arity: 3, flags: &["write", "denyoom", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "SETRANGE", arity: 4, flags: &["write", "denyoom"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "SINTER", arity: -2, flags: &["readonly", "sort_for_script"], first_key: 1, last_key: -1, step: 1 }, + CommandEntry { name: "SINTERCARD", arity: -3, flags: &["readonly"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "SINTERSTORE", arity: -3, flags: &["write", "denyoom"], first_key: 1, last_key: -1, step: 1 }, + CommandEntry { name: "SISMEMBER", arity: 3, flags: &["readonly", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "SLOWLOG", arity: -2, flags: &["admin", "loading", "fast"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "SMEMBERS", arity: 2, flags: &["readonly", "sort_for_script"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "SMISMEMBER", arity: -3, flags: &["readonly", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "SMOVE", arity: 4, flags: &["write", "fast"], first_key: 1, last_key: 2, step: 1 }, + CommandEntry { name: "SORT", arity: -2, flags: &["write", "denyoom"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "SPOP", arity: -2, flags: &["write", "random", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "SRANDMEMBER", arity: -2, flags: &["readonly", "random"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "SREM", arity: -3, flags: &["write", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "SSCAN", arity: -3, flags: &["readonly", "random"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "STRLEN", arity: 2, flags: &["readonly", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "SUBSCRIBE", arity: -2, flags: &["pubsub", "loading", "fast"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "SUNION", arity: -2, flags: &["readonly", "sort_for_script"], first_key: 1, last_key: -1, step: 1 }, + CommandEntry { name: "SUNIONSTORE", arity: -3, flags: &["write", "denyoom"], first_key: 1, last_key: -1, step: 1 }, + CommandEntry { name: "TIME", arity: 1, flags: &["random", "loading", "fast"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "TOUCH", arity: -2, flags: &["readonly", "fast"], first_key: 1, last_key: -1, step: 1 }, + CommandEntry { name: "TTL", arity: 2, flags: &["readonly", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "TYPE", arity: 2, flags: &["readonly", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "UNLINK", arity: -2, flags: &["write", "fast"], first_key: 1, last_key: -1, step: 1 }, + CommandEntry { name: "UNSUBSCRIBE", arity: -1, flags: &["pubsub", "loading", "fast"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "UNWATCH", arity: 1, flags: &["noscript", "loading", "fast"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "WAIT", arity: 3, flags: &["noscript"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "WATCH", arity: -2, flags: &["noscript", "loading", "fast"], first_key: 1, last_key: -1, step: 1 }, + CommandEntry { name: "ZADD", arity: -4, flags: &["write", "denyoom", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "ZCARD", arity: 2, flags: &["readonly", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "ZCOUNT", arity: 4, flags: &["readonly", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "ZDIFF", arity: -3, flags: &["readonly", "sort_for_script"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "ZDIFFSTORE", arity: -4, flags: &["write", "denyoom"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "ZINCRBY", arity: 4, flags: &["write", "denyoom", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "ZINTER", arity: -3, flags: &["readonly", "sort_for_script"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "ZINTERSTORE", arity: -4, flags: &["write", "denyoom"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "ZLEXCOUNT", arity: 4, flags: &["readonly", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "ZMPOP", arity: -4, flags: &["write", "fast"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "ZPOPMAX", arity: -2, flags: &["write", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "ZPOPMIN", arity: -2, flags: &["write", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "ZRANDMEMBER", arity: -2, flags: &["readonly", "random"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "ZRANGE", arity: -4, flags: &["readonly"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "ZRANGEBYSCORE", arity: -4, flags: &["readonly"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "ZRANK", arity: 3, flags: &["readonly", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "ZREM", arity: -3, flags: &["write", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "ZREVRANGE", arity: -4, flags: &["readonly"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "ZREVRANGEBYSCORE", arity: -4, flags: &["readonly"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "ZREVRANK", arity: 3, flags: &["readonly", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "ZSCAN", arity: -3, flags: &["readonly", "random"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "ZSCORE", arity: 3, flags: &["readonly", "fast"], first_key: 1, last_key: 1, step: 1 }, + CommandEntry { name: "ZUNION", arity: -3, flags: &["readonly", "sort_for_script"], first_key: 0, last_key: 0, step: 0 }, + CommandEntry { name: "ZUNIONSTORE", arity: -4, flags: &["write", "denyoom"], first_key: 1, last_key: 1, step: 1 }, +]; + /// Implements the WAIT command: blocks until `needed` replicas have /// acknowledged all writes at or before the current primary offset, /// or until `timeout_ms` milliseconds elapse. From ba379dd0daa994c8e56d24daeaa4dea5eecd7477 Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Thu, 26 Feb 2026 21:51:37 -0500 Subject: [PATCH 2/3] feat: add ZUNIONSTORE, ZINTERSTORE, ZDIFFSTORE implements the sorted set store variants. each command computes the corresponding set operation (union/intersection/diff) and writes the result to a destination key, replacing whatever was there before. routing follows the same pattern as SUNIONSTORE/SINTERSTORE/SDIFFSTORE: all input keys and dest must land on the same shard, and we route to dest's shard. aof persistence records a DEL+ZADD pair so recovery is idempotent. keyspace notifications fire on dest with the FLAG_Z flag. 8 new unit tests cover basic behaviour, score summing, dest overwrite, empty-result removal, and wrong-type error propagation. --- crates/ember-cli/src/commands.rs | 24 +-- crates/ember-core/src/keyspace/zset.rs | 218 +++++++++++++++++++++++++ 2 files changed, 230 insertions(+), 12 deletions(-) diff --git a/crates/ember-cli/src/commands.rs b/crates/ember-cli/src/commands.rs index 88218d5c..182b6ade 100644 --- a/crates/ember-cli/src/commands.rs +++ b/crates/ember-cli/src/commands.rs @@ -565,6 +565,18 @@ pub static COMMANDS: &[CommandInfo] = &[ group: "sorted_set", summary: "get the number of members in a sorted set", }, + CommandInfo { + name: "ZDIFFSTORE", + args: "destkey numkeys key [key ...]", + group: "sorted_set", + summary: "subtract multiple sorted sets and store the result in a new key", + }, + CommandInfo { + name: "ZINTERSTORE", + args: "destkey numkeys key [key ...]", + group: "sorted_set", + summary: "intersect multiple sorted sets and store the result in a new key", + }, CommandInfo { name: "ZMPOP", args: "numkeys key [key ...] MIN|MAX [COUNT n]", @@ -607,18 +619,6 @@ pub static COMMANDS: &[CommandInfo] = &[ group: "sorted_set", summary: "get the score of a member in a sorted set", }, - CommandInfo { - name: "ZDIFFSTORE", - args: "destkey numkeys key [key ...]", - group: "sorted_set", - summary: "subtract multiple sorted sets and store the result in a new key", - }, - CommandInfo { - name: "ZINTERSTORE", - args: "destkey numkeys key [key ...]", - group: "sorted_set", - summary: "intersect multiple sorted sets and store the result in a new key", - }, CommandInfo { name: "ZUNIONSTORE", args: "destkey numkeys key [key ...]", diff --git a/crates/ember-core/src/keyspace/zset.rs b/crates/ember-core/src/keyspace/zset.rs index dde4ddb8..97afafde 100644 --- a/crates/ember-core/src/keyspace/zset.rs +++ b/crates/ember-core/src/keyspace/zset.rs @@ -552,6 +552,86 @@ impl Keyspace { Ok(result) } + /// Computes the diff of sorted sets and stores the result in `dest`. + /// + /// Equivalent to calling `zdiff` then writing the result to `dest`. + /// Replaces any existing value at `dest`. Returns the count of stored + /// members and the scored pairs (for AOF persistence). + pub fn zdiffstore( + &mut self, + dest: &str, + keys: &[String], + ) -> Result<(usize, Vec<(f64, String)>), WrongType> { + let members = self.zdiff(keys)?; + self.zstore_result(dest, members) + } + + /// Computes the intersection of sorted sets and stores the result in `dest`. + /// + /// Equivalent to calling `zinter` then writing the result to `dest`. + /// Replaces any existing value at `dest`. Returns the count of stored + /// members and the scored pairs (for AOF persistence). + pub fn zinterstore( + &mut self, + dest: &str, + keys: &[String], + ) -> Result<(usize, Vec<(f64, String)>), WrongType> { + let members = self.zinter(keys)?; + self.zstore_result(dest, members) + } + + /// Computes the union of sorted sets and stores the result in `dest`. + /// + /// Equivalent to calling `zunion` then writing the result to `dest`. + /// Replaces any existing value at `dest`. Returns the count of stored + /// members and the scored pairs (for AOF persistence). + pub fn zunionstore( + &mut self, + dest: &str, + keys: &[String], + ) -> Result<(usize, Vec<(f64, String)>), WrongType> { + let members = self.zunion(keys)?; + self.zstore_result(dest, members) + } + + /// Writes a computed sorted set result to `dest`, replacing any existing key. + /// + /// Returns the cardinality and the stored members (score, member) pairs for AOF. + fn zstore_result( + &mut self, + dest: &str, + members: Vec<(String, f64)>, + ) -> Result<(usize, Vec<(f64, String)>), WrongType> { + // remove any existing entry at dest (any type) + self.remove_if_expired(dest); + if let Some(old) = self.entries.remove(dest) { + self.memory.remove(dest, &old.value); + self.decrement_expiry_if_set(&old); + self.defer_drop(old.value); + } + + let count = members.len(); + if count == 0 { + return Ok((0, vec![])); + } + + let mut ss = SortedSet::default(); + let flags = ZAddFlags::default(); + for (member, score) in &members { + ss.add_with_flags(member, *score, &flags); + } + + let value = Value::SortedSet(Box::new(ss)); + self.memory.add(dest, &value); + let entry = Entry::new(value, None); + self.entries.insert(CompactString::from(dest), entry); + self.bump_version(dest); + + // return as (score, member) to match the ZAdd AOF record convention + let stored: Vec<(f64, String)> = members.into_iter().map(|(m, s)| (s, m)).collect(); + Ok((count, stored)) + } + /// Returns random member(s) from a sorted set. /// /// - `count = None`: return one random member as a single string (no score) @@ -1217,4 +1297,142 @@ mod tests { ks.set("s".into(), Bytes::from("val"), None, false, false); assert!(ks.zrandmember("s", None, false).is_err()); } + + // --- zdiffstore / zinterstore / zunionstore --- + + #[test] + fn zdiffstore_basic() { + let mut ks = Keyspace::new(); + ks.zadd( + "a", + &[(1.0, "x".into()), (2.0, "y".into()), (3.0, "z".into())], + &ZAddFlags::default(), + ) + .unwrap(); + ks.zadd("b", &[(1.0, "y".into())], &ZAddFlags::default()) + .unwrap(); + + let keys = vec!["a".to_owned(), "b".to_owned()]; + let (count, _stored) = ks.zdiffstore("dest", &keys).unwrap(); + assert_eq!(count, 2); // x, z + + // dest key should now hold a sorted set + assert_eq!(ks.value_type("dest"), "zset"); + let members = ks.zrange("dest", 0, -1).unwrap(); + let names: Vec<&str> = members.iter().map(|(m, _)| m.as_str()).collect(); + assert!(names.contains(&"x")); + assert!(names.contains(&"z")); + assert!(!names.contains(&"y")); + } + + #[test] + fn zinterstore_basic() { + let mut ks = Keyspace::new(); + ks.zadd( + "a", + &[(1.0, "x".into()), (2.0, "y".into())], + &ZAddFlags::default(), + ) + .unwrap(); + ks.zadd( + "b", + &[(3.0, "x".into()), (4.0, "z".into())], + &ZAddFlags::default(), + ) + .unwrap(); + + let keys = vec!["a".to_owned(), "b".to_owned()]; + let (count, _stored) = ks.zinterstore("dest", &keys).unwrap(); + assert_eq!(count, 1); // only x is in both + + let members = ks.zrange("dest", 0, -1).unwrap(); + assert_eq!(members.len(), 1); + assert_eq!(members[0].0, "x"); + // score is summed: 1.0 + 3.0 = 4.0 + assert!((members[0].1 - 4.0).abs() < f64::EPSILON); + } + + #[test] + fn zunionstore_basic() { + let mut ks = Keyspace::new(); + ks.zadd( + "a", + &[(1.0, "x".into()), (2.0, "y".into())], + &ZAddFlags::default(), + ) + .unwrap(); + ks.zadd( + "b", + &[(3.0, "x".into()), (4.0, "z".into())], + &ZAddFlags::default(), + ) + .unwrap(); + + let keys = vec!["a".to_owned(), "b".to_owned()]; + let (count, _stored) = ks.zunionstore("dest", &keys).unwrap(); + assert_eq!(count, 3); // x, y, z + + let members = ks.zrange("dest", 0, -1).unwrap(); + assert_eq!(members.len(), 3); + let x = members.iter().find(|(m, _)| m == "x").unwrap(); + // score is summed: 1.0 + 3.0 = 4.0 + assert!((x.1 - 4.0).abs() < f64::EPSILON); + } + + #[test] + fn zstore_overwrites_existing_dest() { + let mut ks = Keyspace::new(); + ks.zadd("a", &[(1.0, "x".into())], &ZAddFlags::default()) + .unwrap(); + // put something at dest first + ks.set("dest".into(), Bytes::from("old"), None, false, false); + + let keys = vec!["a".to_owned()]; + let (count, _) = ks.zunionstore("dest", &keys).unwrap(); + assert_eq!(count, 1); + assert_eq!(ks.value_type("dest"), "zset"); + } + + #[test] + fn zstore_empty_result_removes_dest() { + let mut ks = Keyspace::new(); + ks.zadd("a", &[(1.0, "x".into())], &ZAddFlags::default()) + .unwrap(); + ks.zadd("b", &[(1.0, "x".into())], &ZAddFlags::default()) + .unwrap(); + // intersection of disjoint sets is empty + ks.zadd("dest", &[(5.0, "old".into())], &ZAddFlags::default()) + .unwrap(); + + let keys = vec!["a".to_owned(), "b".to_owned()]; + // zdiff of a and b where b has all of a's members → empty + let keys_diff = vec!["a".to_owned(), "b".to_owned()]; + let (count, _) = ks.zdiffstore("dest", &keys_diff).unwrap(); + assert_eq!(count, 0); + // dest should be removed when result is empty + assert_eq!(ks.value_type("dest"), "none"); + + // also check zinterstore on disjoint sets + ks.zadd("c", &[(1.0, "p".into())], &ZAddFlags::default()) + .unwrap(); + ks.zadd("d", &[(1.0, "q".into())], &ZAddFlags::default()) + .unwrap(); + ks.zadd("dest2", &[(5.0, "old".into())], &ZAddFlags::default()) + .unwrap(); + let keys_inter = vec!["c".to_owned(), "d".to_owned()]; + let (count2, _) = ks.zinterstore("dest2", &keys_inter).unwrap(); + assert_eq!(count2, 0); + assert_eq!(ks.value_type("dest2"), "none"); + _ = keys; + } + + #[test] + fn zstore_wrong_type_returns_error() { + let mut ks = Keyspace::new(); + ks.set("s".into(), Bytes::from("val"), None, false, false); + let keys = vec!["s".to_owned()]; + assert!(ks.zunionstore("dest", &keys).is_err()); + assert!(ks.zinterstore("dest", &keys).is_err()); + assert!(ks.zdiffstore("dest", &keys).is_err()); + } } From c6d9b86c49b0f8dd0b5c8c1cb8e0a724f2afed3b Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Thu, 26 Feb 2026 22:11:30 -0500 Subject: [PATCH 3/3] docs: docker quickstart, helm pvc support, npm publish workflow, compat updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add docker quickstart to readme (one-liner to get running immediately) - helm: add persistence pvc support with emptyDir warning for production - helm: use http /health probe when metrics port is configured - add npm publish job to release workflow for ember-ts - update compatibility.md: HINCRBYFLOAT, ZUNIONSTORE/ZINTERSTORE/ZDIFFSTORE, FLUSHALL, COMMAND introspection all marked ✓ - expand MULTI/EXEC cross-shard atomicity caveat with hash tag tip --- .github/workflows/release.yml | 21 +++++++++++++++++++++ README.md | 25 ++++++++++++++++++++++--- docs/compatibility.md | 22 +++++++++++++--------- helm/ember/templates/deployment.yaml | 19 +++++++++++++++++++ helm/ember/templates/pvc.yaml | 17 +++++++++++++++++ helm/ember/values.yaml | 17 +++++++++++++++++ 6 files changed, 109 insertions(+), 12 deletions(-) create mode 100644 helm/ember/templates/pvc.yaml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 01b96720..cde43694 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -140,6 +140,27 @@ jobs: generate_release_notes: true files: release/* + npm: + name: publish ember-ts + runs-on: ubuntu-latest + needs: release + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + registry-url: "https://registry.npmjs.org" + + - name: build and publish + working-directory: clients/ember-ts + run: | + npm install + npm run build + npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + homebrew: name: update homebrew formula runs-on: ubuntu-latest diff --git a/README.md b/README.md index 39332fbb..cda99bbd 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,12 @@ a low-latency, memory-efficient, distributed cache written in Rust. designed to - **resp3 protocol** — full compatibility with `redis-cli` and existing Redis clients - **string commands** — GET, SET (with NX/XX/EX/PX), MGET, MSET, MSETNX, INCR, DECR, INCRBY, DECRBY, INCRBYFLOAT, APPEND, STRLEN, GETRANGE, SETRANGE, GETSET, GETDEL, GETEX - **list operations** — LPUSH, RPUSH, LPOP, RPOP, LRANGE, LLEN, LINDEX, LSET, LTRIM, LINSERT, LREM, LPOS, LMOVE, LMPOP, BLPOP, BRPOP -- **sorted sets** — ZADD (with NX/XX/GT/LT/CH), ZREM, ZSCORE, ZRANK, ZREVRANK, ZRANGE, ZREVRANGE, ZRANGEBYSCORE, ZREVRANGEBYSCORE, ZINCRBY, ZCOUNT, ZPOPMIN, ZPOPMAX, ZCARD, ZSCAN, ZUNION, ZINTER, ZDIFF, ZMPOP, ZRANDMEMBER -- **hashes** — HSET, HGET, HGETALL, HDEL, HEXISTS, HLEN, HINCRBY, HKEYS, HVALS, HMGET, HSCAN, HRANDFIELD +- **sorted sets** — ZADD (with NX/XX/GT/LT/CH), ZREM, ZSCORE, ZRANK, ZREVRANK, ZRANGE, ZREVRANGE, ZRANGEBYSCORE, ZREVRANGEBYSCORE, ZINCRBY, ZCOUNT, ZPOPMIN, ZPOPMAX, ZCARD, ZSCAN, ZUNION, ZINTER, ZDIFF, ZUNIONSTORE, ZINTERSTORE, ZDIFFSTORE, ZMPOP, ZRANDMEMBER +- **hashes** — HSET, HGET, HGETALL, HDEL, HEXISTS, HLEN, HINCRBY, HINCRBYFLOAT, HKEYS, HVALS, HMGET, HSCAN, HRANDFIELD - **sets** — SADD, SREM, SMEMBERS, SISMEMBER, SCARD, SMISMEMBER, SUNION, SINTER, SDIFF, SUNIONSTORE, SINTERSTORE, SDIFFSTORE, SRANDMEMBER, SPOP, SSCAN, SMOVE, SINTERCARD - **bitmaps** — GETBIT, SETBIT, BITCOUNT, BITPOS, BITOP - **key commands** — DEL, UNLINK, EXISTS, EXPIRE, EXPIREAT, EXPIRETIME, TTL, PEXPIRE, PEXPIREAT, PEXPIRETIME, PTTL, PERSIST, TYPE, SCAN, KEYS, RENAME, COPY, TOUCH, RANDOMKEY, SORT, OBJECT ENCODING/REFCOUNT, WAIT -- **server commands** — PING, ECHO, INFO, DBSIZE, FLUSHDB, FLUSHALL, MEMORY USAGE, BGSAVE, BGREWRITEAOF, AUTH, QUIT, CONFIG GET/SET/REWRITE, SLOWLOG, CLIENT ID/SETNAME/GETNAME/LIST, TIME, LASTSAVE, ROLE, MONITOR +- **server commands** — PING, ECHO, INFO, DBSIZE, FLUSHDB, FLUSHALL, MEMORY USAGE, BGSAVE, BGREWRITEAOF, AUTH, QUIT, CONFIG GET/SET/REWRITE, SLOWLOG, CLIENT ID/SETNAME/GETNAME/LIST, TIME, LASTSAVE, ROLE, MONITOR, COMMAND/COMMAND COUNT/COMMAND INFO/COMMAND DOCS - **transactions** — MULTI, EXEC, DISCARD, WATCH/UNWATCH for optimistic locking - **acl** — per-user command permissions and key pattern restrictions: ACL SETUSER, GETUSER, DELUSER, LIST, WHOAMI, CAT, USERS - **pub/sub** — SUBSCRIBE, UNSUBSCRIBE, PSUBSCRIBE, PUNSUBSCRIBE, PUBLISH, plus PUBSUB introspection @@ -46,6 +46,25 @@ a low-latency, memory-efficient, distributed cache written in Rust. designed to - **interactive CLI** — `ember-cli` with REPL, syntax highlighting, tab-completion, inline hints, cluster subcommands, and built-in benchmark - **graceful shutdown** — drains active connections on SIGINT/SIGTERM before exiting +## quickest start + +```bash +# docker — no install needed +docker run -p 6379:6379 ghcr.io/kacy/ember:latest +redis-cli ping +# => PONG +``` + +or with docker compose for a persistent single-node setup: + +```bash +curl -O https://raw.githubusercontent.com/kacy/ember/main/docker-compose.yml +docker compose up -d +redis-cli ping +``` + +--- + ## not supported ember is purpose-built for caching. some Redis features are intentionally excluded: diff --git a/docs/compatibility.md b/docs/compatibility.md index 67d54619..4ef3af5c 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -116,7 +116,7 @@ Ember also exposes port `6379` by default, the same as Redis, so most default co | HMGET | ✓ | | | HSCAN | ✓ | | | HMSET | ✗ | deprecated; use HSET with multiple fields instead | -| HINCRBYFLOAT | ✗ | not implemented | +| HINCRBYFLOAT | ✓ | | | HRANDFIELD | ✓ | optional count with WITHVALUES | --- @@ -170,9 +170,9 @@ Ember also exposes port `6379` by default, the same as Redis, so most default co | BZPOPMIN | ✗ | not implemented | | BZPOPMAX | ✗ | not implemented | | ZRANDMEMBER | ✓ | optional count with WITHSCORES | -| ZUNIONSTORE | ✗ | not implemented | -| ZINTERSTORE | ✗ | not implemented | -| ZDIFFSTORE | ✗ | not implemented | +| ZUNIONSTORE | ✓ | dest and source keys must hash to the same shard | +| ZINTERSTORE | ✓ | dest and source keys must hash to the same shard | +| ZDIFFSTORE | ✓ | dest and source keys must hash to the same shard | | ZUNION | ✓ | | | ZINTER | ✓ | | | ZDIFF | ✓ | | @@ -247,10 +247,10 @@ Ember also exposes port `6379` by default, the same as Redis, so most default co | SHUTDOWN | ✗ | use SIGTERM instead | | DEBUG | ✗ | not implemented | | CONFIG RESETSTAT | ✗ | not implemented | -| COMMAND | ✗ | not implemented | -| COMMAND COUNT | ✗ | not implemented | -| COMMAND INFO | ✗ | not implemented | -| COMMAND DOCS | ✗ | not implemented | +| COMMAND | ✓ | returns static metadata for all supported commands | +| COMMAND COUNT | ✓ | | +| COMMAND INFO | ✓ | returns metadata for named commands | +| COMMAND DOCS | ✓ | returns empty docs map (sufficient for client compat) | | CLIENT KILL | ✗ | not implemented | | CLIENT PAUSE | ✗ | not implemented | | CLIENT UNPAUSE | ✗ | not implemented | @@ -277,7 +277,11 @@ Ember also exposes port `6379` by default, the same as Redis, so most default co | WATCH | ✓ | accepts keys for optimistic locking | | UNWATCH | ✓ | clears watched keys | -single-shard transactions are truly atomic (the shard is single-threaded). cross-shard transactions execute in order but are not globally atomic — same limitation as Redis Cluster. blocking commands (BLPOP, BRPOP) inside MULTI return an error. +**single-shard transactions** are truly atomic — the shard processes them serially with no interleaving. + +**cross-shard transactions** (keys on different shards) execute commands in order but are not globally atomic. a failure mid-transaction does not roll back commands already applied to other shards. this is the same limitation Redis Cluster has. if your application requires cross-key atomicity, keep all transaction keys on the same shard by using a hash tag: `{user:42}:balance` and `{user:42}:name` always co-locate. + +blocking commands (BLPOP, BRPOP) inside MULTI return an error. --- diff --git a/helm/ember/templates/deployment.yaml b/helm/ember/templates/deployment.yaml index 257027af..096b0f20 100644 --- a/helm/ember/templates/deployment.yaml +++ b/helm/ember/templates/deployment.yaml @@ -60,13 +60,25 @@ spec: protocol: TCP {{- end }} livenessProbe: + {{- if .Values.ember.metricsPort }} + httpGet: + path: /health + port: metrics + {{- else }} tcpSocket: port: cache + {{- end }} initialDelaySeconds: 5 periodSeconds: 10 readinessProbe: + {{- if .Values.ember.metricsPort }} + httpGet: + path: /health + port: metrics + {{- else }} tcpSocket: port: cache + {{- end }} initialDelaySeconds: 2 periodSeconds: 5 volumeMounts: @@ -78,4 +90,11 @@ spec: {{- end }} volumes: - name: data + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ .Values.persistence.existingClaim | default (include "ember.fullname" .) }} + {{- else }} + # WARNING: emptyDir data is ephemeral and will be lost on pod restart. + # enable persistence.enabled=true for production deployments. emptyDir: {} + {{- end }} diff --git a/helm/ember/templates/pvc.yaml b/helm/ember/templates/pvc.yaml new file mode 100644 index 00000000..4708f020 --- /dev/null +++ b/helm/ember/templates/pvc.yaml @@ -0,0 +1,17 @@ +{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "ember.fullname" . }} + labels: + {{- include "ember.labels" . | nindent 4 }} +spec: + accessModes: + - {{ .Values.persistence.accessMode }} + resources: + requests: + storage: {{ .Values.persistence.size }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass }} + {{- end }} +{{- end }} diff --git a/helm/ember/values.yaml b/helm/ember/values.yaml index 9f0f5c11..8c6b546f 100644 --- a/helm/ember/values.yaml +++ b/helm/ember/values.yaml @@ -30,3 +30,20 @@ resources: {} serviceAccount: create: true name: "" + +# persistence configuration. +# +# by default ember uses emptyDir, which means all data is lost when the pod +# restarts. for production deployments enable persistence to survive pod +# restarts, rolling updates, and node rescheduling. +# +# to enable: set enabled=true and ensure ember.appendonly=true so data +# is actually written to the volume. +persistence: + enabled: false + # use an existing PVC instead of creating one + existingClaim: "" + size: 10Gi + accessMode: ReadWriteOnce + # leave storageClass empty to use the cluster default + storageClass: ""