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/crates/ember-cli/src/commands.rs b/crates/ember-cli/src/commands.rs index b3a92f8e..182b6ade 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", @@ -553,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]", @@ -595,6 +619,12 @@ pub static COMMANDS: &[CommandInfo] = &[ group: "sorted_set", summary: "get the score of a member in a sorted set", }, + 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/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()); + } } 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. 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: ""