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;