Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
35 changes: 17 additions & 18 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -456,30 +456,29 @@ Every PR that touches performance-critical code must include benchmark results.

### Key Benchmarks

```
# Micro-benchmarks (criterion)
cargo bench -p ember-core
```bash
# quick sanity check (ember only)
./scripts/bench-quick.sh

# System benchmarks
ember benchmark --clients 50 --pipeline 16 --requests 1000000
# full comparison with Redis
./scripts/bench.sh

# Comparison with Redis
./bench/compare-redis.sh
# memory usage comparison
./scripts/bench-memory.sh
```

### Metrics to Track

| Metric | Target | Redis Baseline |
|--------|--------|----------------|
| GET throughput (single core) | 500k ops/sec | 100k ops/sec |
| SET throughput (single core) | 400k ops/sec | 80k ops/sec |
| P50 latency | <50µs | ~200µs |
| P99 latency | <200µs | ~1ms |
| P999 latency | <1ms | ~5ms |
| Memory per string key (32B key, 64B value) | <150B | ~200B |
| Memory per sorted set entry | <80B | ~120B |
| Snapshot speed | >500MB/s | ~200MB/s |
| Recovery speed | >1GB/s | ~300MB/s |
| Metric | Target | Achieved | Redis Baseline |
|--------|--------|----------|----------------|
| SET throughput (P=16) | 500k+ ops/sec | **1.86M ops/sec** | 1.0M ops/sec |
| GET throughput (P=16) | 500k+ ops/sec | **2.48M ops/sec** | 1.16M ops/sec |
| SET throughput (P=1) | 100k+ ops/sec | **200k ops/sec** | 100k ops/sec |
| GET throughput (P=1) | 100k+ ops/sec | **200k ops/sec** | 100k ops/sec |
| P99 latency | <1ms | **0.4ms** | 0.4ms |
| Memory per string key (64B value) | <200B | **257B** | ~165B |

*Benchmarked on GCP c2-standard-8 (8 vCPU Intel Xeon @ 3.1GHz) using concurrent mode with jemalloc.*

### Workloads

Expand Down
12 changes: 6 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
| 4 | clustering (raft, gossip, slots, migration) | ✅ complete |
| 5 | developer experience (observability, CLI, clients) | 🚧 in progress |

**current**: 65 commands, 579 tests, ~18k lines of code
**current**: 65+ commands, 609 tests, ~14k lines of code

## security

Expand Down
186 changes: 186 additions & 0 deletions crates/ember-core/src/keyspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3247,4 +3247,190 @@ mod tests {
assert!(ks.sismember("s", "m").is_err());
assert!(ks.scard("s").is_err());
}

// --- edge case tests ---

#[test]
fn zero_ttl_expires_immediately() {
let mut ks = Keyspace::new();
ks.set("key".into(), Bytes::from("val"), Some(Duration::ZERO));

// key should be expired immediately
std::thread::sleep(Duration::from_millis(1));
assert!(ks.get("key").unwrap().is_none());
}

#[test]
fn very_small_ttl_expires_quickly() {
let mut ks = Keyspace::new();
ks.set(
"key".into(),
Bytes::from("val"),
Some(Duration::from_millis(1)),
);

std::thread::sleep(Duration::from_millis(5));
assert!(ks.get("key").unwrap().is_none());
}

#[test]
fn list_auto_deleted_when_empty() {
let mut ks = Keyspace::new();
ks.lpush("list", &[Bytes::from("a"), Bytes::from("b")])
.unwrap();
assert_eq!(ks.len(), 1);

// pop all elements
let _ = ks.lpop("list");
let _ = ks.lpop("list");

// list should be auto-deleted
assert_eq!(ks.len(), 0);
assert!(!ks.exists("list"));
}

#[test]
fn set_auto_deleted_when_empty() {
let mut ks = Keyspace::new();
ks.sadd("s", &["a".into(), "b".into()]).unwrap();
assert_eq!(ks.len(), 1);

// remove all members
ks.srem("s", &["a".into(), "b".into()]).unwrap();

// set should be auto-deleted
assert_eq!(ks.len(), 0);
assert!(!ks.exists("s"));
}

#[test]
fn hash_auto_deleted_when_empty() {
let mut ks = Keyspace::new();
ks.hset(
"h",
&[
("f1".into(), Bytes::from("v1")),
("f2".into(), Bytes::from("v2")),
],
)
.unwrap();
assert_eq!(ks.len(), 1);

// delete all fields
ks.hdel("h", &["f1".into(), "f2".into()]).unwrap();

// hash should be auto-deleted
assert_eq!(ks.len(), 0);
assert!(!ks.exists("h"));
}

#[test]
fn sadd_duplicate_members_counted_once() {
let mut ks = Keyspace::new();
// add same member twice in one call
let count = ks.sadd("s", &["a".into(), "a".into()]).unwrap();
// should only count as 1 new member
assert_eq!(count, 1);
assert_eq!(ks.scard("s").unwrap(), 1);
}

#[test]
fn srem_non_existent_member_returns_zero() {
let mut ks = Keyspace::new();
ks.sadd("s", &["a".into()]).unwrap();
let removed = ks.srem("s", &["nonexistent".into()]).unwrap();
assert_eq!(removed, 0);
}

#[test]
fn hincrby_overflow_returns_error() {
let mut ks = Keyspace::new();
// set field to near max
ks.hset("h", &[("count".into(), Bytes::from(i64::MAX.to_string()))])
.unwrap();

// try to increment by 1 - should overflow
let result = ks.hincrby("h", "count", 1);
assert!(result.is_err());
}

#[test]
fn hincrby_on_non_integer_returns_error() {
let mut ks = Keyspace::new();
ks.hset("h", &[("field".into(), Bytes::from("not_a_number"))])
.unwrap();

let result = ks.hincrby("h", "field", 1);
assert!(result.is_err());
}

#[test]
fn incr_at_max_value_overflows() {
let mut ks = Keyspace::new();
ks.set("counter".into(), Bytes::from(i64::MAX.to_string()), None);

let result = ks.incr("counter");
assert!(matches!(result, Err(IncrError::Overflow)));
}

#[test]
fn decr_at_min_value_underflows() {
let mut ks = Keyspace::new();
ks.set("counter".into(), Bytes::from(i64::MIN.to_string()), None);

let result = ks.decr("counter");
assert!(matches!(result, Err(IncrError::Overflow)));
}

#[test]
fn lrange_inverted_start_stop_returns_empty() {
let mut ks = Keyspace::new();
ks.lpush(
"list",
&[Bytes::from("a"), Bytes::from("b"), Bytes::from("c")],
)
.unwrap();

// start > stop with positive indices
let result = ks.lrange("list", 2, 0).unwrap();
assert!(result.is_empty());
}

#[test]
fn lrange_large_stop_clamps_to_len() {
let mut ks = Keyspace::new();
ks.lpush("list", &[Bytes::from("a"), Bytes::from("b")])
.unwrap();

// large indices should clamp to list bounds
let result = ks.lrange("list", 0, 1000).unwrap();
assert_eq!(result.len(), 2);
}

#[test]
fn empty_string_key_works() {
let mut ks = Keyspace::new();
ks.set("".into(), Bytes::from("value"), None);
assert_eq!(
ks.get("").unwrap(),
Some(Value::String(Bytes::from("value")))
);
assert!(ks.exists(""));
}

#[test]
fn empty_value_works() {
let mut ks = Keyspace::new();
ks.set("key".into(), Bytes::from(""), None);
assert_eq!(ks.get("key").unwrap(), Some(Value::String(Bytes::from(""))));
}

#[test]
fn binary_data_in_value() {
let mut ks = Keyspace::new();
// value with null bytes and other binary data
let binary = Bytes::from(vec![0u8, 1, 2, 255, 0, 128]);
ks.set("binary".into(), binary.clone(), None);
assert_eq!(ks.get("binary").unwrap(), Some(Value::String(binary)));
}
}
70 changes: 70 additions & 0 deletions crates/ember-core/src/types/sorted_set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -443,4 +443,74 @@ mod tests {
assert_eq!(ss.rank("a"), Some(2));
assert_eq!(ss.rank("b"), Some(0));
}

#[test]
fn positive_infinity_score() {
let mut ss = SortedSet::new();
ss.add("normal".into(), 100.0);
ss.add("infinite".into(), f64::INFINITY);
ss.add("large".into(), 1e308);

// infinity should sort after everything
assert_eq!(ss.rank("infinite"), Some(2));
assert_eq!(ss.rank("large"), Some(1));
assert_eq!(ss.rank("normal"), Some(0));
}

#[test]
fn negative_infinity_score() {
let mut ss = SortedSet::new();
ss.add("normal".into(), 100.0);
ss.add("neg_inf".into(), f64::NEG_INFINITY);
ss.add("small".into(), -1e308);

// negative infinity should sort before everything
assert_eq!(ss.rank("neg_inf"), Some(0));
assert_eq!(ss.rank("small"), Some(1));
assert_eq!(ss.rank("normal"), Some(2));
}

#[test]
fn zero_score() {
let mut ss = SortedSet::new();
ss.add("positive".into(), 1.0);
ss.add("zero".into(), 0.0);
ss.add("negative".into(), -1.0);

assert_eq!(ss.rank("negative"), Some(0));
assert_eq!(ss.rank("zero"), Some(1));
assert_eq!(ss.rank("positive"), Some(2));
}

#[test]
fn range_by_rank_on_empty_set() {
let ss = SortedSet::new();
assert!(ss.range_by_rank(0, -1).is_empty());
assert!(ss.range_by_rank(0, 100).is_empty());
}

#[test]
fn range_by_rank_inverted_indices() {
let mut ss = SortedSet::new();
ss.add("a".into(), 1.0);
ss.add("b".into(), 2.0);
ss.add("c".into(), 3.0);

// start > stop with positive indices should return empty
let result = ss.range_by_rank(2, 0);
assert!(result.is_empty());
}

#[test]
fn remove_all_members_leaves_empty() {
let mut ss = SortedSet::new();
ss.add("a".into(), 1.0);
ss.add("b".into(), 2.0);

ss.remove("a");
ss.remove("b");

assert_eq!(ss.len(), 0);
assert!(ss.range_by_rank(0, -1).is_empty());
}
}
16 changes: 11 additions & 5 deletions crates/ember-server/src/concurrent_handler.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
//! Concurrent handler that bypasses shard channels for GET/SET.
//!
//! Uses DashMap-backed ConcurrentKeyspace for lock-free multi-threaded access.
//! Falls back to sharded engine for complex commands.
//! This mode trades feature completeness for raw throughput — only string
//! operations are supported, but they execute 2x faster than sharded mode
//! by avoiding channel round-trips.
//!
//! ## Performance characteristics
//!
//! - GET/SET: ~2M ops/sec (vs ~1M in sharded mode)
//! - No channel overhead — direct DashMap access
//! - Processes frames serially (vs parallel dispatch in sharded mode)
//! - Falls back to error for unsupported commands (lists, hashes, etc.)

use std::sync::atomic::Ordering;
use std::sync::Arc;
Expand All @@ -13,13 +22,10 @@ use ember_protocol::{parse_frame, Command, Frame, SetExpire};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;

use crate::connection_common::{BUF_CAPACITY, IDLE_TIMEOUT, MAX_BUF_SIZE};
use crate::server::ServerContext;
use crate::slowlog::SlowLog;

const BUF_CAPACITY: usize = 4096;
const MAX_BUF_SIZE: usize = 64 * 1024 * 1024;
const IDLE_TIMEOUT: Duration = Duration::from_secs(300);

/// Handles a connection using the concurrent keyspace for GET/SET.
pub async fn handle(
mut stream: TcpStream,
Expand Down
Loading