From b5e7ce744e20b68ccba44e52d80e8b2ebf44cb81 Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Fri, 27 Feb 2026 13:50:01 -0500 Subject: [PATCH 1/3] refactor: introduce exec/ module skeleton with ExecCtx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adds the exec/ sub-directory under connection/ with mod.rs containing: - ExecCtx<'a> struct bundling engine, ctx, pubsub, slow_log, client_id - notify_write() helper for keyspace event emission - set_expire_to_duration() for converting SetExpire → Duration - multi_key_bool() fan-out helper for multi-key boolean commands - wrongtype_error(), oom_error(), resolve_collection_scan() shared helpers sub-module declarations are in place (pub(super) mod) but files are empty; this commit establishes the structure before filling in the handlers. --- .../ember-server/src/connection/exec/mod.rs | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 crates/ember-server/src/connection/exec/mod.rs diff --git a/crates/ember-server/src/connection/exec/mod.rs b/crates/ember-server/src/connection/exec/mod.rs new file mode 100644 index 00000000..77e2b576 --- /dev/null +++ b/crates/ember-server/src/connection/exec/mod.rs @@ -0,0 +1,150 @@ +//! Shared execution context and helpers for command sub-modules. + +use std::sync::Arc; +use std::time::Duration; + +use bytes::Bytes; +use ember_core::{Engine, ShardRequest, ShardResponse}; +use ember_protocol::{Frame, SetExpire}; + +use crate::pubsub::PubSubManager; +use crate::server::ServerContext; +use crate::slowlog::SlowLog; + +pub(super) mod acl; +pub(super) mod cluster; +pub(super) mod hashes; +pub(super) mod keyspace; +pub(super) mod lists; +pub(super) mod protobuf; +pub(super) mod pubsub; +pub(super) mod server; +pub(super) mod sets; +pub(super) mod sorted_sets; +pub(super) mod strings; +pub(super) mod vector; + +/// Shared execution context passed to every command handler. +/// +/// Bundles the references that most commands need — engine for shard +/// routing, server context for config/cluster state, pub/sub manager +/// for notifications, slow log, and the current client ID. +pub(in crate::connection) struct ExecCtx<'a> { + pub engine: &'a Engine, + pub ctx: &'a Arc, + pub pubsub: &'a Arc, + pub slow_log: &'a Arc, + /// Client identifier, available for commands that need per-connection context. + #[allow(dead_code)] + pub client_id: u64, +} + +impl<'a> ExecCtx<'a> { + /// Emits keyspace/keyevent notifications for a successful write command. + /// + /// No-op when `notify-keyspace-events` is `""` / zero (the common case). + /// A single atomic load guards the allocation path. + #[inline] + pub fn notify_write(&self, event_flag: u32, event: &str, key: &str) { + let flags = self + .ctx + .keyspace_event_flags + .load(std::sync::atomic::Ordering::Relaxed); + if flags != 0 { + crate::keyspace_notifications::notify_keyspace_event( + flags, + event_flag, + event, + key, + self.pubsub, + ); + } + } +} + +/// Converts a [`SetExpire`] option to a [`Duration`] relative to now. +/// +/// EX/PX are relative; EXAT/PXAT are unix timestamps converted to a +/// duration by subtracting the current wall time. A past timestamp +/// results in a zero duration (the key expires immediately). +pub(in crate::connection) fn set_expire_to_duration(expire: SetExpire) -> Duration { + use std::time::{SystemTime, UNIX_EPOCH}; + match expire { + SetExpire::Ex(secs) => Duration::from_secs(secs), + SetExpire::Px(ms) => Duration::from_millis(ms), + SetExpire::ExAt(ts) => { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_else(|_| { + tracing::warn!( + "system clock is before UNIX epoch; EXAT TTL calculations may be incorrect" + ); + Duration::ZERO + }) + .as_secs(); + Duration::from_secs(ts.saturating_sub(now)) + } + SetExpire::PxAt(ts_ms) => { + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_else(|_| { + tracing::warn!( + "system clock is before UNIX epoch; PXAT TTL calculations may be incorrect" + ); + Duration::ZERO + }) + .as_millis() as u64; + Duration::from_millis(ts_ms.saturating_sub(now_ms)) + } + } +} + +/// Fans out a boolean-result command across shards for multiple keys +/// and returns the count of `true` results as an integer frame. +pub(in crate::connection) async fn multi_key_bool( + engine: &Engine, + keys: &[String], + make_req: F, +) -> Frame +where + F: Fn(String) -> ShardRequest, +{ + match engine.route_multi(keys, make_req).await { + Ok(responses) => { + let count = responses + .iter() + .filter(|r| matches!(r, ShardResponse::Bool(true))) + .count(); + Frame::Integer(count as i64) + } + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +/// Returns the standard WRONGTYPE error frame. +#[inline] +pub(in crate::connection) fn wrongtype_error() -> Frame { + Frame::Error("WRONGTYPE Operation against a key holding the wrong kind of value".into()) +} + +/// Returns the standard OOM error frame. +#[inline] +pub(in crate::connection) fn oom_error() -> Frame { + Frame::Error("OOM command not allowed when used memory > 'maxmemory'".into()) +} + +/// Resolves a collection scan (SSCAN/HSCAN/ZSCAN) shard response into a RESP frame. +pub(in crate::connection) fn resolve_collection_scan( + result: Result, +) -> Frame { + match result { + Ok(ShardResponse::CollectionScan { cursor, items }) => { + let cursor_frame = Frame::Bulk(Bytes::from(cursor.to_string())); + let item_frames = items.into_iter().map(Frame::Bulk).collect(); + Frame::Array(vec![cursor_frame, Frame::Array(item_frames)]) + } + Ok(ShardResponse::WrongType) => wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} From e8552bc772d5ea44864ee3a072eb50311c89f066 Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Fri, 27 Feb 2026 13:50:21 -0500 Subject: [PATCH 2/3] refactor: split execute.rs into exec/ sub-modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit execute.rs was a 5020-line file handling all command dispatch. this commit distributes handlers into focused sub-modules and rewrites execute.rs as a thin router (~580 lines): - strings.rs — GET/SET/INCR/DECR/APPEND/STRLEN/GETRANGE/SETRANGE GETBIT/SETBIT/BITCOUNT/BITPOS/BITOP/GETDEL/GETEX GETSET/MGET/MSET/MSETNX - keyspace.rs — DEL/UNLINK/EXISTS/TOUCH/EXPIRE/TTL/PERSIST/KEYS SCAN/RENAME/COPY/RANDOMKEY/SORT/TYPE/OBJECT/MEMORY - server.rs — DBSIZE/INFO/CONFIG/BGSAVE/BGREWRITEAOF/TIME/ROLE FLUSHDB/FLUSHALL/SLOWLOG/WAIT and render_info helper - lists.rs — LPUSH/RPUSH/LPOP/RPOP/LRANGE/LLEN/LINDEX/LSET LTRIM/LINSERT/LREM/LPOS/LMOVE/LMPOP/blocking stubs - sorted_sets.rs — ZADD/ZREM/ZSCORE/ZRANK/ZRANGE/ZCOUNT/ZINCRBY ZRANGEBYSCORE/ZPOPMIN/ZMPOP/ZDIFF/ZSTORE/ZRANDMEMBER - hashes.rs — HSET/HGET/HGETALL/HDEL/HEXISTS/HLEN/HINCRBY HINCRBYFLOAT/HKEYS/HVALS/HMGET/HRANDFIELD/HSCAN - sets.rs — SADD/SREM/SMEMBERS/SISMEMBER/SCARD/SUNION/SINTER SDIFF/SSTORE/SRANDMEMBER/SPOP/SMISMEMBER/SMOVE/SINTERCARD - cluster.rs — all CLUSTER_* commands, MIGRATE, RESTORE - pubsub.rs — PUBLISH, PUBSUB subcommands - acl.rs — AUTH, ACL subcommands - vector.rs — VADD/VSIM/VREM/VGET/VCARD/VDIM/VINFO (feature-gated) - protobuf.rs — all PROTO* commands (feature-gated) mod.rs is also added to connection/mod.rs so the module path resolves correctly; execute.rs imports exec via `use super::exec`. zero warnings, zero errors (cargo check -p ember-server). --- .../ember-server/src/connection/exec/acl.rs | 151 + .../src/connection/exec/cluster.rs | 307 ++ .../src/connection/exec/hashes.rs | 239 ++ .../src/connection/exec/keyspace.rs | 429 ++ .../ember-server/src/connection/exec/lists.rs | 323 ++ .../src/connection/exec/protobuf.rs | 279 ++ .../src/connection/exec/pubsub.rs | 39 + .../src/connection/exec/server.rs | 449 ++ .../ember-server/src/connection/exec/sets.rs | 366 ++ .../src/connection/exec/sorted_sets.rs | 544 +++ .../src/connection/exec/strings.rs | 505 +++ .../src/connection/exec/vector.rs | 189 + crates/ember-server/src/connection/execute.rs | 3718 ++--------------- crates/ember-server/src/connection/mod.rs | 1 + 14 files changed, 4104 insertions(+), 3435 deletions(-) create mode 100644 crates/ember-server/src/connection/exec/acl.rs create mode 100644 crates/ember-server/src/connection/exec/cluster.rs create mode 100644 crates/ember-server/src/connection/exec/hashes.rs create mode 100644 crates/ember-server/src/connection/exec/keyspace.rs create mode 100644 crates/ember-server/src/connection/exec/lists.rs create mode 100644 crates/ember-server/src/connection/exec/protobuf.rs create mode 100644 crates/ember-server/src/connection/exec/pubsub.rs create mode 100644 crates/ember-server/src/connection/exec/server.rs create mode 100644 crates/ember-server/src/connection/exec/sets.rs create mode 100644 crates/ember-server/src/connection/exec/sorted_sets.rs create mode 100644 crates/ember-server/src/connection/exec/strings.rs create mode 100644 crates/ember-server/src/connection/exec/vector.rs diff --git a/crates/ember-server/src/connection/exec/acl.rs b/crates/ember-server/src/connection/exec/acl.rs new file mode 100644 index 00000000..3f22b104 --- /dev/null +++ b/crates/ember-server/src/connection/exec/acl.rs @@ -0,0 +1,151 @@ +//! ACL and AUTH command handlers. + +use bytes::Bytes; +use ember_protocol::Frame; +use subtle::ConstantTimeEq; + +use super::ExecCtx; + +pub(in crate::connection) fn auth( + username: Option, + password: String, + cx: &ExecCtx<'_>, +) -> Frame { + let uname = username.unwrap_or_else(|| "default".into()); + if let Some(ref acl_state) = cx.ctx.acl { + match acl_state.read() { + Ok(state) => match state.get_user(&uname) { + Some(user) if user.enabled && user.verify_password(&password) => { + Frame::Simple("OK".into()) + } + _ => Frame::Error( + "WRONGPASS invalid username-password pair or user is disabled.".into(), + ), + }, + Err(_) => Frame::Error("ERR ACL state lock poisoned".into()), + } + } else { + match &cx.ctx.requirepass { + None => Frame::Error( + "ERR Client sent AUTH, but no password is set. \ + Did you mean ACL SETUSER with >password?" + .into(), + ), + Some(expected) => { + if uname != "default" { + Frame::Error( + "WRONGPASS invalid username-password pair or user is disabled.".into(), + ) + } else if bool::from(password.as_bytes().ct_eq(expected.as_bytes())) { + Frame::Simple("OK".into()) + } else { + Frame::Error( + "WRONGPASS invalid username-password pair or user is disabled.".into(), + ) + } + } + } + } +} + +/// WHOAMI is handled at the connection level (needs current_username). +/// If it reaches here, return a generic response. +pub(in crate::connection) fn acl_whoami() -> Frame { + Frame::Bulk(Bytes::from_static(b"default")) +} + +pub(in crate::connection) fn acl_list(cx: &ExecCtx<'_>) -> Frame { + if let Some(ref acl) = cx.ctx.acl { + match acl.read() { + Ok(state) => { + let lines = state.list(); + Frame::Array( + lines + .into_iter() + .map(|l| Frame::Bulk(Bytes::from(l))) + .collect(), + ) + } + Err(_) => Frame::Error("ERR ACL state lock poisoned".into()), + } + } else { + // legacy mode — synthesize a default user entry + Frame::Array(vec![Frame::Bulk(Bytes::from( + "user default on nopass +@all ~*", + ))]) + } +} + +pub(in crate::connection) fn acl_users(cx: &ExecCtx<'_>) -> Frame { + if let Some(ref acl) = cx.ctx.acl { + match acl.read() { + Ok(state) => { + let names = state.usernames(); + Frame::Array( + names + .into_iter() + .map(|n| Frame::Bulk(Bytes::from(n))) + .collect(), + ) + } + Err(_) => Frame::Error("ERR ACL state lock poisoned".into()), + } + } else { + Frame::Array(vec![Frame::Bulk(Bytes::from_static(b"default"))]) + } +} + +pub(in crate::connection) fn acl_getuser(username: String, cx: &ExecCtx<'_>) -> Frame { + if let Some(ref acl) = cx.ctx.acl { + match acl.read() { + Ok(state) => match state.get_user_detail(&username) { + Some(detail) => detail, + None => Frame::Error(format!("ERR no such user '{username}'")), + }, + Err(_) => Frame::Error("ERR ACL state lock poisoned".into()), + } + } else if username == "default" { + // legacy mode: synthesize default user detail + crate::acl::AclState::new() + .get_user_detail("default") + .unwrap_or(Frame::Null) + } else { + Frame::Error(format!("ERR no such user '{username}'")) + } +} + +pub(in crate::connection) fn acl_deluser(usernames: Vec, cx: &ExecCtx<'_>) -> Frame { + if let Some(ref acl) = cx.ctx.acl { + match acl.write() { + Ok(mut state) => match state.del_users(&usernames) { + Ok(count) => Frame::Integer(count as i64), + Err(msg) => Frame::Error(msg), + }, + Err(_) => Frame::Error("ERR ACL state lock poisoned".into()), + } + } else { + Frame::Error("ERR ACL is not enabled. Configure ACL users to use this command.".into()) + } +} + +pub(in crate::connection) fn acl_setuser( + username: String, + rules: Vec, + cx: &ExecCtx<'_>, +) -> Frame { + if let Some(ref acl) = cx.ctx.acl { + match acl.write() { + Ok(mut state) => match state.set_user(&username, &rules) { + Ok(()) => Frame::Simple("OK".into()), + Err(msg) => Frame::Error(msg), + }, + Err(_) => Frame::Error("ERR ACL state lock poisoned".into()), + } + } else { + Frame::Error("ERR ACL is not enabled. Configure ACL users to use this command.".into()) + } +} + +pub(in crate::connection) fn acl_cat(category: Option) -> Frame { + crate::acl::handle_acl_cat(category.as_deref()) +} diff --git a/crates/ember-server/src/connection/exec/cluster.rs b/crates/ember-server/src/connection/exec/cluster.rs new file mode 100644 index 00000000..c03ffbc5 --- /dev/null +++ b/crates/ember-server/src/connection/exec/cluster.rs @@ -0,0 +1,307 @@ +//! Cluster command handlers. + +use std::io; +use std::time::Duration; + +use bytes::{Bytes, BytesMut}; +use ember_core::{ShardRequest, ShardResponse}; +use ember_protocol::{parse_frame, Frame}; + +use super::ExecCtx; + +pub(in crate::connection) fn cluster_keyslot(key: String) -> Frame { + let slot = ember_cluster::key_slot(key.as_bytes()); + Frame::Integer(slot as i64) +} + +pub(in crate::connection) async fn cluster_info(cx: &ExecCtx<'_>) -> Frame { + match &cx.ctx.cluster { + Some(c) => c.cluster_info().await, + None => Frame::Bulk(Bytes::from("cluster_enabled:0\r\n")), + } +} + +pub(in crate::connection) async fn cluster_nodes(cx: &ExecCtx<'_>) -> Frame { + match &cx.ctx.cluster { + Some(c) => c.cluster_nodes().await, + None => Frame::Bulk(Bytes::from("")), + } +} + +pub(in crate::connection) async fn cluster_slots(cx: &ExecCtx<'_>) -> Frame { + match &cx.ctx.cluster { + Some(c) => c.cluster_slots().await, + None => Frame::Array(vec![]), + } +} + +pub(in crate::connection) fn cluster_myid(cx: &ExecCtx<'_>) -> Frame { + match &cx.ctx.cluster { + Some(c) => c.cluster_myid(), + None => Frame::Error("ERR This instance has cluster support disabled".into()), + } +} + +pub(in crate::connection) async fn cluster_meet(ip: String, port: u16, cx: &ExecCtx<'_>) -> Frame { + match &cx.ctx.cluster { + Some(c) => c.cluster_meet(&ip, port).await, + None => Frame::Error("ERR This instance has cluster support disabled".into()), + } +} + +pub(in crate::connection) async fn cluster_addslots(slots: Vec, cx: &ExecCtx<'_>) -> Frame { + match &cx.ctx.cluster { + Some(c) => c.cluster_addslots(&slots).await, + None => Frame::Error("ERR This instance has cluster support disabled".into()), + } +} + +pub(in crate::connection) async fn cluster_addslots_range( + ranges: Vec<(u16, u16)>, + cx: &ExecCtx<'_>, +) -> Frame { + match &cx.ctx.cluster { + Some(c) => { + let slots: Vec = ranges.iter().flat_map(|&(s, e)| s..=e).collect(); + c.cluster_addslots(&slots).await + } + None => Frame::Error("ERR This instance has cluster support disabled".into()), + } +} + +pub(in crate::connection) async fn cluster_delslots(slots: Vec, cx: &ExecCtx<'_>) -> Frame { + match &cx.ctx.cluster { + Some(c) => c.cluster_delslots(&slots).await, + None => Frame::Error("ERR This instance has cluster support disabled".into()), + } +} + +pub(in crate::connection) async fn cluster_forget(node_id: String, cx: &ExecCtx<'_>) -> Frame { + match &cx.ctx.cluster { + Some(c) => c.cluster_forget(&node_id).await, + None => Frame::Error("ERR This instance has cluster support disabled".into()), + } +} + +pub(in crate::connection) async fn cluster_setslot_importing( + slot: u16, + node_id: String, + cx: &ExecCtx<'_>, +) -> Frame { + match &cx.ctx.cluster { + Some(c) => c.cluster_setslot_importing(slot, &node_id).await, + None => Frame::Error("ERR This instance has cluster support disabled".into()), + } +} + +pub(in crate::connection) async fn cluster_setslot_migrating( + slot: u16, + node_id: String, + cx: &ExecCtx<'_>, +) -> Frame { + match &cx.ctx.cluster { + Some(c) => c.cluster_setslot_migrating(slot, &node_id).await, + None => Frame::Error("ERR This instance has cluster support disabled".into()), + } +} + +pub(in crate::connection) async fn cluster_setslot_node( + slot: u16, + node_id: String, + cx: &ExecCtx<'_>, +) -> Frame { + match &cx.ctx.cluster { + Some(c) => c.cluster_setslot_node(slot, &node_id).await, + None => Frame::Error("ERR This instance has cluster support disabled".into()), + } +} + +pub(in crate::connection) async fn cluster_setslot_stable(slot: u16, cx: &ExecCtx<'_>) -> Frame { + match &cx.ctx.cluster { + Some(c) => c.cluster_setslot_stable(slot).await, + None => Frame::Error("ERR This instance has cluster support disabled".into()), + } +} + +pub(in crate::connection) async fn cluster_count_keys_in_slot( + slot: u16, + cx: &ExecCtx<'_>, +) -> Frame { + match cx + .engine + .broadcast(|| ShardRequest::CountKeysInSlot { slot }) + .await + { + Ok(responses) => { + let total: usize = responses + .iter() + .map(|r| match r { + ShardResponse::KeyCount(n) => *n, + _ => 0, + }) + .sum(); + Frame::Integer(total as i64) + } + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn cluster_get_keys_in_slot( + slot: u16, + count: u32, + cx: &ExecCtx<'_>, +) -> Frame { + let count = count as usize; + match cx + .engine + .broadcast(|| ShardRequest::GetKeysInSlot { slot, count }) + .await + { + Ok(responses) => { + let mut all_keys = Vec::new(); + for r in responses { + if let ShardResponse::StringArray(keys) = r { + all_keys.extend(keys); + } + } + all_keys.truncate(count); + Frame::Array( + all_keys + .into_iter() + .map(|k| Frame::Bulk(Bytes::from(k))) + .collect(), + ) + } + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn cluster_replicate(node_id: String, cx: &ExecCtx<'_>) -> Frame { + match &cx.ctx.cluster { + Some(c) => c.cluster_replicate(&node_id).await, + None => Frame::Error("ERR This instance has cluster support disabled".into()), + } +} + +pub(in crate::connection) async fn cluster_failover( + force: bool, + takeover: bool, + cx: &ExecCtx<'_>, +) -> Frame { + match &cx.ctx.cluster { + Some(c) => c.cluster_failover(force, takeover).await, + None => Frame::Error("ERR This instance has cluster support disabled".into()), + } +} + +pub(in crate::connection) async fn migrate( + host: String, + port: u16, + key: String, + timeout_ms: u64, + replace: bool, + cx: &ExecCtx<'_>, +) -> Frame { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + // dump the key from the local shard + let idx = cx.engine.shard_for_key(&key); + let dump_req = ShardRequest::DumpKey { key: key.clone() }; + let dump_resp = match cx.engine.send_to_shard(idx, dump_req).await { + Ok(r) => r, + Err(e) => return Frame::Error(format!("ERR {e}")), + }; + + let (data, ttl_ms) = match dump_resp { + ShardResponse::KeyDump { data, ttl_ms } => (data, ttl_ms), + ShardResponse::Value(None) => { + return Frame::Error("ERR no such key".into()); + } + _ => return Frame::Error("ERR internal error".into()), + }; + + // send RESTORE to the target node + let ttl_arg = if ttl_ms < 0 { 0u64 } else { ttl_ms as u64 }; + let timeout = Duration::from_millis(timeout_ms.max(1000)); + let addr = format!("{host}:{port}"); + + let result = tokio::time::timeout(timeout, async { + let mut stream = tokio::net::TcpStream::connect(&addr).await?; + + // build RESTORE command as RESP3 array + let mut parts = vec![ + Frame::Bulk(Bytes::from("RESTORE")), + Frame::Bulk(Bytes::from(key.clone())), + Frame::Bulk(Bytes::from(ttl_arg.to_string())), + Frame::Bulk(Bytes::from(data)), + ]; + if replace { + parts.push(Frame::Bulk(Bytes::from("REPLACE"))); + } + let cmd_frame = Frame::Array(parts); + + let mut buf = BytesMut::new(); + cmd_frame.serialize(&mut buf); + stream.write_all(&buf).await?; + + // read response + let mut read_buf = BytesMut::with_capacity(256); + loop { + let n = stream.read_buf(&mut read_buf).await?; + if n == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "connection closed by target", + )); + } + match parse_frame(&read_buf) { + Ok(Some((frame, _))) => return Ok(frame), + Ok(None) => {} // need more data + Err(e) => { + return Err(io::Error::new(io::ErrorKind::InvalidData, e.to_string())); + } + } + } + }) + .await; + + match result { + Ok(Ok(Frame::Simple(_))) => { + // success — delete local key and mark as migrated + let del_req = ShardRequest::Del { key: key.clone() }; + let _ = cx.engine.send_to_shard(idx, del_req).await; + + if let Some(c) = &cx.ctx.cluster { + let slot = ember_cluster::key_slot(key.as_bytes()); + c.mark_key_migrated(slot, key.as_bytes()).await; + } + Frame::Simple("OK".into()) + } + Ok(Ok(Frame::Error(e))) => Frame::Error(format!("ERR target error: {e}")), + Ok(Ok(_)) => Frame::Error("ERR unexpected response from target".into()), + Ok(Err(e)) => Frame::Error(format!("ERR {e}")), + Err(_) => Frame::Error("ERR timeout connecting to target".into()), + } +} + +pub(in crate::connection) async fn restore( + key: String, + ttl_ms: u64, + data: Bytes, + replace: bool, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::RestoreKey { + key, + ttl_ms, + data, + replace, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Ok) => Frame::Simple("OK".into()), + Ok(ShardResponse::Err(e)) => Frame::Error(e), + Ok(_) => Frame::Error("ERR internal error".into()), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} diff --git a/crates/ember-server/src/connection/exec/hashes.rs b/crates/ember-server/src/connection/exec/hashes.rs new file mode 100644 index 00000000..06d9b6d8 --- /dev/null +++ b/crates/ember-server/src/connection/exec/hashes.rs @@ -0,0 +1,239 @@ +//! Hash command handlers. + +use bytes::Bytes; +use ember_core::{ShardRequest, ShardResponse, Value}; +use ember_protocol::Frame; + +use super::ExecCtx; + +pub(in crate::connection) async fn hset( + key: String, + fields: Vec<(String, Bytes)>, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::HSet { + key: key.clone(), + fields, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Len(n)) => { + cx.notify_write(crate::keyspace_notifications::FLAG_H, "hset", &key); + Frame::Integer(n as i64) + } + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(ShardResponse::OutOfMemory) => super::oom_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn hget(key: String, field: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::HGet { key, field }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Value(Some(Value::String(data)))) => Frame::Bulk(data), + Ok(ShardResponse::Value(None)) => Frame::Null, + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn hgetall(key: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::HGetAll { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::HashFields(fields)) => { + let mut frames = Vec::with_capacity(fields.len() * 2); + for (field, value) in fields { + frames.push(Frame::Bulk(Bytes::from(field))); + frames.push(Frame::Bulk(value)); + } + Frame::Array(frames) + } + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn hdel( + key: String, + fields: Vec, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::HDel { key, fields }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::HDelLen { count, .. }) => Frame::Integer(count as i64), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn hexists(key: String, field: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::HExists { key, field }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Bool(b)) => Frame::Integer(if b { 1 } else { 0 }), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn hlen(key: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::HLen { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Len(n)) => Frame::Integer(n as i64), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn hincrby( + key: String, + field: String, + delta: i64, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::HIncrBy { key, field, delta }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Integer(n)) => Frame::Integer(n), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(ShardResponse::OutOfMemory) => super::oom_error(), + Ok(ShardResponse::Err(msg)) => Frame::Error(format!("ERR {msg}")), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn hincrbyfloat( + key: String, + field: String, + delta: f64, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::HIncrByFloat { key, field, delta }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::BulkString(val)) => Frame::Bulk(Bytes::from(val)), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(ShardResponse::OutOfMemory) => super::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}")), + } +} + +pub(in crate::connection) async fn hkeys(key: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::HKeys { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::StringArray(keys)) => Frame::Array( + keys.into_iter() + .map(|k| Frame::Bulk(Bytes::from(k))) + .collect(), + ), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn hvals(key: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::HVals { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Array(vals)) => Frame::Array(vals.into_iter().map(Frame::Bulk).collect()), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn hmget( + key: String, + fields: Vec, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::HMGet { key, fields }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::OptionalArray(vals)) => Frame::Array( + vals.into_iter() + .map(|v| match v { + Some(data) => Frame::Bulk(data), + None => Frame::Null, + }) + .collect(), + ), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn hrandfield( + key: String, + count: Option, + with_values: bool, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::HRandField { + key, + count, + with_values, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::HRandFieldResult(pairs)) => { + if count.is_none() { + // no count: return a single bulk string (or nil if empty) + match pairs.into_iter().next() { + Some((field, _)) => Frame::Bulk(Bytes::from(field)), + None => Frame::Null, + } + } else { + // with count: return array, interleaved with values if requested + let frames: Vec = pairs + .into_iter() + .flat_map(|(f, v)| { + let mut items = vec![Frame::Bulk(Bytes::from(f))]; + if let Some(val) = v { + items.push(Frame::Bulk(val)); + } + items + }) + .collect(); + Frame::Array(frames) + } + } + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn hscan( + key: String, + cursor: u64, + pattern: Option, + count: Option, + cx: &ExecCtx<'_>, +) -> Frame { + let count = count.unwrap_or(10); + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::HScan { + key, + cursor, + count, + pattern, + }; + super::resolve_collection_scan(cx.engine.send_to_shard(idx, req).await) +} diff --git a/crates/ember-server/src/connection/exec/keyspace.rs b/crates/ember-server/src/connection/exec/keyspace.rs new file mode 100644 index 00000000..51082375 --- /dev/null +++ b/crates/ember-server/src/connection/exec/keyspace.rs @@ -0,0 +1,429 @@ +//! Keyspace management command handlers (TTL, expiry, scan, type, etc.). + +use bytes::Bytes; +use ember_core::{ShardRequest, ShardResponse, TtlResult}; +use ember_protocol::Frame; + +use super::ExecCtx; + +pub(in crate::connection) async fn expire(key: String, seconds: u64, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::Expire { + key: key.clone(), + seconds, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Bool(true)) => { + cx.notify_write(crate::keyspace_notifications::FLAG_G, "expire", &key); + Frame::Integer(1) + } + Ok(ShardResponse::Bool(false)) => Frame::Integer(0), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn expireat( + key: String, + timestamp: u64, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::Expireat { + key: key.clone(), + timestamp, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Bool(true)) => { + cx.notify_write(crate::keyspace_notifications::FLAG_G, "expireat", &key); + Frame::Integer(1) + } + Ok(ShardResponse::Bool(false)) => Frame::Integer(0), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn pexpire( + key: String, + milliseconds: u64, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::Pexpire { key, milliseconds }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Bool(b)) => Frame::Integer(i64::from(b)), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn pexpireat( + key: String, + timestamp_ms: u64, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::Pexpireat { + key: key.clone(), + timestamp_ms, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Bool(true)) => { + cx.notify_write(crate::keyspace_notifications::FLAG_G, "pexpireat", &key); + Frame::Integer(1) + } + Ok(ShardResponse::Bool(false)) => Frame::Integer(0), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn ttl(key: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::Ttl { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Ttl(TtlResult::Seconds(s))) => Frame::Integer(s as i64), + Ok(ShardResponse::Ttl(TtlResult::NoExpiry)) => Frame::Integer(-1), + Ok(ShardResponse::Ttl(TtlResult::NotFound)) => Frame::Integer(-2), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn pttl(key: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::Pttl { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Ttl(TtlResult::Milliseconds(ms))) => Frame::Integer(ms as i64), + Ok(ShardResponse::Ttl(TtlResult::NoExpiry)) => Frame::Integer(-1), + Ok(ShardResponse::Ttl(TtlResult::NotFound)) => Frame::Integer(-2), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn persist(key: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::Persist { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Bool(b)) => Frame::Integer(i64::from(b)), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn expiretime(key: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::Expiretime { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Integer(n)) => Frame::Integer(n), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn pexpiretime(key: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::Pexpiretime { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Integer(n)) => Frame::Integer(n), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn del(keys: Vec, cx: &ExecCtx<'_>) -> Frame { + super::multi_key_bool(cx.engine, &keys, |k| ShardRequest::Del { key: k }).await +} + +pub(in crate::connection) async fn unlink(keys: Vec, cx: &ExecCtx<'_>) -> Frame { + super::multi_key_bool(cx.engine, &keys, |k| ShardRequest::Unlink { key: k }).await +} + +pub(in crate::connection) async fn exists(keys: Vec, cx: &ExecCtx<'_>) -> Frame { + super::multi_key_bool(cx.engine, &keys, |k| ShardRequest::Exists { key: k }).await +} + +pub(in crate::connection) async fn touch(keys: Vec, cx: &ExecCtx<'_>) -> Frame { + super::multi_key_bool(cx.engine, &keys, |k| ShardRequest::Touch { key: k }).await +} + +pub(in crate::connection) async fn keys(pattern: String, cx: &ExecCtx<'_>) -> Frame { + match cx + .engine + .broadcast(|| ShardRequest::Keys { + pattern: pattern.clone(), + }) + .await + { + Ok(responses) => { + let mut all_keys = Vec::new(); + for r in responses { + if let ShardResponse::StringArray(ks) = r { + all_keys.extend(ks); + } + } + Frame::Array( + all_keys + .into_iter() + .map(|k| Frame::Bulk(Bytes::from(k))) + .collect(), + ) + } + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn scan( + cursor: u64, + pattern: Option, + count: Option, + cx: &ExecCtx<'_>, +) -> Frame { + // cursor encoding: (shard_id << 48) | position_within_shard + // + // this gives us 16 bits for shard_id (up to 65536 shards) and 48 bits + // for position within each shard. cursor 0 always means "start fresh". + // + // the cursor is opaque to clients — they just pass back whatever we + // returned last time. this lets us iterate across the sharded keyspace + // without clients needing to know the topology. + let shard_count = cx.engine.shard_count(); + let count = count.unwrap_or(10); + + let (shard_id, position) = if cursor == 0 { + (0usize, 0u64) + } else { + let shard_id = (cursor >> 48) as usize; + let position = cursor & 0xFFFF_FFFF_FFFF; + + // guard against invalid cursor (shard_id out of range) + if shard_id >= shard_count { + return Frame::Array(vec![Frame::Bulk(Bytes::from("0")), Frame::Array(vec![])]); + } + + (shard_id, position) + }; + + // collect keys from current shard and possibly subsequent shards + let mut all_keys = Vec::new(); + let mut current_shard = shard_id; + let mut current_pos = position; + + while all_keys.len() < count && current_shard < shard_count { + let req = ShardRequest::Scan { + cursor: current_pos, + count: count.saturating_sub(all_keys.len()), + pattern: pattern.clone(), + }; + match cx.engine.send_to_shard(current_shard, req).await { + Ok(ShardResponse::Scan { + cursor: next_pos, + keys, + }) => { + all_keys.extend(keys); + if next_pos == 0 { + // shard exhausted, move to next + current_shard += 1; + current_pos = 0; + } else { + current_pos = next_pos; + break; // have more in this shard, stop here + } + } + Ok(other) => { + return Frame::Error(format!("ERR unexpected shard response: {other:?}")); + } + Err(e) => { + return Frame::Error(format!("ERR {e}")); + } + } + } + + // compute next cursor + let next_cursor = if current_shard >= shard_count { + 0 // scan complete + } else { + ((current_shard as u64) << 48) | current_pos + }; + + // return [cursor, [keys...]] + let cursor_str = next_cursor.to_string(); + let keys_frames: Vec = all_keys + .into_iter() + .map(|k| Frame::Bulk(Bytes::from(k))) + .collect(); + Frame::Array(vec![ + Frame::Bulk(Bytes::from(cursor_str)), + Frame::Array(keys_frames), + ]) +} + +pub(in crate::connection) async fn rename(key: String, newkey: String, cx: &ExecCtx<'_>) -> Frame { + if !cx.engine.same_shard(&key, &newkey) { + return Frame::Error("ERR source and destination keys must hash to the same shard".into()); + } + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::Rename { key, newkey }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Ok) => Frame::Simple("OK".into()), + Ok(ShardResponse::Err(msg)) => Frame::Error(msg), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn copy( + source: String, + destination: String, + replace: bool, + cx: &ExecCtx<'_>, +) -> Frame { + if !cx.engine.same_shard(&source, &destination) { + return Frame::Error("ERR source and destination keys must hash to the same shard".into()); + } + let idx = cx.engine.shard_for_key(&source); + let req = ShardRequest::Copy { + source, + destination, + replace, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Bool(b)) => Frame::Integer(i64::from(b)), + Ok(ShardResponse::Err(msg)) => Frame::Error(msg), + Ok(ShardResponse::OutOfMemory) => super::oom_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn randomkey(cx: &ExecCtx<'_>) -> Frame { + match cx.engine.broadcast(|| ShardRequest::RandomKey).await { + Ok(responses) => { + let ks: Vec = responses + .into_iter() + .flat_map(|r| match r { + ShardResponse::StringArray(v) => v, + _ => vec![], + }) + .collect(); + if ks.is_empty() { + Frame::Null + } else { + use rand::seq::IndexedRandom; + let mut rng = rand::rng(); + match ks.choose(&mut rng) { + Some(k) => Frame::Bulk(Bytes::from(k.to_owned())), + None => Frame::Null, + } + } + } + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn sort_with_store( + key: String, + desc: bool, + alpha: bool, + limit: Option<(i64, i64)>, + dest: String, + cx: &ExecCtx<'_>, +) -> Frame { + // phase 1: sort on the source shard + let src_idx = cx.engine.shard_for_key(&key); + let sort_req = ShardRequest::Sort { + key, + desc, + alpha, + limit, + }; + match cx.engine.send_to_shard(src_idx, sort_req).await { + Ok(ShardResponse::Array(items)) => { + let count = items.len() as i64; + // phase 2: delete dest + rpush sorted items + let dst_idx = cx.engine.shard_for_key(&dest); + let del_req = ShardRequest::Del { key: dest.clone() }; + let _ = cx.engine.send_to_shard(dst_idx, del_req).await; + if !items.is_empty() { + let rpush_req = ShardRequest::RPush { + key: dest, + values: items, + }; + let _ = cx.engine.send_to_shard(dst_idx, rpush_req).await; + } + Frame::Integer(count) + } + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn sort_no_store( + key: String, + desc: bool, + alpha: bool, + limit: Option<(i64, i64)>, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::Sort { + key, + desc, + alpha, + limit, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Array(items)) => { + Frame::Array(items.into_iter().map(Frame::Bulk).collect()) + } + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn type_cmd(key: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::Type { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::TypeName(name)) => Frame::Simple(name.into()), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn object_encoding(key: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::ObjectEncoding { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::EncodingName(Some(name))) => Frame::Bulk(Bytes::from(name)), + Ok(ShardResponse::EncodingName(None)) => Frame::Null, + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn object_refcount(key: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::Exists { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Bool(true)) => Frame::Integer(1), + Ok(ShardResponse::Bool(false)) => Frame::Null, + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn memory_usage(key: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::MemoryUsage { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Integer(-1)) => Frame::Null, + Ok(ShardResponse::Integer(n)) => Frame::Integer(n), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} diff --git a/crates/ember-server/src/connection/exec/lists.rs b/crates/ember-server/src/connection/exec/lists.rs new file mode 100644 index 00000000..b634be38 --- /dev/null +++ b/crates/ember-server/src/connection/exec/lists.rs @@ -0,0 +1,323 @@ +//! List command handlers. + +use bytes::Bytes; +use ember_core::{ShardRequest, ShardResponse, Value}; +use ember_protocol::Frame; + +use super::ExecCtx; + +pub(in crate::connection) async fn lpush( + key: String, + values: Vec, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::LPush { + key: key.clone(), + values, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Len(n)) => { + cx.notify_write(crate::keyspace_notifications::FLAG_L, "lpush", &key); + Frame::Integer(n as i64) + } + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(ShardResponse::OutOfMemory) => super::oom_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn rpush( + key: String, + values: Vec, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::RPush { + key: key.clone(), + values, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Len(n)) => { + cx.notify_write(crate::keyspace_notifications::FLAG_L, "rpush", &key); + Frame::Integer(n as i64) + } + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(ShardResponse::OutOfMemory) => super::oom_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn lpop( + key: String, + count: Option, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + match count { + None => { + let req = ShardRequest::LPop { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Value(Some(Value::String(data)))) => Frame::Bulk(data), + Ok(ShardResponse::Value(None)) => Frame::Null, + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } + } + Some(count) => { + let req = ShardRequest::LPopCount { key, count }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Array(items)) => { + Frame::Array(items.into_iter().map(Frame::Bulk).collect()) + } + Ok(ShardResponse::Value(None)) => Frame::Null, + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } + } + } +} + +pub(in crate::connection) async fn rpop( + key: String, + count: Option, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + match count { + None => { + let req = ShardRequest::RPop { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Value(Some(Value::String(data)))) => Frame::Bulk(data), + Ok(ShardResponse::Value(None)) => Frame::Null, + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } + } + Some(count) => { + let req = ShardRequest::RPopCount { key, count }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Array(items)) => { + Frame::Array(items.into_iter().map(Frame::Bulk).collect()) + } + Ok(ShardResponse::Value(None)) => Frame::Null, + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } + } + } +} + +pub(in crate::connection) async fn lrange( + key: String, + start: i64, + stop: i64, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::LRange { key, start, stop }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Array(items)) => { + Frame::Array(items.into_iter().map(Frame::Bulk).collect()) + } + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn llen(key: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::LLen { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Len(n)) => Frame::Integer(n as i64), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn lindex(key: String, index: i64, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::LIndex { key, index }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Value(Some(Value::String(data)))) => Frame::Bulk(data), + Ok(ShardResponse::Value(None)) => Frame::Null, + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn lset( + key: String, + index: i64, + value: Bytes, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::LSet { key, index, value }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Ok) => Frame::Simple("OK".into()), + Ok(ShardResponse::WrongType) => super::wrongtype_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}")), + } +} + +pub(in crate::connection) async fn ltrim( + key: String, + start: i64, + stop: i64, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::LTrim { key, start, stop }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Ok) => Frame::Simple("OK".into()), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn linsert( + key: String, + before: bool, + pivot: Bytes, + value: Bytes, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::LInsert { + key, + before, + pivot, + value, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Integer(n)) => Frame::Integer(n), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(ShardResponse::OutOfMemory) => super::oom_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn lrem( + key: String, + count: i64, + value: Bytes, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::LRem { key, count, value }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Len(n)) => Frame::Integer(n as i64), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn lpos( + key: String, + element: Bytes, + rank: i64, + count: Option, + maxlen: usize, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let shard_count = count.unwrap_or(1); + let req = ShardRequest::LPos { + key, + element, + rank, + count: shard_count, + maxlen, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::IntegerArray(positions)) => { + if count.is_some() { + Frame::Array(positions.into_iter().map(Frame::Integer).collect()) + } else if let Some(&pos) = positions.first() { + Frame::Integer(pos) + } else { + Frame::Null + } + } + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn lmove( + source: String, + destination: String, + src_left: bool, + dst_left: bool, + cx: &ExecCtx<'_>, +) -> Frame { + // route to the source key's shard + let idx = cx.engine.shard_for_key(&source); + let req = ShardRequest::LMove { + source, + destination, + src_left, + dst_left, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Value(Some(Value::String(data)))) => Frame::Bulk(data), + Ok(ShardResponse::Value(None)) => Frame::Null, + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(ShardResponse::OutOfMemory) => super::oom_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn lmpop( + keys: Vec, + left: bool, + count: usize, + cx: &ExecCtx<'_>, +) -> Frame { + for key in &keys { + let idx = cx.engine.shard_for_key(key); + let req = ShardRequest::LmpopSingle { + key: key.clone(), + left, + count, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Array(items)) if !items.is_empty() => { + let elems = Frame::Array(items.into_iter().map(Frame::Bulk).collect()); + return Frame::Array(vec![Frame::Bulk(Bytes::from(key.clone())), elems]); + } + Ok(ShardResponse::Array(_)) | Ok(ShardResponse::Value(None)) => continue, + Ok(ShardResponse::WrongType) => return super::wrongtype_error(), + Ok(other) => return Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => return Frame::Error(format!("ERR {e}")), + } + } + Frame::Null +} + +/// blocking list ops are handled by handle_blocking_pop_cmd in the +/// main loop; reaching here means they're inside a transaction. +pub(in crate::connection) fn blpop_in_tx() -> Frame { + Frame::Error("ERR blocking commands are not allowed inside transactions".into()) +} + +pub(in crate::connection) fn brpop_in_tx() -> Frame { + Frame::Error("ERR blocking commands are not allowed inside transactions".into()) +} diff --git a/crates/ember-server/src/connection/exec/protobuf.rs b/crates/ember-server/src/connection/exec/protobuf.rs new file mode 100644 index 00000000..d32ca80d --- /dev/null +++ b/crates/ember-server/src/connection/exec/protobuf.rs @@ -0,0 +1,279 @@ +//! Protobuf command handlers. +//! +//! All items in this module are gated behind `#[cfg(feature = "protobuf")]`. + +use ember_protocol::Frame; + +#[cfg(feature = "protobuf")] +use super::ExecCtx; +#[cfg(feature = "protobuf")] +use bytes::Bytes; +#[cfg(feature = "protobuf")] +use ember_core::{ShardRequest, ShardResponse}; + +#[cfg(feature = "protobuf")] +pub(in crate::connection) async fn proto_register( + name: String, + descriptor: Bytes, + cx: &ExecCtx<'_>, +) -> Frame { + use std::time::Duration; + + let registry = match cx.engine.schema_registry() { + Some(r) => r, + None => return Frame::Error("ERR protobuf support is not enabled".into()), + }; + let result = { + let mut reg = match registry.write() { + Ok(r) => r, + Err(_) => return Frame::Error("ERR schema registry lock poisoned".into()), + }; + reg.register(name.clone(), descriptor.clone()) + }; + match result { + Ok(types) => { + // persist the registration to all shards' AOF + if let Err(e) = cx + .engine + .broadcast(|| ShardRequest::ProtoRegisterAof { + name: name.clone(), + descriptor: descriptor.clone(), + }) + .await + { + tracing::warn!("failed to persist proto registration to AOF: {e}"); + } + Frame::Array( + types + .into_iter() + .map(|t| Frame::Bulk(Bytes::from(t))) + .collect(), + ) + } + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +#[cfg(feature = "protobuf")] +pub(in crate::connection) async fn proto_set( + key: String, + type_name: String, + data: Bytes, + expire: Option, + nx: bool, + xx: bool, + cx: &ExecCtx<'_>, +) -> Frame { + use std::time::Duration; + + let registry = match cx.engine.schema_registry() { + Some(r) => r, + None => return Frame::Error("ERR protobuf support is not enabled".into()), + }; + // validate the bytes against the schema before storing + { + let reg = match registry.read() { + Ok(r) => r, + Err(_) => return Frame::Error("ERR schema registry lock poisoned".into()), + }; + if let Err(e) = reg.validate(&type_name, &data) { + return Frame::Error(format!("ERR {e}")); + } + } + let duration = expire.map(|e| { + use std::time::{SystemTime, UNIX_EPOCH}; + match e { + ember_protocol::SetExpire::Ex(secs) => Duration::from_secs(secs), + ember_protocol::SetExpire::Px(millis) => Duration::from_millis(millis), + ember_protocol::SetExpire::ExAt(ts) => { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + Duration::from_secs(ts.saturating_sub(now)) + } + ember_protocol::SetExpire::PxAt(ts_ms) => { + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + Duration::from_millis(ts_ms.saturating_sub(now_ms)) + } + } + }); + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::ProtoSet { + key, + type_name, + data, + expire: duration, + nx, + xx, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Ok) => Frame::Simple("OK".into()), + Ok(ShardResponse::Value(None)) => Frame::Null, + Ok(ShardResponse::OutOfMemory) => super::oom_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +#[cfg(feature = "protobuf")] +pub(in crate::connection) async fn proto_get(key: String, cx: &ExecCtx<'_>) -> Frame { + if cx.engine.schema_registry().is_none() { + return Frame::Error("ERR protobuf support is not enabled".into()); + } + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::ProtoGet { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::ProtoValue(Some((type_name, data, _ttl)))) => { + Frame::Array(vec![Frame::Bulk(Bytes::from(type_name)), Frame::Bulk(data)]) + } + Ok(ShardResponse::ProtoValue(None)) => Frame::Null, + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +#[cfg(feature = "protobuf")] +pub(in crate::connection) async fn proto_type(key: String, cx: &ExecCtx<'_>) -> Frame { + if cx.engine.schema_registry().is_none() { + return Frame::Error("ERR protobuf support is not enabled".into()); + } + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::ProtoType { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::ProtoTypeName(Some(name))) => Frame::Bulk(Bytes::from(name)), + Ok(ShardResponse::ProtoTypeName(None)) => Frame::Null, + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +#[cfg(feature = "protobuf")] +pub(in crate::connection) async fn proto_schemas(cx: &ExecCtx<'_>) -> Frame { + let registry = match cx.engine.schema_registry() { + Some(r) => r, + None => return Frame::Error("ERR protobuf support is not enabled".into()), + }; + let reg = match registry.read() { + Ok(r) => r, + Err(_) => return Frame::Error("ERR schema registry lock poisoned".into()), + }; + let names = reg.schema_names(); + Frame::Array( + names + .into_iter() + .map(|n| Frame::Bulk(Bytes::from(n))) + .collect(), + ) +} + +#[cfg(feature = "protobuf")] +pub(in crate::connection) async fn proto_describe(name: String, cx: &ExecCtx<'_>) -> Frame { + let registry = match cx.engine.schema_registry() { + Some(r) => r, + None => return Frame::Error("ERR protobuf support is not enabled".into()), + }; + let reg = match registry.read() { + Ok(r) => r, + Err(_) => return Frame::Error("ERR schema registry lock poisoned".into()), + }; + match reg.describe(&name) { + Some(types) => Frame::Array( + types + .into_iter() + .map(|t| Frame::Bulk(Bytes::from(t))) + .collect(), + ), + None => Frame::Error(format!("ERR unknown schema '{name}'")), + } +} + +#[cfg(feature = "protobuf")] +pub(in crate::connection) async fn proto_get_field( + key: String, + field_path: String, + cx: &ExecCtx<'_>, +) -> Frame { + let registry = match cx.engine.schema_registry() { + Some(r) => r, + None => return Frame::Error("ERR protobuf support is not enabled".into()), + }; + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::ProtoGet { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::ProtoValue(Some((type_name, data, _ttl)))) => { + let reg = match registry.read() { + Ok(r) => r, + Err(_) => return Frame::Error("ERR schema registry lock poisoned".into()), + }; + match reg.get_field(&type_name, &data, &field_path) { + Ok(frame) => frame, + Err(e) => Frame::Error(format!("ERR {e}")), + } + } + Ok(ShardResponse::ProtoValue(None)) => Frame::Null, + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +#[cfg(feature = "protobuf")] +pub(in crate::connection) async fn proto_set_field( + key: String, + field_path: String, + value: bytes::Bytes, + cx: &ExecCtx<'_>, +) -> Frame { + if cx.engine.schema_registry().is_none() { + return Frame::Error("ERR protobuf support is not enabled".into()); + } + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::ProtoSetField { + key, + field_path, + value, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::ProtoFieldUpdated { .. }) => Frame::Simple("OK".into()), + Ok(ShardResponse::Value(None)) => Frame::Null, + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(ShardResponse::OutOfMemory) => super::oom_error(), + Ok(ShardResponse::Err(msg)) => Frame::Error(format!("ERR {msg}")), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +#[cfg(feature = "protobuf")] +pub(in crate::connection) async fn proto_del_field( + key: String, + field_path: String, + cx: &ExecCtx<'_>, +) -> Frame { + if cx.engine.schema_registry().is_none() { + return Frame::Error("ERR protobuf support is not enabled".into()); + } + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::ProtoDelField { key, field_path }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::ProtoFieldUpdated { .. }) => Frame::Integer(1), + Ok(ShardResponse::Value(None)) => Frame::Null, + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(ShardResponse::OutOfMemory) => super::oom_error(), + Ok(ShardResponse::Err(msg)) => Frame::Error(format!("ERR {msg}")), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +/// Returns an error when protobuf support is not compiled in. +#[cfg(not(feature = "protobuf"))] +pub(in crate::connection) fn not_compiled() -> Frame { + Frame::Error("ERR unknown command (protobuf support not compiled)".into()) +} diff --git a/crates/ember-server/src/connection/exec/pubsub.rs b/crates/ember-server/src/connection/exec/pubsub.rs new file mode 100644 index 00000000..78911fc1 --- /dev/null +++ b/crates/ember-server/src/connection/exec/pubsub.rs @@ -0,0 +1,39 @@ +//! Pub/sub command handlers. + +use ember_protocol::Frame; + +use super::ExecCtx; + +pub(in crate::connection) fn publish( + channel: String, + message: bytes::Bytes, + cx: &ExecCtx<'_>, +) -> Frame { + let count = cx.pubsub.publish(&channel, message); + Frame::Integer(count as i64) +} + +pub(in crate::connection) fn pubsub_channels(pattern: Option, cx: &ExecCtx<'_>) -> Frame { + let names = cx.pubsub.channel_names(pattern.as_deref()); + Frame::Array(names.into_iter().map(|n| Frame::Bulk(n.into())).collect()) +} + +pub(in crate::connection) fn pubsub_numsub(channels: Vec, cx: &ExecCtx<'_>) -> Frame { + let pairs = cx.pubsub.numsub(&channels); + let mut frames = Vec::with_capacity(pairs.len() * 2); + for (ch, count) in pairs { + frames.push(Frame::Bulk(ch.into())); + frames.push(Frame::Integer(count as i64)); + } + Frame::Array(frames) +} + +pub(in crate::connection) fn pubsub_numpat(cx: &ExecCtx<'_>) -> Frame { + Frame::Integer(cx.pubsub.active_patterns() as i64) +} + +/// subscribe commands are handled in the connection loop, not here. +/// if we reach this point, something went wrong. +pub(in crate::connection) fn subscribe_error() -> Frame { + Frame::Error("ERR subscribe commands should not reach execute".into()) +} diff --git a/crates/ember-server/src/connection/exec/server.rs b/crates/ember-server/src/connection/exec/server.rs new file mode 100644 index 00000000..a82ddcaf --- /dev/null +++ b/crates/ember-server/src/connection/exec/server.rs @@ -0,0 +1,449 @@ +//! Server management command handlers (INFO, CONFIG, BGSAVE, FLUSHDB, etc.). + +use std::sync::atomic::Ordering; +use std::sync::Arc; + +use bytes::Bytes; +use ember_core::{KeyspaceStats, ShardRequest, ShardResponse}; +use ember_protocol::Frame; + +use crate::connection_common::{get_rss_bytes, human_bytes}; +use crate::server::ServerContext; + +use super::ExecCtx; + +pub(in crate::connection) fn dbsize_from_responses(responses: Vec) -> Frame { + let total: usize = responses + .iter() + .map(|r| match r { + ShardResponse::KeyCount(n) => *n, + _ => 0, + }) + .sum(); + Frame::Integer(total as i64) +} + +pub(in crate::connection) async fn dbsize(cx: &ExecCtx<'_>) -> Frame { + match cx.engine.broadcast(|| ShardRequest::DbSize).await { + Ok(responses) => dbsize_from_responses(responses), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn info(cx: &ExecCtx<'_>, section: Option<&str>) -> Frame { + render_info(cx.engine, cx.ctx, section).await +} + +pub(in crate::connection) async fn config_get(pattern: String, cx: &ExecCtx<'_>) -> Frame { + let pairs = cx.ctx.config.get_matching(&pattern); + let mut frames = Vec::with_capacity(pairs.len() * 2); + for (key, value) in pairs { + frames.push(Frame::Bulk(Bytes::from(key))); + frames.push(Frame::Bulk(Bytes::from(value))); + } + Frame::Array(frames) +} + +pub(in crate::connection) async fn config_set( + param: String, + value: String, + cx: &ExecCtx<'_>, +) -> Frame { + if let Err(e) = cx.ctx.config.set(¶m, &value) { + return Frame::Error(e); + } + // apply dynamic updates for known parameters + let key = param.to_ascii_lowercase(); + if key == "slowlog-log-slower-than" { + if let Ok(us) = value.parse::() { + cx.slow_log.update_threshold(us); + } + } else if key == "slowlog-max-len" { + if let Ok(len) = value.parse::() { + cx.slow_log.update_max_len(len); + } + } else if key == "maxmemory" || key == "maxmemory-policy" { + let limit = cx.ctx.config.memory_limit(); + let policy = cx.ctx.config.eviction_policy(); + // broadcast is fallible but config is already stored — log and continue + let _ = cx + .engine + .broadcast(move || ShardRequest::UpdateMemoryConfig { + max_memory: limit, + eviction_policy: policy, + }) + .await; + // keep the INFO-visible limit in sync + cx.ctx.max_memory_limit.store( + limit.unwrap_or(0) as u64, + std::sync::atomic::Ordering::Relaxed, + ); + } else if key == "notify-keyspace-events" { + let flags = crate::keyspace_notifications::parse_keyspace_event_flags(&value); + cx.ctx + .keyspace_event_flags + .store(flags, std::sync::atomic::Ordering::Relaxed); + } + Frame::Simple("OK".into()) +} + +pub(in crate::connection) async fn config_rewrite(cx: &ExecCtx<'_>) -> Frame { + match &cx.ctx.config_path { + Some(path) => match cx.ctx.config.rewrite(path) { + Ok(()) => Frame::Simple("OK".into()), + Err(e) => Frame::Error(e), + }, + None => Frame::Error("ERR The server is running without a config file".into()), + } +} + +pub(in crate::connection) async fn bgsave(cx: &ExecCtx<'_>) -> Frame { + match cx.engine.broadcast(|| ShardRequest::Snapshot).await { + Ok(_) => { + use std::time::{SystemTime, UNIX_EPOCH}; + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + cx.ctx + .last_save_timestamp + .store(ts, std::sync::atomic::Ordering::Relaxed); + Frame::Simple("Background saving started".into()) + } + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn bgrewriteaof(cx: &ExecCtx<'_>) -> Frame { + match cx.engine.broadcast(|| ShardRequest::RewriteAof).await { + Ok(_) => Frame::Simple("Background append only file rewriting started".into()), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) fn time() -> Frame { + use std::time::{SystemTime, UNIX_EPOCH}; + let dur = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + Frame::Array(vec![ + Frame::Bulk(Bytes::from(dur.as_secs().to_string())), + Frame::Bulk(Bytes::from(dur.subsec_micros().to_string())), + ]) +} + +pub(in crate::connection) fn lastsave(cx: &ExecCtx<'_>) -> Frame { + let ts = cx + .ctx + .last_save_timestamp + .load(std::sync::atomic::Ordering::Relaxed); + Frame::Integer(ts as i64) +} + +pub(in crate::connection) async fn role(cx: &ExecCtx<'_>) -> Frame { + if let Some(ref cluster) = cx.ctx.cluster { + use ember_cluster::NodeRole; + let info = cluster.replication_info().await; + match info.role { + NodeRole::Primary => Frame::Array(vec![ + Frame::Bulk(Bytes::from("master")), + Frame::Integer(0), + Frame::Array(vec![]), + ]), + NodeRole::Replica => { + let (host, port) = match info.primary_addr { + Some(addr) => (addr.ip().to_string(), addr.port() as i64), + None => (String::new(), 0), + }; + Frame::Array(vec![ + Frame::Bulk(Bytes::from("slave")), + Frame::Bulk(Bytes::from(host)), + Frame::Integer(port), + Frame::Bulk(Bytes::from("connected")), + Frame::Integer(0), + ]) + } + } + } else { + Frame::Array(vec![ + Frame::Bulk(Bytes::from("master")), + Frame::Integer(0), + Frame::Array(vec![]), + ]) + } +} + +pub(in crate::connection) async fn flushdb(async_mode: bool, cx: &ExecCtx<'_>) -> Frame { + let req = if async_mode { + || ShardRequest::FlushDbAsync + } else { + || ShardRequest::FlushDb + }; + match cx.engine.broadcast(req).await { + Ok(_) => Frame::Simple("OK".into()), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn flushall(async_mode: bool, cx: &ExecCtx<'_>) -> Frame { + // Ember is single-database, so FLUSHALL is identical to FLUSHDB. + flushdb(async_mode, cx).await +} + +pub(in crate::connection) fn slowlog_get(count: Option, cx: &ExecCtx<'_>) -> Frame { + let entries = cx.slow_log.get(count); + let frames: Vec = entries + .into_iter() + .map(|e| { + let ts = e + .timestamp + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + Frame::Array(vec![ + Frame::Integer(e.id.min(i64::MAX as u64) as i64), + Frame::Integer(ts.min(i64::MAX as u64) as i64), + Frame::Integer(e.duration.as_micros().min(i64::MAX as u128) as i64), + Frame::Array(vec![Frame::Bulk(Bytes::from(e.command))]), + ]) + }) + .collect(); + Frame::Array(frames) +} + +pub(in crate::connection) fn slowlog_len(cx: &ExecCtx<'_>) -> Frame { + Frame::Integer(cx.slow_log.len() as i64) +} + +pub(in crate::connection) fn slowlog_reset(cx: &ExecCtx<'_>) -> Frame { + cx.slow_log.reset(); + Frame::Simple("OK".into()) +} + +/// 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. +/// +/// Returns the count of replicas that acknowledged in time as a +/// RESP integer. When there are no replicas or no writes, returns +/// immediately without sleeping. +pub(in crate::connection) async fn handle_wait( + ctx: &Arc, + numreplicas: u64, + timeout_ms: u64, +) -> Frame { + use std::sync::atomic::Ordering; + use std::time::Duration; + + let needed = numreplicas as usize; + let tracker = &ctx.replica_tracker; + + // fast path: no replicas connected + if tracker.connected_count() == 0 { + return Frame::Integer(0); + } + + let target = tracker.write_offset.load(Ordering::Relaxed); + + // fast path: already satisfied or no timeout needed + let count = tracker.count_at_or_above(target); + if count >= needed || timeout_ms == 0 { + return Frame::Integer(count as i64); + } + + // poll until enough replicas have caught up or the deadline passes + let deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms); + loop { + tokio::time::sleep(Duration::from_millis(25)).await; + let c = tracker.count_at_or_above(target); + if c >= needed || tokio::time::Instant::now() >= deadline { + return Frame::Integer(c as i64); + } + } +} + +/// Renders the INFO response with multiple sections. +/// +/// With no argument, returns all sections. With a section name, +/// returns only that section. Matches Redis convention of `#` headers +/// followed by `key:value` pairs separated by `\r\n`. +async fn render_info( + engine: &ember_core::Engine, + ctx: &Arc, + section: Option<&str>, +) -> Frame { + let section_upper = section.map(|s| s.to_ascii_uppercase()); + let want_all = section_upper.is_none(); + let want = |name: &str| want_all || section_upper.as_deref() == Some(name); + + // only broadcast to shards if we need keyspace/memory/persistence sections + let stats = if want("KEYSPACE") || want("MEMORY") || want("PERSISTENCE") || want("STATS") { + match engine.broadcast(|| ShardRequest::Stats).await { + Ok(responses) => { + let mut total = KeyspaceStats { + key_count: 0, + used_bytes: 0, + keys_with_expiry: 0, + keys_expired: 0, + keys_evicted: 0, + oom_rejections: 0, + keyspace_hits: 0, + keyspace_misses: 0, + }; + for r in &responses { + if let ShardResponse::Stats(s) = r { + total.key_count += s.key_count; + total.used_bytes += s.used_bytes; + total.keys_with_expiry += s.keys_with_expiry; + total.keys_expired += s.keys_expired; + total.keys_evicted += s.keys_evicted; + total.oom_rejections += s.oom_rejections; + total.keyspace_hits += s.keyspace_hits; + total.keyspace_misses += s.keyspace_misses; + } + } + Some(total) + } + Err(e) => return Frame::Error(format!("ERR {e}")), + } + } else { + None + }; + + let mut out = String::with_capacity(512); + + if want("SERVER") { + let uptime = ctx.start_time.elapsed().as_secs(); + out.push_str("# Server\r\n"); + out.push_str(&format!("ember_version:{}\r\n", ctx.version)); + out.push_str(&format!("process_id:{}\r\n", std::process::id())); + out.push_str(&format!("uptime_in_seconds:{uptime}\r\n")); + out.push_str(&format!("shard_count:{}\r\n", ctx.shard_count)); + out.push_str(&format!("tcp_port:{}\r\n", ctx.bind_addr.port())); + out.push_str("hz:10\r\n"); + if let Some(ref path) = ctx.config_path { + out.push_str(&format!("config_file:{}\r\n", path.display())); + } else { + out.push_str("config_file:\r\n"); + } + out.push_str("\r\n"); + } + + if want("CLIENTS") { + let connected = ctx.connections_active.load(Ordering::Relaxed); + out.push_str("# Clients\r\n"); + out.push_str(&format!("connected_clients:{connected}\r\n")); + out.push_str(&format!("max_clients:{}\r\n", ctx.max_connections)); + out.push_str("\r\n"); + } + + if want("MEMORY") { + if let Some(ref stats) = stats { + out.push_str("# Memory\r\n"); + out.push_str(&format!("used_memory:{}\r\n", stats.used_bytes)); + out.push_str(&format!( + "used_memory_human:{}\r\n", + human_bytes(stats.used_bytes) + )); + if let Some(rss) = get_rss_bytes() { + out.push_str(&format!("used_memory_rss:{rss}\r\n")); + out.push_str(&format!("used_memory_rss_human:{}\r\n", human_bytes(rss))); + } + let max_bytes = ctx + .max_memory_limit + .load(std::sync::atomic::Ordering::Relaxed) as usize; + if max_bytes > 0 { + let effective = ember_core::memory::effective_limit(max_bytes); + out.push_str(&format!("max_memory:{max_bytes}\r\n")); + out.push_str(&format!("max_memory_human:{}\r\n", human_bytes(max_bytes))); + out.push_str(&format!("max_memory_effective:{effective}\r\n")); + out.push_str(&format!( + "max_memory_effective_human:{}\r\n", + human_bytes(effective) + )); + } else { + out.push_str("max_memory:0\r\n"); + out.push_str("max_memory_human:unlimited\r\n"); + } + out.push_str("\r\n"); + } + } + + if want("PERSISTENCE") { + let last_save = ctx + .last_save_timestamp + .load(std::sync::atomic::Ordering::Relaxed); + out.push_str("# Persistence\r\n"); + out.push_str(&format!( + "aof_enabled:{}\r\n", + if ctx.aof_enabled { 1 } else { 0 } + )); + out.push_str("aof_last_bgrewrite_status:ok\r\n"); + out.push_str(&format!("rdb_last_save_time:{last_save}\r\n")); + out.push_str("\r\n"); + } + + if want("STATS") { + let total_conns = ctx.connections_accepted.load(Ordering::Relaxed); + let total_cmds = ctx.commands_processed.load(Ordering::Relaxed); + out.push_str("# Stats\r\n"); + out.push_str(&format!("total_connections_received:{total_conns}\r\n")); + out.push_str(&format!("total_commands_processed:{total_cmds}\r\n")); + if let Some(ref stats) = stats { + out.push_str(&format!("expired_keys:{}\r\n", stats.keys_expired)); + out.push_str(&format!("evicted_keys:{}\r\n", stats.keys_evicted)); + out.push_str(&format!("oom_rejections:{}\r\n", stats.oom_rejections)); + out.push_str(&format!("keyspace_hits:{}\r\n", stats.keyspace_hits)); + out.push_str(&format!("keyspace_misses:{}\r\n", stats.keyspace_misses)); + } + out.push_str("\r\n"); + } + + if want("KEYSPACE") { + if let Some(ref stats) = stats { + out.push_str("# Keyspace\r\n"); + if stats.key_count > 0 { + out.push_str(&format!( + "db0:keys={},expires={},used_bytes={}\r\n", + stats.key_count, stats.keys_with_expiry, stats.used_bytes + )); + } + out.push_str("\r\n"); + } + } + + if want("REPLICATION") { + out.push_str("# Replication\r\n"); + if let Some(ref cluster) = ctx.cluster { + let info = cluster.replication_info().await; + use ember_cluster::NodeRole; + match info.role { + NodeRole::Primary => { + out.push_str("role:primary\r\n"); + out.push_str(&format!("connected_replicas:{}\r\n", info.replica_count)); + } + NodeRole::Replica => { + out.push_str("role:replica\r\n"); + if let Some(addr) = info.primary_addr { + out.push_str(&format!("master_host:{}\r\n", addr.ip())); + out.push_str(&format!("master_port:{}\r\n", addr.port())); + out.push_str("master_link_status:up\r\n"); + } else { + out.push_str("master_link_status:down\r\n"); + } + } + } + } else { + out.push_str("role:primary\r\n"); + out.push_str("connected_replicas:0\r\n"); + } + out.push_str("\r\n"); + } + + // trim trailing blank line + if out.ends_with("\r\n\r\n") { + out.truncate(out.len() - 2); + } + + Frame::Bulk(Bytes::from(out)) +} diff --git a/crates/ember-server/src/connection/exec/sets.rs b/crates/ember-server/src/connection/exec/sets.rs new file mode 100644 index 00000000..036e24fb --- /dev/null +++ b/crates/ember-server/src/connection/exec/sets.rs @@ -0,0 +1,366 @@ +//! Set command handlers. + +use bytes::Bytes; +use ember_core::{ShardRequest, ShardResponse}; +use ember_protocol::Frame; + +use super::ExecCtx; + +pub(in crate::connection) async fn sadd( + key: String, + members: Vec, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::SAdd { + key: key.clone(), + members, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Len(n)) => { + if n > 0 { + cx.notify_write(crate::keyspace_notifications::FLAG_S, "sadd", &key); + } + Frame::Integer(n as i64) + } + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(ShardResponse::OutOfMemory) => super::oom_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn srem( + key: String, + members: Vec, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::SRem { key, members }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Len(n)) => Frame::Integer(n as i64), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn smembers(key: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::SMembers { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::StringArray(members)) => Frame::Array( + members + .into_iter() + .map(|m| Frame::Bulk(Bytes::from(m))) + .collect(), + ), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn sismember( + key: String, + member: String, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::SIsMember { key, member }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Bool(b)) => Frame::Integer(if b { 1 } else { 0 }), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn scard(key: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::SCard { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Len(n)) => Frame::Integer(n as i64), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn sunion(keys: Vec, cx: &ExecCtx<'_>) -> Frame { + let key = keys.first().cloned().unwrap_or_default(); + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::SUnion { keys }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::StringArray(members)) => Frame::Array( + members + .into_iter() + .map(|m| Frame::Bulk(Bytes::from(m))) + .collect(), + ), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn sinter(keys: Vec, cx: &ExecCtx<'_>) -> Frame { + let key = keys.first().cloned().unwrap_or_default(); + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::SInter { keys }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::StringArray(members)) => Frame::Array( + members + .into_iter() + .map(|m| Frame::Bulk(Bytes::from(m))) + .collect(), + ), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn sdiff(keys: Vec, cx: &ExecCtx<'_>) -> Frame { + let key = keys.first().cloned().unwrap_or_default(); + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::SDiff { keys }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::StringArray(members)) => Frame::Array( + members + .into_iter() + .map(|m| Frame::Bulk(Bytes::from(m))) + .collect(), + ), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn sunionstore( + dest: String, + keys: Vec, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&dest); + let req = ShardRequest::SUnionStore { dest, keys }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::SetStoreResult { count, .. }) => Frame::Integer(count as i64), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(ShardResponse::OutOfMemory) => super::oom_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn sinterstore( + dest: String, + keys: Vec, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&dest); + let req = ShardRequest::SInterStore { dest, keys }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::SetStoreResult { count, .. }) => Frame::Integer(count as i64), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(ShardResponse::OutOfMemory) => super::oom_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn sdiffstore( + dest: String, + keys: Vec, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&dest); + let req = ShardRequest::SDiffStore { dest, keys }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::SetStoreResult { count, .. }) => Frame::Integer(count as i64), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(ShardResponse::OutOfMemory) => super::oom_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn srandmember( + key: String, + count: Option, + cx: &ExecCtx<'_>, +) -> Frame { + let count = count.unwrap_or(1); + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::SRandMember { key, count }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::StringArray(members)) => Frame::Array( + members + .into_iter() + .map(|m| Frame::Bulk(Bytes::from(m))) + .collect(), + ), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn spop(key: String, count: usize, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::SPop { key, count }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::StringArray(members)) => Frame::Array( + members + .into_iter() + .map(|m| Frame::Bulk(Bytes::from(m))) + .collect(), + ), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn smismember( + key: String, + members: Vec, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::SMisMember { key, members }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::BoolArray(arr)) => Frame::Array( + arr.into_iter() + .map(|b| Frame::Integer(i64::from(b))) + .collect(), + ), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn smove( + source: String, + destination: String, + member: String, + cx: &ExecCtx<'_>, +) -> Frame { + let src_idx = cx.engine.shard_for_key(&source); + let dst_idx = cx.engine.shard_for_key(&destination); + + if src_idx == dst_idx { + // same shard — single atomic operation + let req = ShardRequest::SMove { + source, + destination, + member, + }; + match cx.engine.send_to_shard(src_idx, req).await { + Ok(ShardResponse::Bool(moved)) => Frame::Integer(if moved { 1 } else { 0 }), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(ShardResponse::OutOfMemory) => super::oom_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } + } else { + // cross-shard: remove from source, then add to destination + let rem_req = ShardRequest::SRem { + key: source, + members: vec![member.clone()], + }; + let removed = match cx.engine.send_to_shard(src_idx, rem_req).await { + Ok(ShardResponse::Len(n)) => n, + Ok(ShardResponse::WrongType) => return super::wrongtype_error(), + Ok(other) => return Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => return Frame::Error(format!("ERR {e}")), + }; + + if removed == 0 { + return Frame::Integer(0); + } + + let add_req = ShardRequest::SAdd { + key: destination, + members: vec![member], + }; + match cx.engine.send_to_shard(dst_idx, add_req).await { + Ok(ShardResponse::Len(_)) => Frame::Integer(1), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(ShardResponse::OutOfMemory) => super::oom_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } + } +} + +pub(in crate::connection) async fn sintercard( + keys: Vec, + limit: usize, + cx: &ExecCtx<'_>, +) -> Frame { + // Fetch members for each key from its owning shard, then intersect. + // This handles keys spread across shards without cross-shard calls + // inside the keyspace layer. + let mut sets: Vec> = Vec::with_capacity(keys.len()); + for key in &keys { + let idx = cx.engine.shard_for_key(key); + let req = ShardRequest::SMembers { key: key.clone() }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::StringArray(members)) => { + // an empty set (including missing key) short-circuits to 0 + if members.is_empty() { + return Frame::Integer(0); + } + sets.push(members.into_iter().collect()); + } + Ok(ShardResponse::WrongType) => return super::wrongtype_error(), + Ok(other) => return Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => return Frame::Error(format!("ERR {e}")), + } + } + + if sets.is_empty() { + return Frame::Integer(0); + } + + // Start with the smallest set to minimise comparisons. + sets.sort_unstable_by_key(|s| s.len()); + let Some((first, rest)) = sets.split_first() else { + return Frame::Integer(0); + }; + let mut count = 0usize; + 'outer: for member in first { + for other in rest { + if !other.contains(member.as_str()) { + continue 'outer; + } + } + count += 1; + if limit > 0 && count >= limit { + break; + } + } + + Frame::Integer(count as i64) +} + +pub(in crate::connection) async fn sscan( + key: String, + cursor: u64, + pattern: Option, + count: Option, + cx: &ExecCtx<'_>, +) -> Frame { + let count = count.unwrap_or(10); + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::SScan { + key, + cursor, + count, + pattern, + }; + super::resolve_collection_scan(cx.engine.send_to_shard(idx, req).await) +} diff --git a/crates/ember-server/src/connection/exec/sorted_sets.rs b/crates/ember-server/src/connection/exec/sorted_sets.rs new file mode 100644 index 00000000..fbb35b1a --- /dev/null +++ b/crates/ember-server/src/connection/exec/sorted_sets.rs @@ -0,0 +1,544 @@ +//! Sorted set command handlers. + +use bytes::Bytes; +use ember_core::{ShardRequest, ShardResponse}; +use ember_protocol::command::ScoreBound; +use ember_protocol::Frame; + +use super::ExecCtx; + +pub(in crate::connection) async fn zadd( + key: String, + flags: ember_protocol::command::ZAddFlags, + members: Vec<(f64, String)>, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::ZAdd { + key: key.clone(), + members, + nx: flags.nx, + xx: flags.xx, + gt: flags.gt, + lt: flags.lt, + ch: flags.ch, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::ZAddLen { count, .. }) => { + if count > 0 { + cx.notify_write(crate::keyspace_notifications::FLAG_Z, "zadd", &key); + } + Frame::Integer(count as i64) + } + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(ShardResponse::OutOfMemory) => super::oom_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn zrem( + key: String, + members: Vec, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::ZRem { key, members }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::ZRemLen { count, .. }) => Frame::Integer(count as i64), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn zscore(key: String, member: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::ZScore { key, member }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Score(Some(s))) => Frame::Bulk(Bytes::from(format!("{s}"))), + Ok(ShardResponse::Score(None)) => Frame::Null, + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn zrank(key: String, member: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::ZRank { key, member }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Rank(Some(r))) => Frame::Integer(r as i64), + Ok(ShardResponse::Rank(None)) => Frame::Null, + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn zrevrank( + key: String, + member: String, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::ZRevRank { key, member }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Rank(Some(r))) => Frame::Integer(r as i64), + Ok(ShardResponse::Rank(None)) => Frame::Null, + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn zrange( + key: String, + start: i64, + stop: i64, + with_scores: bool, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::ZRange { + key, + start, + stop, + with_scores, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::ScoredArray(items)) => { + let mut frames = Vec::new(); + for (member, score) in items { + frames.push(Frame::Bulk(Bytes::from(member))); + if with_scores { + frames.push(Frame::Bulk(Bytes::from(format!("{score}")))); + } + } + Frame::Array(frames) + } + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn zrevrange( + key: String, + start: i64, + stop: i64, + with_scores: bool, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::ZRevRange { + key, + start, + stop, + with_scores, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::ScoredArray(items)) => { + let mut frames = Vec::new(); + for (member, score) in items { + frames.push(Frame::Bulk(Bytes::from(member))); + if with_scores { + frames.push(Frame::Bulk(Bytes::from(format!("{score}")))); + } + } + Frame::Array(frames) + } + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn zcard(key: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::ZCard { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Len(n)) => Frame::Integer(n as i64), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn zcount( + key: String, + min: ScoreBound, + max: ScoreBound, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::ZCount { key, min, max }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Len(n)) => Frame::Integer(n as i64), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn zincrby( + key: String, + increment: f64, + member: String, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::ZIncrBy { + key, + increment, + member, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::ZIncrByResult { new_score, .. }) => { + Frame::Bulk(Bytes::from(format!("{new_score}"))) + } + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(ShardResponse::OutOfMemory) => super::oom_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn zrangebyscore( + key: String, + min: ScoreBound, + max: ScoreBound, + with_scores: bool, + offset: usize, + count: Option, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::ZRangeByScore { + key, + min, + max, + offset, + count, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::ScoredArray(items)) => { + let mut frames = Vec::new(); + for (member, score) in items { + frames.push(Frame::Bulk(Bytes::from(member))); + if with_scores { + frames.push(Frame::Bulk(Bytes::from(format!("{score}")))); + } + } + Frame::Array(frames) + } + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn zrevrangebyscore( + key: String, + min: ScoreBound, + max: ScoreBound, + with_scores: bool, + offset: usize, + count: Option, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::ZRevRangeByScore { + key, + min, + max, + offset, + count, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::ScoredArray(items)) => { + let mut frames = Vec::new(); + for (member, score) in items { + frames.push(Frame::Bulk(Bytes::from(member))); + if with_scores { + frames.push(Frame::Bulk(Bytes::from(format!("{score}")))); + } + } + Frame::Array(frames) + } + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn zpopmin(key: String, count: usize, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::ZPopMin { key, count }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::ZPopResult(items)) => { + let mut frames = Vec::with_capacity(items.len() * 2); + for (member, score) in items { + frames.push(Frame::Bulk(Bytes::from(member))); + frames.push(Frame::Bulk(Bytes::from(format!("{score}")))); + } + Frame::Array(frames) + } + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn zpopmax(key: String, count: usize, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::ZPopMax { key, count }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::ZPopResult(items)) => { + let mut frames = Vec::with_capacity(items.len() * 2); + for (member, score) in items { + frames.push(Frame::Bulk(Bytes::from(member))); + frames.push(Frame::Bulk(Bytes::from(format!("{score}")))); + } + Frame::Array(frames) + } + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn zmpop( + keys: Vec, + min: bool, + count: usize, + cx: &ExecCtx<'_>, +) -> Frame { + for key in &keys { + let idx = cx.engine.shard_for_key(key); + let req = ShardRequest::ZmpopSingle { + key: key.clone(), + min, + count, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::ZPopResult(members)) if !members.is_empty() => { + let pairs: Vec = members + .into_iter() + .flat_map(|(m, s)| { + vec![ + Frame::Bulk(Bytes::from(m)), + Frame::Bulk(Bytes::from(format!("{s}"))), + ] + }) + .collect(); + return Frame::Array(vec![ + Frame::Bulk(Bytes::from(key.clone())), + Frame::Array(pairs), + ]); + } + Ok(ShardResponse::ZPopResult(_)) | Ok(ShardResponse::Value(None)) => continue, + Ok(ShardResponse::WrongType) => return super::wrongtype_error(), + Ok(other) => return Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => return Frame::Error(format!("ERR {e}")), + } + } + Frame::Null +} + +pub(in crate::connection) async fn zdiff( + keys: Vec, + with_scores: bool, + cx: &ExecCtx<'_>, +) -> Frame { + let key = keys.first().cloned().unwrap_or_default(); + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::ZDiff { keys }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::ScoredArray(items)) => { + let mut frames = Vec::new(); + for (member, score) in items { + frames.push(Frame::Bulk(Bytes::from(member))); + if with_scores { + frames.push(Frame::Bulk(Bytes::from(format!("{score}")))); + } + } + Frame::Array(frames) + } + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn zinter( + keys: Vec, + with_scores: bool, + cx: &ExecCtx<'_>, +) -> Frame { + let key = keys.first().cloned().unwrap_or_default(); + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::ZInter { keys }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::ScoredArray(items)) => { + let mut frames = Vec::new(); + for (member, score) in items { + frames.push(Frame::Bulk(Bytes::from(member))); + if with_scores { + frames.push(Frame::Bulk(Bytes::from(format!("{score}")))); + } + } + Frame::Array(frames) + } + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn zunion( + keys: Vec, + with_scores: bool, + cx: &ExecCtx<'_>, +) -> Frame { + let key = keys.first().cloned().unwrap_or_default(); + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::ZUnion { keys }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::ScoredArray(items)) => { + let mut frames = Vec::new(); + for (member, score) in items { + frames.push(Frame::Bulk(Bytes::from(member))); + if with_scores { + frames.push(Frame::Bulk(Bytes::from(format!("{score}")))); + } + } + Frame::Array(frames) + } + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn zdiffstore( + dest: String, + keys: Vec, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&dest); + let req = ShardRequest::ZDiffStore { + dest: dest.clone(), + keys, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::ZStoreResult { count, .. }) => { + cx.notify_write(crate::keyspace_notifications::FLAG_Z, "zdiffstore", &dest); + Frame::Integer(count as i64) + } + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn zinterstore( + dest: String, + keys: Vec, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&dest); + let req = ShardRequest::ZInterStore { + dest: dest.clone(), + keys, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::ZStoreResult { count, .. }) => { + cx.notify_write(crate::keyspace_notifications::FLAG_Z, "zinterstore", &dest); + Frame::Integer(count as i64) + } + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn zunionstore( + dest: String, + keys: Vec, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&dest); + let req = ShardRequest::ZUnionStore { + dest: dest.clone(), + keys, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::ZStoreResult { count, .. }) => { + cx.notify_write(crate::keyspace_notifications::FLAG_Z, "zunionstore", &dest); + Frame::Integer(count as i64) + } + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn zrandmember( + key: String, + count: Option, + with_scores: bool, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::ZRandMember { + key, + count, + with_scores, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::ZRandMemberResult(pairs)) => { + if count.is_none() { + // no count: return a single bulk string (or nil if empty) + match pairs.into_iter().next() { + Some((member, _)) => Frame::Bulk(Bytes::from(member)), + None => Frame::Null, + } + } else { + // with count: return array, interleaved with scores if requested + let frames: Vec = pairs + .into_iter() + .flat_map(|(m, s)| { + let mut items = vec![Frame::Bulk(Bytes::from(m))]; + if let Some(score) = s { + items.push(Frame::Bulk(Bytes::from(score.to_string()))); + } + items + }) + .collect(); + Frame::Array(frames) + } + } + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn zscan( + key: String, + cursor: u64, + pattern: Option, + count: Option, + cx: &ExecCtx<'_>, +) -> Frame { + let count = count.unwrap_or(10); + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::ZScan { + key, + cursor, + count, + pattern, + }; + super::resolve_collection_scan(cx.engine.send_to_shard(idx, req).await) +} diff --git a/crates/ember-server/src/connection/exec/strings.rs b/crates/ember-server/src/connection/exec/strings.rs new file mode 100644 index 00000000..04bde792 --- /dev/null +++ b/crates/ember-server/src/connection/exec/strings.rs @@ -0,0 +1,505 @@ +//! String and bitmap command handlers. + +use std::time::Duration; + +use bytes::Bytes; +use ember_core::{ShardRequest, ShardResponse, Value}; +use ember_protocol::{command::BitOpKind, Frame, SetExpire}; + +use super::ExecCtx; + +pub(in crate::connection) async fn get(key: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::Get { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Value(Some(Value::String(data)))) => Frame::Bulk(data), + Ok(ShardResponse::Value(None)) => Frame::Null, + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn set( + key: String, + value: Bytes, + expire: Option, + nx: bool, + xx: bool, + cx: &ExecCtx<'_>, +) -> Frame { + let duration = expire.map(super::set_expire_to_duration); + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::Set { + key: key.clone(), + value, + expire: duration, + nx, + xx, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Ok) => { + cx.notify_write(crate::keyspace_notifications::FLAG_DOLLAR, "set", &key); + Frame::Simple("OK".into()) + } + Ok(ShardResponse::Value(None)) => Frame::Null, + Ok(ShardResponse::OutOfMemory) => super::oom_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn incr(key: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::Incr { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Integer(n)) => Frame::Integer(n), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(ShardResponse::OutOfMemory) => super::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}")), + } +} + +pub(in crate::connection) async fn decr(key: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::Decr { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Integer(n)) => Frame::Integer(n), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(ShardResponse::OutOfMemory) => super::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}")), + } +} + +pub(in crate::connection) async fn incrby(key: String, delta: i64, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::IncrBy { key, delta }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Integer(n)) => Frame::Integer(n), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(ShardResponse::OutOfMemory) => super::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}")), + } +} + +pub(in crate::connection) async fn decrby(key: String, delta: i64, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::DecrBy { key, delta }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Integer(n)) => Frame::Integer(n), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(ShardResponse::OutOfMemory) => super::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}")), + } +} + +pub(in crate::connection) async fn incrbyfloat(key: String, delta: f64, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::IncrByFloat { key, delta }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::BulkString(val)) => Frame::Bulk(Bytes::from(val)), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(ShardResponse::OutOfMemory) => super::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}")), + } +} + +pub(in crate::connection) async fn append(key: String, value: Bytes, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::Append { key, value }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Len(n)) => Frame::Integer(n as i64), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(ShardResponse::OutOfMemory) => super::oom_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn strlen(key: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::Strlen { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Len(n)) => Frame::Integer(n as i64), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn getrange( + key: String, + start: i64, + end: i64, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::GetRange { key, start, end }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Value(Some(Value::String(data)))) => Frame::Bulk(data), + Ok(ShardResponse::Value(None)) => Frame::Null, + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn setrange( + key: String, + offset: usize, + value: Bytes, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::SetRange { key, offset, value }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Len(n)) => Frame::Integer(n as i64), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(ShardResponse::OutOfMemory) => super::oom_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn getbit(key: String, offset: u64, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::GetBit { key, offset }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Integer(bit)) => Frame::Integer(bit), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn setbit( + key: String, + offset: u64, + value: u8, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::SetBit { key, offset, value }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Integer(old_bit)) => Frame::Integer(old_bit), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(ShardResponse::OutOfMemory) => super::oom_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn bitcount( + key: String, + range: Option, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::BitCount { key, range }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Integer(n)) => Frame::Integer(n), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn bitpos( + key: String, + bit: u8, + range: Option, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::BitPos { key, bit, range }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Integer(pos)) => Frame::Integer(pos), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn bitop( + op: BitOpKind, + dest: String, + keys: Vec, + cx: &ExecCtx<'_>, +) -> Frame { + // Read source keys from their respective shards, then compute the + // bitwise result here and write it to dest's shard. This is + // necessary because source keys may live on different shards. + let responses = match cx + .engine + .route_multi(&keys, |k| ShardRequest::Get { key: k }) + .await + { + Ok(r) => r, + Err(e) => return Frame::Error(format!("ERR {e}")), + }; + + let mut sources: Vec = Vec::with_capacity(responses.len()); + for r in responses { + match r { + ShardResponse::Value(Some(Value::String(b))) => sources.push(b), + ShardResponse::Value(None) => sources.push(Bytes::new()), + ShardResponse::WrongType => return super::wrongtype_error(), + _ => sources.push(Bytes::new()), + } + } + + let result_len = sources.iter().map(|s| s.len()).max().unwrap_or(0); + let mut result = vec![0u8; result_len]; + match op { + BitOpKind::Not => { + let src = sources.first().map(|b| b.as_ref()).unwrap_or(&[]); + for (i, b) in result.iter_mut().enumerate() { + *b = if i < src.len() { !src[i] } else { 0xFF }; + } + } + BitOpKind::And => { + if let Some(first) = sources.first() { + for (i, b) in result.iter_mut().enumerate() { + *b = if i < first.len() { first[i] } else { 0 }; + } + } + for src in sources.iter().skip(1) { + for (i, b) in result.iter_mut().enumerate() { + *b &= if i < src.len() { src[i] } else { 0 }; + } + } + } + BitOpKind::Or => { + for src in &sources { + for (i, b) in result.iter_mut().enumerate() { + if i < src.len() { + *b |= src[i]; + } + } + } + } + BitOpKind::Xor => { + for src in &sources { + for (i, b) in result.iter_mut().enumerate() { + if i < src.len() { + *b ^= src[i]; + } + } + } + } + } + + let dest_idx = cx.engine.shard_for_key(&dest); + let req = ShardRequest::Set { + key: dest, + value: Bytes::from(result), + expire: None, + nx: false, + xx: false, + }; + match cx.engine.send_to_shard(dest_idx, req).await { + Ok(ShardResponse::Ok) | Ok(ShardResponse::Value(_)) => Frame::Integer(result_len as i64), + Ok(ShardResponse::OutOfMemory) => super::oom_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn getdel(key: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::GetDel { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Value(Some(Value::String(data)))) => Frame::Bulk(data), + Ok(ShardResponse::Value(None)) => Frame::Null, + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn getex( + key: String, + expire: Option>, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + // convert SetExpire into an Option> (milliseconds from now) + let expire_ms: Option> = expire.map(|opt| { + opt.map(|se| match se { + SetExpire::Ex(s) => Duration::from_secs(s).as_millis() as u64, + SetExpire::Px(ms) => ms, + SetExpire::ExAt(ts) => { + use std::time::{SystemTime, UNIX_EPOCH}; + let now_s = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + Duration::from_secs(ts.saturating_sub(now_s)).as_millis() as u64 + } + SetExpire::PxAt(ts_ms) => { + use std::time::{SystemTime, UNIX_EPOCH}; + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + ts_ms.saturating_sub(now_ms) + } + }) + }); + let req = ShardRequest::GetEx { + key, + expire: expire_ms, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Value(Some(Value::String(data)))) => Frame::Bulk(data), + Ok(ShardResponse::Value(None)) => Frame::Null, + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn getset(key: String, value: Bytes, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::GetSet { key, value }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Value(Some(Value::String(data)))) => Frame::Bulk(data), + Ok(ShardResponse::Value(None)) => Frame::Null, + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn mget(keys: Vec, cx: &ExecCtx<'_>) -> Frame { + match cx + .engine + .route_multi(&keys, |k| ShardRequest::Get { key: k }) + .await + { + Ok(responses) => { + let frames: Vec = responses + .into_iter() + .map(|r| match r { + ShardResponse::Value(Some(Value::String(data))) => Frame::Bulk(data), + ShardResponse::Value(None) => Frame::Null, + // MGET on wrong type returns null per Redis behavior + ShardResponse::WrongType => Frame::Null, + _ => Frame::Null, + }) + .collect(); + Frame::Array(frames) + } + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn mset(pairs: Vec<(String, Bytes)>, cx: &ExecCtx<'_>) -> Frame { + // Fan out individual SET requests — MSET always succeeds (or OOMs). + // We build a HashMap for O(1) value lookups during routing. If there + // are duplicate keys in pairs, the HashMap keeps the last value, which + // matches Redis semantics (last write wins). + let keys: Vec = pairs.iter().map(|(k, _)| k.clone()).collect(); + let values: std::collections::HashMap = pairs.into_iter().collect(); + + match cx + .engine + .route_multi(&keys, |k| { + // Safe: k comes from keys, which came from pairs, so it exists in values. + let value = values.get(&k).cloned().unwrap_or_default(); + ShardRequest::Set { + key: k, + value, + expire: None, + nx: false, + xx: false, + } + }) + .await + { + Ok(responses) => { + // check if any SET failed due to OOM + for r in &responses { + if matches!(r, ShardResponse::OutOfMemory) { + return super::oom_error(); + } + } + Frame::Simple("OK".into()) + } + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +pub(in crate::connection) async fn msetnx(pairs: Vec<(String, Bytes)>, cx: &ExecCtx<'_>) -> Frame { + // MSETNX is all-or-nothing: set all keys only if none exist. + // + // We implement this with two fan-out passes: + // 1. Check existence of every key across all shards. + // 2. If all are absent, write all pairs. + // + // This is not atomic across shards (no distributed transaction), + // but matches Redis cluster semantics where MSETNX pairs must + // share a hash slot. For single-node mode it is correct. + if pairs.is_empty() { + return Frame::Error("ERR wrong number of arguments for 'MSETNX'".into()); + } + + let keys: Vec = pairs.iter().map(|(k, _)| k.clone()).collect(); + + // phase 1: check existence + let exists_responses = match cx + .engine + .route_multi(&keys, |k| ShardRequest::Exists { key: k }) + .await + { + Ok(r) => r, + Err(e) => return Frame::Error(format!("ERR {e}")), + }; + + let any_exists = exists_responses + .iter() + .any(|r| matches!(r, ShardResponse::Bool(true))); + if any_exists { + return Frame::Integer(0); + } + + // phase 2: write all pairs + let values: std::collections::HashMap = pairs.into_iter().collect(); + match cx + .engine + .route_multi(&keys, |k| { + let value = values.get(&k).cloned().unwrap_or_default(); + ShardRequest::Set { + key: k, + value, + expire: None, + nx: false, + xx: false, + } + }) + .await + { + Ok(responses) => { + for r in &responses { + if matches!(r, ShardResponse::OutOfMemory) { + return super::oom_error(); + } + } + Frame::Integer(1) + } + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +// Suppress unused import warning — BytesMut is not needed in this module but +// was present in the original. Keep it removed since we don't use it here. diff --git a/crates/ember-server/src/connection/exec/vector.rs b/crates/ember-server/src/connection/exec/vector.rs new file mode 100644 index 00000000..cf7aebbe --- /dev/null +++ b/crates/ember-server/src/connection/exec/vector.rs @@ -0,0 +1,189 @@ +//! Vector command handlers. +//! +//! All items in this module are gated behind `#[cfg(feature = "vector")]`. + +use ember_protocol::Frame; + +#[cfg(feature = "vector")] +use super::ExecCtx; +#[cfg(feature = "vector")] +use bytes::Bytes; +#[cfg(feature = "vector")] +use ember_core::{ShardRequest, ShardResponse}; + +#[cfg(feature = "vector")] +pub(in crate::connection) async fn vadd( + key: String, + element: String, + vector: Vec, + metric: ember_core::VectorMetric, + quantization: ember_core::VectorQuantization, + connectivity: Option, + expansion_add: Option, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::VAdd { + key, + element, + vector, + metric, + quantization, + connectivity, + expansion_add, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::VAddResult { added, .. }) => Frame::Integer(if added { 1 } else { 0 }), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(ShardResponse::OutOfMemory) => super::oom_error(), + Ok(ShardResponse::Err(msg)) => Frame::Error(format!("ERR {msg}")), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +#[cfg(feature = "vector")] +pub(in crate::connection) async fn vaddbatch( + key: String, + entries: Vec<(String, Vec)>, + dim: usize, + metric: ember_core::VectorMetric, + quantization: ember_core::VectorQuantization, + connectivity: Option, + expansion_add: Option, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::VAddBatch { + key, + entries, + dim, + metric, + quantization, + connectivity, + expansion_add, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::VAddBatchResult { added_count, .. }) => { + Frame::Integer(added_count as i64) + } + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(ShardResponse::OutOfMemory) => super::oom_error(), + Ok(ShardResponse::Err(msg)) => Frame::Error(format!("ERR {msg}")), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +#[cfg(feature = "vector")] +pub(in crate::connection) async fn vsim( + key: String, + query: ember_core::VectorQuery, + count: usize, + ef_search: Option, + with_scores: bool, + cx: &ExecCtx<'_>, +) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::VSim { + key, + query, + count, + ef_search, + }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::VSimResult(results)) => { + let mut frames = Vec::new(); + for (element, distance) in results { + frames.push(Frame::Bulk(Bytes::from(element))); + if with_scores { + frames.push(Frame::Bulk(Bytes::from(distance.to_string()))); + } + } + Frame::Array(frames) + } + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +#[cfg(feature = "vector")] +pub(in crate::connection) async fn vrem(key: String, element: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::VRem { key, element }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Bool(removed)) => Frame::Integer(if removed { 1 } else { 0 }), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +#[cfg(feature = "vector")] +pub(in crate::connection) async fn vget(key: String, element: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::VGet { key, element }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::VectorData(Some(vector))) => Frame::Array( + vector + .into_iter() + .map(|v| Frame::Bulk(Bytes::from(v.to_string()))) + .collect(), + ), + Ok(ShardResponse::VectorData(None)) => Frame::Null, + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +#[cfg(feature = "vector")] +pub(in crate::connection) async fn vcard(key: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::VCard { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Integer(count)) => Frame::Integer(count), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +#[cfg(feature = "vector")] +pub(in crate::connection) async fn vdim(key: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::VDim { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::Integer(dim)) => Frame::Integer(dim), + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +#[cfg(feature = "vector")] +pub(in crate::connection) async fn vinfo(key: String, cx: &ExecCtx<'_>) -> Frame { + let idx = cx.engine.shard_for_key(&key); + let req = ShardRequest::VInfo { key }; + match cx.engine.send_to_shard(idx, req).await { + Ok(ShardResponse::VectorInfo(Some(fields))) => { + let mut frames = Vec::with_capacity(fields.len() * 2); + for (k, v) in fields { + frames.push(Frame::Bulk(Bytes::from(k))); + frames.push(Frame::Bulk(Bytes::from(v))); + } + Frame::Array(frames) + } + Ok(ShardResponse::VectorInfo(None)) => Frame::Null, + Ok(ShardResponse::WrongType) => super::wrongtype_error(), + Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), + Err(e) => Frame::Error(format!("ERR {e}")), + } +} + +/// Returns an error when vector support is not compiled in. +#[cfg(not(feature = "vector"))] +pub(in crate::connection) fn not_compiled() -> Frame { + Frame::Error("ERR unknown command (vector support not compiled)".into()) +} diff --git a/crates/ember-server/src/connection/execute.rs b/crates/ember-server/src/connection/execute.rs index 596ddda9..ee7fff3d 100644 --- a/crates/ember-server/src/connection/execute.rs +++ b/crates/ember-server/src/connection/execute.rs @@ -1,77 +1,16 @@ //! Command execution — routes parsed commands to engine shards. -use std::io; -use std::sync::atomic::Ordering; use std::sync::Arc; -use std::time::Duration; -use crate::connection_common::{get_rss_bytes, human_bytes}; +use bytes::Bytes; +use ember_core::Engine; +use ember_protocol::{Command, Frame}; + use crate::pubsub::PubSubManager; use crate::server::{format_client_list, ServerContext}; use crate::slowlog::SlowLog; -use bytes::{Bytes, BytesMut}; -use ember_core::{Engine, KeyspaceStats, ShardRequest, ShardResponse, TtlResult, Value}; -use ember_protocol::{command::BitOpKind, parse_frame, Command, Frame, SetExpire}; -use subtle::ConstantTimeEq; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; - -/// Converts a [`SetExpire`] option to a [`Duration`] relative to now. -/// -/// EX/PX are relative; EXAT/PXAT are unix timestamps that are converted to -/// a duration by subtracting the current wall time. A past timestamp results -/// in a zero duration (the key expires immediately). -fn set_expire_to_duration(expire: SetExpire) -> Duration { - use std::time::{SystemTime, UNIX_EPOCH}; - match expire { - SetExpire::Ex(secs) => Duration::from_secs(secs), - SetExpire::Px(ms) => Duration::from_millis(ms), - SetExpire::ExAt(ts) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_else(|_| { - tracing::warn!( - "system clock is before UNIX epoch; EXAT TTL calculations may be incorrect" - ); - Duration::ZERO - }) - .as_secs(); - Duration::from_secs(ts.saturating_sub(now)) - } - SetExpire::PxAt(ts_ms) => { - let now_ms = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_else(|_| { - tracing::warn!( - "system clock is before UNIX epoch; PXAT TTL calculations may be incorrect" - ); - Duration::ZERO - }) - .as_millis() as u64; - Duration::from_millis(ts_ms.saturating_sub(now_ms)) - } - } -} -/// Emits keyspace/keyevent notifications for a successful write command. -/// -/// No-op when `notify-keyspace-events` is `""` / zero (the common case). -/// The `flags == 0` check is a single atomic load — true zero overhead -/// when notifications are disabled. -#[inline] -fn notify_write( - ctx: &Arc, - pubsub: &Arc, - event_flag: u32, - event: &str, - key: &str, -) { - let flags = ctx - .keyspace_event_flags - .load(std::sync::atomic::Ordering::Relaxed); - if flags != 0 { - crate::keyspace_notifications::notify_keyspace_event(flags, event_flag, event, key, pubsub); - } -} +use super::exec; /// Executes a parsed command and returns the response frame. /// @@ -114,6 +53,14 @@ pub(super) async fn execute( return redirect; } + let cx = exec::ExecCtx { + engine, + ctx, + pubsub, + slow_log, + client_id, + }; + match cmd { // -- no shard needed -- Command::Ping(None) => Frame::Simple("PONG".into()), @@ -147,1420 +94,178 @@ pub(super) async fn execute( Frame::Bulk(Bytes::from(output)) } - // -- single-key commands -- - Command::Get { key } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::Get { key }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Value(Some(Value::String(data)))) => Frame::Bulk(data), - Ok(ShardResponse::Value(None)) => Frame::Null, - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - + // -- string commands -- + Command::Get { key } => exec::strings::get(key, &cx).await, Command::Set { key, value, expire, nx, xx, - } => { - let duration = expire.map(set_expire_to_duration); - let idx = engine.shard_for_key(&key); - let req = ShardRequest::Set { - key: key.clone(), - value, - expire: duration, - nx, - xx, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Ok) => { - notify_write( - ctx, - pubsub, - crate::keyspace_notifications::FLAG_DOLLAR, - "set", - &key, - ); - Frame::Simple("OK".into()) - } - Ok(ShardResponse::Value(None)) => Frame::Null, - Ok(ShardResponse::OutOfMemory) => oom_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::Expire { key, seconds } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::Expire { - key: key.clone(), - seconds, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Bool(true)) => { - notify_write( - ctx, - pubsub, - crate::keyspace_notifications::FLAG_G, - "expire", - &key, - ); - Frame::Integer(1) - } - Ok(ShardResponse::Bool(false)) => Frame::Integer(0), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::Expireat { key, timestamp } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::Expireat { - key: key.clone(), - timestamp, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Bool(true)) => { - notify_write( - ctx, - pubsub, - crate::keyspace_notifications::FLAG_G, - "expireat", - &key, - ); - Frame::Integer(1) - } - Ok(ShardResponse::Bool(false)) => Frame::Integer(0), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::Pexpireat { key, timestamp_ms } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::Pexpireat { - key: key.clone(), - timestamp_ms, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Bool(true)) => { - notify_write( - ctx, - pubsub, - crate::keyspace_notifications::FLAG_G, - "pexpireat", - &key, - ); - Frame::Integer(1) - } - Ok(ShardResponse::Bool(false)) => Frame::Integer(0), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::Ttl { key } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::Ttl { key }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Ttl(TtlResult::Seconds(s))) => Frame::Integer(s as i64), - Ok(ShardResponse::Ttl(TtlResult::NoExpiry)) => Frame::Integer(-1), - Ok(ShardResponse::Ttl(TtlResult::NotFound)) => Frame::Integer(-2), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::Incr { key } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::Incr { key }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Integer(n)) => Frame::Integer(n), - 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::Decr { key } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::Decr { key }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Integer(n)) => Frame::Integer(n), - 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::IncrBy { key, delta } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::IncrBy { key, delta }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Integer(n)) => Frame::Integer(n), - 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::DecrBy { key, delta } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::DecrBy { key, delta }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Integer(n)) => Frame::Integer(n), - 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::Append { key, value } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::Append { key, value }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Len(n)) => Frame::Integer(n as i64), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(ShardResponse::OutOfMemory) => oom_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::Strlen { key } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::Strlen { key }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Len(n)) => Frame::Integer(n as i64), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - + } => exec::strings::set(key, value, expire, nx, xx, &cx).await, + Command::Incr { key } => exec::strings::incr(key, &cx).await, + Command::Decr { key } => exec::strings::decr(key, &cx).await, + Command::IncrBy { key, delta } => exec::strings::incrby(key, delta, &cx).await, + Command::DecrBy { key, delta } => exec::strings::decrby(key, delta, &cx).await, + Command::IncrByFloat { key, delta } => exec::strings::incrbyfloat(key, delta, &cx).await, + Command::Append { key, value } => exec::strings::append(key, value, &cx).await, + Command::Strlen { key } => exec::strings::strlen(key, &cx).await, Command::GetRange { key, start, end } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::GetRange { key, start, end }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Value(Some(Value::String(data)))) => Frame::Bulk(data), - Ok(ShardResponse::Value(None)) => Frame::Null, - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } + exec::strings::getrange(key, start, end, &cx).await } - Command::SetRange { key, offset, value } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::SetRange { key, offset, value }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Len(n)) => Frame::Integer(n as i64), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(ShardResponse::OutOfMemory) => oom_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::GetBit { key, offset } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::GetBit { key, offset }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Integer(bit)) => Frame::Integer(bit), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } + exec::strings::setrange(key, offset, value, &cx).await } - + Command::GetBit { key, offset } => exec::strings::getbit(key, offset, &cx).await, Command::SetBit { key, offset, value } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::SetBit { key, offset, value }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Integer(old_bit)) => Frame::Integer(old_bit), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(ShardResponse::OutOfMemory) => oom_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::BitCount { key, range } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::BitCount { key, range }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Integer(n)) => Frame::Integer(n), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::BitPos { key, bit, range } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::BitPos { key, bit, range }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Integer(pos)) => Frame::Integer(pos), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::BitOp { op, dest, keys } => { - // Read source keys from their respective shards, then compute the - // bitwise result here and write it to dest's shard. This is - // necessary because source keys may live on different shards. - let responses = match engine - .route_multi(&keys, |k| ShardRequest::Get { key: k }) - .await - { - Ok(r) => r, - Err(e) => return Frame::Error(format!("ERR {e}")), - }; - - let mut sources: Vec = Vec::with_capacity(responses.len()); - for r in responses { - match r { - ShardResponse::Value(Some(Value::String(b))) => sources.push(b), - ShardResponse::Value(None) => sources.push(Bytes::new()), - ShardResponse::WrongType => return wrongtype_error(), - _ => sources.push(Bytes::new()), - } - } - - let result_len = sources.iter().map(|s| s.len()).max().unwrap_or(0); - let mut result = vec![0u8; result_len]; - match op { - BitOpKind::Not => { - let src = sources.first().map(|b| b.as_ref()).unwrap_or(&[]); - for (i, b) in result.iter_mut().enumerate() { - *b = if i < src.len() { !src[i] } else { 0xFF }; - } - } - BitOpKind::And => { - if let Some(first) = sources.first() { - for (i, b) in result.iter_mut().enumerate() { - *b = if i < first.len() { first[i] } else { 0 }; - } - } - for src in sources.iter().skip(1) { - for (i, b) in result.iter_mut().enumerate() { - *b &= if i < src.len() { src[i] } else { 0 }; - } - } - } - BitOpKind::Or => { - for src in &sources { - for (i, b) in result.iter_mut().enumerate() { - if i < src.len() { - *b |= src[i]; - } - } - } - } - BitOpKind::Xor => { - for src in &sources { - for (i, b) in result.iter_mut().enumerate() { - if i < src.len() { - *b ^= src[i]; - } - } - } - } - } - - let dest_idx = engine.shard_for_key(&dest); - let req = ShardRequest::Set { - key: dest, - value: Bytes::from(result), - expire: None, - nx: false, - xx: false, - }; - match engine.send_to_shard(dest_idx, req).await { - Ok(ShardResponse::Ok) | Ok(ShardResponse::Value(_)) => { - Frame::Integer(result_len as i64) - } - Ok(ShardResponse::OutOfMemory) => oom_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::IncrByFloat { key, delta } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::IncrByFloat { key, 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::Persist { key } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::Persist { key }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Bool(b)) => Frame::Integer(i64::from(b)), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::Pttl { key } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::Pttl { key }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Ttl(TtlResult::Milliseconds(ms))) => Frame::Integer(ms as i64), - Ok(ShardResponse::Ttl(TtlResult::NoExpiry)) => Frame::Integer(-1), - Ok(ShardResponse::Ttl(TtlResult::NotFound)) => Frame::Integer(-2), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - + exec::strings::setbit(key, offset, value, &cx).await + } + Command::BitCount { key, range } => exec::strings::bitcount(key, range, &cx).await, + Command::BitPos { key, bit, range } => exec::strings::bitpos(key, bit, range, &cx).await, + Command::BitOp { op, dest, keys } => exec::strings::bitop(op, dest, keys, &cx).await, + Command::GetDel { key } => exec::strings::getdel(key, &cx).await, + Command::GetEx { key, expire } => exec::strings::getex(key, expire, &cx).await, + Command::GetSet { key, value } => exec::strings::getset(key, value, &cx).await, + Command::MGet { keys } => exec::strings::mget(keys, &cx).await, + Command::MSet { pairs } => exec::strings::mset(pairs, &cx).await, + Command::MSetNx { pairs } => exec::strings::msetnx(pairs, &cx).await, + + // -- keyspace commands -- + Command::Del { keys } => exec::keyspace::del(keys, &cx).await, + Command::Unlink { keys } => exec::keyspace::unlink(keys, &cx).await, + Command::Exists { keys } => exec::keyspace::exists(keys, &cx).await, + Command::Touch { keys } => exec::keyspace::touch(keys, &cx).await, + Command::Expire { key, seconds } => exec::keyspace::expire(key, seconds, &cx).await, + Command::Expireat { key, timestamp } => exec::keyspace::expireat(key, timestamp, &cx).await, Command::Pexpire { key, milliseconds } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::Pexpire { key, milliseconds }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Bool(b)) => Frame::Integer(i64::from(b)), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::Expiretime { key } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::Expiretime { key }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Integer(n)) => Frame::Integer(n), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::Pexpiretime { key } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::Pexpiretime { key }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Integer(n)) => Frame::Integer(n), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - // -- multi-key fan-out -- - Command::Del { keys } => { - multi_key_bool(engine, &keys, |k| ShardRequest::Del { key: k }).await - } - - Command::Unlink { keys } => { - multi_key_bool(engine, &keys, |k| ShardRequest::Unlink { key: k }).await - } - - Command::Exists { keys } => { - multi_key_bool(engine, &keys, |k| ShardRequest::Exists { key: k }).await - } - - Command::Touch { keys } => { - multi_key_bool(engine, &keys, |k| ShardRequest::Touch { key: k }).await - } - - Command::MGet { keys } => { - match engine - .route_multi(&keys, |k| ShardRequest::Get { key: k }) - .await - { - Ok(responses) => { - let frames: Vec = responses - .into_iter() - .map(|r| match r { - ShardResponse::Value(Some(Value::String(data))) => Frame::Bulk(data), - ShardResponse::Value(None) => Frame::Null, - // MGET on wrong type returns null per Redis behavior - ShardResponse::WrongType => Frame::Null, - _ => Frame::Null, - }) - .collect(); - Frame::Array(frames) - } - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::MSet { pairs } => { - // Fan out individual SET requests — MSET always succeeds (or OOMs). - // We build a HashMap for O(1) value lookups during routing. If there - // are duplicate keys in pairs, the HashMap keeps the last value, which - // matches Redis semantics (last write wins). - let keys: Vec = pairs.iter().map(|(k, _)| k.clone()).collect(); - let values: std::collections::HashMap = pairs.into_iter().collect(); - - match engine - .route_multi(&keys, |k| { - // Safe: k comes from keys, which came from pairs, so it exists in values. - let value = values.get(&k).cloned().unwrap_or_default(); - ShardRequest::Set { - key: k, - value, - expire: None, - nx: false, - xx: false, - } - }) - .await - { - Ok(responses) => { - // check if any SET failed due to OOM - for r in &responses { - if matches!(r, ShardResponse::OutOfMemory) { - return oom_error(); - } - } - Frame::Simple("OK".into()) - } - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::MSetNx { pairs } => { - // MSETNX is all-or-nothing: set all keys only if none exist. - // - // We implement this with two fan-out passes: - // 1. Check existence of every key across all shards. - // 2. If all are absent, write all pairs. - // - // This is not atomic across shards (no distributed transaction), - // but matches Redis cluster semantics where MSETNX pairs must - // share a hash slot. For single-node mode it is correct. - if pairs.is_empty() { - return Frame::Error("ERR wrong number of arguments for 'MSETNX'".into()); - } - - let keys: Vec = pairs.iter().map(|(k, _)| k.clone()).collect(); - - // phase 1: check existence - let exists_responses = match engine - .route_multi(&keys, |k| ShardRequest::Exists { key: k }) - .await - { - Ok(r) => r, - Err(e) => return Frame::Error(format!("ERR {e}")), - }; - - let any_exists = exists_responses - .iter() - .any(|r| matches!(r, ShardResponse::Bool(true))); - if any_exists { - return Frame::Integer(0); - } - - // phase 2: write all pairs - let values: std::collections::HashMap = pairs.into_iter().collect(); - match engine - .route_multi(&keys, |k| { - let value = values.get(&k).cloned().unwrap_or_default(); - ShardRequest::Set { - key: k, - value, - expire: None, - nx: false, - xx: false, - } - }) - .await - { - Ok(responses) => { - for r in &responses { - if matches!(r, ShardResponse::OutOfMemory) { - return oom_error(); - } - } - Frame::Integer(1) - } - Err(e) => Frame::Error(format!("ERR {e}")), - } + exec::keyspace::pexpire(key, milliseconds, &cx).await } - - Command::GetSet { key, value } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::GetSet { key, value }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Value(Some(Value::String(data)))) => Frame::Bulk(data), - Ok(ShardResponse::Value(None)) => Frame::Null, - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::RandomKey => match engine.broadcast(|| ShardRequest::RandomKey).await { - Ok(responses) => { - let keys: Vec = responses - .into_iter() - .flat_map(|r| match r { - ShardResponse::StringArray(v) => v, - _ => vec![], - }) - .collect(); - if keys.is_empty() { - Frame::Null - } else { - use rand::seq::IndexedRandom; - let mut rng = rand::rng(); - match keys.choose(&mut rng) { - Some(k) => Frame::Bulk(Bytes::from(k.to_owned())), - None => Frame::Null, - } - } - } - Err(e) => Frame::Error(format!("ERR {e}")), - }, - + Command::Pexpireat { key, timestamp_ms } => { + exec::keyspace::pexpireat(key, timestamp_ms, &cx).await + } + Command::Ttl { key } => exec::keyspace::ttl(key, &cx).await, + Command::Pttl { key } => exec::keyspace::pttl(key, &cx).await, + Command::Persist { key } => exec::keyspace::persist(key, &cx).await, + Command::Expiretime { key } => exec::keyspace::expiretime(key, &cx).await, + Command::Pexpiretime { key } => exec::keyspace::pexpiretime(key, &cx).await, + Command::Keys { pattern } => exec::keyspace::keys(pattern, &cx).await, + Command::Scan { + cursor, + pattern, + count, + } => exec::keyspace::scan(cursor, pattern, count, &cx).await, + Command::Rename { key, newkey } => exec::keyspace::rename(key, newkey, &cx).await, + Command::Copy { + source, + destination, + replace, + } => exec::keyspace::copy(source, destination, replace, &cx).await, + Command::RandomKey => exec::keyspace::randomkey(&cx).await, Command::Sort { key, desc, alpha, limit, store: Some(dest), - } => { - // phase 1: sort on the source shard - let src_idx = engine.shard_for_key(&key); - let sort_req = ShardRequest::Sort { - key, - desc, - alpha, - limit, - }; - match engine.send_to_shard(src_idx, sort_req).await { - Ok(ShardResponse::Array(items)) => { - let count = items.len() as i64; - // phase 2: delete dest + rpush sorted items - let dst_idx = engine.shard_for_key(&dest); - let del_req = ShardRequest::Del { key: dest.clone() }; - let _ = engine.send_to_shard(dst_idx, del_req).await; - if !items.is_empty() { - let rpush_req = ShardRequest::RPush { - key: dest, - values: items, - }; - let _ = engine.send_to_shard(dst_idx, rpush_req).await; - } - Frame::Integer(count) - } - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - // -- broadcast commands -- - Command::DbSize => match engine.broadcast(|| ShardRequest::DbSize).await { - Ok(responses) => { - let total: usize = responses - .iter() - .map(|r| match r { - ShardResponse::KeyCount(n) => *n, - _ => 0, - }) - .sum(); - Frame::Integer(total as i64) - } - Err(e) => Frame::Error(format!("ERR {e}")), - }, - - Command::Info { section } => render_info(engine, ctx, section.as_deref()).await, - - Command::ConfigGet { pattern } => { - let pairs = ctx.config.get_matching(&pattern); - let mut frames = Vec::with_capacity(pairs.len() * 2); - for (key, value) in pairs { - frames.push(Frame::Bulk(Bytes::from(key))); - frames.push(Frame::Bulk(Bytes::from(value))); - } - Frame::Array(frames) - } - - Command::ConfigSet { param, value } => { - if let Err(e) = ctx.config.set(¶m, &value) { - Frame::Error(e) - } else { - // apply dynamic updates for known parameters - let key = param.to_ascii_lowercase(); - if key == "slowlog-log-slower-than" { - if let Ok(us) = value.parse::() { - slow_log.update_threshold(us); - } - } else if key == "slowlog-max-len" { - if let Ok(len) = value.parse::() { - slow_log.update_max_len(len); - } - } else if key == "maxmemory" || key == "maxmemory-policy" { - let limit = ctx.config.memory_limit(); - let policy = ctx.config.eviction_policy(); - // broadcast is fallible but config is already stored — log and continue - let _ = engine - .broadcast(move || ShardRequest::UpdateMemoryConfig { - max_memory: limit, - eviction_policy: policy, - }) - .await; - // keep the INFO-visible limit in sync - ctx.max_memory_limit.store( - limit.unwrap_or(0) as u64, - std::sync::atomic::Ordering::Relaxed, - ); - } else if key == "notify-keyspace-events" { - let flags = crate::keyspace_notifications::parse_keyspace_event_flags(&value); - ctx.keyspace_event_flags - .store(flags, std::sync::atomic::Ordering::Relaxed); - } - Frame::Simple("OK".into()) - } - } - - Command::ConfigRewrite => match &ctx.config_path { - Some(path) => match ctx.config.rewrite(path) { - Ok(()) => Frame::Simple("OK".into()), - Err(e) => Frame::Error(e), - }, - None => Frame::Error("ERR The server is running without a config file".into()), - }, - - Command::BgSave => match engine.broadcast(|| ShardRequest::Snapshot).await { - Ok(_) => { - use std::time::{SystemTime, UNIX_EPOCH}; - let ts = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - ctx.last_save_timestamp - .store(ts, std::sync::atomic::Ordering::Relaxed); - Frame::Simple("Background saving started".into()) - } - Err(e) => Frame::Error(format!("ERR {e}")), - }, - - Command::BgRewriteAof => match engine.broadcast(|| ShardRequest::RewriteAof).await { - Ok(_) => Frame::Simple("Background append only file rewriting started".into()), - Err(e) => Frame::Error(format!("ERR {e}")), - }, - - Command::Time => { - use std::time::{SystemTime, UNIX_EPOCH}; - let dur = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default(); - Frame::Array(vec![ - Frame::Bulk(Bytes::from(dur.as_secs().to_string())), - Frame::Bulk(Bytes::from(dur.subsec_micros().to_string())), - ]) - } - - Command::LastSave => { - let ts = ctx - .last_save_timestamp - .load(std::sync::atomic::Ordering::Relaxed); - Frame::Integer(ts as i64) - } - - Command::Role => { - if let Some(ref cluster) = ctx.cluster { - use ember_cluster::NodeRole; - let info = cluster.replication_info().await; - match info.role { - NodeRole::Primary => Frame::Array(vec![ - Frame::Bulk(Bytes::from("master")), - Frame::Integer(0), - Frame::Array(vec![]), - ]), - NodeRole::Replica => { - let (host, port) = match info.primary_addr { - Some(addr) => (addr.ip().to_string(), addr.port() as i64), - None => (String::new(), 0), - }; - Frame::Array(vec![ - Frame::Bulk(Bytes::from("slave")), - Frame::Bulk(Bytes::from(host)), - Frame::Integer(port), - Frame::Bulk(Bytes::from("connected")), - Frame::Integer(0), - ]) - } - } - } else { - Frame::Array(vec![ - Frame::Bulk(Bytes::from("master")), - Frame::Integer(0), - Frame::Array(vec![]), - ]) - } - } - + } => exec::keyspace::sort_with_store(key, desc, alpha, limit, dest, &cx).await, + Command::Sort { + key, + desc, + alpha, + limit, + store: None, + } => exec::keyspace::sort_no_store(key, desc, alpha, limit, &cx).await, + Command::Type { key } => exec::keyspace::type_cmd(key, &cx).await, + Command::ObjectEncoding { key } => exec::keyspace::object_encoding(key, &cx).await, + Command::ObjectRefcount { key } => exec::keyspace::object_refcount(key, &cx).await, + Command::MemoryUsage { key } => exec::keyspace::memory_usage(key, &cx).await, + + // -- server commands -- + Command::DbSize => exec::server::dbsize(&cx).await, + Command::Info { section } => exec::server::info(&cx, section.as_deref()).await, + Command::ConfigGet { pattern } => exec::server::config_get(pattern, &cx).await, + Command::ConfigSet { param, value } => exec::server::config_set(param, value, &cx).await, + Command::ConfigRewrite => exec::server::config_rewrite(&cx).await, + Command::BgSave => exec::server::bgsave(&cx).await, + Command::BgRewriteAof => exec::server::bgrewriteaof(&cx).await, + Command::Time => exec::server::time(), + Command::LastSave => exec::server::lastsave(&cx), + Command::Role => exec::server::role(&cx).await, Command::Wait { numreplicas, timeout_ms, - } => handle_wait(ctx, numreplicas, timeout_ms).await, - - Command::FlushDb { async_mode } => { - let req = if async_mode { - || ShardRequest::FlushDbAsync - } else { - || ShardRequest::FlushDb - }; - match engine.broadcast(req).await { - Ok(_) => Frame::Simple("OK".into()), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - // Ember is single-database, so FLUSHALL is identical to FLUSHDB. - Command::FlushAll { async_mode } => { - let req = if async_mode { - || ShardRequest::FlushDbAsync - } else { - || ShardRequest::FlushDb - }; - match engine.broadcast(req).await { - Ok(_) => Frame::Simple("OK".into()), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::MemoryUsage { key } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::MemoryUsage { key: key.clone() }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Integer(-1)) => Frame::Null, - Ok(ShardResponse::Integer(n)) => Frame::Integer(n), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::Keys { pattern } => { - match engine - .broadcast(|| ShardRequest::Keys { - pattern: pattern.clone(), - }) - .await - { - Ok(responses) => { - let mut all_keys = Vec::new(); - for r in responses { - if let ShardResponse::StringArray(keys) = r { - all_keys.extend(keys); - } - } - Frame::Array( - all_keys - .into_iter() - .map(|k| Frame::Bulk(Bytes::from(k))) - .collect(), - ) - } - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::Rename { key, newkey } => { - if !engine.same_shard(&key, &newkey) { - Frame::Error("ERR source and destination keys must hash to the same shard".into()) - } else { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::Rename { key, newkey }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Ok) => Frame::Simple("OK".into()), - Ok(ShardResponse::Err(msg)) => Frame::Error(msg), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - } + } => exec::server::handle_wait(ctx, numreplicas, timeout_ms).await, + Command::FlushDb { async_mode } => exec::server::flushdb(async_mode, &cx).await, + Command::FlushAll { async_mode } => exec::server::flushall(async_mode, &cx).await, + Command::SlowLogGet { count } => exec::server::slowlog_get(count, &cx), + Command::SlowLogLen => exec::server::slowlog_len(&cx), + Command::SlowLogReset => exec::server::slowlog_reset(&cx), - Command::Copy { + // -- list commands -- + Command::LPush { key, values } => exec::lists::lpush(key, values, &cx).await, + Command::RPush { key, values } => exec::lists::rpush(key, values, &cx).await, + Command::LPop { key, count } => exec::lists::lpop(key, count, &cx).await, + Command::RPop { key, count } => exec::lists::rpop(key, count, &cx).await, + Command::LRange { key, start, stop } => exec::lists::lrange(key, start, stop, &cx).await, + Command::LLen { key } => exec::lists::llen(key, &cx).await, + Command::LIndex { key, index } => exec::lists::lindex(key, index, &cx).await, + Command::LSet { key, index, value } => exec::lists::lset(key, index, value, &cx).await, + Command::LTrim { key, start, stop } => exec::lists::ltrim(key, start, stop, &cx).await, + Command::LInsert { + key, + before, + pivot, + value, + } => exec::lists::linsert(key, before, pivot, value, &cx).await, + Command::LRem { key, count, value } => exec::lists::lrem(key, count, value, &cx).await, + Command::LPos { + key, + element, + rank, + count, + maxlen, + } => exec::lists::lpos(key, element, rank, count, maxlen, &cx).await, + Command::LMove { source, destination, - replace, - } => { - if !engine.same_shard(&source, &destination) { - Frame::Error("ERR source and destination keys must hash to the same shard".into()) - } else { - let idx = engine.shard_for_key(&source); - let req = ShardRequest::Copy { - source, - destination, - replace, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Bool(b)) => Frame::Integer(i64::from(b)), - Ok(ShardResponse::Err(msg)) => Frame::Error(msg), - Ok(ShardResponse::OutOfMemory) => oom_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - } - - Command::ObjectEncoding { key } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::ObjectEncoding { key }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::EncodingName(Some(name))) => Frame::Bulk(Bytes::from(name)), - Ok(ShardResponse::EncodingName(None)) => Frame::Null, - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::ObjectRefcount { key } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::Exists { key }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Bool(true)) => Frame::Integer(1), - Ok(ShardResponse::Bool(false)) => Frame::Null, - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::Scan { - cursor, - pattern, - count, - } => { - // cursor encoding: (shard_id << 48) | position_within_shard - // - // this gives us 16 bits for shard_id (up to 65536 shards) and 48 bits - // for position within each shard. cursor 0 always means "start fresh". - // - // the cursor is opaque to clients — they just pass back whatever we - // returned last time. this lets us iterate across the sharded keyspace - // without clients needing to know the topology. - let shard_count = engine.shard_count(); - let count = count.unwrap_or(10); - - let (shard_id, position) = if cursor == 0 { - (0usize, 0u64) - } else { - let shard_id = (cursor >> 48) as usize; - let position = cursor & 0xFFFF_FFFF_FFFF; - - // guard against invalid cursor (shard_id out of range) - if shard_id >= shard_count { - return Frame::Array(vec![Frame::Bulk(Bytes::from("0")), Frame::Array(vec![])]); - } - - (shard_id, position) - }; - - // collect keys from current shard and possibly subsequent shards - let mut all_keys = Vec::new(); - let mut current_shard = shard_id; - let mut current_pos = position; - - while all_keys.len() < count && current_shard < shard_count { - let req = ShardRequest::Scan { - cursor: current_pos, - count: count.saturating_sub(all_keys.len()), - pattern: pattern.clone(), - }; - match engine.send_to_shard(current_shard, req).await { - Ok(ShardResponse::Scan { - cursor: next_pos, - keys, - }) => { - all_keys.extend(keys); - if next_pos == 0 { - // shard exhausted, move to next - current_shard += 1; - current_pos = 0; - } else { - current_pos = next_pos; - break; // have more in this shard, stop here - } - } - Ok(other) => { - return Frame::Error(format!("ERR unexpected shard response: {other:?}")); - } - Err(e) => { - return Frame::Error(format!("ERR {e}")); - } - } - } - - // compute next cursor - let next_cursor = if current_shard >= shard_count { - 0 // scan complete - } else { - ((current_shard as u64) << 48) | current_pos - }; - - // return [cursor, [keys...]] - let cursor_str = next_cursor.to_string(); - let keys_frames: Vec = all_keys - .into_iter() - .map(|k| Frame::Bulk(Bytes::from(k))) - .collect(); - Frame::Array(vec![ - Frame::Bulk(Bytes::from(cursor_str)), - Frame::Array(keys_frames), - ]) - } - - // -- list commands -- - Command::LPush { key, values } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::LPush { - key: key.clone(), - values, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Len(n)) => { - notify_write( - ctx, - pubsub, - crate::keyspace_notifications::FLAG_L, - "lpush", - &key, - ); - Frame::Integer(n as i64) - } - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(ShardResponse::OutOfMemory) => oom_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::RPush { key, values } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::RPush { - key: key.clone(), - values, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Len(n)) => { - notify_write( - ctx, - pubsub, - crate::keyspace_notifications::FLAG_L, - "rpush", - &key, - ); - Frame::Integer(n as i64) - } - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(ShardResponse::OutOfMemory) => oom_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::LPop { key, count: None } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::LPop { key }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Value(Some(Value::String(data)))) => Frame::Bulk(data), - Ok(ShardResponse::Value(None)) => Frame::Null, - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::LPop { - key, - count: Some(count), - } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::LPopCount { key, count }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Array(items)) => { - let frames = items.into_iter().map(Frame::Bulk).collect(); - Frame::Array(frames) - } - Ok(ShardResponse::Value(None)) => Frame::Null, - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::RPop { key, count: None } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::RPop { key }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Value(Some(Value::String(data)))) => Frame::Bulk(data), - Ok(ShardResponse::Value(None)) => Frame::Null, - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::RPop { - key, - count: Some(count), - } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::RPopCount { key, count }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Array(items)) => { - let frames = items.into_iter().map(Frame::Bulk).collect(); - Frame::Array(frames) - } - Ok(ShardResponse::Value(None)) => Frame::Null, - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::LRange { key, start, stop } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::LRange { key, start, stop }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Array(items)) => { - let frames = items.into_iter().map(Frame::Bulk).collect(); - Frame::Array(frames) - } - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::LLen { key } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::LLen { key }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Len(n)) => Frame::Integer(n 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::LIndex { key, index } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::LIndex { key, index }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Value(Some(Value::String(data)))) => Frame::Bulk(data), - Ok(ShardResponse::Value(None)) => Frame::Null, - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::LSet { key, index, value } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::LSet { key, index, value }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Ok) => Frame::Simple("OK".into()), - Ok(ShardResponse::WrongType) => wrongtype_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::LTrim { key, start, stop } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::LTrim { key, start, stop }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Ok) => Frame::Simple("OK".into()), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::LInsert { - key, - before, - pivot, - value, - } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::LInsert { - key, - before, - pivot, - value, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Integer(n)) => Frame::Integer(n), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(ShardResponse::OutOfMemory) => oom_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::LRem { key, count, value } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::LRem { key, count, value }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Len(n)) => Frame::Integer(n 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::LPos { - key, - element, - rank, - count, - maxlen, - } => { - let idx = engine.shard_for_key(&key); - let shard_count = count.unwrap_or(1); - let req = ShardRequest::LPos { - key, - element, - rank, - count: shard_count, - maxlen, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::IntegerArray(positions)) => { - if count.is_some() { - Frame::Array(positions.into_iter().map(Frame::Integer).collect()) - } else if let Some(&pos) = positions.first() { - Frame::Integer(pos) - } else { - Frame::Null - } - } - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - // blocking list ops are handled by handle_blocking_pop_cmd in the - // main loop; reaching here means they're inside a transaction. - Command::BLPop { .. } | Command::BRPop { .. } => { - Frame::Error("ERR blocking commands are not allowed inside transactions".into()) - } - - Command::Type { key } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::Type { key }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::TypeName(name)) => Frame::Simple(name.into()), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } + src_left, + dst_left, + } => exec::lists::lmove(source, destination, src_left, dst_left, &cx).await, + Command::Lmpop { keys, left, count } => exec::lists::lmpop(keys, left, count, &cx).await, + // blocking list ops are handled by handle_blocking_pop_cmd in the + // main loop; reaching here means they're inside a transaction. + Command::BLPop { .. } => exec::lists::blpop_in_tx(), + Command::BRPop { .. } => exec::lists::brpop_in_tx(), // -- sorted set commands -- Command::ZAdd { key, flags, members, - } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::ZAdd { - key: key.clone(), - members, - nx: flags.nx, - xx: flags.xx, - gt: flags.gt, - lt: flags.lt, - ch: flags.ch, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::ZAddLen { count, .. }) => { - if count > 0 { - notify_write( - ctx, - pubsub, - crate::keyspace_notifications::FLAG_Z, - "zadd", - &key, - ); - } - Frame::Integer(count as i64) - } - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(ShardResponse::OutOfMemory) => oom_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::ZRem { key, members } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::ZRem { key, members }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::ZRemLen { count, .. }) => 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::ZScore { key, member } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::ZScore { key, member }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Score(Some(s))) => Frame::Bulk(Bytes::from(format!("{s}"))), - Ok(ShardResponse::Score(None)) => Frame::Null, - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::ZRank { key, member } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::ZRank { key, member }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Rank(Some(r))) => Frame::Integer(r as i64), - Ok(ShardResponse::Rank(None)) => Frame::Null, - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - + } => exec::sorted_sets::zadd(key, flags, members, &cx).await, + Command::ZRem { key, members } => exec::sorted_sets::zrem(key, members, &cx).await, + Command::ZScore { key, member } => exec::sorted_sets::zscore(key, member, &cx).await, + Command::ZRank { key, member } => exec::sorted_sets::zrank(key, member, &cx).await, + Command::ZRevRank { key, member } => exec::sorted_sets::zrevrank(key, member, &cx).await, Command::ZRange { key, start, stop, with_scores, - } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::ZRange { - key, - start, - stop, - with_scores, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::ScoredArray(items)) => { - let mut frames = Vec::new(); - for (member, score) in items { - frames.push(Frame::Bulk(Bytes::from(member))); - if with_scores { - frames.push(Frame::Bulk(Bytes::from(format!("{score}")))); - } - } - Frame::Array(frames) - } - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::ZRevRank { key, member } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::ZRevRank { key, member }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Rank(Some(r))) => Frame::Integer(r as i64), - Ok(ShardResponse::Rank(None)) => Frame::Null, - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::ZCard { key } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::ZCard { key }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Len(n)) => Frame::Integer(n as i64), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - + } => exec::sorted_sets::zrange(key, start, stop, with_scores, &cx).await, Command::ZRevRange { key, start, stop, with_scores, - } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::ZRevRange { - key, - start, - stop, - with_scores, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::ScoredArray(items)) => { - let mut frames = Vec::new(); - for (member, score) in items { - frames.push(Frame::Bulk(Bytes::from(member))); - if with_scores { - frames.push(Frame::Bulk(Bytes::from(format!("{score}")))); - } - } - Frame::Array(frames) - } - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::ZCount { key, min, max } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::ZCount { key, min, max }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Len(n)) => Frame::Integer(n as i64), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - + } => exec::sorted_sets::zrevrange(key, start, stop, with_scores, &cx).await, + Command::ZCard { key } => exec::sorted_sets::zcard(key, &cx).await, + Command::ZCount { key, min, max } => exec::sorted_sets::zcount(key, min, max, &cx).await, Command::ZIncrBy { key, increment, member, - } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::ZIncrBy { - key, - increment, - member, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::ZIncrByResult { new_score, .. }) => { - Frame::Bulk(Bytes::from(format!("{new_score}"))) - } - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(ShardResponse::OutOfMemory) => oom_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - + } => exec::sorted_sets::zincrby(key, increment, member, &cx).await, Command::ZRangeByScore { key, min, @@ -1568,32 +273,7 @@ pub(super) async fn execute( with_scores, offset, count, - } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::ZRangeByScore { - key, - min, - max, - offset, - count, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::ScoredArray(items)) => { - let mut frames = Vec::new(); - for (member, score) in items { - frames.push(Frame::Bulk(Bytes::from(member))); - if with_scores { - frames.push(Frame::Bulk(Bytes::from(format!("{score}")))); - } - } - Frame::Array(frames) - } - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - + } => exec::sorted_sets::zrangebyscore(key, min, max, with_scores, offset, count, &cx).await, Command::ZRevRangeByScore { key, min, @@ -1602,1066 +282,135 @@ pub(super) async fn execute( offset, count, } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::ZRevRangeByScore { - key, - min, - max, - offset, - count, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::ScoredArray(items)) => { - let mut frames = Vec::new(); - for (member, score) in items { - frames.push(Frame::Bulk(Bytes::from(member))); - if with_scores { - frames.push(Frame::Bulk(Bytes::from(format!("{score}")))); - } - } - Frame::Array(frames) - } - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::ZPopMin { key, count } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::ZPopMin { key, count }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::ZPopResult(items)) => { - let mut frames = Vec::with_capacity(items.len() * 2); - for (member, score) in items { - frames.push(Frame::Bulk(Bytes::from(member))); - frames.push(Frame::Bulk(Bytes::from(format!("{score}")))); - } - Frame::Array(frames) - } - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::ZPopMax { key, count } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::ZPopMax { key, count }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::ZPopResult(items)) => { - let mut frames = Vec::with_capacity(items.len() * 2); - for (member, score) in items { - frames.push(Frame::Bulk(Bytes::from(member))); - frames.push(Frame::Bulk(Bytes::from(format!("{score}")))); - } - Frame::Array(frames) - } - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::Lmpop { keys, left, count } => { - for key in &keys { - let idx = engine.shard_for_key(key); - let req = ShardRequest::LmpopSingle { - key: key.clone(), - left, - count, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Array(items)) if !items.is_empty() => { - let elems = Frame::Array(items.into_iter().map(Frame::Bulk).collect()); - return Frame::Array(vec![Frame::Bulk(Bytes::from(key.clone())), elems]); - } - Ok(ShardResponse::Array(_)) | Ok(ShardResponse::Value(None)) => continue, - Ok(ShardResponse::WrongType) => return wrongtype_error(), - Ok(other) => { - return Frame::Error(format!("ERR unexpected shard response: {other:?}")) - } - Err(e) => return Frame::Error(format!("ERR {e}")), - } - } - Frame::Null + exec::sorted_sets::zrevrangebyscore(key, min, max, with_scores, offset, count, &cx) + .await } - + Command::ZPopMin { key, count } => exec::sorted_sets::zpopmin(key, count, &cx).await, + Command::ZPopMax { key, count } => exec::sorted_sets::zpopmax(key, count, &cx).await, Command::Zmpop { keys, min, count } => { - for key in &keys { - let idx = engine.shard_for_key(key); - let req = ShardRequest::ZmpopSingle { - key: key.clone(), - min, - count, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::ZPopResult(members)) if !members.is_empty() => { - let pairs: Vec = members - .into_iter() - .flat_map(|(m, s)| { - vec![ - Frame::Bulk(Bytes::from(m)), - Frame::Bulk(Bytes::from(format!("{s}"))), - ] - }) - .collect(); - return Frame::Array(vec![ - Frame::Bulk(Bytes::from(key.clone())), - Frame::Array(pairs), - ]); - } - Ok(ShardResponse::ZPopResult(_)) | Ok(ShardResponse::Value(None)) => continue, - Ok(ShardResponse::WrongType) => return wrongtype_error(), - Ok(other) => { - return Frame::Error(format!("ERR unexpected shard response: {other:?}")) - } - Err(e) => return Frame::Error(format!("ERR {e}")), - } - } - Frame::Null - } - - // --- hash commands --- - Command::HSet { key, fields } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::HSet { - key: key.clone(), - fields, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Len(n)) => { - notify_write( - ctx, - pubsub, - crate::keyspace_notifications::FLAG_H, - "hset", - &key, - ); - Frame::Integer(n as i64) - } - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(ShardResponse::OutOfMemory) => oom_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::HGet { key, field } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::HGet { key, field }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Value(Some(Value::String(data)))) => Frame::Bulk(data), - Ok(ShardResponse::Value(None)) => Frame::Null, - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::HGetAll { key } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::HGetAll { key }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::HashFields(fields)) => { - let mut frames = Vec::with_capacity(fields.len() * 2); - for (field, value) in fields { - frames.push(Frame::Bulk(Bytes::from(field))); - frames.push(Frame::Bulk(value)); - } - Frame::Array(frames) - } - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::HDel { key, fields } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::HDel { key, fields }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::HDelLen { count, .. }) => 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::HExists { key, field } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::HExists { key, field }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Bool(b)) => Frame::Integer(if b { 1 } else { 0 }), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } + exec::sorted_sets::zmpop(keys, min, count, &cx).await } - - Command::HLen { key } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::HLen { key }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Len(n)) => Frame::Integer(n 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::HIncrBy { key, field, delta } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::HIncrBy { key, field, delta }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Integer(n)) => Frame::Integer(n), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(ShardResponse::OutOfMemory) => oom_error(), - Ok(ShardResponse::Err(msg)) => Frame::Error(format!("ERR {msg}")), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - 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 }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::StringArray(keys)) => Frame::Array( - keys.into_iter() - .map(|k| Frame::Bulk(Bytes::from(k))) - .collect(), - ), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::HVals { key } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::HVals { key }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Array(vals)) => { - Frame::Array(vals.into_iter().map(Frame::Bulk).collect()) - } - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::HMGet { key, fields } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::HMGet { key, fields }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::OptionalArray(vals)) => Frame::Array( - vals.into_iter() - .map(|v| match v { - Some(data) => Frame::Bulk(data), - None => Frame::Null, - }) - .collect(), - ), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::HRandField { - key, - count, - with_values, - } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::HRandField { - key, - count, - with_values, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::HRandFieldResult(pairs)) => { - if count.is_none() { - // no count: return a single bulk string (or nil if empty) - match pairs.into_iter().next() { - Some((field, _)) => Frame::Bulk(Bytes::from(field)), - None => Frame::Null, - } - } else { - // with count: return array, interleaved with values if requested - let frames: Vec = pairs - .into_iter() - .flat_map(|(f, v)| { - let mut items = vec![Frame::Bulk(Bytes::from(f))]; - if let Some(val) = v { - items.push(Frame::Bulk(val)); - } - items - }) - .collect(); - Frame::Array(frames) - } - } - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - // --- set commands --- - Command::SAdd { key, members } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::SAdd { - key: key.clone(), - members, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Len(n)) => { - if n > 0 { - notify_write( - ctx, - pubsub, - crate::keyspace_notifications::FLAG_S, - "sadd", - &key, - ); - } - Frame::Integer(n as i64) - } - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(ShardResponse::OutOfMemory) => oom_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::SRem { key, members } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::SRem { key, members }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Len(n)) => Frame::Integer(n 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::SMembers { key } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::SMembers { key }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::StringArray(members)) => Frame::Array( - members - .into_iter() - .map(|m| Frame::Bulk(Bytes::from(m))) - .collect(), - ), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::SIsMember { key, member } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::SIsMember { key, member }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Bool(b)) => Frame::Integer(if b { 1 } else { 0 }), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::SCard { key } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::SCard { key }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Len(n)) => Frame::Integer(n 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::SUnion { keys } => { - let key = keys.first().cloned().unwrap_or_default(); - let idx = engine.shard_for_key(&key); - let req = ShardRequest::SUnion { keys }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::StringArray(members)) => Frame::Array( - members - .into_iter() - .map(|m| Frame::Bulk(Bytes::from(m))) - .collect(), - ), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::SInter { keys } => { - let key = keys.first().cloned().unwrap_or_default(); - let idx = engine.shard_for_key(&key); - let req = ShardRequest::SInter { keys }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::StringArray(members)) => Frame::Array( - members - .into_iter() - .map(|m| Frame::Bulk(Bytes::from(m))) - .collect(), - ), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::SDiff { keys } => { - let key = keys.first().cloned().unwrap_or_default(); - let idx = engine.shard_for_key(&key); - let req = ShardRequest::SDiff { keys }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::StringArray(members)) => Frame::Array( - members - .into_iter() - .map(|m| Frame::Bulk(Bytes::from(m))) - .collect(), - ), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::SUnionStore { dest, keys } => { - let idx = engine.shard_for_key(&dest); - let req = ShardRequest::SUnionStore { dest, keys }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::SetStoreResult { count, .. }) => Frame::Integer(count as i64), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(ShardResponse::OutOfMemory) => oom_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::SInterStore { dest, keys } => { - let idx = engine.shard_for_key(&dest); - let req = ShardRequest::SInterStore { dest, keys }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::SetStoreResult { count, .. }) => Frame::Integer(count as i64), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(ShardResponse::OutOfMemory) => oom_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::SDiffStore { dest, keys } => { - let idx = engine.shard_for_key(&dest); - let req = ShardRequest::SDiffStore { dest, keys }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::SetStoreResult { count, .. }) => Frame::Integer(count as i64), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(ShardResponse::OutOfMemory) => oom_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::SRandMember { key, count } => { - let count = count.unwrap_or(1); - let idx = engine.shard_for_key(&key); - let req = ShardRequest::SRandMember { key, count }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::StringArray(members)) => Frame::Array( - members - .into_iter() - .map(|m| Frame::Bulk(Bytes::from(m))) - .collect(), - ), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::SPop { key, count } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::SPop { key, count }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::StringArray(members)) => Frame::Array( - members - .into_iter() - .map(|m| Frame::Bulk(Bytes::from(m))) - .collect(), - ), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::SMisMember { key, members } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::SMisMember { key, members }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::BoolArray(arr)) => Frame::Array( - arr.into_iter() - .map(|b| Frame::Integer(i64::from(b))) - .collect(), - ), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::SMove { - source, - destination, - member, - } => { - let src_idx = engine.shard_for_key(&source); - let dst_idx = engine.shard_for_key(&destination); - - if src_idx == dst_idx { - // same shard — single atomic operation - let req = ShardRequest::SMove { - source, - destination, - member, - }; - match engine.send_to_shard(src_idx, req).await { - Ok(ShardResponse::Bool(moved)) => Frame::Integer(if moved { 1 } else { 0 }), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(ShardResponse::OutOfMemory) => oom_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } else { - // cross-shard: remove from source, then add to destination - let rem_req = ShardRequest::SRem { - key: source, - members: vec![member.clone()], - }; - let removed = match engine.send_to_shard(src_idx, rem_req).await { - Ok(ShardResponse::Len(n)) => n, - Ok(ShardResponse::WrongType) => return wrongtype_error(), - Ok(other) => { - return Frame::Error(format!("ERR unexpected shard response: {other:?}")) - } - Err(e) => return Frame::Error(format!("ERR {e}")), - }; - - if removed == 0 { - return Frame::Integer(0); - } - - let add_req = ShardRequest::SAdd { - key: destination, - members: vec![member], - }; - match engine.send_to_shard(dst_idx, add_req).await { - Ok(ShardResponse::Len(_)) => Frame::Integer(1), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(ShardResponse::OutOfMemory) => oom_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - } - - Command::SInterCard { keys, limit } => { - // Fetch members for each key from its owning shard, then intersect. - // This handles keys spread across shards without cross-shard calls - // inside the keyspace layer. - let mut sets: Vec> = Vec::with_capacity(keys.len()); - for key in &keys { - let idx = engine.shard_for_key(key); - let req = ShardRequest::SMembers { key: key.clone() }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::StringArray(members)) => { - // an empty set (including missing key) short-circuits to 0 - if members.is_empty() { - return Frame::Integer(0); - } - sets.push(members.into_iter().collect()); - } - Ok(ShardResponse::WrongType) => return wrongtype_error(), - Ok(other) => { - return Frame::Error(format!("ERR unexpected shard response: {other:?}")) - } - Err(e) => return Frame::Error(format!("ERR {e}")), - } - } - - if sets.is_empty() { - return Frame::Integer(0); - } - - // Start with the smallest set to minimise comparisons. - sets.sort_unstable_by_key(|s| s.len()); - let Some((first, rest)) = sets.split_first() else { - return Frame::Integer(0); - }; - let mut count = 0usize; - 'outer: for member in first { - for other in rest { - if !other.contains(member.as_str()) { - continue 'outer; - } - } - count += 1; - if limit > 0 && count >= limit { - break; - } - } - - Frame::Integer(count as i64) - } - - Command::LMove { - source, - destination, - src_left, - dst_left, - } => { - // route to the source key's shard - let idx = engine.shard_for_key(&source); - let req = ShardRequest::LMove { - source, - destination, - src_left, - dst_left, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Value(Some(Value::String(data)))) => Frame::Bulk(data), - Ok(ShardResponse::Value(None)) => Frame::Null, - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(ShardResponse::OutOfMemory) => oom_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::GetDel { key } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::GetDel { key }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Value(Some(Value::String(data)))) => Frame::Bulk(data), - Ok(ShardResponse::Value(None)) => Frame::Null, - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::GetEx { key, expire } => { - let idx = engine.shard_for_key(&key); - // convert SetExpire into an Option> (milliseconds from now) - let expire_ms: Option> = expire.map(|opt| { - opt.map(|se| match se { - SetExpire::Ex(s) => Duration::from_secs(s).as_millis() as u64, - SetExpire::Px(ms) => ms, - SetExpire::ExAt(ts) => { - use std::time::{SystemTime, UNIX_EPOCH}; - let now_s = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - Duration::from_secs(ts.saturating_sub(now_s)).as_millis() as u64 - } - SetExpire::PxAt(ts_ms) => { - use std::time::{SystemTime, UNIX_EPOCH}; - let now_ms = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64; - ts_ms.saturating_sub(now_ms) - } - }) - }); - let req = ShardRequest::GetEx { - key, - expire: expire_ms, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Value(Some(Value::String(data)))) => Frame::Bulk(data), - Ok(ShardResponse::Value(None)) => Frame::Null, - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - Command::ZDiff { keys, with_scores } => { - let key = keys.first().cloned().unwrap_or_default(); - let idx = engine.shard_for_key(&key); - let req = ShardRequest::ZDiff { keys }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::ScoredArray(items)) => { - let mut frames = Vec::new(); - for (member, score) in items { - frames.push(Frame::Bulk(Bytes::from(member))); - if with_scores { - frames.push(Frame::Bulk(Bytes::from(format!("{score}")))); - } - } - Frame::Array(frames) - } - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } + exec::sorted_sets::zdiff(keys, with_scores, &cx).await } - Command::ZInter { keys, with_scores } => { - let key = keys.first().cloned().unwrap_or_default(); - let idx = engine.shard_for_key(&key); - let req = ShardRequest::ZInter { keys }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::ScoredArray(items)) => { - let mut frames = Vec::new(); - for (member, score) in items { - frames.push(Frame::Bulk(Bytes::from(member))); - if with_scores { - frames.push(Frame::Bulk(Bytes::from(format!("{score}")))); - } - } - Frame::Array(frames) - } - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } + exec::sorted_sets::zinter(keys, with_scores, &cx).await } - Command::ZUnion { keys, with_scores } => { - let key = keys.first().cloned().unwrap_or_default(); - let idx = engine.shard_for_key(&key); - let req = ShardRequest::ZUnion { keys }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::ScoredArray(items)) => { - let mut frames = Vec::new(); - for (member, score) in items { - frames.push(Frame::Bulk(Bytes::from(member))); - if with_scores { - frames.push(Frame::Bulk(Bytes::from(format!("{score}")))); - } - } - Frame::Array(frames) - } - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - 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}")), - } + exec::sorted_sets::zunion(keys, with_scores, &cx).await } - + Command::ZDiffStore { dest, keys } => exec::sorted_sets::zdiffstore(dest, keys, &cx).await, 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}")), - } + exec::sorted_sets::zinterstore(dest, keys, &cx).await } - 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}")), - } + exec::sorted_sets::zunionstore(dest, keys, &cx).await } - Command::ZRandMember { key, count, with_scores, - } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::ZRandMember { - key, - count, - with_scores, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::ZRandMemberResult(pairs)) => { - if count.is_none() { - // no count: return a single bulk string (or nil if empty) - match pairs.into_iter().next() { - Some((member, _)) => Frame::Bulk(Bytes::from(member)), - None => Frame::Null, - } - } else { - // with count: return array, interleaved with scores if requested - let frames: Vec = pairs - .into_iter() - .flat_map(|(m, s)| { - let mut items = vec![Frame::Bulk(Bytes::from(m))]; - if let Some(score) = s { - items.push(Frame::Bulk(Bytes::from(score.to_string()))); - } - items - }) - .collect(); - Frame::Array(frames) - } - } - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - Command::SScan { + } => exec::sorted_sets::zrandmember(key, count, with_scores, &cx).await, + Command::ZScan { key, cursor, pattern, count, - } => { - let count = count.unwrap_or(10); - let idx = engine.shard_for_key(&key); - let req = ShardRequest::SScan { - key, - cursor, - count, - pattern, - }; - resolve_collection_scan(engine.send_to_shard(idx, req).await) + } => exec::sorted_sets::zscan(key, cursor, pattern, count, &cx).await, + + // -- hash commands -- + Command::HSet { key, fields } => exec::hashes::hset(key, fields, &cx).await, + Command::HGet { key, field } => exec::hashes::hget(key, field, &cx).await, + Command::HGetAll { key } => exec::hashes::hgetall(key, &cx).await, + Command::HDel { key, fields } => exec::hashes::hdel(key, fields, &cx).await, + Command::HExists { key, field } => exec::hashes::hexists(key, field, &cx).await, + Command::HLen { key } => exec::hashes::hlen(key, &cx).await, + Command::HIncrBy { key, field, delta } => { + exec::hashes::hincrby(key, field, delta, &cx).await } - - Command::HScan { + Command::HIncrByFloat { key, field, delta } => { + exec::hashes::hincrbyfloat(key, field, delta, &cx).await + } + Command::HKeys { key } => exec::hashes::hkeys(key, &cx).await, + Command::HVals { key } => exec::hashes::hvals(key, &cx).await, + Command::HMGet { key, fields } => exec::hashes::hmget(key, fields, &cx).await, + Command::HRandField { key, - cursor, - pattern, count, - } => { - let count = count.unwrap_or(10); - let idx = engine.shard_for_key(&key); - let req = ShardRequest::HScan { - key, - cursor, - count, - pattern, - }; - resolve_collection_scan(engine.send_to_shard(idx, req).await) - } - - Command::ZScan { + with_values, + } => exec::hashes::hrandfield(key, count, with_values, &cx).await, + Command::HScan { key, cursor, pattern, count, - } => { - let count = count.unwrap_or(10); - let idx = engine.shard_for_key(&key); - let req = ShardRequest::ZScan { - key, - cursor, - count, - pattern, - }; - resolve_collection_scan(engine.send_to_shard(idx, req).await) - } - - // --- cluster commands --- - Command::ClusterKeySlot { key } => { - let slot = ember_cluster::key_slot(key.as_bytes()); - Frame::Integer(slot as i64) - } - - Command::ClusterInfo => match &ctx.cluster { - Some(c) => c.cluster_info().await, - None => Frame::Bulk(Bytes::from("cluster_enabled:0\r\n")), - }, - - Command::ClusterNodes => match &ctx.cluster { - Some(c) => c.cluster_nodes().await, - None => Frame::Bulk(Bytes::from("")), - }, - - Command::ClusterSlots => match &ctx.cluster { - Some(c) => c.cluster_slots().await, - None => Frame::Array(vec![]), - }, - - Command::ClusterMyId => match &ctx.cluster { - Some(c) => c.cluster_myid(), - None => Frame::Error("ERR This instance has cluster support disabled".into()), - }, - - Command::ClusterMeet { ip, port } => match &ctx.cluster { - Some(c) => c.cluster_meet(&ip, port).await, - None => Frame::Error("ERR This instance has cluster support disabled".into()), - }, - - Command::ClusterAddSlots { slots } => match &ctx.cluster { - Some(c) => c.cluster_addslots(&slots).await, - None => Frame::Error("ERR This instance has cluster support disabled".into()), - }, - - Command::ClusterAddSlotsRange { ranges } => match &ctx.cluster { - Some(c) => { - let slots: Vec = ranges.iter().flat_map(|&(s, e)| s..=e).collect(); - c.cluster_addslots(&slots).await - } - None => Frame::Error("ERR This instance has cluster support disabled".into()), - }, - - Command::ClusterDelSlots { slots } => match &ctx.cluster { - Some(c) => c.cluster_delslots(&slots).await, - None => Frame::Error("ERR This instance has cluster support disabled".into()), - }, - - Command::ClusterForget { node_id } => match &ctx.cluster { - Some(c) => c.cluster_forget(&node_id).await, - None => Frame::Error("ERR This instance has cluster support disabled".into()), - }, - - Command::ClusterSetSlotImporting { slot, node_id } => match &ctx.cluster { - Some(c) => c.cluster_setslot_importing(slot, &node_id).await, - None => Frame::Error("ERR This instance has cluster support disabled".into()), - }, - - Command::ClusterSetSlotMigrating { slot, node_id } => match &ctx.cluster { - Some(c) => c.cluster_setslot_migrating(slot, &node_id).await, - None => Frame::Error("ERR This instance has cluster support disabled".into()), - }, - - Command::ClusterSetSlotNode { slot, node_id } => match &ctx.cluster { - Some(c) => c.cluster_setslot_node(slot, &node_id).await, - None => Frame::Error("ERR This instance has cluster support disabled".into()), - }, - - Command::ClusterSetSlotStable { slot } => match &ctx.cluster { - Some(c) => c.cluster_setslot_stable(slot).await, - None => Frame::Error("ERR This instance has cluster support disabled".into()), - }, + } => exec::hashes::hscan(key, cursor, pattern, count, &cx).await, + + // -- set commands -- + Command::SAdd { key, members } => exec::sets::sadd(key, members, &cx).await, + Command::SRem { key, members } => exec::sets::srem(key, members, &cx).await, + Command::SMembers { key } => exec::sets::smembers(key, &cx).await, + Command::SIsMember { key, member } => exec::sets::sismember(key, member, &cx).await, + Command::SCard { key } => exec::sets::scard(key, &cx).await, + Command::SUnion { keys } => exec::sets::sunion(keys, &cx).await, + Command::SInter { keys } => exec::sets::sinter(keys, &cx).await, + Command::SDiff { keys } => exec::sets::sdiff(keys, &cx).await, + Command::SUnionStore { dest, keys } => exec::sets::sunionstore(dest, keys, &cx).await, + Command::SInterStore { dest, keys } => exec::sets::sinterstore(dest, keys, &cx).await, + Command::SDiffStore { dest, keys } => exec::sets::sdiffstore(dest, keys, &cx).await, + Command::SRandMember { key, count } => exec::sets::srandmember(key, count, &cx).await, + Command::SPop { key, count } => exec::sets::spop(key, count, &cx).await, + Command::SMisMember { key, members } => exec::sets::smismember(key, members, &cx).await, + Command::SMove { + source, + destination, + member, + } => exec::sets::smove(source, destination, member, &cx).await, + Command::SInterCard { keys, limit } => exec::sets::sintercard(keys, limit, &cx).await, + Command::SScan { + key, + cursor, + pattern, + count, + } => exec::sets::sscan(key, cursor, pattern, count, &cx).await, + // -- cluster commands -- + Command::ClusterKeySlot { key } => exec::cluster::cluster_keyslot(key), + Command::ClusterInfo => exec::cluster::cluster_info(&cx).await, + Command::ClusterNodes => exec::cluster::cluster_nodes(&cx).await, + Command::ClusterSlots => exec::cluster::cluster_slots(&cx).await, + Command::ClusterMyId => exec::cluster::cluster_myid(&cx), + Command::ClusterMeet { ip, port } => exec::cluster::cluster_meet(ip, port, &cx).await, + Command::ClusterAddSlots { slots } => exec::cluster::cluster_addslots(slots, &cx).await, + Command::ClusterAddSlotsRange { ranges } => { + exec::cluster::cluster_addslots_range(ranges, &cx).await + } + Command::ClusterDelSlots { slots } => exec::cluster::cluster_delslots(slots, &cx).await, + Command::ClusterForget { node_id } => exec::cluster::cluster_forget(node_id, &cx).await, + Command::ClusterSetSlotImporting { slot, node_id } => { + exec::cluster::cluster_setslot_importing(slot, node_id, &cx).await + } + Command::ClusterSetSlotMigrating { slot, node_id } => { + exec::cluster::cluster_setslot_migrating(slot, node_id, &cx).await + } + Command::ClusterSetSlotNode { slot, node_id } => { + exec::cluster::cluster_setslot_node(slot, node_id, &cx).await + } + Command::ClusterSetSlotStable { slot } => { + exec::cluster::cluster_setslot_stable(slot, &cx).await + } Command::ClusterCountKeysInSlot { slot } => { - match engine - .broadcast(|| ShardRequest::CountKeysInSlot { slot }) - .await - { - Ok(responses) => { - let total: usize = responses - .iter() - .map(|r| match r { - ShardResponse::KeyCount(n) => *n, - _ => 0, - }) - .sum(); - Frame::Integer(total as i64) - } - Err(e) => Frame::Error(format!("ERR {e}")), - } + exec::cluster::cluster_count_keys_in_slot(slot, &cx).await } - Command::ClusterGetKeysInSlot { slot, count } => { - let count = count as usize; - match engine - .broadcast(|| ShardRequest::GetKeysInSlot { slot, count }) - .await - { - Ok(responses) => { - let mut all_keys = Vec::new(); - for r in responses { - if let ShardResponse::StringArray(keys) = r { - all_keys.extend(keys); - } - } - all_keys.truncate(count); - Frame::Array( - all_keys - .into_iter() - .map(|k| Frame::Bulk(Bytes::from(k))) - .collect(), - ) - } - Err(e) => Frame::Error(format!("ERR {e}")), - } + exec::cluster::cluster_get_keys_in_slot(slot, count, &cx).await + } + Command::ClusterReplicate { node_id } => { + exec::cluster::cluster_replicate(node_id, &cx).await + } + Command::ClusterFailover { force, takeover } => { + exec::cluster::cluster_failover(force, takeover, &cx).await } - - Command::ClusterReplicate { node_id } => match &ctx.cluster { - Some(c) => c.cluster_replicate(&node_id).await, - None => Frame::Error("ERR This instance has cluster support disabled".into()), - }, - - Command::ClusterFailover { force, takeover } => match &ctx.cluster { - Some(c) => c.cluster_failover(force, takeover).await, - None => Frame::Error("ERR This instance has cluster support disabled".into()), - }, - Command::Migrate { host, port, @@ -2669,170 +418,36 @@ pub(super) async fn execute( timeout_ms, replace, .. - } => { - // dump the key from the local shard - let idx = engine.shard_for_key(&key); - let dump_req = ShardRequest::DumpKey { key: key.clone() }; - let dump_resp = match engine.send_to_shard(idx, dump_req).await { - Ok(r) => r, - Err(e) => return Frame::Error(format!("ERR {e}")), - }; - - let (data, ttl_ms) = match dump_resp { - ShardResponse::KeyDump { data, ttl_ms } => (data, ttl_ms), - ShardResponse::Value(None) => { - return Frame::Error("ERR no such key".into()); - } - _ => return Frame::Error("ERR internal error".into()), - }; - - // send RESTORE to the target node - let ttl_arg = if ttl_ms < 0 { 0u64 } else { ttl_ms as u64 }; - let timeout = Duration::from_millis(timeout_ms.max(1000)); - let addr = format!("{host}:{port}"); - - let result = tokio::time::timeout(timeout, async { - let mut stream = tokio::net::TcpStream::connect(&addr).await?; - - // build RESTORE command as RESP3 array - let mut parts = vec![ - Frame::Bulk(Bytes::from("RESTORE")), - Frame::Bulk(Bytes::from(key.clone())), - Frame::Bulk(Bytes::from(ttl_arg.to_string())), - Frame::Bulk(Bytes::from(data)), - ]; - if replace { - parts.push(Frame::Bulk(Bytes::from("REPLACE"))); - } - let cmd_frame = Frame::Array(parts); - - let mut buf = BytesMut::new(); - cmd_frame.serialize(&mut buf); - stream.write_all(&buf).await?; - - // read response - let mut read_buf = BytesMut::with_capacity(256); - loop { - let n = stream.read_buf(&mut read_buf).await?; - if n == 0 { - return Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - "connection closed by target", - )); - } - match parse_frame(&read_buf) { - Ok(Some((frame, _))) => return Ok(frame), - Ok(None) => {} // need more data - Err(e) => { - return Err(io::Error::new(io::ErrorKind::InvalidData, e.to_string())); - } - } - } - }) - .await; - - match result { - Ok(Ok(Frame::Simple(_))) => { - // success — delete local key and mark as migrated - let del_req = ShardRequest::Del { key: key.clone() }; - let _ = engine.send_to_shard(idx, del_req).await; - - if let Some(c) = &ctx.cluster { - let slot = ember_cluster::key_slot(key.as_bytes()); - c.mark_key_migrated(slot, key.as_bytes()).await; - } - Frame::Simple("OK".into()) - } - Ok(Ok(Frame::Error(e))) => Frame::Error(format!("ERR target error: {e}")), - Ok(Ok(_)) => Frame::Error("ERR unexpected response from target".into()), - Ok(Err(e)) => Frame::Error(format!("ERR {e}")), - Err(_) => Frame::Error("ERR timeout connecting to target".into()), - } - } - + } => exec::cluster::migrate(host, port, key, timeout_ms, replace, &cx).await, Command::Restore { key, ttl_ms, data, replace, - } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::RestoreKey { - key, - ttl_ms, - data, - replace, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Ok) => Frame::Simple("OK".into()), - Ok(ShardResponse::Err(e)) => Frame::Error(e), - Ok(_) => Frame::Error("ERR internal error".into()), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - - // -- slow log commands -- - Command::SlowLogGet { count } => { - let entries = slow_log.get(count); - let frames: Vec = entries - .into_iter() - .map(|e| { - let ts = e - .timestamp - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - Frame::Array(vec![ - Frame::Integer(e.id.min(i64::MAX as u64) as i64), - Frame::Integer(ts.min(i64::MAX as u64) as i64), - Frame::Integer(e.duration.as_micros().min(i64::MAX as u128) as i64), - Frame::Array(vec![Frame::Bulk(Bytes::from(e.command))]), - ]) - }) - .collect(); - Frame::Array(frames) - } - - Command::SlowLogLen => Frame::Integer(slow_log.len() as i64), - - Command::SlowLogReset => { - slow_log.reset(); - Frame::Simple("OK".into()) - } - - // -- pub/sub -- - Command::Publish { channel, message } => { - let count = pubsub.publish(&channel, message); - Frame::Integer(count as i64) - } - - Command::PubSubChannels { pattern } => { - let names = pubsub.channel_names(pattern.as_deref()); - Frame::Array(names.into_iter().map(|n| Frame::Bulk(n.into())).collect()) - } - - Command::PubSubNumSub { channels } => { - let pairs = pubsub.numsub(&channels); - let mut frames = Vec::with_capacity(pairs.len() * 2); - for (ch, count) in pairs { - frames.push(Frame::Bulk(ch.into())); - frames.push(Frame::Integer(count as i64)); - } - Frame::Array(frames) - } - - Command::PubSubNumPat => Frame::Integer(pubsub.active_patterns() as i64), + } => exec::cluster::restore(key, ttl_ms, data, replace, &cx).await, + // -- pub/sub commands -- + Command::Publish { channel, message } => exec::pubsub::publish(channel, message, &cx), + Command::PubSubChannels { pattern } => exec::pubsub::pubsub_channels(pattern, &cx), + Command::PubSubNumSub { channels } => exec::pubsub::pubsub_numsub(channels, &cx), + Command::PubSubNumPat => exec::pubsub::pubsub_numpat(&cx), // subscribe commands are handled in the connection loop, not here. - // if we reach this point, something went wrong. Command::Subscribe { .. } | Command::Unsubscribe { .. } | Command::PSubscribe { .. } - | Command::PUnsubscribe { .. } => { - Frame::Error("ERR subscribe commands should not reach execute".into()) - } - - // --- vector commands --- + | Command::PUnsubscribe { .. } => exec::pubsub::subscribe_error(), + + // -- ACL / AUTH commands -- + Command::Auth { username, password } => exec::acl::auth(username, password, &cx), + Command::AclWhoAmI => exec::acl::acl_whoami(), + Command::AclList => exec::acl::acl_list(&cx), + Command::AclUsers => exec::acl::acl_users(&cx), + Command::AclGetUser { username } => exec::acl::acl_getuser(username, &cx), + Command::AclDelUser { usernames } => exec::acl::acl_deluser(usernames, &cx), + Command::AclSetUser { username, rules } => exec::acl::acl_setuser(username, rules, &cx), + Command::AclCat { category } => exec::acl::acl_cat(category), + + // -- vector commands -- #[cfg(feature = "vector")] Command::VAdd { key, @@ -2843,8 +458,7 @@ pub(super) async fn execute( connectivity, expansion_add, } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::VAdd { + exec::vector::vadd( key, element, vector, @@ -2852,19 +466,10 @@ pub(super) async fn execute( quantization, connectivity, expansion_add, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::VAddResult { added, .. }) => { - Frame::Integer(if added { 1 } else { 0 }) - } - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(ShardResponse::OutOfMemory) => oom_error(), - Ok(ShardResponse::Err(msg)) => Frame::Error(format!("ERR {msg}")), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } + &cx, + ) + .await } - #[cfg(feature = "vector")] Command::VAddBatch { key, @@ -2875,8 +480,7 @@ pub(super) async fn execute( connectivity, expansion_add, } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::VAddBatch { + exec::vector::vaddbatch( key, entries, dim, @@ -2884,19 +488,10 @@ pub(super) async fn execute( quantization, connectivity, expansion_add, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::VAddBatchResult { added_count, .. }) => { - Frame::Integer(added_count as i64) - } - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(ShardResponse::OutOfMemory) => oom_error(), - Ok(ShardResponse::Err(msg)) => Frame::Error(format!("ERR {msg}")), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } + &cx, + ) + .await } - #[cfg(feature = "vector")] Command::VSim { key, @@ -2904,105 +499,17 @@ pub(super) async fn execute( count, ef_search, with_scores, - } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::VSim { - key, - query, - count, - ef_search, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::VSimResult(results)) => { - let mut frames = Vec::new(); - for (element, distance) in results { - frames.push(Frame::Bulk(Bytes::from(element))); - if with_scores { - frames.push(Frame::Bulk(Bytes::from(distance.to_string()))); - } - } - Frame::Array(frames) - } - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - + } => exec::vector::vsim(key, query, count, ef_search, with_scores, &cx).await, #[cfg(feature = "vector")] - Command::VRem { key, element } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::VRem { key, element }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Bool(removed)) => Frame::Integer(if removed { 1 } else { 0 }), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - + Command::VRem { key, element } => exec::vector::vrem(key, element, &cx).await, #[cfg(feature = "vector")] - Command::VGet { key, element } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::VGet { key, element }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::VectorData(Some(vector))) => Frame::Array( - vector - .into_iter() - .map(|v| Frame::Bulk(Bytes::from(v.to_string()))) - .collect(), - ), - Ok(ShardResponse::VectorData(None)) => Frame::Null, - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - + Command::VGet { key, element } => exec::vector::vget(key, element, &cx).await, #[cfg(feature = "vector")] - Command::VCard { key } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::VCard { key }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Integer(count)) => Frame::Integer(count), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - + Command::VCard { key } => exec::vector::vcard(key, &cx).await, #[cfg(feature = "vector")] - Command::VDim { key } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::VDim { key }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Integer(dim)) => Frame::Integer(dim), - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - + Command::VDim { key } => exec::vector::vdim(key, &cx).await, #[cfg(feature = "vector")] - Command::VInfo { key } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::VInfo { key }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::VectorInfo(Some(fields))) => { - let mut frames = Vec::with_capacity(fields.len() * 2); - for (k, v) in fields { - frames.push(Frame::Bulk(Bytes::from(k))); - frames.push(Frame::Bulk(Bytes::from(v))); - } - Frame::Array(frames) - } - Ok(ShardResponse::VectorInfo(None)) => Frame::Null, - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - + Command::VInfo { key } => exec::vector::vinfo(key, &cx).await, #[cfg(not(feature = "vector"))] Command::VAdd { .. } | Command::VAddBatch { .. } @@ -3011,46 +518,13 @@ pub(super) async fn execute( | Command::VGet { .. } | Command::VCard { .. } | Command::VDim { .. } - | Command::VInfo { .. } => { - Frame::Error("ERR unknown command (vector support not compiled)".into()) - } + | Command::VInfo { .. } => exec::vector::not_compiled(), + // -- protobuf commands -- #[cfg(feature = "protobuf")] Command::ProtoRegister { name, descriptor } => { - let registry = match engine.schema_registry() { - Some(r) => r, - None => return Frame::Error("ERR protobuf support is not enabled".into()), - }; - let result = { - let mut reg = match registry.write() { - Ok(r) => r, - Err(_) => return Frame::Error("ERR schema registry lock poisoned".into()), - }; - reg.register(name.clone(), descriptor.clone()) - }; - match result { - Ok(types) => { - // persist the registration to all shards' AOF - if let Err(e) = engine - .broadcast(|| ShardRequest::ProtoRegisterAof { - name: name.clone(), - descriptor: descriptor.clone(), - }) - .await - { - tracing::warn!("failed to persist proto registration to AOF: {e}"); - } - Frame::Array( - types - .into_iter() - .map(|t| Frame::Bulk(Bytes::from(t))) - .collect(), - ) - } - Err(e) => Frame::Error(format!("ERR {e}")), - } + exec::protobuf::proto_register(name, descriptor, &cx).await } - #[cfg(feature = "protobuf")] Command::ProtoSet { key, @@ -3059,205 +533,29 @@ pub(super) async fn execute( expire, nx, xx, - } => { - let registry = match engine.schema_registry() { - Some(r) => r, - None => return Frame::Error("ERR protobuf support is not enabled".into()), - }; - // validate the bytes against the schema before storing - { - let reg = match registry.read() { - Ok(r) => r, - Err(_) => return Frame::Error("ERR schema registry lock poisoned".into()), - }; - if let Err(e) = reg.validate(&type_name, &data) { - return Frame::Error(format!("ERR {e}")); - } - } - let duration = expire.map(|e| { - use std::time::{SystemTime, UNIX_EPOCH}; - match e { - SetExpire::Ex(secs) => Duration::from_secs(secs), - SetExpire::Px(millis) => Duration::from_millis(millis), - SetExpire::ExAt(ts) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - Duration::from_secs(ts.saturating_sub(now)) - } - SetExpire::PxAt(ts_ms) => { - let now_ms = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64; - Duration::from_millis(ts_ms.saturating_sub(now_ms)) - } - } - }); - let idx = engine.shard_for_key(&key); - let req = ShardRequest::ProtoSet { - key, - type_name, - data, - expire: duration, - nx, - xx, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Ok) => Frame::Simple("OK".into()), - Ok(ShardResponse::Value(None)) => Frame::Null, - Ok(ShardResponse::OutOfMemory) => oom_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - + } => exec::protobuf::proto_set(key, type_name, data, expire, nx, xx, &cx).await, #[cfg(feature = "protobuf")] - Command::ProtoGet { key } => { - if engine.schema_registry().is_none() { - return Frame::Error("ERR protobuf support is not enabled".into()); - } - let idx = engine.shard_for_key(&key); - let req = ShardRequest::ProtoGet { key }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::ProtoValue(Some((type_name, data, _ttl)))) => { - Frame::Array(vec![Frame::Bulk(Bytes::from(type_name)), Frame::Bulk(data)]) - } - Ok(ShardResponse::ProtoValue(None)) => Frame::Null, - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - + Command::ProtoGet { key } => exec::protobuf::proto_get(key, &cx).await, #[cfg(feature = "protobuf")] - Command::ProtoType { key } => { - if engine.schema_registry().is_none() { - return Frame::Error("ERR protobuf support is not enabled".into()); - } - let idx = engine.shard_for_key(&key); - let req = ShardRequest::ProtoType { key }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::ProtoTypeName(Some(name))) => Frame::Bulk(Bytes::from(name)), - Ok(ShardResponse::ProtoTypeName(None)) => Frame::Null, - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - + Command::ProtoType { key } => exec::protobuf::proto_type(key, &cx).await, #[cfg(feature = "protobuf")] - Command::ProtoSchemas => { - let registry = match engine.schema_registry() { - Some(r) => r, - None => return Frame::Error("ERR protobuf support is not enabled".into()), - }; - let reg = match registry.read() { - Ok(r) => r, - Err(_) => return Frame::Error("ERR schema registry lock poisoned".into()), - }; - let names = reg.schema_names(); - Frame::Array( - names - .into_iter() - .map(|n| Frame::Bulk(Bytes::from(n))) - .collect(), - ) - } - + Command::ProtoSchemas => exec::protobuf::proto_schemas(&cx).await, #[cfg(feature = "protobuf")] - Command::ProtoDescribe { name } => { - let registry = match engine.schema_registry() { - Some(r) => r, - None => return Frame::Error("ERR protobuf support is not enabled".into()), - }; - let reg = match registry.read() { - Ok(r) => r, - Err(_) => return Frame::Error("ERR schema registry lock poisoned".into()), - }; - match reg.describe(&name) { - Some(types) => Frame::Array( - types - .into_iter() - .map(|t| Frame::Bulk(Bytes::from(t))) - .collect(), - ), - None => Frame::Error(format!("ERR unknown schema '{name}'")), - } - } - + Command::ProtoDescribe { name } => exec::protobuf::proto_describe(name, &cx).await, #[cfg(feature = "protobuf")] Command::ProtoGetField { key, field_path } => { - let registry = match engine.schema_registry() { - Some(r) => r, - None => return Frame::Error("ERR protobuf support is not enabled".into()), - }; - let idx = engine.shard_for_key(&key); - let req = ShardRequest::ProtoGet { key }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::ProtoValue(Some((type_name, data, _ttl)))) => { - let reg = match registry.read() { - Ok(r) => r, - Err(_) => return Frame::Error("ERR schema registry lock poisoned".into()), - }; - match reg.get_field(&type_name, &data, &field_path) { - Ok(frame) => frame, - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - Ok(ShardResponse::ProtoValue(None)) => Frame::Null, - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } + exec::protobuf::proto_get_field(key, field_path, &cx).await } - #[cfg(feature = "protobuf")] Command::ProtoSetField { key, field_path, value, - } => { - if engine.schema_registry().is_none() { - return Frame::Error("ERR protobuf support is not enabled".into()); - } - let idx = engine.shard_for_key(&key); - let req = ShardRequest::ProtoSetField { - key, - field_path, - value, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::ProtoFieldUpdated { .. }) => Frame::Simple("OK".into()), - Ok(ShardResponse::Value(None)) => Frame::Null, - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(ShardResponse::OutOfMemory) => oom_error(), - Ok(ShardResponse::Err(msg)) => Frame::Error(format!("ERR {msg}")), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - + } => exec::protobuf::proto_set_field(key, field_path, value, &cx).await, #[cfg(feature = "protobuf")] Command::ProtoDelField { key, field_path } => { - if engine.schema_registry().is_none() { - return Frame::Error("ERR protobuf support is not enabled".into()); - } - let idx = engine.shard_for_key(&key); - let req = ShardRequest::ProtoDelField { key, field_path }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::ProtoFieldUpdated { .. }) => Frame::Integer(1), - Ok(ShardResponse::Value(None)) => Frame::Null, - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(ShardResponse::OutOfMemory) => oom_error(), - Ok(ShardResponse::Err(msg)) => Frame::Error(format!("ERR {msg}")), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } + exec::protobuf::proto_del_field(key, field_path, &cx).await } - - // when protobuf feature is disabled, proto commands are unknown #[cfg(not(feature = "protobuf"))] Command::ProtoRegister { .. } | Command::ProtoSet { .. } @@ -3267,432 +565,20 @@ pub(super) async fn execute( | Command::ProtoDescribe { .. } | Command::ProtoGetField { .. } | Command::ProtoSetField { .. } - | Command::ProtoDelField { .. } => { - Frame::Error("ERR unknown command (protobuf support not compiled)".into()) - } - - // AUTH on an already-authenticated connection (re-auth). - // note: this verifies the password but doesn't update per-connection - // ACL state — re-auth with a different user requires reconnecting. - Command::Auth { username, password } => { - let uname = username.unwrap_or_else(|| "default".into()); - if let Some(ref acl_state) = ctx.acl { - match acl_state.read() { - Ok(state) => match state.get_user(&uname) { - Some(user) if user.enabled && user.verify_password(&password) => { - Frame::Simple("OK".into()) - } - _ => Frame::Error( - "WRONGPASS invalid username-password pair or user is disabled.".into(), - ), - }, - Err(_) => Frame::Error("ERR ACL state lock poisoned".into()), - } - } else { - match &ctx.requirepass { - None => Frame::Error( - "ERR Client sent AUTH, but no password is set. \ - Did you mean ACL SETUSER with >password?" - .into(), - ), - Some(expected) => { - if uname != "default" { - Frame::Error( - "WRONGPASS invalid username-password pair or user is disabled." - .into(), - ) - } else if bool::from(password.as_bytes().ct_eq(expected.as_bytes())) { - Frame::Simple("OK".into()) - } else { - Frame::Error( - "WRONGPASS invalid username-password pair or user is disabled." - .into(), - ) - } - } - } - } - } + | Command::ProtoDelField { .. } => exec::protobuf::not_compiled(), Command::Quit => Frame::Simple("OK".into()), - - // ASKING is intercepted by process() and prepare_command() before - // reaching here, but the match must be exhaustive. Command::Asking => Frame::Simple("OK".into()), - - // MULTI/EXEC/DISCARD are intercepted by handle_frame_with_tx() before - // reaching here. If they arrive directly (e.g. EXEC without MULTI), - // they should have been caught earlier — return an error for safety. Command::Multi => Frame::Error("ERR MULTI calls can not be nested".into()), Command::Exec => Frame::Error("ERR EXEC without MULTI".into()), Command::Discard => Frame::Error("ERR DISCARD without MULTI".into()), - - // MONITOR is handled at the frame level before process() is called. - // If it arrives here (e.g. during a transaction), just return OK. Command::Monitor => Frame::Simple("OK".into()), - - // WATCH/UNWATCH are intercepted by handle_frame_with_tx() before - // process(). If they reach here, return sensible defaults. Command::Watch { .. } | Command::Unwatch => Frame::Simple("OK".into()), - // -- ACL commands -- - // WHOAMI is handled at the connection level (needs current_username). - // If it reaches here, return a generic response. - Command::AclWhoAmI => Frame::Bulk(Bytes::from_static(b"default")), - - Command::AclList => { - if let Some(ref acl) = ctx.acl { - match acl.read() { - Ok(state) => { - let lines = state.list(); - Frame::Array( - lines - .into_iter() - .map(|l| Frame::Bulk(Bytes::from(l))) - .collect(), - ) - } - Err(_) => Frame::Error("ERR ACL state lock poisoned".into()), - } - } else { - // legacy mode — synthesize a default user entry - Frame::Array(vec![Frame::Bulk(Bytes::from( - "user default on nopass +@all ~*", - ))]) - } - } - - Command::AclUsers => { - if let Some(ref acl) = ctx.acl { - match acl.read() { - Ok(state) => { - let names = state.usernames(); - Frame::Array( - names - .into_iter() - .map(|n| Frame::Bulk(Bytes::from(n))) - .collect(), - ) - } - Err(_) => Frame::Error("ERR ACL state lock poisoned".into()), - } - } else { - Frame::Array(vec![Frame::Bulk(Bytes::from_static(b"default"))]) - } - } - - Command::AclGetUser { username } => { - if let Some(ref acl) = ctx.acl { - match acl.read() { - Ok(state) => match state.get_user_detail(&username) { - Some(detail) => detail, - None => Frame::Error(format!("ERR no such user '{username}'")), - }, - Err(_) => Frame::Error("ERR ACL state lock poisoned".into()), - } - } else if username == "default" { - // legacy mode: synthesize default user detail - crate::acl::AclState::new() - .get_user_detail("default") - .unwrap_or(Frame::Null) - } else { - Frame::Error(format!("ERR no such user '{username}'")) - } - } - - Command::AclDelUser { usernames } => { - if let Some(ref acl) = ctx.acl { - match acl.write() { - Ok(mut state) => match state.del_users(&usernames) { - Ok(count) => Frame::Integer(count as i64), - Err(msg) => Frame::Error(msg), - }, - Err(_) => Frame::Error("ERR ACL state lock poisoned".into()), - } - } else { - Frame::Error( - "ERR ACL is not enabled. Configure ACL users to use this command.".into(), - ) - } - } - - Command::AclSetUser { username, rules } => { - if let Some(ref acl) = ctx.acl { - match acl.write() { - Ok(mut state) => match state.set_user(&username, &rules) { - Ok(()) => Frame::Simple("OK".into()), - Err(msg) => Frame::Error(msg), - }, - Err(_) => Frame::Error("ERR ACL state lock poisoned".into()), - } - } else { - Frame::Error( - "ERR ACL is not enabled. Configure ACL users to use this command.".into(), - ) - } - } - - Command::AclCat { category } => crate::acl::handle_acl_cat(category.as_deref()), - - // SORT without STORE is normally dispatched via route! in prepare_command, - // but execute() must be exhaustive. - Command::Sort { - key, - desc, - alpha, - limit, - store: None, - } => { - let idx = engine.shard_for_key(&key); - let req = ShardRequest::Sort { - key, - desc, - alpha, - limit, - }; - match engine.send_to_shard(idx, req).await { - Ok(ShardResponse::Array(items)) => { - Frame::Array(items.into_iter().map(Frame::Bulk).collect()) - } - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } - } - Command::Unknown(name) => Frame::Error(format!("ERR unknown command '{name}'")), } } -/// Fans out a boolean-result command across shards for multiple keys -/// and returns the count of `true` results as an integer frame. -/// -/// Uses `route_multi` to dispatch all keys concurrently rather than -/// awaiting each one sequentially. -async fn multi_key_bool(engine: &Engine, keys: &[String], make_req: F) -> Frame -where - F: Fn(String) -> ShardRequest, -{ - match engine.route_multi(keys, make_req).await { - Ok(responses) => { - let count = responses - .iter() - .filter(|r| matches!(r, ShardResponse::Bool(true))) - .count(); - Frame::Integer(count as i64) - } - Err(e) => Frame::Error(format!("ERR {e}")), - } -} - -/// Renders the INFO response with multiple sections. -/// -/// With no argument, returns all sections. With a section name, -/// returns only that section. Matches Redis convention of `#` headers -/// followed by `key:value` pairs separated by `\r\n`. -async fn render_info(engine: &Engine, ctx: &Arc, section: Option<&str>) -> Frame { - let section_upper = section.map(|s| s.to_ascii_uppercase()); - let want_all = section_upper.is_none(); - let want = |name: &str| want_all || section_upper.as_deref() == Some(name); - - // only broadcast to shards if we need keyspace/memory/persistence sections - let stats = if want("KEYSPACE") || want("MEMORY") || want("PERSISTENCE") || want("STATS") { - match engine.broadcast(|| ShardRequest::Stats).await { - Ok(responses) => { - let mut total = KeyspaceStats { - key_count: 0, - used_bytes: 0, - keys_with_expiry: 0, - keys_expired: 0, - keys_evicted: 0, - oom_rejections: 0, - keyspace_hits: 0, - keyspace_misses: 0, - }; - for r in &responses { - if let ShardResponse::Stats(s) = r { - total.key_count += s.key_count; - total.used_bytes += s.used_bytes; - total.keys_with_expiry += s.keys_with_expiry; - total.keys_expired += s.keys_expired; - total.keys_evicted += s.keys_evicted; - total.oom_rejections += s.oom_rejections; - total.keyspace_hits += s.keyspace_hits; - total.keyspace_misses += s.keyspace_misses; - } - } - Some(total) - } - Err(e) => return Frame::Error(format!("ERR {e}")), - } - } else { - None - }; - - let mut out = String::with_capacity(512); - - if want("SERVER") { - let uptime = ctx.start_time.elapsed().as_secs(); - out.push_str("# Server\r\n"); - out.push_str(&format!("ember_version:{}\r\n", ctx.version)); - out.push_str(&format!("process_id:{}\r\n", std::process::id())); - out.push_str(&format!("uptime_in_seconds:{uptime}\r\n")); - out.push_str(&format!("shard_count:{}\r\n", ctx.shard_count)); - out.push_str(&format!("tcp_port:{}\r\n", ctx.bind_addr.port())); - out.push_str("hz:10\r\n"); - if let Some(ref path) = ctx.config_path { - out.push_str(&format!("config_file:{}\r\n", path.display())); - } else { - out.push_str("config_file:\r\n"); - } - out.push_str("\r\n"); - } - - if want("CLIENTS") { - let connected = ctx.connections_active.load(Ordering::Relaxed); - out.push_str("# Clients\r\n"); - out.push_str(&format!("connected_clients:{connected}\r\n")); - out.push_str(&format!("max_clients:{}\r\n", ctx.max_connections)); - out.push_str("\r\n"); - } - - if want("MEMORY") { - if let Some(ref stats) = stats { - out.push_str("# Memory\r\n"); - out.push_str(&format!("used_memory:{}\r\n", stats.used_bytes)); - out.push_str(&format!( - "used_memory_human:{}\r\n", - human_bytes(stats.used_bytes) - )); - if let Some(rss) = get_rss_bytes() { - out.push_str(&format!("used_memory_rss:{rss}\r\n")); - out.push_str(&format!("used_memory_rss_human:{}\r\n", human_bytes(rss))); - } - let max_bytes = ctx - .max_memory_limit - .load(std::sync::atomic::Ordering::Relaxed) as usize; - if max_bytes > 0 { - let effective = ember_core::memory::effective_limit(max_bytes); - out.push_str(&format!("max_memory:{max_bytes}\r\n")); - out.push_str(&format!("max_memory_human:{}\r\n", human_bytes(max_bytes))); - out.push_str(&format!("max_memory_effective:{effective}\r\n")); - out.push_str(&format!( - "max_memory_effective_human:{}\r\n", - human_bytes(effective) - )); - } else { - out.push_str("max_memory:0\r\n"); - out.push_str("max_memory_human:unlimited\r\n"); - } - out.push_str("\r\n"); - } - } - - if want("PERSISTENCE") { - let last_save = ctx - .last_save_timestamp - .load(std::sync::atomic::Ordering::Relaxed); - out.push_str("# Persistence\r\n"); - out.push_str(&format!( - "aof_enabled:{}\r\n", - if ctx.aof_enabled { 1 } else { 0 } - )); - out.push_str("aof_last_bgrewrite_status:ok\r\n"); - out.push_str(&format!("rdb_last_save_time:{last_save}\r\n")); - out.push_str("\r\n"); - } - - if want("STATS") { - let total_conns = ctx.connections_accepted.load(Ordering::Relaxed); - let total_cmds = ctx.commands_processed.load(Ordering::Relaxed); - out.push_str("# Stats\r\n"); - out.push_str(&format!("total_connections_received:{total_conns}\r\n")); - out.push_str(&format!("total_commands_processed:{total_cmds}\r\n")); - if let Some(ref stats) = stats { - out.push_str(&format!("expired_keys:{}\r\n", stats.keys_expired)); - out.push_str(&format!("evicted_keys:{}\r\n", stats.keys_evicted)); - out.push_str(&format!("oom_rejections:{}\r\n", stats.oom_rejections)); - out.push_str(&format!("keyspace_hits:{}\r\n", stats.keyspace_hits)); - out.push_str(&format!("keyspace_misses:{}\r\n", stats.keyspace_misses)); - } - out.push_str("\r\n"); - } - - if want("KEYSPACE") { - if let Some(ref stats) = stats { - out.push_str("# Keyspace\r\n"); - if stats.key_count > 0 { - out.push_str(&format!( - "db0:keys={},expires={},used_bytes={}\r\n", - stats.key_count, stats.keys_with_expiry, stats.used_bytes - )); - } - out.push_str("\r\n"); - } - } - - if want("REPLICATION") { - out.push_str("# Replication\r\n"); - if let Some(ref cluster) = ctx.cluster { - let info = cluster.replication_info().await; - use ember_cluster::NodeRole; - match info.role { - NodeRole::Primary => { - out.push_str("role:primary\r\n"); - out.push_str(&format!("connected_replicas:{}\r\n", info.replica_count)); - } - NodeRole::Replica => { - out.push_str("role:replica\r\n"); - if let Some(addr) = info.primary_addr { - out.push_str(&format!("master_host:{}\r\n", addr.ip())); - out.push_str(&format!("master_port:{}\r\n", addr.port())); - out.push_str("master_link_status:up\r\n"); - } else { - out.push_str("master_link_status:down\r\n"); - } - } - } - } else { - out.push_str("role:primary\r\n"); - out.push_str("connected_replicas:0\r\n"); - } - out.push_str("\r\n"); - } - - // trim trailing blank line - if out.ends_with("\r\n\r\n") { - out.truncate(out.len() - 2); - } - - Frame::Bulk(Bytes::from(out)) -} - -/// Returns the standard WRONGTYPE error frame. -pub(super) fn wrongtype_error() -> Frame { - Frame::Error("WRONGTYPE Operation against a key holding the wrong kind of value".into()) -} - -/// Resolves a collection scan (SSCAN/HSCAN/ZSCAN) shard response into a RESP frame. -pub(super) fn resolve_collection_scan( - result: Result, -) -> Frame { - match result { - Ok(ShardResponse::CollectionScan { cursor, items }) => { - let cursor_frame = Frame::Bulk(Bytes::from(cursor.to_string())); - let item_frames = items.into_iter().map(Frame::Bulk).collect(); - Frame::Array(vec![cursor_frame, Frame::Array(item_frames)]) - } - Ok(ShardResponse::WrongType) => wrongtype_error(), - Ok(other) => Frame::Error(format!("ERR unexpected shard response: {other:?}")), - Err(e) => Frame::Error(format!("ERR {e}")), - } -} - -/// Returns the standard OOM error frame. -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. @@ -4980,41 +1866,3 @@ static COMMAND_TABLE: &[CommandEntry] = &[ 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. -/// -/// Returns the count of replicas that acknowledged in time as a -/// RESP integer. When there are no replicas or no writes, returns -/// immediately without sleeping. -async fn handle_wait(ctx: &Arc, numreplicas: u64, timeout_ms: u64) -> Frame { - use std::sync::atomic::Ordering; - use std::time::Duration; - - let needed = numreplicas as usize; - let tracker = &ctx.replica_tracker; - - // fast path: no replicas connected - if tracker.connected_count() == 0 { - return Frame::Integer(0); - } - - let target = tracker.write_offset.load(Ordering::Relaxed); - - // fast path: already satisfied or no timeout needed - let count = tracker.count_at_or_above(target); - if count >= needed || timeout_ms == 0 { - return Frame::Integer(count as i64); - } - - // poll until enough replicas have caught up or the deadline passes - let deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms); - loop { - tokio::time::sleep(Duration::from_millis(25)).await; - let c = tracker.count_at_or_above(target); - if c >= needed || tokio::time::Instant::now() >= deadline { - return Frame::Integer(c as i64); - } - } -} diff --git a/crates/ember-server/src/connection/mod.rs b/crates/ember-server/src/connection/mod.rs index 3878d096..f0e52afd 100644 --- a/crates/ember-server/src/connection/mod.rs +++ b/crates/ember-server/src/connection/mod.rs @@ -27,6 +27,7 @@ use crate::server::ServerContext; use crate::slowlog::SlowLog; mod dispatch; +mod exec; mod execute; mod handler; mod response; From 84210cb0228c0d1782a55178254edb56c230f5c3 Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Fri, 27 Feb 2026 13:55:33 -0500 Subject: [PATCH 3/3] refactor: move COMMAND_TABLE to ember-protocol/src/command/table.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit all command-describing data now lives in one crate: enum variants, attribute methods (is_write, acl_categories), and wire-protocol metadata (arity, flags, key positions) are all in ember-protocol. adding a new command means editing ember-protocol and one match arm in execute.rs — no more two separate places to update. execute.rs call site is now a one-liner: ember_protocol::command::table::handle_command_cmd(...) --- crates/ember-protocol/src/command/mod.rs | 1 + crates/ember-protocol/src/command/table.rs | 1306 +++++++++++++++++ crates/ember-server/src/connection/execute.rs | 1292 +--------------- 3 files changed, 1310 insertions(+), 1289 deletions(-) create mode 100644 crates/ember-protocol/src/command/table.rs diff --git a/crates/ember-protocol/src/command/mod.rs b/crates/ember-protocol/src/command/mod.rs index 4db17aa7..5e6ea4ef 100644 --- a/crates/ember-protocol/src/command/mod.rs +++ b/crates/ember-protocol/src/command/mod.rs @@ -981,5 +981,6 @@ impl Eq for ZAddFlags {} mod attributes; mod parse; +pub mod table; #[cfg(test)] mod tests; diff --git a/crates/ember-protocol/src/command/table.rs b/crates/ember-protocol/src/command/table.rs new file mode 100644 index 00000000..2e2a0b3a --- /dev/null +++ b/crates/ember-protocol/src/command/table.rs @@ -0,0 +1,1306 @@ +//! Static command metadata table for the COMMAND command. +//! +//! Provides arity, flags, and key positions for every command ember supports. +//! Kept here so all command-describing data lives in one crate: enum variants, +//! attribute methods, and wire-protocol metadata are all in ember-protocol. +//! Adding a new command means editing this crate and one match arm in execute.rs. + +use bytes::Bytes; + +use crate::Frame; + +/// 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. +pub 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: "ACL", + arity: -2, + flags: &["admin", "noscript", "loading"], + first_key: 0, + last_key: 0, + step: 0, + }, + 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, + }, +]; diff --git a/crates/ember-server/src/connection/execute.rs b/crates/ember-server/src/connection/execute.rs index ee7fff3d..31be09eb 100644 --- a/crates/ember-server/src/connection/execute.rs +++ b/crates/ember-server/src/connection/execute.rs @@ -66,7 +66,9 @@ 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), + Command::Command { subcommand, args } => { + ember_protocol::command::table::handle_command_cmd(subcommand.as_deref(), &args) + } // -- client commands (connection-scoped, no shard needed) -- Command::ClientId => Frame::Integer(client_id as i64), @@ -578,1291 +580,3 @@ pub(super) async fn execute( Command::Unknown(name) => Frame::Error(format!("ERR unknown command '{name}'")), } } - -/// 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, - }, -];