Skip to main content

kevy_embedded/
config.rs

1//! Embedded-store configuration. Builder-style — every knob has a sane
2//! default so `Config::default()` works for the simplest use case
3//! (in-memory, no persistence, background TTL reaper).
4
5use std::path::PathBuf;
6use std::time::Duration;
7
8pub use kevy_persist::Fsync as AppendFsync;
9pub use kevy_store::EvictionPolicy;
10
11/// How the active TTL reaper runs.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum TtlReaperMode {
14    /// Spawn a background thread that ticks at the configured interval
15    /// (default 100 ms / 10 Hz, matching Redis's `hz=10`). Default.
16    Background,
17    /// Caller-driven via [`crate::Store::tick`]. Required for WASM
18    /// targets (no threads) and single-threaded apps that don't want a
19    /// background worker.
20    Manual,
21}
22
23/// Embedded-store config. Build by chaining `with_*` methods on
24/// [`Config::default`].
25#[derive(Debug, Clone)]
26pub struct Config {
27    /// v2.9: optional READ-ONLY RESP listener address (ops tooling —
28    /// redis-cli against a live embedded store). `None` (default) =
29    /// no listener thread, no socket, zero tax.
30    pub resp_listener: Option<std::net::SocketAddr>,
31    /// Soft memory ceiling in bytes. `0` (default) = unlimited.
32    pub maxmemory: u64,
33    /// Eviction policy when over `maxmemory`. Default `NoEviction`.
34    pub eviction_policy: EvictionPolicy,
35    /// Persistence directory. `None` = pure in-memory (no AOF, no snapshot).
36    pub data_dir: Option<PathBuf>,
37    /// AOF on/off when `data_dir` is set. Defaults to `true` (on) when
38    /// `with_persist` was called; ignored if `data_dir` is `None`.
39    pub aof: bool,
40    /// AOF fsync policy. Default `EverySec` (matches Redis: ≤ 1 s loss).
41    pub appendfsync: AppendFsync,
42    /// Snapshot file name inside `data_dir` (single-shard only; `n > 1`
43    /// always uses `dump-{i}.rdb`). Default `"dump-0.rdb"`. A custom name
44    /// opts the dir out of server interop: no `shards.meta` is recorded,
45    /// and a `kevy` server opening the same dir won't find the files.
46    pub snapshot_filename: String,
47    /// AOF file name inside `data_dir` (single-shard only; `n > 1` always
48    /// uses `aof-{i}.aof`). Default `"aof-0.aof"`. Same interop opt-out as
49    /// [`Self::snapshot_filename`].
50    pub aof_filename: String,
51    /// TTL reaper mode. Default `Background`.
52    pub ttl_reaper: TtlReaperMode,
53    /// Reaper tick interval. Default 100 ms (10 Hz).
54    pub reaper_interval: Duration,
55    /// `tick_expire` samples per round. Default 20 (matches Redis).
56    pub reaper_samples: usize,
57    /// Max sample rounds per tick. Default 16.
58    pub reaper_max_rounds: u32,
59    /// Auto-`BGREWRITEAOF` trigger: rewrite when the live AOF has grown by at
60    /// least this percent over its size at the previous rewrite. `0` disables
61    /// (call [`crate::Store::rewrite_aof`] manually). Default `100` (Redis).
62    pub auto_aof_rewrite_pct: u32,
63    /// Floor below which auto-rewrite is skipped. Default `64 MiB` (Redis).
64    pub auto_aof_rewrite_min_size: u64,
65    /// Optional push-style metric callback (replay / rewrite events). Default
66    /// `None`. Set via [`Self::with_metric_sink`]; not part of `Debug` output.
67    pub(crate) metric_sink: Option<crate::metric::MetricSink>,
68    /// Keyspace shard count (`hash(key) % shards`), each a fully independent
69    /// lock + keyspace + AOF (shared-nothing) — concurrent access scales across
70    /// cores. **Default `1`** (single shard = the original single-lock /
71    /// single-`aof-0.aof` layout, zero migration). Set `> 1` via
72    /// [`Self::with_shards`]; the first open with `> 1` re-shards an existing
73    /// single AOF into per-shard files.
74    pub shards: usize,
75    /// Replication upstream. `Some("host:port")` makes this store a
76    /// read-replica that streams writes from the named primary; `None`
77    /// (default) is a normal primary store. Configured via
78    /// [`Self::with_replica_upstream`] or the convenience constructor
79    /// [`crate::Store::open_replica`].
80    pub replica_upstream: Option<String>,
81    /// Replica identity string sent to the primary at handshake
82    /// (`REPLICATE FROM <offset> ID <replica_id>`). Default
83    /// `"kevy-embedded-replica"`. Override per-process when multiple
84    /// embed replicas connect to the same primary (they'd otherwise
85    /// share the slot and clobber each other's session state on the
86    /// primary side).
87    pub replica_id: String,
88    /// Replica reconnect backoff: lower bound. Default 100 ms. The
89    /// runner sleeps this long after the first connection failure;
90    /// each subsequent failure doubles the wait up to
91    /// [`Self::replica_reconnect_max`].
92    pub replica_reconnect_min: Duration,
93    /// Replica reconnect backoff: upper bound. Default 5 s — matches
94    /// the server-side replica reconnect default so embed replicas and
95    /// server replicas behave identically when the same primary
96    /// disappears.
97    pub replica_reconnect_max: Duration,
98    /// Embed-as-writer bind address (`"host:port"` or
99    /// `"0.0.0.0:port"`) for the replication source listener. When
100    /// `Some`, every commit on this store pushes its argv into a
101    /// process-local `ReplicationSource` backlog, and replicas (other
102    /// embeds, server-as-replicas) connect to this port to stream the
103    /// writes. `None` (default) keeps the embed in pure-local mode.
104    /// Mutually exclusive in spirit with `replica_upstream` (a single
105    /// store should be either a writer source or a reader sink, not
106    /// both); the builder does not reject the combo so tests can
107    /// exercise the guard rails.
108    pub embed_writer_listen_addr: Option<String>,
109    /// v2.3 CDC feed (changes_since / changes_tail). Default off.
110    pub feed_enabled: bool,
111    /// Feed backlog byte budget. Default 64 MB, capped at 1 GB.
112    pub feed_buffer_size: u64,
113    /// Backlog byte budget for the embed-as-writer source. Default
114    /// `1 MiB` (matches the v1.18 server replication default).
115    /// Set higher when consumers may disconnect for longer than
116    /// the backlog can buffer (otherwise reconnects hit `TooOld`
117    /// and v1.21 closes the link — snapshot-ship from embed is a
118    /// follow-up).
119    pub embed_writer_backlog_bytes: usize,
120}
121
122impl Default for Config {
123    fn default() -> Self {
124        Self {
125            resp_listener: None,
126            maxmemory: 0,
127            eviction_policy: EvictionPolicy::NoEviction,
128            data_dir: None,
129            aof: true,
130            appendfsync: AppendFsync::EverySec,
131            snapshot_filename: String::from("dump-0.rdb"),
132            aof_filename: String::from("aof-0.aof"),
133            ttl_reaper: TtlReaperMode::Background,
134            reaper_interval: Duration::from_millis(100),
135            reaper_samples: 20,
136            reaper_max_rounds: 16,
137            auto_aof_rewrite_pct: 100,
138            auto_aof_rewrite_min_size: 64 * 1024 * 1024,
139            metric_sink: None,
140            shards: 1,
141            replica_upstream: None,
142            replica_id: String::from("kevy-embedded-replica"),
143            replica_reconnect_min: Duration::from_millis(100),
144            replica_reconnect_max: Duration::from_secs(5),
145            embed_writer_listen_addr: None,
146            feed_enabled: false,
147            feed_buffer_size: 64 * 1024 * 1024,
148            embed_writer_backlog_bytes: 1024 * 1024,
149        }
150    }
151}
152
153impl Config {
154    /// v2.9: enable the read-only RESP listener on `addr`
155    /// (e.g. `"127.0.0.1:6009".parse().unwrap()`).
156    #[must_use]
157    pub fn with_resp_listener(mut self, addr: std::net::SocketAddr) -> Self {
158        self.resp_listener = Some(addr);
159        self
160    }
161
162    /// Enable persistence under `dir` — snapshot file + AOF land inside.
163    /// AOF defaults on; turn it off with [`Self::without_aof`] for pure
164    /// snapshot-only durability.
165    #[must_use]
166    pub fn with_persist(mut self, dir: impl Into<PathBuf>) -> Self {
167        self.data_dir = Some(dir.into());
168        self
169    }
170
171    /// Disable the AOF (snapshot-only persistence — explicit `save_snapshot`
172    /// calls are the only way data survives restart).
173    #[must_use]
174    pub fn without_aof(mut self) -> Self {
175        self.aof = false;
176        self
177    }
178
179    /// Soft memory ceiling in bytes. `0` keeps the default (unlimited).
180    #[must_use]
181    pub fn with_max_memory(mut self, bytes: u64) -> Self {
182        self.maxmemory = bytes;
183        self
184    }
185
186    /// Eviction policy when over [`Self::with_max_memory`].
187    #[must_use]
188    pub fn with_eviction(mut self, policy: EvictionPolicy) -> Self {
189        self.eviction_policy = policy;
190        self
191    }
192
193    /// AOF fsync policy. Default [`AppendFsync::EverySec`].
194    #[must_use]
195    pub fn with_appendfsync(mut self, fsync: AppendFsync) -> Self {
196        self.appendfsync = fsync;
197        self
198    }
199
200    /// Auto-`BGREWRITEAOF` thresholds: rewrite once the AOF has grown `pct`
201    /// percent past its size at the last rewrite AND is at least `min_size`
202    /// bytes. In `Background` reaper mode the check runs on the reaper tick;
203    /// in `Manual` mode it runs when you call [`crate::Store::tick`]. Pass
204    /// `pct = 0` to disable auto-rewrite (you can still call
205    /// [`crate::Store::rewrite_aof`] yourself). Defaults: 100 % / 64 MiB.
206    #[must_use]
207    pub fn with_auto_aof_rewrite(mut self, pct: u32, min_size: u64) -> Self {
208        self.auto_aof_rewrite_pct = pct;
209        self.auto_aof_rewrite_min_size = min_size;
210        self
211    }
212
213    /// Shard the keyspace into `n` shared-nothing partitions (`hash(key) % n`),
214    /// each with its own lock + keyspace + AOF, so concurrent access scales
215    /// across cores. `n` clamps to ≥ 1; `1` (default) is the original
216    /// single-shard layout. Going from a single-AOF store to `n > 1`
217    /// re-shards the existing `aof-0.aof` into `aof-0..aof-{n-1}` on the next
218    /// open (the old file is backed up to `aof-0.aof.premigration.<ts>` first).
219    /// Pub/sub is process-wide (handled on shard 0), not sharded.
220    #[must_use]
221    pub fn with_shards(mut self, n: usize) -> Self {
222        self.shards = n.max(1);
223        self
224    }
225
226    /// Register a push-style metric callback. It receives a [`crate::KevyMetric`] for
227    /// each AOF replay (startup) and AOF rewrite (compaction) — wire it to
228    /// Prometheus / a log line / a counter. The callback runs synchronously on
229    /// the emitting thread (reaper thread for background rewrites), so keep it
230    /// fast and non-blocking. Replaces any previously-set sink.
231    #[must_use]
232    pub fn with_metric_sink(
233        mut self,
234        sink: impl Fn(crate::KevyMetric) + Send + Sync + 'static,
235    ) -> Self {
236        self.metric_sink = Some(crate::metric::MetricSink::new(sink));
237        self
238    }
239
240    /// Caller-driven TTL reaping — disables the background thread.
241    /// Required for WASM (no threads available). Call
242    /// [`crate::Store::tick`] yourself from your event loop.
243    #[must_use]
244    pub fn with_ttl_reaper_manual(mut self) -> Self {
245        self.ttl_reaper = TtlReaperMode::Manual;
246        self
247    }
248
249    /// Configure this store as a replication replica of `upstream`
250    /// (`"host:port"` of a kevy server's replication listener). A
251    /// background thread streams writes from the primary and applies
252    /// them locally; this store rejects local writes with a
253    /// `READONLY` error. See [`crate::Store::open_replica`] for the
254    /// convenience constructor.
255    #[must_use]
256    pub fn with_replica_upstream(mut self, upstream: impl Into<String>) -> Self {
257        self.replica_upstream = Some(upstream.into());
258        self
259    }
260
261    /// Override the replica identity sent to the primary at handshake.
262    /// Useful when multiple embed replicas share one primary —
263    /// otherwise they'd share the slot and stomp each other's session
264    /// state.
265    #[must_use]
266    pub fn with_replica_id(mut self, id: impl Into<String>) -> Self {
267        self.replica_id = id.into();
268        self
269    }
270
271    /// Override the replica reconnect backoff bounds.
272    #[must_use]
273    pub fn with_replica_reconnect(mut self, min: Duration, max: Duration) -> Self {
274        self.replica_reconnect_min = min;
275        self.replica_reconnect_max = max.max(min);
276        self
277    }
278
279    /// Run this store as an embed-as-writer: bind a replication
280    /// source listener on `bind_addr` so replicas can subscribe to
281    /// the writes applied here.
282    #[must_use]
283    pub fn with_embed_writer(mut self, bind_addr: impl Into<String>) -> Self {
284        self.embed_writer_listen_addr = Some(bind_addr.into());
285        self
286    }
287
288    /// v2.3: enable the CDC feed (`changes_since` / `changes_tail`).
289    /// `buffer_size` = 0 keeps the 64 MB default; values cap at 1 GB.
290    #[must_use]
291    pub fn with_feed(mut self, buffer_size: u64) -> Self {
292        self.feed_enabled = true;
293        if buffer_size > 0 {
294            self.feed_buffer_size = buffer_size.min(1024 * 1024 * 1024);
295        }
296        self
297    }
298
299    /// Override the embed-as-writer backlog byte budget.
300    #[must_use]
301    pub fn with_embed_writer_backlog(mut self, bytes: usize) -> Self {
302        self.embed_writer_backlog_bytes = bytes.max(64 * 1024);
303        self
304    }
305
306    /// Override the background reaper interval. Default 100 ms.
307    #[must_use]
308    pub fn with_reaper_interval(mut self, iv: Duration) -> Self {
309        self.reaper_interval = iv;
310        self
311    }
312
313    /// Override the snapshot file name inside `data_dir`.
314    #[must_use]
315    pub fn with_snapshot_filename(mut self, name: impl Into<String>) -> Self {
316        self.snapshot_filename = name.into();
317        self
318    }
319
320    /// Override the AOF file name inside `data_dir`.
321    #[must_use]
322    pub fn with_aof_filename(mut self, name: impl Into<String>) -> Self {
323        self.aof_filename = name.into();
324        self
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn default_is_pure_in_memory() {
334        let c = Config::default();
335        assert_eq!(c.maxmemory, 0);
336        assert!(c.data_dir.is_none());
337        assert_eq!(c.ttl_reaper, TtlReaperMode::Background);
338        assert!(c.aof);
339    }
340
341    #[test]
342    fn builder_chains() {
343        let c = Config::default()
344            .with_persist("/tmp/foo")
345            .with_max_memory(1024)
346            .with_eviction(EvictionPolicy::AllKeysLru)
347            .with_ttl_reaper_manual()
348            .with_appendfsync(AppendFsync::Always);
349        assert_eq!(c.data_dir.as_deref(), Some(std::path::Path::new("/tmp/foo")));
350        assert_eq!(c.maxmemory, 1024);
351        assert_eq!(c.eviction_policy, EvictionPolicy::AllKeysLru);
352        assert_eq!(c.ttl_reaper, TtlReaperMode::Manual);
353    }
354
355    #[test]
356    fn without_aof_disables_logging_path() {
357        let c = Config::default().with_persist("/tmp/foo").without_aof();
358        assert!(c.data_dir.is_some());
359        assert!(!c.aof);
360    }
361}