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// The four Redis-compatible enums live in `crate::enums` (500-LOC house
9// cap); re-exported here so `crate::schema::{AppendFsync, …}` paths keep
10// working unchanged.
11pub use crate::enums::{AppendFsync, EvictionPolicy, LogLevel, LogOutput};
12
13// ───────────── sections ─────────────
14
15/// `[server]` section.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct ServerSection {
18    /// IPv4 bind address. Default `127.0.0.1`.
19    pub bind: [u8; 4],
20    /// TCP port. Default `6004`.
21    pub port: u16,
22    /// Shard / reactor thread count. `0` = auto (CPU count). Default `0`.
23    pub threads: usize,
24    /// Only shards `0..N` arm accept SQE; rest stay compute-only.
25    pub accept_shards: Option<usize>,
26    /// Store a declared table's rows in the packed representation: the
27    /// columns in declared order in one buffer, with no per-row field names
28    /// and no per-row hash table.
29    ///
30    /// Default `true` since 5.4.1. It shipped off in 5.4.0 for three reasons
31    /// and each was then measured away:
32    ///
33    /// - *the adoption path costs memory* — true only of a probe that never
34    ///   read a row back. The saving is collected on reads, not writes: a
35    ///   query phase adds 359 B/row to the general form and 56 to this one
36    ///   (`the-gap-opens-when-the-rows-are-read`);
37    /// - *an unexplained sign difference* — that was the same thing;
38    /// - *it stops tiering demoting* — at three million rows against a
39    ///   512 MB budget it demotes 2,998,956 keys, more than the general form
40    ///   (`the-tiering-budget-is-denominated-in-a-number-that-is-not-the-memory`).
41    ///
42    /// A deployment that wants 5.4.0's representation sets this to `false`;
43    /// nothing about the wire or the on-disk formats changes either way.
44    pub packed_rows: bool,
45    /// Cap on total active client connections. `0` = unlimited.
46    /// Default `10000` (matches Redis). New connection past cap is closed
47    /// + `rejected_connections` counter increments + INFO clients reports.
48    pub max_clients: usize,
49    /// Snapshot + AOF location. Default `.`.
50    pub data_dir: PathBuf,
51}
52
53impl Default for ServerSection {
54    fn default() -> Self {
55        Self {
56            bind: [127, 0, 0, 1],
57            port: 6004,
58            threads: 0,
59            accept_shards: None,
60            packed_rows: true,
61            max_clients: 10_000,
62            data_dir: PathBuf::from("."),
63        }
64    }
65}
66
67/// `[persistence]` section.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct PersistenceSection {
70    /// Append-only file enabled. Default `true`.
71    pub aof: bool,
72    /// AOF fsync policy. Default `EverySec`.
73    pub appendfsync: AppendFsync,
74    /// Trigger BGREWRITEAOF when current AOF is at least this fraction
75    /// (as a percent — 100 = 2× the last-rewrite size) larger than the
76    /// last rewrite. Default `100`.
77    pub auto_aof_rewrite_percentage: u32,
78    /// Never auto-rewrite an AOF smaller than this. Default `64mb` =
79    /// `64 * 1024 * 1024`.
80    pub auto_aof_rewrite_min_size: u64,
81    /// Absolute-size auto-rewrite trigger: compact whenever the AOF
82    /// reaches this many bytes, regardless of growth ratio. `0` = rule
83    /// off (the default). The growth rule alone lets a large log double
84    /// before compacting — this caps it outright.
85    pub auto_aof_rewrite_bytes: u64,
86    /// Time-based auto-rewrite trigger: compact at least this often (in
87    /// seconds) while the log grows. `0` = rule off (the default).
88    pub auto_aof_rewrite_interval_secs: u64,
89    /// Best-effort boot replay: recover the good records behind a corrupt
90    /// v2 AOF record instead of dropping them. Default `false` (strict).
91    pub replay_resync: bool,
92}
93
94impl Default for PersistenceSection {
95    fn default() -> Self {
96        Self {
97            aof: true,
98            appendfsync: AppendFsync::EverySec,
99            auto_aof_rewrite_percentage: 100,
100            auto_aof_rewrite_min_size: 64 * 1024 * 1024,
101            auto_aof_rewrite_bytes: 0,
102            auto_aof_rewrite_interval_secs: 0,
103            replay_resync: false,
104        }
105    }
106}
107
108/// `[memory]` section.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub struct MemorySection {
111    /// Soft memory ceiling in bytes. `0` = unlimited. Default `0`.
112    pub maxmemory: u64,
113    /// Action when `maxmemory` is hit. Default `NoEviction`.
114    pub maxmemory_policy: EvictionPolicy,
115}
116
117impl Default for MemorySection {
118    fn default() -> Self {
119        Self { maxmemory: 0, maxmemory_policy: EvictionPolicy::NoEviction }
120    }
121}
122
123/// `[metrics]` section — Prometheus-format HTTP exposition.
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
125pub struct MetricsSection {
126    /// TCP port for the `/metrics` HTTP endpoint. `0` = OFF (default).
127    pub listen_port: u16,
128}
129
130/// `[audit]` section — append-only audit log of ADMIN-class
131/// commands (`CONFIG SET` / `CONFIG REWRITE` / `DEBUG` / `FLUSHDB` /
132/// `FLUSHALL` / `CLIENT KILL` / `SCRIPT FLUSH` etc.).
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct AuditSection {
135    /// Append-only audit log file. Empty string = OFF (default).
136    pub log_path: PathBuf,
137}
138
139impl Default for AuditSection {
140    fn default() -> Self {
141        Self { log_path: PathBuf::new() }
142    }
143}
144
145/// `[expiry]` section. Controls the TTL background reaper.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub struct ExpirySection {
148    /// Reaper frequency in Hz. Default `10` (every 100 ms).
149    pub hz: u32,
150    /// Keys sampled per reaper cycle. Default `20`.
151    pub sample: u32,
152}
153
154impl Default for ExpirySection {
155    fn default() -> Self {
156        Self { hz: 10, sample: 20 }
157    }
158}
159
160/// `[log]` section.
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct LogSection {
163    /// Log verbosity. Default `Info`.
164    pub level: LogLevel,
165    /// Log sink. Default `Stderr`.
166    pub output: LogOutput,
167}
168
169impl Default for LogSection {
170    fn default() -> Self {
171        Self { level: LogLevel::Info, output: LogOutput::Stderr }
172    }
173}
174
175/// `[advanced]` section — reactor-loop tuning knobs that used to be
176/// hardcoded `const`s in `kevy-rt`. Defaults match the previously
177/// hardcoded values, so the existing benchmark numbers
178/// translate one-to-one. Tune only if you know what you're doing
179/// (`bench/REPORT.md` documents the trade-offs).
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub struct AdvancedSection {
182    /// Iterations the per-core reactor spins on `poll(timeout=0)`
183    /// before parking on a blocking wait. Higher = lower wake-up
184    /// latency under contention, higher idle CPU; lower = the inverse.
185    /// Default `256` (matches the original hardcoded const).
186    pub spin_limit: u32,
187    /// Bounded blocking wait in ms once the reactor parks. Acts as a
188    /// safety backstop for any missed cross-core wake (the per-pair
189    /// SeqCst fence is the primary mechanism).
190    /// Default `50` ms.
191    pub park_timeout_ms: u32,
192    /// How many reactor loop iterations between wall-clock reads for
193    /// the tick (TTL reaper / auto-AOF-rewrite / live-config refresh).
194    /// In busy-poll mode (~1M iter/s) the default `256` is one check
195    /// per ~256 µs — plenty for a 10 Hz tick. In park mode the
196    /// reactor bypasses this throttle (each iter is already ≥ 1 ms),
197    /// so the value only matters under sustained load. Default `256`.
198    pub tick_check_every: u32,
199    /// Per-direction SPSC ring slot count (one ring per ordered
200    /// core-pair). Must be a power of two; the ring code rounds up.
201    /// Overflow spills to a local backlog Vec rather than blocking,
202    /// so a small ring just shifts work to the slower path. Default
203    /// `1024`.
204    pub ring_capacity: usize,
205}
206
207impl Default for AdvancedSection {
208    fn default() -> Self {
209        Self { spin_limit: 256, park_timeout_ms: 50, tick_check_every: 256, ring_capacity: 1024 }
210    }
211}
212
213/// `[notification]` section. `notify_keyspace_events` is a string of
214/// flag chars (Redis convention): `K` keyspace channel, `E` keyevent
215/// channel, `g` generic cmds, `$` string cmds, `l` list, `s` set, `h`
216/// hash, `z` zset, `t` stream, `x` expired events, `e` evicted
217/// events, `n` new-key events, `A` alias for `g$lshztxe` (every
218/// event class except `n`, matching Redis's `A`). Default empty =
219/// OFF (Redis default — zero hot-path cost). Any other character is
220/// a config error.
221///
222/// Example: `notify_keyspace_events = "KEA"` enables every event
223/// class on BOTH channels. `"K$"` enables only string events on the
224/// keyspace channel.
225#[derive(Debug, Clone, Default, PartialEq, Eq)]
226pub struct NotificationSection {
227    /// Flag string controlling which keyspace notifications fire. Empty
228    /// (default) = OFF: writes pay one atomic load + skip, no publish.
229    pub notify_keyspace_events: String,
230}
231
232/// Parsed view of [`NotificationSection::notify_keyspace_events`]. The
233/// runtime caches this struct per-shard (hot-reload via the existing
234/// `LiveRuntimeConfig` tick path) so the per-write-command check
235/// reduces to four bool reads on the hot path.
236// struct_excessive_bools: each field mirrors one independent letter of the
237// redis notify-keyspace-events flag string; they are flags, not a state machine.
238#[allow(clippy::struct_excessive_bools)]
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
240pub struct NotificationFlags {
241    /// `K` — publish on `__keyspace@<db>__:<key>` channel.
242    pub keyspace: bool,
243    /// `E` — publish on `__keyevent@<db>__:<event>` channel.
244    pub keyevent: bool,
245    /// `g` — DEL / EXPIRE / PERSIST / RENAME / TYPE / FLUSH etc.
246    pub generic: bool,
247    /// `$` — SET / GETSET / INCR* / APPEND / MSET / etc.
248    pub string: bool,
249    /// `l` — LPUSH / RPUSH / LPOP / RPOP / LREM / LSET / LTRIM / …
250    pub list: bool,
251    /// `s` — SADD / SREM / SPOP / SMOVE / …
252    pub set: bool,
253    /// `h` — HSET / HDEL / HINCRBY / HSETNX / …
254    pub hash: bool,
255    /// `z` — ZADD / ZINCRBY / ZREM / ZREMRANGEBY* / …
256    pub zset: bool,
257    /// `t` — XADD / XDEL / XTRIM / XGROUP / XACK / XCLAIM / XREADGROUP …
258    pub stream: bool,
259    /// `x` — `expired` events, fired when a TTL'd key is removed
260    /// (lazily on access or by the active reaper).
261    pub expired: bool,
262    /// `e` — `evicted` events, fired when maxmemory pressure removes
263    /// a key.
264    pub evicted: bool,
265    /// `n` — `new` events, fired when a key is added to the keyspace.
266    /// Not part of the `A` alias (Redis convention).
267    pub new_key: bool,
268}
269
270impl NotificationFlags {
271    /// Notifications are entirely off (no channel enabled OR no class
272    /// enabled). The hot-path emits skip via this check before any
273    /// further classification or string formatting.
274    pub fn is_empty(&self) -> bool {
275        !(self.keyspace || self.keyevent)
276            || !(self.generic
277                || self.string
278                || self.list
279                || self.set
280                || self.hash
281                || self.zset
282                || self.stream
283                || self.expired
284                || self.evicted
285                || self.new_key)
286    }
287}
288
289/// `[slowlog]` section — controls the per-shard slow-command ring
290/// buffer surfaced by `SLOWLOG GET/LEN/RESET`. Default is OFF
291/// (`slower_than_micros = -1`) so the hot path never pays the
292/// `Instant::now()` pair around dispatch (~30 ns/op, ≈9 % at 3 M
293/// ops/s). To enable Redis-style 10 ms tracking, set
294/// `slower_than_micros = 10000` in `[slowlog]` or run
295/// `CONFIG SET slowlog-log-slower-than 10000`.
296/// `[lua]` section — Lua scripting limits.
297#[derive(Debug, Clone, PartialEq, Eq)]
298pub struct LuaSection {
299    /// Hard cap on per-`EVAL` Lua execution time in milliseconds.
300    /// Matches Redis's `lua-time-limit`. The bridge translates this
301    /// to a luna-core instruction budget at VM construction time using
302    /// a conservative 40 000-instr/ms estimate (so 5000 ms ≈ 200 M
303    /// instructions, the same default that used to be hard-coded).
304    /// Set to 0 to disable the cap (unlimited execution).
305    /// Default: 5000.
306    pub time_limit_ms: u64,
307    /// Whitelist of allowed Lua dialects. Empty = all five
308    /// (5.1/5.2/5.3/5.4/5.5) accepted. Set to `["5.1"]` to lock the
309    /// server to pure Redis ecosystem-compat mode and reject any
310    /// EVAL whose `#!lua version=N` shebang asks for a newer
311    /// dialect. Default: empty (all dialects).
312    pub allow_dialects: Vec<String>,
313}
314
315impl Default for LuaSection {
316    fn default() -> Self {
317        Self { time_limit_ms: 5000, allow_dialects: Vec::new() }
318    }
319}
320
321/// `[slowlog]` section — ring buffer of slow commands per shard.
322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
323pub struct SlowlogSection {
324    /// Record any command whose execution took at least this many
325    /// microseconds (Redis: `< slower_than_micros` is skipped). `-1`
326    /// disables the log (zero hot-path cost — no `Instant::now()`
327    /// taken); `0` records every command. Default `-1` (OFF).
328    pub slower_than_micros: i64,
329    /// Cap on the per-shard ring buffer. Once exceeded, the oldest
330    /// entry is dropped to make room. Across `nshards` shards the
331    /// effective server-wide cap is `max_len * nshards`. Default `128`.
332    pub max_len: u32,
333}
334
335impl Default for SlowlogSection {
336    fn default() -> Self {
337        Self { slower_than_micros: -1, max_len: 128 }
338    }
339}
340
341/// Parse a Redis-style `notify_keyspace_events` flag string into
342/// [`NotificationFlags`]. The `A` alias enables every event-class
343/// flag except channels and `n` (Redis convention). An unknown char
344/// is an error carrying the offending character — a typo'd flag
345/// string must fail config admission, not silently drop events.
346pub fn parse_notification_flags(s: &str) -> Result<NotificationFlags, char> {
347    let mut f = NotificationFlags::default();
348    for c in s.chars() {
349        match c {
350            'K' => f.keyspace = true,
351            'E' => f.keyevent = true,
352            'g' => f.generic = true,
353            '$' => f.string = true,
354            'l' => f.list = true,
355            's' => f.set = true,
356            'h' => f.hash = true,
357            'z' => f.zset = true,
358            't' => f.stream = true,
359            'x' => f.expired = true,
360            'e' => f.evicted = true,
361            'n' => f.new_key = true,
362            'A' => {
363                // Alias for "g$lshztxe" — every event class except
364                // `n`, per the Redis contract for `A`.
365                f.generic = true;
366                f.string = true;
367                f.list = true;
368                f.set = true;
369                f.hash = true;
370                f.zset = true;
371                f.stream = true;
372                f.expired = true;
373                f.evicted = true;
374            }
375            other => return Err(other),
376        }
377    }
378    Ok(f)
379}
380/// the TOML file + env + CLI.
381#[derive(Debug, Clone, PartialEq, Eq, Default)]
382pub struct Config {
383    /// `[server]` settings.
384    pub server: ServerSection,
385    /// `[persistence]` settings.
386    pub persistence: PersistenceSection,
387    /// `[memory]` settings.
388    pub memory: MemorySection,
389    /// `[metrics]` settings (Prometheus /metrics endpoint).
390    pub metrics: MetricsSection,
391    /// `[audit]` settings (append-only ADMIN-command audit).
392    pub audit: AuditSection,
393    /// `[expiry]` settings.
394    pub expiry: ExpirySection,
395    /// `[log]` settings.
396    pub log: LogSection,
397    /// `[notification]` settings (keyspace events).
398    pub notification: NotificationSection,
399    /// `[advanced]` settings (reactor tuning knobs).
400    pub advanced: AdvancedSection,
401    /// `[slowlog]` settings (slow-command ring buffer).
402    pub slowlog: SlowlogSection,
403    /// `[cluster]` settings (single-node cluster mode).
404    pub cluster: crate::cluster::ClusterSection,
405    /// `[lua]` settings — server-side Lua scripting via the
406    /// `kevy-lua` bridge.
407    pub lua: LuaSection,
408    /// `[replication]` settings — primary/replica streaming.
409    pub replication: crate::replication::ReplicationSection,
410    /// `[feed]` settings — CDC consumer surface (FEED.*).
411    pub feed: FeedSection,
412    /// `[tiering]` settings — the transparent-tiering RAM budget
413    /// (capacity arc). No budget = tiering off.
414    pub tiering: crate::tiering::TieringSection,
415    /// Path the config was loaded from (for `CONFIG REWRITE`). `None` =
416    /// loaded from defaults only / from in-memory string.
417    pub source_path: Option<PathBuf>,
418}
419
420/// `[feed]` — the CDC consumer surface. When enabled every shard
421/// keeps a mutation backlog (even with no replicas) and serves
422/// `FEED.READ` / `FEED.TAIL` under the `(generation, offset)` cursor
423/// contract (docs/cdc.md).
424#[derive(Clone, Debug, PartialEq, Eq)]
425pub struct FeedSection {
426    /// Enable the FEED.* surface. Default `false`.
427    pub enabled: bool,
428    /// Per-shard backlog byte budget. Default `64mb`; hard cap `1gb`
429    /// (bring-up refuses louder budgets — memory formula:
430    /// `nshards × feed_buffer_size` upper bound).
431    pub feed_buffer_size: u64,
432}
433
434impl Default for FeedSection {
435    fn default() -> Self {
436        Self { enabled: false, feed_buffer_size: 64 * 1024 * 1024 }
437    }
438}
439
440// `ConfigError` lives in [`crate::error`] — split out so this file
441// stays under the 500-LOC house rule. Re-exported below for any caller
442// that still does `kevy_config::schema::ConfigError`.
443pub use crate::error::ConfigError;