diff --git a/crates/ember-core/src/concurrent.rs b/crates/ember-core/src/concurrent.rs index c642c22c..76252294 100644 --- a/crates/ember-core/src/concurrent.rs +++ b/crates/ember-core/src/concurrent.rs @@ -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. @@ -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; } diff --git a/crates/ember-core/src/keyspace.rs b/crates/ember-core/src/keyspace.rs index 5443a2c3..cda853e1 100644 --- a/crates/ember-core/src/keyspace.rs +++ b/crates/ember-core/src/keyspace.rs @@ -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 => { @@ -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 { diff --git a/crates/ember-core/src/memory.rs b/crates/ember-core/src/memory.rs index 7de82691..60410585 100644 --- a/crates/ember-core/src/memory.rs +++ b/crates/ember-core/src/memory.rs @@ -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 @@ -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); + } } diff --git a/crates/ember-server/src/connection.rs b/crates/ember-server/src/connection.rs index 86e1e099..87751970 100644 --- a/crates/ember-server/src/connection.rs +++ b/crates/ember-server/src/connection.rs @@ -1569,8 +1569,14 @@ async fn render_info(engine: &Engine, ctx: &Arc, 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");