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
6 changes: 4 additions & 2 deletions crates/ember-core/src/concurrent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use bytes::Bytes;
use dashmap::DashMap;

use crate::keyspace::{EvictionPolicy, TtlResult};
use crate::memory;
use crate::time;

/// An entry in the concurrent keyspace.
Expand Down Expand Up @@ -89,10 +90,11 @@ impl ConcurrentKeyspace {
let entry_size = key.len() + value.len() + 48;
let expires_at_ms = time::expiry_from_duration(ttl);

// Check memory limit
// Check memory limit (with safety margin for allocator overhead)
if let Some(max) = self.max_memory {
let limit = memory::effective_limit(max);
let current = self.memory_used.load(Ordering::Relaxed);
if current + entry_size > max {
if current + entry_size > limit {
if self.eviction_policy == EvictionPolicy::NoEviction {
return false;
}
Expand Down
27 changes: 26 additions & 1 deletion crates/ember-core/src/keyspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -403,9 +403,15 @@ impl Keyspace {
/// Checks whether the memory limit allows a write that would increase
/// usage by `estimated_increase` bytes. Attempts eviction if the
/// policy allows it. Returns `true` if the write can proceed.
///
/// The comparison uses [`memory::effective_limit`] rather than the raw
/// configured maximum. This reserves headroom for allocator overhead
/// and fragmentation that our per-entry estimates can't account for,
/// preventing the OS from OOM-killing us before eviction triggers.
fn enforce_memory_limit(&mut self, estimated_increase: usize) -> bool {
if let Some(max) = self.config.max_memory {
while self.memory.used_bytes() + estimated_increase > max {
let limit = memory::effective_limit(max);
while self.memory.used_bytes() + estimated_increase > limit {
match self.config.eviction_policy {
EvictionPolicy::NoEviction => return false,
EvictionPolicy::AllKeysLru => {
Expand Down Expand Up @@ -2132,6 +2138,25 @@ mod tests {
assert!(ks.exists("b"));
}

#[test]
fn safety_margin_rejects_near_raw_limit() {
// one entry = 1 (key) + 3 (val) + 96 (overhead) = 100 bytes.
// configure max_memory = 112. effective limit = 112 * 90 / 100 = 100.
// the entry fills exactly the effective limit, so a second entry should
// be rejected even though the raw limit has 12 bytes of headroom.
let config = ShardConfig {
max_memory: Some(112),
eviction_policy: EvictionPolicy::NoEviction,
..ShardConfig::default()
};
let mut ks = Keyspace::with_config(config);

assert_eq!(ks.set("a".into(), Bytes::from("val"), None), SetResult::Ok);

let result = ks.set("b".into(), Bytes::from("val"), None);
assert_eq!(result, SetResult::OutOfMemory);
}

#[test]
fn overwrite_same_size_succeeds_at_limit() {
let config = ShardConfig {
Expand Down
42 changes: 42 additions & 0 deletions crates/ember-core/src/memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,34 @@
//!
//! The constants assume Rust's standard library allocator. Custom allocators
//! (jemalloc, mimalloc) may have different per-allocation overhead.
//!
//! # Safety margin
//!
//! Because overhead constants are estimates and allocator fragmentation is
//! unpredictable, we apply a safety margin when enforcing memory limits.
//! The effective limit is set to [`MEMORY_SAFETY_MARGIN_PERCENT`]% of the
//! configured max, reserving headroom so the process doesn't OOM before
//! eviction has a chance to kick in.

use crate::types::Value;

/// Percentage of the configured `max_memory` that we actually use as the
/// effective write limit. The remaining headroom absorbs allocator overhead,
/// internal fragmentation, and estimation error in our per-entry constants.
///
/// 90% is conservative — it means a server configured with 1 GB will start
/// rejecting writes (or evicting) at ~922 MB of estimated usage, leaving
/// ~100 MB of breathing room for the allocator.
pub const MEMORY_SAFETY_MARGIN_PERCENT: usize = 90;

/// Computes the effective memory limit after applying the safety margin.
///
/// Returns the number of bytes at which writes should be rejected or
/// eviction should begin — always less than the raw configured limit.
pub fn effective_limit(max_bytes: usize) -> usize {
max_bytes * MEMORY_SAFETY_MARGIN_PERCENT / 100
}

/// Estimated overhead per entry in the HashMap.
///
/// Accounts for: HashMap bucket pointer (8), Entry struct fields
Expand Down Expand Up @@ -283,4 +308,21 @@ mod tests {
assert_eq!(t.key_count(), 1);
assert_eq!(t.used_bytes(), entry_size("k2", &v2));
}

#[test]
fn effective_limit_applies_margin() {
// 1000 bytes configured → 900 effective at 90%
assert_eq!(effective_limit(1000), 900);
}

#[test]
fn effective_limit_rounds_down() {
// 1001 * 90 / 100 = 900 (integer division truncates)
assert_eq!(effective_limit(1001), 900);
}

#[test]
fn effective_limit_zero() {
assert_eq!(effective_limit(0), 0);
}
}
6 changes: 6 additions & 0 deletions crates/ember-server/src/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1569,8 +1569,14 @@ async fn render_info(engine: &Engine, ctx: &Arc<ServerContext>, section: Option<
human_bytes(stats.used_bytes)
));
if let Some(max) = ctx.max_memory {
let effective = ember_core::memory::effective_limit(max);
out.push_str(&format!("max_memory:{max}\r\n"));
out.push_str(&format!("max_memory_human:{}\r\n", human_bytes(max)));
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");
Expand Down