-
Notifications
You must be signed in to change notification settings - Fork 0
fix(shard): OOM shield — macOS maxmemory guardrail + eviction bypass closure (RSS/CPU wave 2) #217
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
94ccce7
d83bc5e
a4847c5
86606c1
3f0970b
436c7d6
6e75a7b
5278adb
410bf22
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1024,10 +1024,26 @@ fn detect_memory_limit_bytes() -> Option<usize> { | |
| } | ||
| } | ||
|
|
||
| /// Non-Linux: no portable, dependency-free memory-limit probe. The guardrail | ||
| /// is skipped (operator sets `--maxmemory` explicitly on dev hosts). Production | ||
| /// targets Linux per the platform policy. | ||
| #[cfg(not(target_os = "linux"))] | ||
| /// macOS (first-class target): probe physical RAM via `sysctl -n hw.memsize`. | ||
| /// A spawned command instead of `sysctlbyname` FFI keeps this free of new | ||
| /// unsafe blocks; it runs once at startup so the fork cost is irrelevant. | ||
| /// No container limit concept applies on macOS, so host RAM is the limit. | ||
| #[cfg(target_os = "macos")] | ||
| fn detect_memory_limit_bytes() -> Option<usize> { | ||
| let out = std::process::Command::new("sysctl") | ||
| .args(["-n", "hw.memsize"]) | ||
| .output() | ||
| .ok()?; | ||
| if !out.status.success() { | ||
| return None; | ||
| } | ||
| std::str::from_utf8(&out.stdout).ok()?.trim().parse().ok() | ||
| } | ||
|
Comment on lines
+1032
to
+1041
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 5. Path-dependent sysctl probe On macOS, detect_memory_limit_bytes() invokes Command::new("sysctl") and converts any spawn/exec
failure into None, which makes the memory guardrail resolve to Skipped and boot UNLIMITED. This
can happen in environments where sysctl is not resolvable via PATH (or is shadowed), undermining
the PR’s goal of making the macOS guardrail reliably engage.
Agent Prompt
|
||
|
|
||
| /// Other platforms: no portable, dependency-free memory-limit probe. The | ||
| /// guardrail is skipped (operator sets `--maxmemory` explicitly). Production | ||
| /// targets Linux/macOS per the platform policy. | ||
| #[cfg(not(any(target_os = "linux", target_os = "macos")))] | ||
| fn detect_memory_limit_bytes() -> Option<usize> { | ||
| None | ||
| } | ||
|
|
@@ -1539,6 +1555,19 @@ mod tests { | |
| assert_eq!(config.to_runtime_config().maxmemory, 0); | ||
| } | ||
|
|
||
| #[test] | ||
| #[cfg(target_os = "macos")] | ||
| fn macos_detects_memory_limit() { | ||
| // macOS is a first-class target; booting UNLIMITED + noeviction by | ||
| // default (guardrail Skipped) was RSS/CPU/OOM review item 4 — the | ||
| // hw.memsize probe must feed the same 80% guardrail as Linux. | ||
| let detected = detect_memory_limit_bytes(); | ||
| assert!( | ||
| detected.is_some_and(|b| b > 1 << 30), | ||
| "hw.memsize probe failed: {detected:?}" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn parse_meminfo_extracts_memtotal_bytes() { | ||
| let sample = "MemTotal: 16384256 kB\nMemFree: 1000 kB\n"; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,11 +7,115 @@ | |
| //! 3. The pointer is cleared immediately after script execution | ||
|
|
||
| use std::cell::Cell; | ||
| use std::path::PathBuf; | ||
| use std::rc::Rc; | ||
| use std::sync::Arc; | ||
|
|
||
| use mlua::prelude::*; | ||
|
|
||
| use crate::config::RuntimeConfig; | ||
| use crate::protocol::Frame; | ||
| use crate::shard::shared_databases::ShardDatabases; | ||
| use crate::storage::engine::StorageEngine; | ||
| use crate::storage::eviction::{ | ||
| try_evict_if_needed_async_spill_budget, try_evict_if_needed_budget, | ||
| }; | ||
| use crate::storage::tiered::spill_thread::SpillRequest; | ||
|
|
||
| /// Shard context needed to enforce `--maxmemory` eviction before a Lua | ||
| /// `redis.call`/`redis.pcall` WRITE actually mutates the database. | ||
| /// | ||
| /// # Why closure-capture instead of a thread-local | ||
| /// | ||
| /// The bridge already uses a thread-local raw pointer (`CURRENT_DB`) for the | ||
| /// `Database` itself, because that pointer's target changes on every script | ||
| /// invocation. This context is different: it is the same for every script run | ||
| /// on a given shard for the shard's entire lifetime (the shard's | ||
| /// `ShardDatabases`/`RuntimeConfig`/spill handles never change identity). | ||
| /// `redis.call`/`redis.pcall` are Lua closures created exactly once per shard | ||
| /// by [`crate::scripting::setup_lua_vm`], at a point where the caller already | ||
| /// owns cloneable handles to all of this — so it is captured directly into | ||
| /// the `move` closure. This adds zero new `unsafe` code (the existing | ||
| /// `CURRENT_DB` unsafe deref is untouched) and zero per-call allocation. The | ||
| /// common case (`maxmemory` unset, no spill) is decided by a single Relaxed | ||
| /// load of the process-global [`crate::storage::eviction::maxmemory_is_set`] | ||
| /// atomic, so a tight `redis.call('SET', ...)` loop never takes the | ||
| /// `RuntimeConfig` lock at all in that case. | ||
| #[derive(Clone)] | ||
| pub struct LuaEvictionCtx(Option<LuaEvictionInner>); | ||
|
|
||
| #[derive(Clone)] | ||
| struct LuaEvictionInner { | ||
| shard_databases: Arc<ShardDatabases>, | ||
| runtime_config: Arc<parking_lot::RwLock<RuntimeConfig>>, | ||
| shard_id: usize, | ||
| spill_sender: Option<flume::Sender<SpillRequest>>, | ||
| spill_file_id: Rc<Cell<u64>>, | ||
| disk_offload_dir: Option<PathBuf>, | ||
| } | ||
|
|
||
| impl LuaEvictionCtx { | ||
| /// No-op gate. Used by unit tests (no real shard context available). | ||
| /// Production call sites (Lua EVAL/EVALSHA and Lua FUNCTION/FCALL) must | ||
| /// build a real ctx via [`LuaEvictionCtx::new`] — see | ||
| /// `src/shard/conn_accept.rs` and `src/scripting/functions.rs`. | ||
| pub fn disabled() -> Self { | ||
| LuaEvictionCtx(None) | ||
| } | ||
|
|
||
| /// Real gate, built from the shard's own handles at VM-setup time. | ||
| #[allow(clippy::too_many_arguments)] | ||
| pub fn new( | ||
|
Comment on lines
+66
to
+68
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 3. luaevictionctx::new allow lacks comment LuaEvictionCtx::new adds #[allow(clippy::too_many_arguments)] without an explicit justification comment. This violates the requirement that new clippy suppressions be justified in-code. Agent Prompt
|
||
| shard_databases: Arc<ShardDatabases>, | ||
| runtime_config: Arc<parking_lot::RwLock<RuntimeConfig>>, | ||
| shard_id: usize, | ||
| spill_sender: Option<flume::Sender<SpillRequest>>, | ||
| spill_file_id: Rc<Cell<u64>>, | ||
| disk_offload_dir: Option<PathBuf>, | ||
| ) -> Self { | ||
| LuaEvictionCtx(Some(LuaEvictionInner { | ||
| shard_databases, | ||
| runtime_config, | ||
| shard_id, | ||
| spill_sender, | ||
| spill_file_id, | ||
| disk_offload_dir, | ||
| })) | ||
| } | ||
|
|
||
| /// Run the same eviction/OOM gate the connection handlers use | ||
| /// (`run_write_eviction_gate` in `handler_monoio/mod.rs`), against `db` | ||
| /// (the shard's `Database`, already borrowed by the caller via the | ||
| /// `CURRENT_DB` thread-local). Returns the standard OOM `Frame::Error` | ||
| /// on failure; `Ok(())` if within budget, eviction succeeded, or | ||
| /// `maxmemory` is unset. | ||
| fn gate(&self, db: &mut crate::storage::Database, db_index: usize) -> Result<(), Frame> { | ||
| let Some(inner) = self.0.as_ref() else { | ||
| return Ok(()); | ||
| }; | ||
| // Lock-free fast path: a script issuing thousands of writes checks | ||
| // the process-global atomic (Gap C), not the RuntimeConfig lock. | ||
| if inner.spill_sender.is_none() && !crate::storage::eviction::maxmemory_is_set() { | ||
| return Ok(()); | ||
| } | ||
| let rt = inner.runtime_config.read(); | ||
| let budget = inner.shard_databases.elastic_budget(inner.shard_id); | ||
| if let Some(sender) = &inner.spill_sender { | ||
| let mut fid = inner.spill_file_id.get(); | ||
| let dir = inner | ||
| .disk_offload_dir | ||
| .as_deref() | ||
| .unwrap_or(std::path::Path::new(".")); | ||
| let res = try_evict_if_needed_async_spill_budget( | ||
| db, &rt, sender, dir, &mut fid, db_index, budget, | ||
| ); | ||
| inner.spill_file_id.set(fid); | ||
| res | ||
| } else { | ||
| try_evict_if_needed_budget(db, &rt, budget) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| thread_local! { | ||
| /// Raw pointer to the current shard's Database during script execution. | ||
|
|
@@ -59,7 +163,11 @@ pub fn script_had_write() -> bool { | |
| /// | ||
| /// If `propagate_errors` is true (redis.call), Frame::Error results are raised as Lua errors. | ||
| /// If false (redis.pcall), errors are returned as {err = "..."} tables. | ||
| pub fn make_redis_call_fn(lua: &Lua, propagate_errors: bool) -> mlua::Result<LuaFunction> { | ||
| pub fn make_redis_call_fn( | ||
| lua: &Lua, | ||
| propagate_errors: bool, | ||
| eviction_ctx: LuaEvictionCtx, | ||
| ) -> mlua::Result<LuaFunction> { | ||
| lua.create_function(move |lua, args: LuaMultiValue| { | ||
| // Convert all Lua arguments to Frames | ||
| let frames: Vec<Frame> = args | ||
|
|
@@ -110,6 +218,14 @@ pub fn make_redis_call_fn(lua: &Lua, propagate_errors: bool) -> mlua::Result<Lua | |
| if cmd_is_write { | ||
| // Track writes for SCRIPT KILL safety check | ||
| SCRIPT_HAD_WRITE.with(|c| c.set(true)); | ||
| // OOM eviction gate (M3): mirrors the connection handlers' | ||
| // `run_write_eviction_gate` — without this, a write inside a | ||
| // script could grow memory past `maxmemory` without limit | ||
| // (EVAL/EVALSHA carry no WRITE command flag, so the | ||
| // dispatch-level OOM check never sees them at all). | ||
| if let Err(oom) = eviction_ctx.gate(db, db_idx) { | ||
| return Ok(oom); | ||
| } | ||
| } | ||
|
|
||
| let frame = db.execute_command(&cmd_bytes, &frames[1..], &mut db_idx, db_count); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
Use an absolute path for the
sysctlbinary.Command::new("sysctl")resolves the binary via$PATH, which is attacker-influenceable in some deployment scenarios (e.g., inherited/misconfigured environment). Sincesysctllives at a fixed, well-known location on macOS, pinning the path removes this class of risk for essentially no cost.🔒 Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents