diff --git a/crates/ember-core/src/keyspace/mod.rs b/crates/ember-core/src/keyspace/mod.rs index 93b16396..374efe1e 100644 --- a/crates/ember-core/src/keyspace/mod.rs +++ b/crates/ember-core/src/keyspace/mod.rs @@ -966,6 +966,38 @@ impl Keyspace { } } + /// Returns the absolute Unix timestamp (seconds) when the key expires. + /// + /// Returns `-2` if the key doesn't exist, `-1` if it has no expiry. + pub fn expiretime(&mut self, key: &str) -> i64 { + if self.remove_if_expired(key) { + return -2; + } + match self.entries.get(key) { + None => -2, + Some(entry) => match time::monotonic_to_unix_ms(entry.expires_at_ms) { + None => -1, + Some(unix_ms) => (unix_ms / 1000) as i64, + }, + } + } + + /// Returns the absolute Unix timestamp (milliseconds) when the key expires. + /// + /// Returns `-2` if the key doesn't exist, `-1` if it has no expiry. + pub fn pexpiretime(&mut self, key: &str) -> i64 { + if self.remove_if_expired(key) { + return -2; + } + match self.entries.get(key) { + None => -2, + Some(entry) => match time::monotonic_to_unix_ms(entry.expires_at_ms) { + None => -1, + Some(unix_ms) => unix_ms as i64, + }, + } + } + /// Returns all keys matching a glob pattern. /// /// Warning: O(n) scan of the entire keyspace. Use SCAN for production diff --git a/crates/ember-core/src/keyspace/set.rs b/crates/ember-core/src/keyspace/set.rs index 7249f974..ec277734 100644 --- a/crates/ember-core/src/keyspace/set.rs +++ b/crates/ember-core/src/keyspace/set.rs @@ -457,6 +457,92 @@ impl Keyspace { } } + /// Atomically moves `member` from `source` set to `destination` set. + /// + /// Returns `true` if the member was moved, `false` if it wasn't in the source + /// set. Returns an error if either key holds a non-set value. + /// + /// Both keys must hash to the same shard. The caller is responsible for + /// enforcing that constraint before routing to this method. + pub fn smove( + &mut self, + source: &str, + destination: &str, + member: &str, + ) -> Result { + self.remove_if_expired(source); + self.remove_if_expired(destination); + + // type-check source + match self.entries.get(source) { + None => return Ok(false), + Some(entry) => { + if !matches!(&entry.value, Value::Set(_)) { + return Err(WriteError::WrongType); + } + } + } + + // type-check destination if it exists + if let Some(entry) = self.entries.get(destination) { + if !matches!(&entry.value, Value::Set(_)) { + return Err(WriteError::WrongType); + } + } + + let old_src_size = self + .entries + .get(source) + .map(|e| e.entry_size(source)) + .unwrap_or(0); + + // try removing the member from source + let removed = if let Some(entry) = self.entries.get_mut(source) { + if let Value::Set(ref mut set) = entry.value { + set.remove(member) + } else { + false + } + } else { + false + }; + + if !removed { + return Ok(false); + } + + let member_bytes = member.len() + memory::HASHSET_MEMBER_OVERHEAD; + + let src_empty = self + .entries + .get(source) + .map(|e| matches!(&e.value, Value::Set(s) if s.is_empty())) + .unwrap_or(false); + + self.cleanup_after_remove(source, old_src_size, src_empty, member_bytes); + self.bump_version(source); + + // add to destination (creates the set if needed) + self.sadd(destination, &[member.to_string()])?; + + Ok(true) + } + + /// Returns the cardinality of the intersection of all given sets. + /// + /// If `limit` is nonzero, the count is capped at that value. + /// Returns 0 if any key is missing. Returns an error if any key + /// holds a non-set type. + pub fn sintercard(&mut self, keys: &[String], limit: usize) -> Result { + let members = self.sinter(keys)?; + let count = if limit > 0 { + members.len().min(limit) + } else { + members.len() + }; + Ok(count) + } + /// Returns the cardinality (number of elements) of a set. pub fn scard(&mut self, key: &str) -> Result { if self.remove_if_expired(key) { @@ -931,4 +1017,108 @@ mod tests { assert_eq!(ks.len(), 0); assert!(!ks.exists("s")); } + + // --- smove --- + + #[test] + fn smove_moves_member() { + let mut ks = Keyspace::new(); + ks.sadd("src", &["a".into(), "b".into()]).unwrap(); + + let moved = ks.smove("src", "dst", "a").unwrap(); + assert!(moved); + assert!(!ks.sismember("src", "a").unwrap()); + assert!(ks.sismember("dst", "a").unwrap()); + assert_eq!(ks.scard("src").unwrap(), 1); + assert_eq!(ks.scard("dst").unwrap(), 1); + } + + #[test] + fn smove_missing_member_returns_false() { + let mut ks = Keyspace::new(); + ks.sadd("src", &["x".into()]).unwrap(); + + let moved = ks.smove("src", "dst", "missing").unwrap(); + assert!(!moved); + assert_eq!(ks.scard("src").unwrap(), 1); + assert!(!ks.exists("dst")); + } + + #[test] + fn smove_missing_source_returns_false() { + let mut ks = Keyspace::new(); + let moved = ks.smove("nosrc", "dst", "m").unwrap(); + assert!(!moved); + } + + #[test] + fn smove_removes_empty_source() { + let mut ks = Keyspace::new(); + ks.sadd("src", &["only".into()]).unwrap(); + + ks.smove("src", "dst", "only").unwrap(); + // source set is auto-deleted when it becomes empty + assert!(!ks.exists("src")); + assert_eq!(ks.scard("dst").unwrap(), 1); + } + + #[test] + fn smove_wrong_type_source_returns_error() { + let mut ks = Keyspace::new(); + ks.set("src".into(), Bytes::from("string"), None, false, false); + assert!(ks.smove("src", "dst", "m").is_err()); + } + + #[test] + fn smove_wrong_type_destination_returns_error() { + let mut ks = Keyspace::new(); + ks.sadd("src", &["m".into()]).unwrap(); + ks.set("dst".into(), Bytes::from("string"), None, false, false); + assert!(ks.smove("src", "dst", "m").is_err()); + } + + // --- sintercard --- + + #[test] + fn sintercard_basic() { + let mut ks = Keyspace::new(); + ks.sadd("s1", &["a".into(), "b".into(), "c".into()]) + .unwrap(); + ks.sadd("s2", &["b".into(), "c".into(), "d".into()]) + .unwrap(); + + assert_eq!(ks.sintercard(&["s1".into(), "s2".into()], 0).unwrap(), 2); + } + + #[test] + fn sintercard_with_limit() { + let mut ks = Keyspace::new(); + ks.sadd("s1", &["a".into(), "b".into(), "c".into()]) + .unwrap(); + ks.sadd("s2", &["a".into(), "b".into(), "c".into()]) + .unwrap(); + + // limit caps the result + assert_eq!(ks.sintercard(&["s1".into(), "s2".into()], 2).unwrap(), 2); + // limit 0 means no cap + assert_eq!(ks.sintercard(&["s1".into(), "s2".into()], 0).unwrap(), 3); + } + + #[test] + fn sintercard_missing_key_returns_zero() { + let mut ks = Keyspace::new(); + ks.sadd("s1", &["a".into()]).unwrap(); + + assert_eq!( + ks.sintercard(&["s1".into(), "missing".into()], 0).unwrap(), + 0 + ); + } + + #[test] + fn sintercard_wrong_type_returns_error() { + let mut ks = Keyspace::new(); + ks.set("str".into(), Bytes::from("val"), None, false, false); + assert!(ks.sintercard(&["str".into()], 0).is_err()); + } } diff --git a/crates/ember-core/src/shard/aof.rs b/crates/ember-core/src/shard/aof.rs index 409fe85e..29d1c31e 100644 --- a/crates/ember-core/src/shard/aof.rs +++ b/crates/ember-core/src/shard/aof.rs @@ -197,6 +197,24 @@ pub(super) fn to_aof_records( members: members.clone(), }] } + // SMOVE: persist as SREM from source + SADD to destination + ( + ShardRequest::SMove { + source, + destination, + member, + }, + ShardResponse::Bool(true), + ) => smallvec![ + AofRecord::SRem { + key: source, + members: vec![member.clone()], + }, + AofRecord::SAdd { + key: destination, + members: vec![member], + }, + ], // STORE commands: persist as DEL + SADD with the resulting members ( ShardRequest::SUnionStore { dest, .. } diff --git a/crates/ember-core/src/shard/mod.rs b/crates/ember-core/src/shard/mod.rs index caf951f2..a8e76184 100644 --- a/crates/ember-core/src/shard/mod.rs +++ b/crates/ember-core/src/shard/mod.rs @@ -445,6 +445,25 @@ pub enum ShardRequest { key: String, members: Vec, }, + /// SMOVE — atomically moves a member between two sets on the same shard. + SMove { + source: String, + destination: String, + member: String, + }, + /// SINTERCARD — returns cardinality of set intersection, capped at limit (0 = no limit). + SInterCard { + keys: Vec, + limit: usize, + }, + /// EXPIRETIME — returns the absolute expiry timestamp in seconds (-1 or -2 for missing/no-expiry). + Expiretime { + key: String, + }, + /// PEXPIRETIME — returns the absolute expiry timestamp in milliseconds (-1 or -2 for missing/no-expiry). + Pexpiretime { + key: String, + }, /// LMOVE: atomically pops from source and pushes to destination. LMove { source: String, @@ -699,6 +718,7 @@ impl ShardRequest { | ShardRequest::SUnionStore { .. } | ShardRequest::SInterStore { .. } | ShardRequest::SDiffStore { .. } + | ShardRequest::SMove { .. } | ShardRequest::LMove { .. } | ShardRequest::GetDel { .. } | ShardRequest::GetEx { .. } @@ -1899,6 +1919,21 @@ fn dispatch( Ok(results) => ShardResponse::BoolArray(results), Err(_) => ShardResponse::WrongType, }, + ShardRequest::SMove { + source, + destination, + member, + } => match ks.smove(source, destination, member) { + Ok(moved) => ShardResponse::Bool(moved), + Err(WriteError::WrongType) => ShardResponse::WrongType, + Err(WriteError::OutOfMemory) => ShardResponse::OutOfMemory, + }, + ShardRequest::SInterCard { keys, limit } => match ks.sintercard(keys, *limit) { + Ok(n) => ShardResponse::Integer(n as i64), + Err(_) => ShardResponse::WrongType, + }, + ShardRequest::Expiretime { key } => ShardResponse::Integer(ks.expiretime(key)), + ShardRequest::Pexpiretime { key } => ShardResponse::Integer(ks.pexpiretime(key)), ShardRequest::LMove { source, destination, diff --git a/crates/ember-core/src/time.rs b/crates/ember-core/src/time.rs index c3b927f2..c2428e24 100644 --- a/crates/ember-core/src/time.rs +++ b/crates/ember-core/src/time.rs @@ -2,9 +2,13 @@ //! //! Uses a process-local monotonic clock for timestamps that are smaller //! than std::time::Instant (8 bytes vs 16 bytes for Option). +//! +//! All internal expiry values are stored as monotonic milliseconds since +//! process start. Use `monotonic_to_unix_ms` to convert to wall-clock Unix +//! timestamps for commands like EXPIRETIME and PEXPIRETIME. use std::sync::OnceLock; -use std::time::Instant; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; /// Returns current monotonic time in milliseconds since process start. #[inline] @@ -26,6 +30,48 @@ pub fn now_secs() -> u32 { start.elapsed().as_secs() as u32 } +/// Converts a monotonic expiry timestamp (ms since process start) to a Unix +/// epoch timestamp in milliseconds. +/// +/// The conversion anchors the monotonic clock to wall time on first call using +/// a single `SystemTime::now()` sample. Subsequent calls use only the fast +/// monotonic clock and arithmetic — no system call. +/// +/// Returns `None` if the system clock predates the Unix epoch (shouldn't +/// happen on any real machine) or if `expires_at_ms` is `NO_EXPIRY`. +#[inline] +pub fn monotonic_to_unix_ms(expires_at_ms: u64) -> Option { + if expires_at_ms == NO_EXPIRY { + return None; + } + + // Capture the relationship between monotonic and wall-clock time once. + struct Anchor { + /// Unix epoch ms at the moment we captured the anchor. + unix_ms_at_capture: u64, + /// Monotonic ms at the moment we captured the anchor. + mono_ms_at_capture: u64, + } + + static ANCHOR: OnceLock = OnceLock::new(); + let anchor = ANCHOR.get_or_init(|| { + let unix_ms_at_capture = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u64::MAX as u128) as u64; + let mono_ms_at_capture = now_ms(); + Anchor { + unix_ms_at_capture, + mono_ms_at_capture, + } + }); + + // unix_ms = unix_at_capture + (mono_expiry - mono_at_capture) + let offset = expires_at_ms.saturating_sub(anchor.mono_ms_at_capture); + Some(anchor.unix_ms_at_capture.saturating_add(offset)) +} + /// Sentinel value meaning "no expiry". pub const NO_EXPIRY: u64 = 0; diff --git a/crates/ember-protocol/src/command/attributes.rs b/crates/ember-protocol/src/command/attributes.rs index b0a5459f..b8894ad5 100644 --- a/crates/ember-protocol/src/command/attributes.rs +++ b/crates/ember-protocol/src/command/attributes.rs @@ -61,6 +61,8 @@ impl Command { Command::Persist { .. } => "persist", Command::Pttl { .. } => "pttl", Command::Pexpire { .. } => "pexpire", + Command::Expiretime { .. } => "expiretime", + Command::Pexpiretime { .. } => "pexpiretime", // server Command::DbSize => "dbsize", @@ -146,6 +148,8 @@ impl Command { Command::SRandMember { .. } => "srandmember", Command::SPop { .. } => "spop", Command::SMisMember { .. } => "smismember", + Command::SMove { .. } => "smove", + Command::SInterCard { .. } => "sintercard", // cluster Command::ClusterInfo => "cluster_info", @@ -280,6 +284,7 @@ impl Command { | Command::SInterStore { .. } | Command::SDiffStore { .. } | Command::SPop { .. } + | Command::SMove { .. } // server / persistence | Command::FlushDb { .. } | Command::ConfigSet { .. } @@ -373,6 +378,8 @@ impl Command { | Command::ZScan { .. } => READ | KEYSPACE | SLOW, Command::Ttl { .. } | Command::Pttl { .. } + | Command::Expiretime { .. } + | Command::Pexpiretime { .. } | Command::ObjectEncoding { .. } | Command::ObjectRefcount { .. } => READ | KEYSPACE | FAST, @@ -450,11 +457,13 @@ impl Command { Command::SUnion { .. } | Command::SInter { .. } | Command::SDiff { .. } => { READ | SET | SLOW } + Command::SInterCard { .. } => READ | SET | SLOW, // set — writes Command::SAdd { .. } | Command::SRem { .. } | Command::SPop { .. } => { WRITE | SET | FAST } + Command::SMove { .. } => WRITE | SET | FAST, Command::SUnionStore { .. } | Command::SInterStore { .. } | Command::SDiffStore { .. } => WRITE | SET | SLOW, @@ -562,6 +571,8 @@ impl Command { | Command::Pexpire { key, .. } | Command::Ttl { key } | Command::Pttl { key } + | Command::Expiretime { key } + | Command::Pexpiretime { key } | Command::Type { key } | Command::Rename { key, .. } | Command::ObjectEncoding { key } @@ -625,6 +636,7 @@ impl Command { | Command::GetEx { key, .. } => Some(key), Command::LMove { source, .. } => Some(source), Command::Copy { source, .. } => Some(source), + Command::SMove { source, .. } => Some(source), Command::Del { keys } | Command::Unlink { keys } | Command::Exists { keys } @@ -635,6 +647,7 @@ impl Command { | Command::SUnion { keys } | Command::SInter { keys } | Command::SDiff { keys } + | Command::SInterCard { keys, .. } | Command::ZDiff { keys, .. } | Command::ZInter { keys, .. } | Command::ZUnion { keys, .. } => keys.first().map(String::as_str), diff --git a/crates/ember-protocol/src/command/mod.rs b/crates/ember-protocol/src/command/mod.rs index 92ffef20..d2e00ca0 100644 --- a/crates/ember-protocol/src/command/mod.rs +++ b/crates/ember-protocol/src/command/mod.rs @@ -445,6 +445,26 @@ pub enum Command { /// SMISMEMBER `key` `member` \[member ...\]. Checks multiple members at once. SMisMember { key: String, members: Vec }, + /// SMOVE `source` `destination` `member`. Atomically moves a member from one set to another. + /// Returns 1 if moved, 0 if the member was not in the source set. + SMove { + source: String, + destination: String, + member: String, + }, + + /// SINTERCARD `numkeys` `key` \[key ...\] \[LIMIT count\]. Returns the cardinality of the set + /// intersection. If LIMIT is given and nonzero, the result is capped at that value. + SInterCard { keys: Vec, limit: usize }, + + /// EXPIRETIME `key`. Returns the absolute Unix timestamp (seconds) when the key expires. + /// Returns -1 if the key has no expiry, -2 if the key does not exist. + Expiretime { key: String }, + + /// PEXPIRETIME `key`. Returns the absolute Unix timestamp (milliseconds) when the key expires. + /// Returns -1 if the key has no expiry, -2 if the key does not exist. + Pexpiretime { key: String }, + // --- cluster commands --- /// CLUSTER INFO. Returns cluster state and configuration information. ClusterInfo, diff --git a/crates/ember-protocol/src/command/parse.rs b/crates/ember-protocol/src/command/parse.rs index 97143572..a0743b9f 100644 --- a/crates/ember-protocol/src/command/parse.rs +++ b/crates/ember-protocol/src/command/parse.rs @@ -175,6 +175,10 @@ impl Command { "SRANDMEMBER" => parse_srandmember(&frames[1..]), "SPOP" => parse_spop(&frames[1..]), "SMISMEMBER" => parse_smismember(&frames[1..]), + "SMOVE" => parse_smove(&frames[1..]), + "SINTERCARD" => parse_sintercard(&frames[1..]), + "EXPIRETIME" => parse_expiretime(&frames[1..]), + "PEXPIRETIME" => parse_pexpiretime(&frames[1..]), "CLUSTER" => parse_cluster(&frames[1..]), "ASKING" => parse_asking(&frames[1..]), "MIGRATE" => parse_migrate(&frames[1..]), @@ -714,6 +718,67 @@ fn parse_pexpire(args: &[Frame]) -> Result { Ok(Command::Pexpire { key, milliseconds }) } +fn parse_expiretime(args: &[Frame]) -> Result { + if args.len() != 1 { + return Err(wrong_arity("EXPIRETIME")); + } + let key = extract_string(&args[0])?; + Ok(Command::Expiretime { key }) +} + +fn parse_pexpiretime(args: &[Frame]) -> Result { + if args.len() != 1 { + return Err(wrong_arity("PEXPIRETIME")); + } + let key = extract_string(&args[0])?; + Ok(Command::Pexpiretime { key }) +} + +fn parse_smove(args: &[Frame]) -> Result { + if args.len() != 3 { + return Err(wrong_arity("SMOVE")); + } + let source = extract_string(&args[0])?; + let destination = extract_string(&args[1])?; + let member = extract_string(&args[2])?; + Ok(Command::SMove { + source, + destination, + member, + }) +} + +fn parse_sintercard(args: &[Frame]) -> Result { + if args.len() < 2 { + return Err(wrong_arity("SINTERCARD")); + } + let numkeys = parse_u64(&args[0], "SINTERCARD")? as usize; + if numkeys == 0 || args.len() < 1 + numkeys { + return Err(ProtocolError::InvalidCommandFrame( + "SINTERCARD numkeys must be positive and match the number of keys provided".into(), + )); + } + let keys: Vec = args[1..=numkeys] + .iter() + .map(extract_string) + .collect::>()?; + // optional LIMIT n + let limit = if args.len() == numkeys + 3 { + let tag = extract_string(&args[numkeys + 1])?.to_ascii_uppercase(); + if tag != "LIMIT" { + return Err(ProtocolError::InvalidCommandFrame( + "SINTERCARD: expected LIMIT keyword".into(), + )); + } + parse_u64(&args[numkeys + 2], "SINTERCARD")? as usize + } else if args.len() == numkeys + 1 { + 0 + } else { + return Err(wrong_arity("SINTERCARD")); + }; + Ok(Command::SInterCard { keys, limit }) +} + fn parse_dbsize(args: &[Frame]) -> Result { if !args.is_empty() { return Err(wrong_arity("DBSIZE")); diff --git a/crates/ember-server/src/connection/execute.rs b/crates/ember-server/src/connection/execute.rs index 1e8ef9ac..af280bf8 100644 --- a/crates/ember-server/src/connection/execute.rs +++ b/crates/ember-server/src/connection/execute.rs @@ -362,6 +362,26 @@ pub(super) async fn execute( } } + 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 @@ -553,8 +573,7 @@ pub(super) async fn execute( std::sync::atomic::Ordering::Relaxed, ); } else if key == "notify-keyspace-events" { - let flags = - crate::keyspace_notifications::parse_keyspace_event_flags(&value); + let flags = crate::keyspace_notifications::parse_keyspace_event_flags(&value); ctx.keyspace_event_flags .store(flags, std::sync::atomic::Ordering::Relaxed); } @@ -1688,6 +1707,108 @@ pub(super) async fn execute( } } + 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 (first, rest) = sets.split_first().expect("non-empty"); + 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, diff --git a/crates/ember-server/src/keyspace_notifications.rs b/crates/ember-server/src/keyspace_notifications.rs index 705c94b4..c490fe4c 100644 --- a/crates/ember-server/src/keyspace_notifications.rs +++ b/crates/ember-server/src/keyspace_notifications.rs @@ -74,14 +74,7 @@ pub fn parse_keyspace_event_flags(s: &str) -> u32 { 'x' => flags |= FLAG_X, 'd' => flags |= FLAG_D, 'A' => { - flags |= FLAG_G - | FLAG_DOLLAR - | FLAG_L - | FLAG_Z - | FLAG_X - | FLAG_H - | FLAG_S - | FLAG_D + flags |= FLAG_G | FLAG_DOLLAR | FLAG_L | FLAG_Z | FLAG_X | FLAG_H | FLAG_S | FLAG_D } _ => {} // unknown flags silently ignored } diff --git a/crates/ember-server/src/metrics.rs b/crates/ember-server/src/metrics.rs index e062970e..ac1857cd 100644 --- a/crates/ember-server/src/metrics.rs +++ b/crates/ember-server/src/metrics.rs @@ -267,8 +267,8 @@ pub fn spawn_stats_poller(engine: Engine, ctx: Arc, poll_interval // expired and evicted are cumulative totals from shards — // publish the delta so prometheus sees a proper counter. - let expired = total.keys_expired as u64; - let evicted = total.keys_evicted as u64; + let expired = total.keys_expired; + let evicted = total.keys_evicted; let delta_expired = expired.saturating_sub(last_expired); let delta_evicted = evicted.saturating_sub(last_evicted); if delta_expired > 0 { diff --git a/crates/ember-server/src/server.rs b/crates/ember-server/src/server.rs index f8ac5b21..6e438371 100644 --- a/crates/ember-server/src/server.rs +++ b/crates/ember-server/src/server.rs @@ -376,9 +376,7 @@ pub async fn run_concurrent( } } Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { - tracing::warn!( - "keyspace notification subscriber lagged by {n} messages" - ); + tracing::warn!("keyspace notification subscriber lagged by {n} messages"); } Err(tokio::sync::broadcast::error::RecvError::Closed) => break, } @@ -705,9 +703,7 @@ pub async fn run_threaded( } } Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { - tracing::warn!( - "keyspace notification subscriber lagged by {n} messages" - ); + tracing::warn!("keyspace notification subscriber lagged by {n} messages"); } Err(tokio::sync::broadcast::error::RecvError::Closed) => break, } diff --git a/tests/integration/src/data_types.rs b/tests/integration/src/data_types.rs index c56041b0..e115bb4c 100644 --- a/tests/integration/src/data_types.rs +++ b/tests/integration/src/data_types.rs @@ -244,3 +244,167 @@ async fn zset_rem() { assert_eq!(c.get_int(&["ZREM", "z", "a", "missing"]).await, 1); assert_eq!(c.get_int(&["ZCARD", "z"]).await, 1); } + +// --- EXPIRETIME / PEXPIRETIME --- + +#[tokio::test] +async fn expiretime_returns_absolute_epoch() { + let server = TestServer::start(); + let mut c = server.connect().await; + + let before = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + c.cmd(&["SET", "k", "v"]).await; + c.cmd(&["EXPIRE", "k", "100"]).await; + + let ts = c.get_int(&["EXPIRETIME", "k"]).await; + // should be ≈ now + 100 seconds + assert!(ts >= before + 99 && ts <= before + 101); +} + +#[tokio::test] +async fn expiretime_no_expiry_returns_minus_one() { + let server = TestServer::start(); + let mut c = server.connect().await; + + c.cmd(&["SET", "k", "v"]).await; + assert_eq!(c.get_int(&["EXPIRETIME", "k"]).await, -1); +} + +#[tokio::test] +async fn expiretime_missing_key_returns_minus_two() { + let server = TestServer::start(); + let mut c = server.connect().await; + + assert_eq!(c.get_int(&["EXPIRETIME", "missing"]).await, -2); +} + +#[tokio::test] +async fn pexpiretime_precision() { + let server = TestServer::start(); + let mut c = server.connect().await; + + let before_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + + c.cmd(&["SET", "k", "v"]).await; + // PEXPIRE with 10_000 ms (10 seconds) + c.cmd(&["PEXPIRE", "k", "10000"]).await; + + let ts_ms = c.get_int(&["PEXPIRETIME", "k"]).await; + assert!(ts_ms >= before_ms + 9_000 && ts_ms <= before_ms + 11_000); +} + +#[tokio::test] +async fn pexpiretime_no_expiry_returns_minus_one() { + let server = TestServer::start(); + let mut c = server.connect().await; + + c.cmd(&["SET", "k", "v"]).await; + assert_eq!(c.get_int(&["PEXPIRETIME", "k"]).await, -1); +} + +#[tokio::test] +async fn pexpiretime_missing_key_returns_minus_two() { + let server = TestServer::start(); + let mut c = server.connect().await; + + assert_eq!(c.get_int(&["PEXPIRETIME", "missing"]).await, -2); +} + +// --- SMOVE --- + +#[tokio::test] +async fn smove_basic() { + let server = TestServer::start(); + let mut c = server.connect().await; + + c.cmd(&["SADD", "src", "a", "b", "c"]).await; + + // move "a" from src to dst + assert_eq!(c.get_int(&["SMOVE", "src", "dst", "a"]).await, 1); + + assert_eq!(c.get_int(&["SCARD", "src"]).await, 2); + assert_eq!(c.get_int(&["SISMEMBER", "src", "a"]).await, 0); + assert_eq!(c.get_int(&["SCARD", "dst"]).await, 1); + assert_eq!(c.get_int(&["SISMEMBER", "dst", "a"]).await, 1); +} + +#[tokio::test] +async fn smove_missing_member_returns_zero() { + let server = TestServer::start(); + let mut c = server.connect().await; + + c.cmd(&["SADD", "src", "x"]).await; + + // "y" is not in src + assert_eq!(c.get_int(&["SMOVE", "src", "dst", "y"]).await, 0); + + // src unchanged + assert_eq!(c.get_int(&["SCARD", "src"]).await, 1); + // dst was not created + assert_eq!(c.get_int(&["EXISTS", "dst"]).await, 0); +} + +#[tokio::test] +async fn smove_missing_source_returns_zero() { + let server = TestServer::start(); + let mut c = server.connect().await; + + assert_eq!(c.get_int(&["SMOVE", "nosrc", "dst", "m"]).await, 0); +} + +// --- SINTERCARD --- + +#[tokio::test] +async fn sintercard_basic() { + let server = TestServer::start(); + let mut c = server.connect().await; + + c.cmd(&["SADD", "s1", "a", "b", "c"]).await; + c.cmd(&["SADD", "s2", "b", "c", "d"]).await; + c.cmd(&["SADD", "s3", "c", "d", "e"]).await; + + // intersection of s1, s2, s3 is {"c"} → cardinality 1 + assert_eq!(c.get_int(&["SINTERCARD", "3", "s1", "s2", "s3"]).await, 1); + + // intersection of s1 and s2 is {"b", "c"} → cardinality 2 + assert_eq!(c.get_int(&["SINTERCARD", "2", "s1", "s2"]).await, 2); +} + +#[tokio::test] +async fn sintercard_with_limit() { + let server = TestServer::start(); + let mut c = server.connect().await; + + c.cmd(&["SADD", "a", "1", "2", "3", "4", "5"]).await; + c.cmd(&["SADD", "b", "1", "2", "3", "4", "5"]).await; + + // full intersection is 5, limit to 3 + assert_eq!( + c.get_int(&["SINTERCARD", "2", "a", "b", "LIMIT", "3"]) + .await, + 3 + ); + // limit 0 means no cap + assert_eq!( + c.get_int(&["SINTERCARD", "2", "a", "b", "LIMIT", "0"]) + .await, + 5 + ); +} + +#[tokio::test] +async fn sintercard_missing_key_returns_zero() { + let server = TestServer::start(); + let mut c = server.connect().await; + + c.cmd(&["SADD", "s", "a"]).await; + + assert_eq!(c.get_int(&["SINTERCARD", "2", "s", "missing"]).await, 0); +} diff --git a/tests/integration/src/persistence.rs b/tests/integration/src/persistence.rs index 2f1f7b1b..8d388d90 100644 --- a/tests/integration/src/persistence.rs +++ b/tests/integration/src/persistence.rs @@ -27,7 +27,8 @@ async fn sigkill_crash_recovery() { for i in 0..KEY_COUNT { // appendfsync=always means each OK guarantees a fsync — all // of these must survive even a SIGKILL immediately after. - c.ok(&["SET", &format!("crash:{i}"), &format!("v{i}")]).await; + c.ok(&["SET", &format!("crash:{i}"), &format!("v{i}")]) + .await; } // drop immediately — no sleep, no graceful shutdown. Child::kill() diff --git a/tests/integration/src/tls.rs b/tests/integration/src/tls.rs index 95689206..cd484c21 100644 --- a/tests/integration/src/tls.rs +++ b/tests/integration/src/tls.rs @@ -17,9 +17,7 @@ use crate::helpers::{ServerOptions, TestServer}; /// Generates a self-signed cert/key pair for `localhost` and writes PEM files /// into the given directory. Returns the cert path, key path, and the raw DER /// bytes needed to build a client-side trust store. -fn generate_test_cert( - dir: &std::path::Path, -) -> (std::path::PathBuf, std::path::PathBuf, Vec) { +fn generate_test_cert(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf, Vec) { let rcgen::CertifiedKey { cert, key_pair } = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]) .expect("rcgen cert generation failed"); @@ -67,7 +65,10 @@ async fn tls_cmd( let mut buf = BytesMut::with_capacity(1024); loop { let n = stream.read_buf(&mut buf).await.expect("read failed"); - assert!(n > 0 || !buf.is_empty(), "server closed connection unexpectedly"); + assert!( + n > 0 || !buf.is_empty(), + "server closed connection unexpectedly" + ); match parse_frame(&buf) { Ok(Some((frame, consumed))) => { let _ = buf.split_to(consumed);