Skip to main content

kevy_config/
schema.rs

1//! kevy `Config` schema, defaults, and error type. Apply-from-parser and
2//! value-coercion logic lives in `apply.rs` so this file stays focused on
3//! "what the settings ARE".
4
5use std::path::PathBuf;
6
7// ───────────── enums ─────────────
8
9/// AOF fsync policy. Matches Redis `appendfsync`.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum AppendFsync {
12    /// `fsync` after every write command. Zero data-loss but ~50% throughput.
13    Always,
14    /// Background `fsync` every second. Lose at most 1s on crash. Default.
15    EverySec,
16    /// No explicit `fsync`; let OS pagecache flush. Lose ~30s on crash.
17    No,
18}
19
20impl AppendFsync {
21    /// Canonical Redis-compatible name (`always` / `everysec` / `no`).
22    /// Used by `CONFIG GET appendfsync` and `CONFIG REWRITE`.
23    pub fn as_str(&self) -> &'static str {
24        match self {
25            Self::Always => "always",
26            Self::EverySec => "everysec",
27            Self::No => "no",
28        }
29    }
30    /// Inverse of [`Self::as_str`] — case-insensitive. `None` for any
31    /// other input; used by both the TOML parser and `CONFIG SET`.
32    pub fn parse(s: &str) -> Option<Self> {
33        match s.to_ascii_lowercase().as_str() {
34            "always" => Some(Self::Always),
35            "everysec" => Some(Self::EverySec),
36            "no" => Some(Self::No),
37            _ => None,
38        }
39    }
40}
41
42/// Maxmemory eviction policy. 8 variants matching Redis. `NoEviction`
43/// (default) returns an error on writes once `maxmemory` is hit.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum EvictionPolicy {
46    /// Refuse writes once `maxmemory` is hit. Default.
47    NoEviction,
48    /// Approximated LRU across all keys.
49    AllKeysLru,
50    /// Approximated LFU across all keys.
51    AllKeysLfu,
52    /// Random key across all keys.
53    AllKeysRandom,
54    /// Approximated LRU across keys with a TTL.
55    VolatileLru,
56    /// Approximated LFU across keys with a TTL.
57    VolatileLfu,
58    /// Random key from those with a TTL.
59    VolatileRandom,
60    /// Key with the shortest remaining TTL.
61    VolatileTtl,
62}
63
64impl EvictionPolicy {
65    /// Canonical Redis-compatible name.
66    pub fn as_str(&self) -> &'static str {
67        match self {
68            Self::NoEviction => "noeviction",
69            Self::AllKeysLru => "allkeys-lru",
70            Self::AllKeysLfu => "allkeys-lfu",
71            Self::AllKeysRandom => "allkeys-random",
72            Self::VolatileLru => "volatile-lru",
73            Self::VolatileLfu => "volatile-lfu",
74            Self::VolatileRandom => "volatile-random",
75            Self::VolatileTtl => "volatile-ttl",
76        }
77    }
78    /// Inverse of [`Self::as_str`] — case-insensitive.
79    pub fn parse(s: &str) -> Option<Self> {
80        match s.to_ascii_lowercase().as_str() {
81            "noeviction" => Some(Self::NoEviction),
82            "allkeys-lru" => Some(Self::AllKeysLru),
83            "allkeys-lfu" => Some(Self::AllKeysLfu),
84            "allkeys-random" => Some(Self::AllKeysRandom),
85            "volatile-lru" => Some(Self::VolatileLru),
86            "volatile-lfu" => Some(Self::VolatileLfu),
87            "volatile-random" => Some(Self::VolatileRandom),
88            "volatile-ttl" => Some(Self::VolatileTtl),
89            _ => None,
90        }
91    }
92}
93
94/// Log verbosity.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum LogLevel {
97    /// Very chatty, useful when debugging a kevy internal bug.
98    Trace,
99    /// Per-command / per-event detail; turn on locally to chase issues.
100    Debug,
101    /// Default; startup banner, WARNs, errors, key lifecycle events.
102    Info,
103    /// Only non-fatal warnings (e.g. unprotected bind) and errors.
104    Warn,
105    /// Only fatal errors.
106    Error,
107}
108
109impl LogLevel {
110    /// Canonical name. `Warn` renders as `warning` (Redis convention).
111    pub fn as_str(&self) -> &'static str {
112        match self {
113            Self::Trace => "trace",
114            Self::Debug => "debug",
115            Self::Info => "info",
116            Self::Warn => "warning",
117            Self::Error => "error",
118        }
119    }
120    /// Inverse of [`Self::as_str`] — case-insensitive; accepts both
121    /// `warn` and `warning` for the Warn level.
122    pub fn parse(s: &str) -> Option<Self> {
123        match s.to_ascii_lowercase().as_str() {
124            "trace" => Some(Self::Trace),
125            "debug" => Some(Self::Debug),
126            "info" => Some(Self::Info),
127            "warn" | "warning" => Some(Self::Warn),
128            "error" => Some(Self::Error),
129            _ => None,
130        }
131    }
132}
133
134/// Where to write log output.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub enum LogOutput {
137    /// Write to standard error (default).
138    Stderr,
139    /// Write to standard output.
140    Stdout,
141    /// Append to the named file (path resolved relative to cwd at startup).
142    File(PathBuf),
143}
144
145impl LogOutput {
146    /// Canonical name. `File(p)` renders as the path string.
147    pub fn as_str(&self) -> std::borrow::Cow<'_, str> {
148        match self {
149            Self::Stderr => "stderr".into(),
150            Self::Stdout => "stdout".into(),
151            Self::File(p) => p.display().to_string().into(),
152        }
153    }
154    /// Inverse of [`Self::as_str`]: `stderr` / `stdout` reserved; any
155    /// other string is treated as a file path.
156    pub fn parse(s: &str) -> Self {
157        match s {
158            "stderr" => Self::Stderr,
159            "stdout" => Self::Stdout,
160            path => Self::File(PathBuf::from(path)),
161        }
162    }
163}
164
165// ───────────── sections ─────────────
166
167/// `[server]` section.
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct ServerSection {
170    /// IPv4 bind address. Default `127.0.0.1`.
171    pub bind: [u8; 4],
172    /// TCP port. Default `6004`.
173    pub port: u16,
174    /// Shard / reactor thread count. `0` = auto (CPU count). Default `0`.
175    pub threads: usize,
176    /// **v1.30** — Only shards `0..N` arm accept SQE; rest stay compute-only.
177    pub accept_shards: Option<usize>,
178    /// **v1.37** — Cap on total active client connections. `0` = unlimited.
179    /// Default `10000` (matches Redis). New connection past cap is closed
180    /// + `rejected_connections` counter increments + INFO clients reports.
181    pub max_clients: usize,
182    /// Snapshot + AOF location. Default `.`.
183    pub data_dir: PathBuf,
184}
185
186impl Default for ServerSection {
187    fn default() -> Self {
188        Self {
189            bind: [127, 0, 0, 1],
190            port: 6004,
191            threads: 0,
192            accept_shards: None,
193            max_clients: 10_000,
194            data_dir: PathBuf::from("."),
195        }
196    }
197}
198
199/// `[persistence]` section.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct PersistenceSection {
202    /// Append-only file enabled. Default `true`.
203    pub aof: bool,
204    /// AOF fsync policy. Default `EverySec`.
205    pub appendfsync: AppendFsync,
206    /// Trigger BGREWRITEAOF when current AOF is at least this fraction
207    /// (as a percent — 100 = 2× the last-rewrite size) larger than the
208    /// last rewrite. Default `100`.
209    pub auto_aof_rewrite_percentage: u32,
210    /// Never auto-rewrite an AOF smaller than this. Default `64mb` =
211    /// `64 * 1024 * 1024`.
212    pub auto_aof_rewrite_min_size: u64,
213}
214
215impl Default for PersistenceSection {
216    fn default() -> Self {
217        Self {
218            aof: true,
219            appendfsync: AppendFsync::EverySec,
220            auto_aof_rewrite_percentage: 100,
221            auto_aof_rewrite_min_size: 64 * 1024 * 1024,
222        }
223    }
224}
225
226/// `[memory]` section.
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub struct MemorySection {
229    /// Soft memory ceiling in bytes. `0` = unlimited. Default `0`.
230    pub maxmemory: u64,
231    /// Action when `maxmemory` is hit. Default `NoEviction`.
232    pub maxmemory_policy: EvictionPolicy,
233}
234
235impl Default for MemorySection {
236    fn default() -> Self {
237        Self {
238            maxmemory: 0,
239            maxmemory_policy: EvictionPolicy::NoEviction,
240        }
241    }
242}
243
244/// `[metrics]` section — v1.41. Prometheus-format HTTP exposition.
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246#[derive(Default)]
247pub struct MetricsSection {
248    /// TCP port for the `/metrics` HTTP endpoint. `0` = OFF (default).
249    pub listen_port: u16,
250}
251
252
253/// `[audit]` section — v1.42. Append-only audit log of ADMIN-class
254/// commands (`CONFIG SET` / `CONFIG REWRITE` / `DEBUG` / `FLUSHDB` /
255/// `FLUSHALL` / `CLIENT KILL` / `SCRIPT FLUSH` etc.).
256#[derive(Debug, Clone, PartialEq, Eq)]
257pub struct AuditSection {
258    /// Append-only audit log file. Empty string = OFF (default).
259    pub log_path: PathBuf,
260}
261
262impl Default for AuditSection {
263    fn default() -> Self {
264        Self { log_path: PathBuf::new() }
265    }
266}
267
268/// `[expiry]` section. Controls the TTL background reaper.
269#[derive(Debug, Clone, Copy, PartialEq, Eq)]
270pub struct ExpirySection {
271    /// Reaper frequency in Hz. Default `10` (every 100 ms).
272    pub hz: u32,
273    /// Keys sampled per reaper cycle. Default `20`.
274    pub sample: u32,
275}
276
277impl Default for ExpirySection {
278    fn default() -> Self {
279        Self { hz: 10, sample: 20 }
280    }
281}
282
283/// `[log]` section.
284#[derive(Debug, Clone, PartialEq, Eq)]
285pub struct LogSection {
286    /// Log verbosity. Default `Info`.
287    pub level: LogLevel,
288    /// Log sink. Default `Stderr`.
289    pub output: LogOutput,
290}
291
292impl Default for LogSection {
293    fn default() -> Self {
294        Self {
295            level: LogLevel::Info,
296            output: LogOutput::Stderr,
297        }
298    }
299}
300
301/// `[advanced]` section — reactor-loop tuning knobs that used to be
302/// hardcoded `const`s in `kevy-rt`. Defaults match the values shipped
303/// in workspace v1.3 / earlier so the existing benchmark numbers
304/// translate one-to-one. Tune only if you know what you're doing
305/// (`bench/REPORT.md` documents the trade-offs).
306#[derive(Debug, Clone, Copy, PartialEq, Eq)]
307pub struct AdvancedSection {
308    /// Iterations the per-core reactor spins on `poll(timeout=0)`
309    /// before parking on a blocking wait. Higher = lower wake-up
310    /// latency under contention, higher idle CPU; lower = the inverse.
311    /// Default `256` (matches v1.0 const).
312    pub spin_limit: u32,
313    /// Bounded blocking wait in ms once the reactor parks. Acts as a
314    /// safety backstop for any missed cross-core wake (the per-pair
315    /// SeqCst fence is the primary mechanism since workspace v1.3.0).
316    /// Default `50` ms.
317    pub park_timeout_ms: u32,
318    /// How many reactor loop iterations between wall-clock reads for
319    /// the tick (TTL reaper / auto-AOF-rewrite / live-config refresh).
320    /// In busy-poll mode (~1M iter/s) the default `256` is one check
321    /// per ~256 µs — plenty for a 10 Hz tick. In park mode the
322    /// reactor bypasses this throttle (each iter is already ≥ 1 ms),
323    /// so the value only matters under sustained load. Default `256`.
324    pub tick_check_every: u32,
325    /// Per-direction SPSC ring slot count (one ring per ordered
326    /// core-pair). Must be a power of two; the ring code rounds up.
327    /// Overflow spills to a local backlog Vec rather than blocking,
328    /// so a small ring just shifts work to the slower path. Default
329    /// `1024`.
330    pub ring_capacity: usize,
331}
332
333impl Default for AdvancedSection {
334    fn default() -> Self {
335        Self {
336            spin_limit: 256,
337            park_timeout_ms: 50,
338            tick_check_every: 256,
339            ring_capacity: 1024,
340        }
341    }
342}
343
344/// `[notification]` section. `notify_keyspace_events` is a string of
345/// flag chars (Redis convention): `K` keyspace channel, `E` keyevent
346/// channel, `g` generic cmds, `$` string cmds, `l` list, `s` set, `h`
347/// hash, `z` zset, `A` alias for `g$lshz` (every event class except
348/// the not-yet-implemented `x`/`e`/`t`/`n`). Default empty = OFF
349/// (Redis default — zero hot-path cost).
350///
351/// Example: `notify_keyspace_events = "KEA"` enables every event
352/// class on BOTH channels. `"K$"` enables only string events on the
353/// keyspace channel.
354#[derive(Debug, Clone, Default, PartialEq, Eq)]
355pub struct NotificationSection {
356    /// Flag string controlling which keyspace notifications fire. Empty
357    /// (default) = OFF: writes pay one atomic load + skip, no publish.
358    pub notify_keyspace_events: String,
359}
360
361/// Parsed view of [`NotificationSection::notify_keyspace_events`]. The
362/// runtime caches this struct per-shard (hot-reload via the existing
363/// `LiveRuntimeConfig` tick path) so the per-write-command check
364/// reduces to four bool reads on the hot path.
365#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
366pub struct NotificationFlags {
367    /// `K` — publish on `__keyspace@<db>__:<key>` channel.
368    pub keyspace: bool,
369    /// `E` — publish on `__keyevent@<db>__:<event>` channel.
370    pub keyevent: bool,
371    /// `g` — DEL / EXPIRE / PERSIST / RENAME / TYPE / FLUSH etc.
372    pub generic: bool,
373    /// `$` — SET / GETSET / INCR* / APPEND / MSET / etc.
374    pub string: bool,
375    /// `l` — LPUSH / RPUSH / LPOP / RPOP / LREM / LSET / LTRIM / …
376    pub list: bool,
377    /// `s` — SADD / SREM / SPOP / SMOVE / …
378    pub set: bool,
379    /// `h` — HSET / HDEL / HINCRBY / HSETNX / …
380    pub hash: bool,
381    /// `z` — ZADD / ZINCRBY / ZREM / ZREMRANGEBY* / …
382    pub zset: bool,
383    /// `t` — XADD / XDEL / XTRIM / XGROUP / XACK / XCLAIM / XREADGROUP …
384    pub stream: bool,
385}
386
387impl NotificationFlags {
388    /// Notifications are entirely off (no channel enabled OR no class
389    /// enabled). The hot-path emits skip via this check before any
390    /// further classification or string formatting.
391    pub fn is_empty(&self) -> bool {
392        !(self.keyspace || self.keyevent)
393            || !(self.generic
394                || self.string
395                || self.list
396                || self.set
397                || self.hash
398                || self.zset
399                || self.stream)
400    }
401}
402
403/// `[slowlog]` section — controls the per-shard slow-command ring
404/// buffer surfaced by `SLOWLOG GET/LEN/RESET`. Default is OFF
405/// (`slower_than_micros = -1`) so the hot path never pays the
406/// `Instant::now()` pair around dispatch (~30 ns/op, ≈9 % at 3 M
407/// ops/s). To enable Redis-style 10 ms tracking, set
408/// `slower_than_micros = 10000` in `[slowlog]` or run
409/// `CONFIG SET slowlog-log-slower-than 10000`.
410/// `[lua]` section — v1.27 Lua scripting limits.
411#[derive(Debug, Clone, PartialEq, Eq)]
412pub struct LuaSection {
413    /// Hard cap on per-`EVAL` Lua execution time in milliseconds.
414    /// Matches Redis's `lua-time-limit`. The bridge translates this
415    /// to a luna instruction budget at VM construction time using a
416    /// conservative 40 000-instr/ms estimate (so 5000 ms ≈ 200 M
417    /// instructions, the same hard-coded default kevy v1.27 P1-P6
418    /// shipped). Set to 0 to disable the cap (unlimited execution).
419    /// Default: 5000.
420    pub time_limit_ms: u64,
421    /// Whitelist of allowed Lua dialects. Empty = all five
422    /// (5.1/5.2/5.3/5.4/5.5) accepted. Set to `["5.1"]` to lock the
423    /// server to pure Redis ecosystem-compat mode and reject any
424    /// EVAL whose `#!lua version=N` shebang asks for a newer
425    /// dialect. Default: empty (all dialects).
426    pub allow_dialects: Vec<String>,
427}
428
429impl Default for LuaSection {
430    fn default() -> Self {
431        Self {
432            time_limit_ms: 5000,
433            allow_dialects: Vec::new(),
434        }
435    }
436}
437
438/// `[slowlog]` section — ring buffer of slow commands per shard.
439#[derive(Debug, Clone, Copy, PartialEq, Eq)]
440pub struct SlowlogSection {
441    /// Record any command whose execution took at least this many
442    /// microseconds (Redis: `< slower_than_micros` is skipped). `-1`
443    /// disables the log (zero hot-path cost — no `Instant::now()`
444    /// taken); `0` records every command. Default `-1` (OFF).
445    pub slower_than_micros: i64,
446    /// Cap on the per-shard ring buffer. Once exceeded, the oldest
447    /// entry is dropped to make room. Across `nshards` shards the
448    /// effective server-wide cap is `max_len * nshards`. Default `128`.
449    pub max_len: u32,
450}
451
452impl Default for SlowlogSection {
453    fn default() -> Self {
454        Self {
455            slower_than_micros: -1,
456            max_len: 128,
457        }
458    }
459}
460
461/// Parse a Redis-style `notify_keyspace_events` flag string into
462/// [`NotificationFlags`]. Unknown chars are ignored (forward-compat
463/// for `x`/`e`/`t`/`n` not yet implemented — see the section docs).
464/// The `A` alias enables every event-class flag except channels.
465pub fn parse_notification_flags(s: &str) -> NotificationFlags {
466    let mut f = NotificationFlags::default();
467    for c in s.chars() {
468        match c {
469            'K' => f.keyspace = true,
470            'E' => f.keyevent = true,
471            'g' => f.generic = true,
472            '$' => f.string = true,
473            'l' => f.list = true,
474            's' => f.set = true,
475            'h' => f.hash = true,
476            'z' => f.zset = true,
477            't' => f.stream = true,
478            'A' => {
479                // Alias for "g$lshzxetd" — every implemented event class.
480                // Per Redis spec `A` includes the stream `t` class.
481                f.generic = true;
482                f.string = true;
483                f.list = true;
484                f.set = true;
485                f.hash = true;
486                f.zset = true;
487                f.stream = true;
488            }
489            _ => {} // forward-compat: silently ignore unknown chars
490        }
491    }
492    f
493}
494/// the TOML file + env + CLI.
495#[derive(Debug, Clone, PartialEq, Eq, Default)]
496pub struct Config {
497    /// `[server]` settings.
498    pub server: ServerSection,
499    /// `[persistence]` settings.
500    pub persistence: PersistenceSection,
501    /// `[memory]` settings.
502    pub memory: MemorySection,
503    /// `[metrics]` settings (Prometheus /metrics endpoint — v1.41).
504    pub metrics: MetricsSection,
505    /// `[audit]` settings (append-only ADMIN-command audit — v1.42).
506    pub audit: AuditSection,
507    /// `[expiry]` settings.
508    pub expiry: ExpirySection,
509    /// `[log]` settings.
510    pub log: LogSection,
511    /// `[notification]` settings (keyspace events).
512    pub notification: NotificationSection,
513    /// `[advanced]` settings (reactor tuning knobs).
514    pub advanced: AdvancedSection,
515    /// `[slowlog]` settings (slow-command ring buffer).
516    pub slowlog: SlowlogSection,
517    /// `[cluster]` settings (single-node cluster mode).
518    pub cluster: crate::cluster::ClusterSection,
519    /// `[lua]` settings — server-side Lua scripting via the
520    /// `kevy-lua` bridge.
521    pub lua: LuaSection,
522    /// `[replication]` settings — primary/replica streaming.
523    pub replication: crate::replication::ReplicationSection,
524    /// `[feed]` settings — v2.3 CDC consumer surface (FEED.*).
525    pub feed: FeedSection,
526    /// Path the config was loaded from (for `CONFIG REWRITE`). `None` =
527    /// loaded from defaults only / from in-memory string.
528    pub source_path: Option<PathBuf>,
529}
530
531/// `[feed]` — the v2.3 CDC consumer surface. When enabled every shard
532/// keeps a mutation backlog (even with no replicas) and serves
533/// `FEED.READ` / `FEED.TAIL` under the `(generation, offset)` cursor
534/// contract (docs/cdc.md).
535#[derive(Clone, Debug, PartialEq, Eq)]
536pub struct FeedSection {
537    /// Enable the FEED.* surface. Default `false`.
538    pub enabled: bool,
539    /// Per-shard backlog byte budget. Default `64mb`; hard cap `1gb`
540    /// (bring-up refuses louder budgets — memory formula:
541    /// `nshards × feed_buffer_size` upper bound).
542    pub feed_buffer_size: u64,
543}
544
545impl Default for FeedSection {
546    fn default() -> Self {
547        Self { enabled: false, feed_buffer_size: 64 * 1024 * 1024 }
548    }
549}
550
551// `ConfigError` lives in [`crate::error`] — split out so this file
552// stays under the 500-LOC house rule. Re-exported below for any caller
553// that still does `kevy_config::schema::ConfigError`.
554pub use crate::error::ConfigError;