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