Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5dd7e11
feat: implement COPY command for atomic key duplication
TinDang97 Apr 10, 2026
efa490e
feat: implement bit operations (GETBIT, SETBIT, BITCOUNT, BITOP, BITPOS)
TinDang97 Apr 10, 2026
757ccb3
feat: implement SORT command with BY/GET/LIMIT/ALPHA/DESC/STORE
TinDang97 Apr 10, 2026
f3b787a
feat: implement geospatial commands (GEOADD, GEOPOS, GEODIST, GEOHASH…
TinDang97 Apr 10, 2026
eb32d3e
style: fix clippy manual_is_multiple_of warning in geo_cmd
TinDang97 Apr 10, 2026
a89f440
fix: update dispatch_read prefilter test for new (6,b'b') bucket
TinDang97 Apr 10, 2026
9be660e
style: cargo fmt
TinDang97 Apr 10, 2026
a32f0b5
feat: implement CONFIG REWRITE and CONFIG RESETSTAT
TinDang97 Apr 10, 2026
e781ee1
feat: implement P1 medium-impact features
TinDang97 Apr 10, 2026
cfbcaf0
style: cargo fmt
TinDang97 Apr 10, 2026
a86dbb9
refactor: split COPY/SORT/MEMORY from key.rs into key_extra.rs
TinDang97 Apr 10, 2026
50db06f
docs: add CHANGELOG entry for high-impact Redis command parity
TinDang97 Apr 10, 2026
dfc4849
fix: address all 16 review findings from PR #68
TinDang97 Apr 10, 2026
05c782a
feat: implement Tier 1 gap commands (EXPIREAT, FLUSHDB, TIME, RANDOMK…
TinDang97 Apr 11, 2026
ce01137
docs: add Tier 1 commands to CHANGELOG
TinDang97 Apr 11, 2026
0f5c2d4
feat: resolve all remaining Redis command gaps
TinDang97 Apr 11, 2026
5d122f4
docs: add remaining gap commands to CHANGELOG
TinDang97 Apr 11, 2026
9649ec6
fix: SORT STORE preserves nil GET results as empty strings
TinDang97 Apr 11, 2026
813c068
fix: CLIENT PAUSE/UNPAUSE, COPY same-key, RANDOMKEY expiry, MEMORY SA…
TinDang97 Apr 11, 2026
3572b1e
fix: COPY same-key ERR, EXPIREAT accepts past timestamps, LCS OOM guard
TinDang97 Apr 11, 2026
6f0c104
merge: sync with main (graph engine PR #70)
TinDang97 Apr 11, 2026
50b2062
merge: sync with main (PR #69 security hardening)
TinDang97 Apr 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/scheduled_tasks.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"sessionId":"66332041-4f74-42df-8954-9e0482baacdd","pid":11173,"acquiredAt":1775933454187}
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<dir>/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.
Expand Down
50 changes: 50 additions & 0 deletions scripts/test-commands.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

# ===========================================================================
Expand Down
67 changes: 67 additions & 0 deletions scripts/test-consistency.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ===========================================================================
Expand Down
126 changes: 126 additions & 0 deletions src/command/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down Expand Up @@ -174,6 +178,15 @@ pub fn config_set(runtime_config: &mut RuntimeConfig, args: &[Frame]) -> Frame {
)));
}
},
"lazyfree-threshold" => match value_str.parse::<usize>() {
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::<usize>() {
Ok(v) => runtime_config.maxclients = v,
Err(_) => {
Expand Down Expand Up @@ -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 `<dir>/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";
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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}")));
Comment on lines +290 to +297
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use a unique temp filename to avoid concurrent rewrite collisions.

CONFIG REWRITE currently uses a shared temp path (moon.conf.tmp). Concurrent calls can clobber each other and make one call fail on Line 265 even when content is valid.

💡 Suggested fix
-    let tmp_path = std::path::Path::new(dir).join("moon.conf.tmp");
+    let nonce = std::time::SystemTime::now()
+        .duration_since(std::time::UNIX_EPOCH)
+        .unwrap_or_default()
+        .as_nanos();
+    let tmp_path = std::path::Path::new(dir).join(format!(
+        "moon.conf.tmp.{}.{}",
+        std::process::id(),
+        nonce
+    ));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/command/config.rs` around lines 260 - 267, The current config rewrite
builds a fixed tmp_path ("moon.conf.tmp") which causes concurrent CONFIG REWRITE
calls to collide when writing/renaming; update the logic that constructs
tmp_path (the variable tmp_path used with std::fs::write and std::fs::rename) to
create a unique temporary filename per operation (e.g., include a UUID/timestamp
+ process id or use a secure temp file API such as tempfile::NamedTempFile) and
then perform the same write -> rename -> cleanup flow using that unique temp
file, ensuring any error messages that reference failures from std::fs::write or
std::fs::rename still include the captured error details.

}

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::*;
Expand Down Expand Up @@ -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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Loading
Loading