diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock
new file mode 100644
index 00000000..1a50fa8a
--- /dev/null
+++ b/.claude/scheduled_tasks.lock
@@ -0,0 +1 @@
+{"sessionId":"66332041-4f74-42df-8954-9e0482baacdd","pid":11173,"acquiredAt":1775933454187}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fffe104a..b9a32777 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Fair comparison benchmark** (`tests/graph_bench_compare.rs`): Moon 2.4x FalkorDB on Cypher MATCH, 19x on native 1-hop, 23x on population.
- **New dependencies**: `slotmap` 1.x (generational indices), `boomphf` 0.6 (MPH), `logos` 0.14 (Cypher lexer, optional).
+### Added — High-Impact Redis Command Parity (2026-04-10)
+
+- **COPY command** — atomic key duplication with DESTINATION, REPLACE options (Redis 6.2+).
+- **Bit operations** — GETBIT, SETBIT, BITCOUNT (byte/bit range modes), BITOP (AND/OR/XOR/NOT), BITPOS (byte/bit range modes) with read-only dispatch variants.
+- **SORT command** — full BY/GET/LIMIT/ALPHA/ASC/DESC/STORE support for lists, sets, and sorted sets.
+- **Geospatial commands** — GEOADD (NX/XX/CH), GEOPOS, GEODIST (M/KM/FT/MI), GEOHASH (11-char base32), GEOSEARCH (FROMLONLAT/FROMMEMBER, BYRADIUS/BYBOX, WITHCOORD/WITHDIST/WITHHASH), GEOSEARCHSTORE.
+- **CONFIG REWRITE** — atomic write of runtime config to `
/moon.conf` (tmpfile + rename). CONFIG RESETSTAT stub.
+- **CLIENT PAUSE/UNPAUSE** — delays command processing with WRITE-only mode support. CLIENT INFO, CLIENT LIST (stub), CLIENT NO-EVICT/NO-TOUCH accepted.
+- **MEMORY USAGE/DOCTOR/HELP** — key memory estimation via `estimate_memory()`.
+- **Lazyfree threshold** — configurable via `CONFIG SET lazyfree-threshold N` (default 64).
+- **GETBIT/SETBIT metadata** — added to PHF command registry.
+- **GEOADD/GEOSEARCHSTORE** — added to AOF write commands test list.
+- **EXPIREAT/PEXPIREAT** — absolute Unix timestamp expiry (seconds/milliseconds).
+- **EXPIRETIME/PEXPIRETIME** — read back absolute expiry timestamp.
+- **FLUSHDB/FLUSHALL** — clear all keys in current database.
+- **TIME** — server clock as `[seconds, microseconds]`.
+- **RANDOMKEY** — return a random key from the database.
+- **TOUCH** — refresh LRU/LFU access time without reading value.
+- **SHUTDOWN** — dispatch entry (graceful stop via signal handler).
+- **BITFIELD** — GET/SET/INCRBY with type specifiers (u8/i16/u32/...), OVERFLOW WRAP/SAT/FAIL.
+- **LCS** — Longest Common Substring with LEN option.
+- **XSETID** — set stream last-delivered ID without adding entries.
+- **GEORADIUS/GEORADIUSBYMEMBER** — deprecated wrappers translating to GEOSEARCH.
+- **OBJECT FREQ/IDLETIME/REFCOUNT** — LFU counter, idle seconds, reference count introspection.
+- **LOLWUT** — Easter egg returning Moon version.
+
### Added — Client Connection Security Hardening (2026-04-10)
- **`--maxclients` (P0):** Connection limit with atomic CAS rejection (default 10000, 0=unlimited). Returns `-ERR max number of clients reached` when exceeded.
diff --git a/scripts/test-commands.sh b/scripts/test-commands.sh
index a1770a77..695ae078 100755
--- a/scripts/test-commands.sh
+++ b/scripts/test-commands.sh
@@ -550,11 +550,61 @@ if should_run "key"; then
rcli SET k:rnx1 v1 >/dev/null 2>&1; mcli SET k:rnx1 v1 >/dev/null 2>&1
rcli SET k:rnx2 v2 >/dev/null 2>&1; mcli SET k:rnx2 v2 >/dev/null 2>&1
assert_match "RENAMENX (blocked)" RENAMENX k:rnx1 k:rnx2
+ rcli SET k:cpsrc cpval >/dev/null 2>&1; mcli SET k:cpsrc cpval >/dev/null 2>&1
+ assert_match "COPY" COPY k:cpsrc k:cpdst
+ assert_match "GET after COPY" GET k:cpdst
+ rcli SET k:cpdst2 old >/dev/null 2>&1; mcli SET k:cpdst2 old >/dev/null 2>&1
+ assert_match "COPY no REPLACE" COPY k:cpsrc k:cpdst2
+ assert_match "COPY REPLACE" COPY k:cpsrc k:cpdst2 REPLACE
assert_match "UNLINK" UNLINK k:renamed
assert_moon_ok "DBSIZE" DBSIZE
assert_moon_ok "SCAN cursor" SCAN 0
assert_moon_ok "KEYS pattern" KEYS "k:*"
assert_moon_ok "OBJECT HELP" OBJECT HELP
+
+ # Bit operations
+ rcli SET k:bits "\xff\x0f" >/dev/null 2>&1; mcli SET k:bits "\xff\x0f" >/dev/null 2>&1
+ assert_match "GETBIT" GETBIT k:bits 0
+ assert_match "SETBIT" SETBIT k:bits 0 0
+ assert_match "BITCOUNT" BITCOUNT k:bits
+ assert_match "BITCOUNT range" BITCOUNT k:bits 0 0
+ rcli SET k:bits2 "\x0f\xff" >/dev/null 2>&1; mcli SET k:bits2 "\x0f\xff" >/dev/null 2>&1
+ assert_match "BITOP AND" BITOP AND k:bitdst k:bits k:bits2
+ assert_match "BITOP OR" BITOP OR k:bitdst k:bits k:bits2
+ assert_match "BITOP XOR" BITOP XOR k:bitdst k:bits k:bits2
+ assert_match "BITOP NOT" BITOP NOT k:bitdst k:bits
+ assert_match "BITPOS 1" BITPOS k:bits 1
+ assert_match "BITPOS 0" BITPOS k:bits 0
+
+ # SORT
+ rcli RPUSH k:sortl 3 1 2 >/dev/null 2>&1; mcli RPUSH k:sortl 3 1 2 >/dev/null 2>&1
+ assert_match "SORT numeric" SORT k:sortl
+ assert_match "SORT DESC" SORT k:sortl DESC
+ assert_match "SORT ALPHA" SORT k:sortl ALPHA
+ assert_match "SORT LIMIT" SORT k:sortl LIMIT 0 2
+
+ # GEO commands
+ rcli GEOADD k:geo 13.361389 38.115556 Palermo 15.087269 37.502669 Catania >/dev/null 2>&1
+ mcli GEOADD k:geo 13.361389 38.115556 Palermo 15.087269 37.502669 Catania >/dev/null 2>&1
+ assert_match "GEOPOS" GEOPOS k:geo Palermo
+ assert_match "GEODIST km" GEODIST k:geo Palermo Catania km
+ assert_match "GEOHASH" GEOHASH k:geo Palermo
+ assert_match "GEOSEARCH" GEOSEARCH k:geo FROMLONLAT 15 37 BYRADIUS 200 km ASC
+ # EXPIREAT / PEXPIREAT / EXPIRETIME / PEXPIRETIME
+ rcli SET k:eat val >/dev/null 2>&1; mcli SET k:eat val >/dev/null 2>&1
+ assert_match "EXPIREAT" EXPIREAT k:eat 9999999999
+ assert_match "TTL after EXPIREAT" TTL k:eat
+ assert_match "EXPIRETIME" EXPIRETIME k:eat
+ assert_match "PEXPIRETIME" PEXPIRETIME k:eat
+
+ # TIME / RANDOMKEY / TOUCH
+ assert_moon_ok "TIME" TIME
+ rcli SET k:rnd val >/dev/null 2>&1; mcli SET k:rnd val >/dev/null 2>&1
+ assert_moon_ok "RANDOMKEY" RANDOMKEY
+ assert_match "TOUCH" TOUCH k:rnd
+
+ # FLUSHDB
+ assert_match "FLUSHDB" FLUSHDB
fi
# ===========================================================================
diff --git a/scripts/test-consistency.sh b/scripts/test-consistency.sh
index f854cb50..61206888 100755
--- a/scripts/test-consistency.sh
+++ b/scripts/test-consistency.sh
@@ -476,6 +476,73 @@ LONGKEY=$(python3 -c "print('k' * 500)")
both SET "$LONGKEY" "long_key_value"
assert_both "GET with 500-char key" GET "$LONGKEY"
+# COPY
+both SET edge:cpsrc "copy_value"
+assert_both "COPY basic" COPY edge:cpsrc edge:cpdst
+assert_both "GET after COPY src" GET edge:cpsrc
+assert_both "GET after COPY dst" GET edge:cpdst
+both SET edge:cpdst2 "old_value"
+assert_both "COPY no REPLACE" COPY edge:cpsrc edge:cpdst2
+assert_both "GET COPY no REPLACE" GET edge:cpdst2
+assert_both "COPY REPLACE" COPY edge:cpsrc edge:cpdst2 REPLACE
+assert_both "GET after COPY REPLACE" GET edge:cpdst2
+
+# SETBIT / GETBIT
+both SETBIT edge:bits 7 1
+assert_both "GETBIT set" GETBIT edge:bits 7
+assert_both "GETBIT unset" GETBIT edge:bits 0
+both SETBIT edge:bits 0 1
+assert_both "BITCOUNT" BITCOUNT edge:bits
+
+# BITOP
+both SET edge:bop1 "\xff"
+both SET edge:bop2 "\x0f"
+assert_both "BITOP AND" BITOP AND edge:bopdst edge:bop1 edge:bop2
+assert_both "GET BITOP AND" GET edge:bopdst
+assert_both "BITOP OR" BITOP OR edge:bopdst edge:bop1 edge:bop2
+assert_both "GET BITOP OR" GET edge:bopdst
+assert_both "BITOP NOT" BITOP NOT edge:bopdst edge:bop1
+assert_both "GET BITOP NOT" GET edge:bopdst
+
+# BITPOS
+both SET edge:bpos "\x00\xff"
+assert_both "BITPOS 1" BITPOS edge:bpos 1
+assert_both "BITPOS 0" BITPOS edge:bpos 0
+
+# SORT
+both RPUSH edge:sortl 3 1 2
+assert_both "SORT numeric" SORT edge:sortl
+assert_both "SORT DESC" SORT edge:sortl DESC
+assert_both "SORT ALPHA" SORT edge:sortl ALPHA
+assert_both "SORT LIMIT" SORT edge:sortl LIMIT 0 2
+assert_both "SORT STORE" SORT edge:sortl STORE edge:sorted
+assert_both "SORT STORE result" LRANGE edge:sorted 0 -1
+
+# GEOADD / GEOPOS / GEODIST / GEOHASH / GEOSEARCH
+both GEOADD edge:geo 13.361389 38.115556 Palermo 15.087269 37.502669 Catania
+assert_both "GEOPOS" GEOPOS edge:geo Palermo
+assert_both "GEOPOS missing" GEOPOS edge:geo NonExistent
+assert_both "GEODIST m" GEODIST edge:geo Palermo Catania
+assert_both "GEODIST km" GEODIST edge:geo Palermo Catania km
+assert_both "GEOHASH" GEOHASH edge:geo Palermo
+assert_both "GEOADD count" GEOADD edge:geo 2.349014 48.864716 Paris
+
+# EXPIREAT / PEXPIREAT / EXPIRETIME / PEXPIRETIME
+both SET edge:eat "val"
+assert_both "EXPIREAT" EXPIREAT edge:eat 9999999999
+assert_both "EXPIRETIME" EXPIRETIME edge:eat
+assert_both "PEXPIRETIME" PEXPIRETIME edge:eat
+assert_both "EXPIRETIME missing" EXPIRETIME edge:nokey
+assert_both "PEXPIRETIME missing" PEXPIRETIME edge:nokey
+
+# TOUCH
+both SET edge:touch "val"
+assert_both "TOUCH" TOUCH edge:touch
+assert_both "TOUCH missing" TOUCH edge:nomiss
+
+# FLUSHDB (run last — clears all keys)
+assert_both "FLUSHDB" FLUSHDB
+
# ===========================================================================
# Summary
# ===========================================================================
diff --git a/src/command/config.rs b/src/command/config.rs
index 3a679618..607fce6b 100644
--- a/src/command/config.rs
+++ b/src/command/config.rs
@@ -51,6 +51,10 @@ pub fn config_get(
runtime_config.protected_mode.clone(),
),
(b"acllog-max-len", runtime_config.acllog_max_len.to_string()),
+ (
+ b"lazyfree-threshold" as &[u8],
+ runtime_config.lazyfree_threshold.to_string(),
+ ),
(b"maxclients", runtime_config.maxclients.to_string()),
(b"timeout", runtime_config.timeout.to_string()),
(b"tcp-keepalive", runtime_config.tcp_keepalive.to_string()),
@@ -174,6 +178,15 @@ pub fn config_set(runtime_config: &mut RuntimeConfig, args: &[Frame]) -> Frame {
)));
}
},
+ "lazyfree-threshold" => match value_str.parse::() {
+ Ok(v) => runtime_config.lazyfree_threshold = v,
+ Err(_) => {
+ return Frame::Error(Bytes::from(format!(
+ "ERR Invalid argument '{}' for CONFIG SET 'lazyfree-threshold'",
+ value_str
+ )));
+ }
+ },
"maxclients" => match value_str.parse::() {
Ok(v) => runtime_config.maxclients = v,
Err(_) => {
@@ -215,6 +228,93 @@ pub fn config_set(runtime_config: &mut RuntimeConfig, args: &[Frame]) -> Frame {
Frame::SimpleString(Bytes::from_static(b"OK"))
}
+/// CONFIG REWRITE — serialize current runtime config to a Redis-style config file.
+///
+/// Writes to `/moon.conf` atomically (tmpfile + rename).
+pub fn config_rewrite(runtime_config: &RuntimeConfig, server_config: &ServerConfig) -> Frame {
+ let mut lines = Vec::with_capacity(20);
+ lines.push("# Moon configuration file — generated by CONFIG REWRITE".to_string());
+ lines.push(format!("# {}", chrono_lite_now()));
+ lines.push(String::new());
+
+ // Server settings (from ServerConfig — immutable at runtime but persisted)
+ lines.push(format!("bind {}", server_config.bind));
+ lines.push(format!("port {}", server_config.port));
+ lines.push(format!("databases {}", server_config.databases));
+ if let Some(ref pass) = runtime_config.requirepass {
+ lines.push(format!("requirepass {}", pass));
+ }
+ lines.push(format!("protected-mode {}", runtime_config.protected_mode));
+ lines.push(String::new());
+
+ // Memory settings
+ lines.push(format!("maxmemory {}", runtime_config.maxmemory));
+ lines.push(format!(
+ "maxmemory-policy {}",
+ runtime_config.maxmemory_policy
+ ));
+ lines.push(format!(
+ "maxmemory-samples {}",
+ runtime_config.maxmemory_samples
+ ));
+ lines.push(format!("lfu-log-factor {}", runtime_config.lfu_log_factor));
+ lines.push(format!("lfu-decay-time {}", runtime_config.lfu_decay_time));
+ lines.push(String::new());
+
+ // Persistence settings
+ lines.push(format!("dir {}", runtime_config.dir));
+ lines.push(format!("dbfilename {}", server_config.dbfilename));
+ lines.push(format!("appendonly {}", runtime_config.appendonly));
+ lines.push(format!("appendfsync {}", runtime_config.appendfsync));
+ lines.push(format!("appendfilename {}", server_config.appendfilename));
+ if let Some(ref save) = runtime_config.save {
+ lines.push(format!("save {}", save));
+ }
+ lines.push(String::new());
+
+ // ACL settings
+ lines.push(format!("acllog-max-len {}", runtime_config.acllog_max_len));
+ if let Some(ref aclfile) = runtime_config.aclfile {
+ lines.push(format!("aclfile {}", aclfile));
+ }
+ lines.push(format!(
+ "lazyfree-threshold {}",
+ runtime_config.lazyfree_threshold
+ ));
+
+ let content = lines.join("\n") + "\n";
+
+ // Atomic write: tmpfile + rename
+ let dir = &runtime_config.dir;
+ let conf_path = std::path::Path::new(dir).join("moon.conf");
+ let tmp_path = std::path::Path::new(dir).join("moon.conf.tmp");
+
+ if let Err(e) = std::fs::write(&tmp_path, content.as_bytes()) {
+ return Frame::Error(Bytes::from(format!("ERR failed to write config: {e}")));
+ }
+ if let Err(e) = std::fs::rename(&tmp_path, &conf_path) {
+ let _ = std::fs::remove_file(&tmp_path);
+ return Frame::Error(Bytes::from(format!("ERR failed to rename config: {e}")));
+ }
+
+ Frame::SimpleString(Bytes::from_static(b"OK"))
+}
+
+/// Lightweight timestamp without chrono dependency.
+fn chrono_lite_now() -> String {
+ use std::time::{SystemTime, UNIX_EPOCH};
+ let secs = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs();
+ format!("Generated at epoch {secs}")
+}
+
+/// CONFIG RESETSTAT — reset server statistics (placeholder).
+pub fn config_resetstat() -> Frame {
+ Frame::SimpleString(Bytes::from_static(b"OK"))
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -323,4 +423,30 @@ mod tests {
assert_eq!(rt.maxmemory, 2048);
assert_eq!(rt.maxmemory_policy, "allkeys-lfu");
}
+
+ #[test]
+ fn test_config_rewrite() {
+ let tmp = std::env::temp_dir().join(format!("moon-test-{}", std::process::id()));
+ std::fs::create_dir_all(&tmp).unwrap();
+
+ let mut rt = RuntimeConfig::default();
+ rt.maxmemory = 1_073_741_824; // 1GB
+ rt.maxmemory_policy = "allkeys-lru".to_string();
+ rt.dir = tmp.to_string_lossy().to_string();
+
+ let sc = default_server_config();
+ let result = config_rewrite(&rt, &sc);
+ assert_eq!(result, Frame::SimpleString(Bytes::from_static(b"OK")));
+
+ // Verify file was created
+ let conf_path = tmp.join("moon.conf");
+ assert!(conf_path.exists());
+ let content = std::fs::read_to_string(&conf_path).unwrap();
+ assert!(content.contains("maxmemory 1073741824"));
+ assert!(content.contains("maxmemory-policy allkeys-lru"));
+ assert!(content.contains("port 6379"));
+
+ // Cleanup
+ let _ = std::fs::remove_dir_all(tmp);
+ }
}
diff --git a/src/command/geo/geo_cmd.rs b/src/command/geo/geo_cmd.rs
new file mode 100644
index 00000000..43a055e6
--- /dev/null
+++ b/src/command/geo/geo_cmd.rs
@@ -0,0 +1,656 @@
+use bytes::Bytes;
+use ordered_float::OrderedFloat;
+
+use crate::protocol::Frame;
+use crate::storage::Database;
+
+use crate::command::helpers::{err_wrong_args, extract_bytes};
+
+use super::{
+ convert_distance, geohash_decode, geohash_encode, geohash_to_string, haversine_distance,
+ parse_unit,
+};
+
+fn parse_f64(frame: &Frame) -> Option {
+ let b = extract_bytes(frame)?;
+ std::str::from_utf8(b).ok()?.parse().ok()
+}
+
+/// GEOADD key [NX|XX] [CH] longitude latitude member [longitude latitude member ...]
+pub fn geoadd(db: &mut Database, args: &[Frame]) -> Frame {
+ if args.len() < 4 {
+ return err_wrong_args("GEOADD");
+ }
+ let key = match extract_bytes(&args[0]) {
+ Some(k) => k,
+ None => return err_wrong_args("GEOADD"),
+ };
+
+ // Parse optional NX/XX/CH flags
+ let mut nx = false;
+ let mut xx = false;
+ let mut ch = false;
+ let mut i = 1;
+ while i < args.len() {
+ let arg = match extract_bytes(&args[i]) {
+ Some(a) => a,
+ None => break,
+ };
+ if arg.eq_ignore_ascii_case(b"NX") {
+ nx = true;
+ i += 1;
+ } else if arg.eq_ignore_ascii_case(b"XX") {
+ xx = true;
+ i += 1;
+ } else if arg.eq_ignore_ascii_case(b"CH") {
+ ch = true;
+ i += 1;
+ } else {
+ break;
+ }
+ }
+
+ if nx && xx {
+ return Frame::Error(Bytes::from_static(
+ b"ERR XX and NX options at the same time are not compatible",
+ ));
+ }
+
+ // Remaining args must be triples: longitude latitude member
+ let remaining = &args[i..];
+ if remaining.len() < 3 || !remaining.len().is_multiple_of(3) {
+ return err_wrong_args("GEOADD");
+ }
+
+ let (members, tree) = match db.get_or_create_sorted_set(key) {
+ Ok(pair) => pair,
+ Err(e) => return e,
+ };
+
+ let mut added = 0i64;
+ let mut changed = 0i64;
+
+ for chunk in remaining.chunks_exact(3) {
+ let lon = match parse_f64(&chunk[0]) {
+ Some(v) if (-180.0..=180.0).contains(&v) => v,
+ _ => {
+ return Frame::Error(Bytes::from_static(
+ b"ERR value is not a valid float or out of range",
+ ));
+ }
+ };
+ let lat = match parse_f64(&chunk[1]) {
+ Some(v) if (-85.05112878..=85.05112878).contains(&v) => v,
+ _ => {
+ return Frame::Error(Bytes::from_static(
+ b"ERR value is not a valid float or out of range",
+ ));
+ }
+ };
+ let member = match extract_bytes(&chunk[2]) {
+ Some(m) => Bytes::copy_from_slice(m),
+ None => return err_wrong_args("GEOADD"),
+ };
+
+ let score = geohash_encode(lon, lat);
+ let exists = members.contains_key(&member);
+
+ if nx && exists {
+ continue;
+ }
+ if xx && !exists {
+ continue;
+ }
+
+ if exists {
+ let old_score = members[&member];
+ if (old_score - score).abs() > f64::EPSILON {
+ tree.remove(OrderedFloat(old_score), &member);
+ tree.insert(OrderedFloat(score), member.clone());
+ members.insert(member, score);
+ changed += 1;
+ }
+ } else {
+ tree.insert(OrderedFloat(score), member.clone());
+ members.insert(member, score);
+ added += 1;
+ changed += 1;
+ }
+ }
+
+ Frame::Integer(if ch { changed } else { added })
+}
+
+/// GEOPOS key member [member ...]
+pub fn geopos(db: &mut Database, args: &[Frame]) -> Frame {
+ if args.len() < 2 {
+ return err_wrong_args("GEOPOS");
+ }
+ let key = match extract_bytes(&args[0]) {
+ Some(k) => k,
+ None => return err_wrong_args("GEOPOS"),
+ };
+
+ // Collect scores first to avoid holding borrow across format! allocations
+ let scores: Vec