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
23 changes: 23 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ edition = "2024"
signal-hook = "0.4.4"
log = "0.4"
env_logger = "0.11"
indexmap = "2"

[dev-dependencies]
criterion = "0.8.2"
Expand Down
130 changes: 130 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@ use std::time::Duration;

use ferrum_kv::config::{FileConfig, FileConfigError};
use ferrum_kv::persistence::config::{AofConfig, FsyncPolicy};
use ferrum_kv::storage::eviction::{EvictionConfig, EvictionPolicy};

pub const DEFAULT_ADDR: &str = "127.0.0.1:6380";

pub const USAGE: &str = concat!(
"usage: ferrum-kv [--config PATH] [--addr HOST:PORT] [--aof-path PATH]\n",
" [--appendfsync always|everysec|no]\n",
" [--client-timeout SECONDS] [--maxclients N]\n",
" [--maxmemory BYTES] [--maxmemory-policy POLICY]\n",
" [--maxmemory-samples N]\n",
" [--loglevel off|error|warn|info|debug|trace]"
);

Expand Down Expand Up @@ -50,6 +53,13 @@ pub struct CliArgs {
/// Explicit `loglevel` requested via `--loglevel` or the config file.
/// The caller may still override this with `FERRUM_LOG`/`RUST_LOG`.
loglevel: Option<String>,
/// Memory ceiling in bytes. `None` keeps the built-in default of
/// zero (disabled); `Some(0)` explicitly disables enforcement.
max_memory: Option<u64>,
/// Eviction policy when `maxmemory` is reached.
max_memory_policy: Option<EvictionPolicy>,
/// How many random candidates each eviction round considers.
max_memory_samples: Option<usize>,
}

/// Raw, un-merged values taken verbatim from the command line.
Expand All @@ -68,6 +78,9 @@ struct RawFlags {
client_timeout: Option<Option<Duration>>, // outer Some = flag was passed, inner None = disabled
max_clients: Option<usize>,
loglevel: Option<String>,
max_memory: Option<u64>,
max_memory_policy: Option<EvictionPolicy>,
max_memory_samples: Option<usize>,
/// Whether AOF was explicitly enabled via the config file's `appendonly yes`.
/// CLI `--aof-path` implies enabled; this field only carries the file's
/// intent so that a later merge step can decide.
Expand Down Expand Up @@ -113,6 +126,18 @@ impl CliArgs {
pub fn loglevel(&self) -> Option<&str> {
self.loglevel.as_deref()
}

/// Returns the resolved eviction configuration. Defaults (unlimited
/// memory, `noeviction`, 5 samples) apply when neither CLI nor config
/// file specifies a value.
pub fn eviction_config(&self) -> EvictionConfig {
let default = EvictionConfig::default();
EvictionConfig {
max_memory: self.max_memory.unwrap_or(default.max_memory),
policy: self.max_memory_policy.unwrap_or(default.policy),
samples: self.max_memory_samples.unwrap_or(default.samples),
}
}
}

enum ScanOutcome {
Expand Down Expand Up @@ -161,6 +186,29 @@ fn scan_argv<I: IntoIterator<Item = String>>(args: I) -> Result<ScanOutcome, Str
let value = take_value(&mut iter, "--loglevel")?;
raw.loglevel = Some(validate_loglevel(&value)?);
}
"--maxmemory" => {
let value = take_value(&mut iter, "--maxmemory")?;
raw.max_memory =
Some(parse_bytes(&value).map_err(|e| format!("invalid --maxmemory: {e}"))?);
}
"--maxmemory-policy" => {
let value = take_value(&mut iter, "--maxmemory-policy")?;
let name = value.to_ascii_lowercase();
raw.max_memory_policy =
Some(EvictionPolicy::from_name(&name).ok_or_else(|| {
format!(
"invalid --maxmemory-policy '{value}' (expected noeviction, \
allkeys-lru, volatile-lru, allkeys-random, volatile-random, \
volatile-ttl)"
)
})?);
}
"--maxmemory-samples" => {
let value = take_value(&mut iter, "--maxmemory-samples")?;
raw.max_memory_samples = Some(value.parse().map_err(|_| {
format!("invalid --maxmemory-samples: '{value}' is not a non-negative integer")
})?);
}
"-h" | "--help" => return Ok(ScanOutcome::Help),
other => return Err(format!("unrecognised argument: '{other}'")),
}
Expand Down Expand Up @@ -221,13 +269,25 @@ fn merge(raw: RawFlags, file: Option<&FileConfig>) -> Result<CliArgs, String> {
.loglevel
.or_else(|| file.and_then(|f| f.loglevel.clone()));

// --- maxmemory family ---------------------------------------------------
let max_memory = raw.max_memory.or_else(|| file.and_then(|f| f.max_memory));
let max_memory_policy = raw
.max_memory_policy
.or_else(|| file.and_then(|f| f.max_memory_policy));
let max_memory_samples = raw
.max_memory_samples
.or_else(|| file.and_then(|f| f.max_memory_samples));

Ok(CliArgs {
addr,
aof_path,
appendfsync,
client_timeout,
max_clients,
loglevel,
max_memory,
max_memory_policy,
max_memory_samples,
})
}

Expand Down Expand Up @@ -285,6 +345,34 @@ fn parse_timeout_seconds(raw: &str) -> Result<Option<Duration>, String> {
}
}

/// Parses a Redis-style byte size (`100mb`, `1gb`, plain integer, …).
fn parse_bytes(raw: &str) -> Result<u64, String> {
let lower = raw.trim().to_ascii_lowercase();
let (num, factor): (&str, u64) = if let Some(s) = lower.strip_suffix("gb") {
(s, 1024 * 1024 * 1024)
} else if let Some(s) = lower.strip_suffix("mb") {
(s, 1024 * 1024)
} else if let Some(s) = lower.strip_suffix("kb") {
(s, 1024)
} else if let Some(s) = lower.strip_suffix('g') {
(s, 1024 * 1024 * 1024)
} else if let Some(s) = lower.strip_suffix('m') {
(s, 1024 * 1024)
} else if let Some(s) = lower.strip_suffix('k') {
(s, 1024)
} else if let Some(s) = lower.strip_suffix('b') {
(s, 1)
} else {
(lower.as_str(), 1)
};
let num: u64 = num
.trim()
.parse()
.map_err(|_| format!("'{raw}' is not a byte size"))?;
num.checked_mul(factor)
.ok_or_else(|| format!("'{raw}' overflows u64"))
}

// Marker to silence the dead_code lint on the currently-reserved fields.
// The struct itself is module-private; this impl is cheap.
impl RawFlags {
Expand Down Expand Up @@ -518,4 +606,46 @@ mod tests {
let err = parse(&["--config", "/definitely/does/not/exist/xyz.conf"]).unwrap_err();
assert!(err.contains("failed to read config"));
}

#[test]
fn maxmemory_flag_accepts_byte_suffixes() {
let args = parse_run(&["--maxmemory", "10mb"]);
assert_eq!(args.eviction_config().max_memory, 10 * 1024 * 1024);
}

#[test]
fn maxmemory_policy_flag_is_parsed() {
let args = parse_run(&["--maxmemory-policy", "allkeys-lru"]);
assert_eq!(args.eviction_config().policy, EvictionPolicy::AllKeysLru);
}

#[test]
fn maxmemory_samples_flag_is_parsed() {
let args = parse_run(&["--maxmemory-samples", "20"]);
assert_eq!(args.eviction_config().samples, 20);
}

#[test]
fn maxmemory_rejects_unknown_policy() {
let err = parse(&["--maxmemory-policy", "wishful"]).unwrap_err();
assert!(err.contains("--maxmemory-policy"));
}

#[test]
fn cli_maxmemory_overrides_config_file() {
let conf = TempConf::new(
"maxmem",
"maxmemory 1kb\nmaxmemory-policy allkeys-lru\nmaxmemory-samples 3\n",
);
let args = parse_run(&[
"--config",
conf.path.to_str().unwrap(),
"--maxmemory",
"2mb",
]);
let cfg = args.eviction_config();
assert_eq!(cfg.max_memory, 2 * 1024 * 1024);
assert_eq!(cfg.policy, EvictionPolicy::AllKeysLru);
assert_eq!(cfg.samples, 3);
}
}
93 changes: 93 additions & 0 deletions src/config/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use std::fs;
use std::path::{Path, PathBuf};

use crate::persistence::config::FsyncPolicy;
use crate::storage::eviction::EvictionPolicy;

/// Parsed configuration file contents.
///
Expand All @@ -35,6 +36,12 @@ pub struct FileConfig {
pub appendfsync: Option<FsyncPolicy>,
/// Log level string accepted by `env_logger` (e.g. `info`, `debug`).
pub loglevel: Option<String>,
/// Memory ceiling in bytes. `0` disables enforcement.
pub max_memory: Option<u64>,
/// Eviction policy selected when `maxmemory` is reached.
pub max_memory_policy: Option<EvictionPolicy>,
/// Number of keys inspected per eviction round.
pub max_memory_samples: Option<usize>,
}

/// Error type for configuration file parsing.
Expand Down Expand Up @@ -156,6 +163,22 @@ fn apply_directive(cfg: &mut FileConfig, key: &str, value: &str) -> Result<(), S
}
cfg.loglevel = Some(normalised);
}
"maxmemory" => {
cfg.max_memory = Some(parse_bytes(value, "maxmemory")?);
}
"maxmemory-policy" => {
let name = value.to_ascii_lowercase();
cfg.max_memory_policy = Some(EvictionPolicy::from_name(&name).ok_or_else(|| {
format!(
"invalid maxmemory-policy '{value}' (expected one of noeviction, \
allkeys-lru, volatile-lru, allkeys-random, volatile-random, \
volatile-ttl)"
)
})?);
}
"maxmemory-samples" => {
cfg.max_memory_samples = Some(parse_usize(value, "maxmemory-samples")?);
}
other => return Err(format!("unknown directive '{other}'")),
}
Ok(())
Expand Down Expand Up @@ -196,6 +219,36 @@ fn strip_quotes(raw: &str) -> &str {
}
}

/// Parses byte sizes in Redis-friendly form: a bare integer, or an integer
/// followed by one of the suffixes `b`, `k`, `kb`, `m`, `mb`, `g`, `gb`
/// (case-insensitive, no space between number and suffix).
fn parse_bytes(raw: &str, name: &str) -> Result<u64, String> {
let lower = raw.trim().to_ascii_lowercase();
let (num, factor): (&str, u64) = if let Some(stripped) = lower.strip_suffix("gb") {
(stripped, 1024 * 1024 * 1024)
} else if let Some(stripped) = lower.strip_suffix("mb") {
(stripped, 1024 * 1024)
} else if let Some(stripped) = lower.strip_suffix("kb") {
(stripped, 1024)
} else if let Some(stripped) = lower.strip_suffix('g') {
(stripped, 1024 * 1024 * 1024)
} else if let Some(stripped) = lower.strip_suffix('m') {
(stripped, 1024 * 1024)
} else if let Some(stripped) = lower.strip_suffix('k') {
(stripped, 1024)
} else if let Some(stripped) = lower.strip_suffix('b') {
(stripped, 1)
} else {
(lower.as_str(), 1)
};
let num: u64 = num
.trim()
.parse()
.map_err(|_| format!("invalid {name}: '{raw}' is not a byte size"))?;
num.checked_mul(factor)
.ok_or_else(|| format!("invalid {name}: '{raw}' overflows u64"))
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -295,4 +348,44 @@ mod tests {
let cfg = parse("PORT 12345\n").unwrap();
assert_eq!(cfg.port, Some(12345));
}

#[test]
fn maxmemory_accepts_byte_and_suffix_forms() {
assert_eq!(parse("maxmemory 0\n").unwrap().max_memory, Some(0));
assert_eq!(parse("maxmemory 1024\n").unwrap().max_memory, Some(1024));
assert_eq!(parse("maxmemory 2k\n").unwrap().max_memory, Some(2 * 1024));
assert_eq!(
parse("maxmemory 100mb\n").unwrap().max_memory,
Some(100 * 1024 * 1024)
);
assert_eq!(
parse("maxmemory 1GB\n").unwrap().max_memory,
Some(1024 * 1024 * 1024)
);
}

#[test]
fn maxmemory_rejects_nonsense() {
assert!(parse("maxmemory abc\n").is_err());
assert!(parse("maxmemory 10tb\n").is_err());
}

#[test]
fn maxmemory_policy_is_parsed_and_validated() {
assert_eq!(
parse("maxmemory-policy allkeys-lru\n")
.unwrap()
.max_memory_policy,
Some(EvictionPolicy::AllKeysLru),
);
assert!(parse("maxmemory-policy bogus\n").is_err());
}

#[test]
fn maxmemory_samples_is_parsed() {
assert_eq!(
parse("maxmemory-samples 12\n").unwrap().max_memory_samples,
Some(12),
);
}
}
16 changes: 16 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,22 @@ fn main() -> ExitCode {
Err(code) => return code,
};

let eviction_cfg = args.eviction_config();
if let Err(e) = engine.set_eviction_config(eviction_cfg) {
error!("failed to apply eviction config: {e}");
return ExitCode::FAILURE;
}
if eviction_cfg.max_memory == 0 {
info!("maxmemory: unlimited");
} else {
info!(
"maxmemory: {} bytes, policy={}, samples={}",
eviction_cfg.max_memory,
eviction_cfg.policy.name(),
eviction_cfg.samples,
);
}

// Bind the listener up front so that `--addr :0` is resolved before we
// install the signal handler that needs the concrete address.
let listener = match TcpListener::bind(&args.addr) {
Expand Down
Loading
Loading