Skip to main content

kevy_embedded/
ops_feed.rs

1//! CDC consumer surface — embedded half.
2//! One stream per store: the embedded write path already
3//! serializes every shard's mutations through `commit_write`, so the
4//! feed is a single `(generation, offset)` stream and
5//! [`Store::feed_shards`] reports 1 (server-parity consumer loops work
6//! unchanged; they just see one shard).
7//!
8//! Persistence: with a `data_dir`, the generation contract rides the
9//! same `feed-0.gen` / `feed-0.meta` sidecars the server uses (clean
10//! close keeps the cursor, crash or FLUSHALL bumps). Without
11//! persistence the store's data dies with the process anyway — each
12//! open starts a fresh generation-1 stream, which is exactly what the
13//! (empty) restored state implies.
14
15use crate::KevyResult;
16use std::sync::{Arc, Mutex};
17
18use kevy_replicate::feed::{FeedPosition, FeedRead, FeedSource};
19
20use crate::store::Store;
21
22/// One mutation delivered by [`Store::changes_since`].
23///
24/// ```
25/// use kevy_embedded::{Config, Store};
26///
27/// let store = Store::open(Config::default().with_feed(0))?;
28/// let from = store.changes_tail()?;
29/// store.set(b"k", b"v")?;
30/// let batch = store.changes_since(from, 10, &[])?;
31/// let change = &batch.changes[0];
32/// assert_eq!(change.offset, from.offset);
33/// assert_eq!(change.argv[0], b"SET");
34/// # Ok::<(), Box<dyn std::error::Error>>(())
35/// ```
36#[derive(Debug, Clone, PartialEq, Eq, Hash)]
37#[non_exhaustive]
38pub struct Change {
39    /// Stream offset (monotonic within a generation).
40    ///
41    /// ```
42    /// use kevy_embedded::{Config, Store};
43    ///
44    /// let store = Store::open(Config::default().with_feed(0))?;
45    /// let from = store.changes_tail()?;
46    /// store.set(b"a", b"1")?;
47    /// store.set(b"b", b"2")?;
48    /// let offsets: Vec<u64> = store.changes_since(from, 10, &[])?.changes.iter().map(|c| c.offset).collect();
49    /// assert_eq!(offsets, [from.offset, from.offset + 1]);
50    /// # Ok::<(), Box<dyn std::error::Error>>(())
51    /// ```
52    pub offset: u64,
53    /// The applied effect's argv (same frames the AOF / a replica sees).
54    ///
55    /// ```
56    /// use kevy_embedded::{Config, Store};
57    ///
58    /// let store = Store::open(Config::default().with_feed(0))?;
59    /// let from = store.changes_tail()?;
60    /// store.hset(b"h", &[(b"f", b"v")])?;
61    /// let argv = &store.changes_since(from, 10, &[])?.changes[0].argv;
62    /// assert_eq!(argv, &[b"HSET".to_vec(), b"h".to_vec(), b"f".to_vec(), b"v".to_vec()]);
63    /// # Ok::<(), Box<dyn std::error::Error>>(())
64    /// ```
65    pub argv: Vec<Vec<u8>>,
66}
67
68impl Change {
69    /// A change at `offset` carrying `argv` — what a client decoding a
70    /// server's `FEED.READ` reply builds, so both backends hand back
71    /// the same type.
72    ///
73    /// ```
74    /// let c = kevy_embedded::Change::new(7, vec![b"DEL".to_vec(), b"k".to_vec()]);
75    /// assert_eq!((c.offset, c.argv.len()), (7, 2));
76    /// ```
77    #[inline]
78    pub fn new(offset: u64, argv: Vec<Vec<u8>>) -> Self {
79        Self { offset, argv }
80    }
81}
82
83/// A batch of changes plus the cursor to resume from.
84///
85/// ```
86/// use kevy_embedded::{Config, Store};
87///
88/// let store = Store::open(Config::default().with_feed(0))?;
89/// let from = store.changes_tail()?;
90/// store.set(b"k", b"v")?;
91/// let batch = store.changes_since(from, 10, &[])?;
92/// assert_eq!(batch.changes.len(), 1);
93/// assert_eq!(batch.next.offset, from.offset + 1);
94/// # Ok::<(), Box<dyn std::error::Error>>(())
95/// ```
96#[derive(Debug, Clone, PartialEq, Eq, Hash)]
97#[non_exhaustive]
98pub struct ChangeBatch {
99    /// Delivered changes, offset order.
100    ///
101    /// ```
102    /// use kevy_embedded::{Config, Store};
103    ///
104    /// let store = Store::open(Config::default().with_feed(0))?;
105    /// let from = store.changes_tail()?;
106    /// store.set(b"a", b"1")?;
107    /// store.del(&[b"a"])?;
108    /// let batch = store.changes_since(from, 10, &[])?;
109    /// let verbs: Vec<&[u8]> = batch.changes.iter().map(|c| &c.argv[0][..]).collect();
110    /// assert_eq!(verbs, [&b"SET"[..], b"DEL"]);
111    /// # Ok::<(), Box<dyn std::error::Error>>(())
112    /// ```
113    pub changes: Vec<Change>,
114    /// The cursor to pass to the next `changes_since`.
115    ///
116    /// ```
117    /// use kevy_embedded::{Config, Store};
118    ///
119    /// let store = Store::open(Config::default().with_feed(0))?;
120    /// let from = store.changes_tail()?;
121    /// store.set(b"a", b"1")?;
122    /// store.set(b"b", b"2")?;
123    /// let first = store.changes_since(from, 1, &[])?;
124    /// // resuming at `next` picks up exactly where the first batch stopped
125    /// let rest = store.changes_since(first.next, 10, &[])?;
126    /// assert_eq!(rest.changes[0].argv[1], b"b");
127    /// # Ok::<(), Box<dyn std::error::Error>>(())
128    /// ```
129    pub next: FeedPosition,
130}
131
132impl ChangeBatch {
133    /// A batch of `changes` resuming at `next` — the decoded form of a
134    /// server's `FEED.READ` reply.
135    ///
136    /// ```
137    /// use kevy_embedded::{ChangeBatch, FeedPosition};
138    /// let caught_up = ChangeBatch::new(Vec::new(), FeedPosition::new(1, 42));
139    /// assert!(caught_up.changes.is_empty());
140    /// assert_eq!(caught_up.next.offset, 42);
141    /// ```
142    #[inline]
143    pub fn new(changes: Vec<Change>, next: FeedPosition) -> Self {
144        Self { changes, next }
145    }
146}
147
148/// Why a feed read could not be served.
149///
150/// ```
151/// use kevy_embedded::{Config, FeedError, Store};
152///
153/// let store = Store::open(Config::default())?;
154/// assert_eq!(store.changes_tail(), Err(FeedError::Disabled));
155/// assert_eq!(FeedError::Disabled.to_string(), "the store was opened without a change feed");
156/// # Ok::<(), Box<dyn std::error::Error>>(())
157/// ```
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
159#[non_exhaustive]
160pub enum FeedError {
161    /// Cursor unservable (stale generation / evicted offsets): rebuild
162    /// from a scan, then resume from `tail`.
163    ///
164    /// ```
165    /// use kevy_embedded::{Config, FeedError, FeedPosition, Store};
166    ///
167    /// let store = Store::open(Config::default().with_feed(0))?;
168    /// let tail = store.changes_tail()?;
169    /// let stale = FeedPosition::new(tail.generation - 1, 0); // an older generation
170    /// assert_eq!(store.changes_since(stale, 10, &[]), Err(FeedError::Resync { tail }));
171    /// # Ok::<(), Box<dyn std::error::Error>>(())
172    /// ```
173    Resync {
174        /// The current generation and the offset to resume from.
175        ///
176        /// ```
177        /// use kevy_embedded::{Config, FeedError, FeedPosition, Store};
178        ///
179        /// let store = Store::open(Config::default().with_feed(0))?;
180        /// store.set(b"k", b"v")?;
181        /// let Err(FeedError::Resync { tail }) = store.changes_since(FeedPosition::new(0, 0), 10, &[]) else {
182        ///     panic!("a generation-0 cursor is stale");
183        /// };
184        /// // after rebuilding from a scan, reading resumes from `tail`
185        /// assert!(store.changes_since(tail, 10, &[])?.changes.is_empty());
186        /// # Ok::<(), Box<dyn std::error::Error>>(())
187        /// ```
188        tail: FeedPosition,
189    },
190    /// Cursor is ahead of the stream — caller bug.
191    ///
192    /// ```
193    /// use kevy_embedded::{Config, FeedError, FeedPosition, Store};
194    ///
195    /// let store = Store::open(Config::default().with_feed(0))?;
196    /// let tail = store.changes_tail()?;
197    /// let ahead = FeedPosition::new(tail.generation, tail.offset + 100);
198    /// assert_eq!(store.changes_since(ahead, 10, &[]), Err(FeedError::Future));
199    /// # Ok::<(), Box<dyn std::error::Error>>(())
200    /// ```
201    Future,
202    /// The store was opened without `Config::with_feed`.
203    ///
204    /// ```
205    /// use kevy_embedded::{Config, FeedError, Store};
206    ///
207    /// assert_eq!(Store::open(Config::default())?.changes_tail(), Err(FeedError::Disabled));
208    /// assert!(Store::open(Config::default().with_feed(0))?.changes_tail().is_ok());
209    /// # Ok::<(), Box<dyn std::error::Error>>(())
210    /// ```
211    Disabled,
212}
213
214impl std::fmt::Display for FeedError {
215    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216        match self {
217            Self::Resync { tail } => write!(
218                f,
219                "feed cursor unservable, resync from generation {} offset {}",
220                tail.generation, tail.offset
221            ),
222            Self::Future => f.write_str("feed cursor ahead of stream"),
223            Self::Disabled => f.write_str("the store was opened without a change feed"),
224        }
225    }
226}
227
228impl std::error::Error for FeedError {}
229
230impl FeedError {
231    /// The error line FEED.TAIL / FEED.READ answer with on the embedded
232    /// wire surfaces, unchanged since those verbs shipped.
233    pub(crate) fn wire_text(&self) -> String {
234        match self {
235            Self::Resync { tail } => format!(
236                "ERR feed: Resync {{ generation: {}, tail: {} }}",
237                tail.generation, tail.offset
238            ),
239            Self::Future => "ERR feed: Future".to_owned(),
240            Self::Disabled => "ERR feed: Disabled".to_owned(),
241        }
242    }
243}
244
245/// Multi-key / keyless verbs the fail-open prefix filter never drops
246/// (their key layout isn't argv[1], or they touch everything).
247const FILTER_DENYLIST: &[&[u8]] = &[
248    b"DEL",
249    b"UNLINK",
250    b"MSET",
251    b"COPY",
252    b"RENAME",
253    b"FLUSHALL",
254    b"BITOP",
255    b"SINTERSTORE",
256    b"SUNIONSTORE",
257    b"SDIFFSTORE",
258    b"ZINTERSTORE",
259    b"ZUNIONSTORE",
260    b"ZDIFFSTORE",
261];
262
263fn matches_prefixes(argv: &[Vec<u8>], prefixes: &[&[u8]]) -> bool {
264    if prefixes.is_empty() {
265        return true;
266    }
267    let Some(verb) = argv.first() else { return true };
268    if FILTER_DENYLIST.iter().any(|d| verb.eq_ignore_ascii_case(d)) {
269        return true; // fail-open: over-delivery is free, drops are not
270    }
271    match argv.get(1) {
272        Some(key) => prefixes.iter().any(|p| key.starts_with(p)),
273        None => true,
274    }
275}
276
277/// Per-prefix keyspace stats from [`Store::info_prefix`].
278///
279/// ```
280/// let store = kevy_embedded::Store::open(kevy_embedded::Config::default())?;
281/// store.set(b"user:1", b"a")?;
282/// store.set(b"order:1", b"b")?;
283/// let info = store.info_prefix(b"user:");
284/// assert_eq!((info.keys, info.expires), (1, 0));
285/// # Ok::<(), Box<dyn std::error::Error>>(())
286/// ```
287#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
288#[non_exhaustive]
289pub struct PrefixInfo {
290    /// Live keys under the prefix.
291    ///
292    /// ```
293    /// let store = kevy_embedded::Store::open(kevy_embedded::Config::default())?;
294    /// store.set(b"user:1", b"a")?;
295    /// store.set(b"user:2", b"b")?;
296    /// store.set(b"order:1", b"c")?;
297    /// assert_eq!(store.info_prefix(b"user:").keys, 2);
298    /// # Ok::<(), Box<dyn std::error::Error>>(())
299    /// ```
300    pub keys: u64,
301    /// How many of them carry a TTL.
302    ///
303    /// ```
304    /// let store = kevy_embedded::Store::open(kevy_embedded::Config::default())?;
305    /// store.set(b"s:1", b"a")?;
306    /// store.set_with_ttl(b"s:2", b"b", std::time::Duration::from_secs(60))?;
307    /// assert_eq!(store.info_prefix(b"s:").expires, 1);
308    /// # Ok::<(), Box<dyn std::error::Error>>(())
309    /// ```
310    pub expires: u64,
311}
312
313impl Store {
314    /// `info_prefix`: count live keys (and TTL'd keys) under a
315    /// byte prefix, across all shards. O(keyspace) — an ops/stats
316    /// call, not a hot-path primitive.
317    pub fn info_prefix(&self, prefix: &[u8]) -> PrefixInfo {
318        let mut keys = 0u64;
319        let mut expires = 0u64;
320        for shard in self.shards.iter() {
321            let g = crate::store::lock_read(shard);
322            let (k, e) = g.store.prefix_stats(prefix);
323            keys += k;
324            expires += e;
325        }
326        PrefixInfo { keys, expires }
327    }
328
329    /// Number of independent change streams this store exposes (the
330    /// embedded write path serializes all shards: always 1).
331    pub fn feed_shards(&self) -> usize {
332        1
333    }
334
335    /// The current cursor: the generation and the next offset — where a
336    /// consumer starting fresh (or resuming after a rebuild) begins.
337    ///
338    /// ```
339    /// use kevy_embedded::{Config, Store};
340    ///
341    /// let store = Store::open(Config::default().with_feed(0))?;
342    /// assert_eq!(store.changes_tail()?.offset, 0);
343    /// # Ok::<(), Box<dyn std::error::Error>>(())
344    /// ```
345    pub fn changes_tail(&self) -> Result<FeedPosition, FeedError> {
346        let feed = self.feed_handle().ok_or(FeedError::Disabled)?;
347        let g = feed.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
348        Ok(g.tail())
349    }
350
351    /// Deliver up to `limit` changes at cursor `from`, optionally
352    /// prefix-filtered (fail-open on multi-key verbs; the filter never
353    /// affects the returned cursor). At-least-once: after a `Resync`
354    /// rebuild, frames already applied may be seen again.
355    ///
356    /// ```
357    /// use kevy_embedded::{Config, Store};
358    ///
359    /// let store = Store::open(Config::default().with_feed(0))?;
360    /// let from = store.changes_tail()?;
361    /// store.set(b"user:1", b"a")?;
362    /// store.set(b"order:1", b"b")?;
363    /// let batch = store.changes_since(from, 10, &[b"user:"])?;
364    /// assert_eq!(batch.changes.len(), 1);
365    /// assert_eq!(batch.next.offset, from.offset + 2);
366    /// # Ok::<(), Box<dyn std::error::Error>>(())
367    /// ```
368    pub fn changes_since(
369        &self,
370        from: FeedPosition,
371        limit: usize,
372        prefixes: &[&[u8]],
373    ) -> Result<ChangeBatch, FeedError> {
374        let feed = self.feed_handle().ok_or(FeedError::Disabled)?;
375        let g = feed.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
376        let frames = match g.read(from, limit.clamp(1, 65536)) {
377            Ok(v) => v,
378            Err(FeedRead::Resync { tail }) => return Err(FeedError::Resync { tail }),
379            Err(FeedRead::Future) => return Err(FeedError::Future),
380        };
381        let next_off = frames.last().map_or(from.offset, |f| f.offset + 1);
382        let mut changes = Vec::with_capacity(frames.len());
383        for f in &frames {
384            let Ok((kevy_replicate::replica::DecodedFrame { offset: foff, argv, .. }, _)) =
385                kevy_replicate::wire::decode_frame(f.bytes)
386            else {
387                continue;
388            };
389            let owned: Vec<Vec<u8>> = (0..argv.len()).map(|i| argv[i].to_vec()).collect();
390            if !matches_prefixes(&owned, prefixes) {
391                continue;
392            }
393            changes.push(Change { offset: foff, argv: owned });
394        }
395        Ok(ChangeBatch { changes, next: FeedPosition::new(g.generation(), next_off) })
396    }
397
398    /// Feed hooks used by `commit_write` / `flushall` / close —
399    /// `None` unless the store was opened with feed enabled.
400    pub(crate) fn feed_handle(&self) -> Option<&Arc<Mutex<FeedSource>>> {
401        self.feed.as_ref()
402    }
403
404    /// Break stream continuity on FLUSHALL: bump + persist the
405    /// generation high-water (mirrors the server's exec_op Flush arm).
406    pub(crate) fn feed_bump_on_flush(&self) {
407        let Some(feed) = self.feed_handle() else { return };
408        let mut g = feed.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
409        g.bump_generation();
410        if let Some(dir) = &self.config.data_dir
411            && let Err(e) = kevy_persist::feed_meta::write_feed_gen(dir, 0, g.generation())
412        {
413            eprintln!("kevy-embedded: feed gen write failed: {e}");
414        }
415    }
416
417    /// Clean-close half of the continuity contract (called from the
418    /// DropGuard after the AOF flush).
419    pub(crate) fn feed_write_close_marker(
420        shards_feed: &Arc<Mutex<FeedSource>>,
421        dir: &std::path::Path,
422    ) {
423        let g = shards_feed.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
424        if let Err(e) = kevy_persist::feed_meta::write_feed_meta(dir, 0, g.tail()) {
425            eprintln!("kevy-embedded: feed marker write failed: {e}");
426        }
427    }
428
429    /// Push one applied effect into the feed (called from
430    /// `commit_write` alongside the AOF append).
431    pub(crate) fn feed_push(feed: &Arc<Mutex<FeedSource>>, parts: &[&[u8]]) {
432        let mut g = feed.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
433        let mut argv = kevy_resp::Argv::default();
434        for p in parts {
435            argv.push(p);
436        }
437        let _ = g.source_mut().push_mutation(&argv);
438    }
439
440    /// Feed boot half for `Store::open` — resolve the cursor via the
441    /// sidecar decision table when persistent, else a fresh gen-1.
442    pub(crate) fn feed_open(
443        config: &crate::config::Config,
444    ) -> KevyResult<Option<Arc<Mutex<FeedSource>>>> {
445        if !config.feed_enabled {
446            return Ok(None);
447        }
448        let budget = usize::try_from(config.feed_buffer_size).unwrap_or(usize::MAX);
449        let at = match &config.data_dir {
450            Some(dir) => kevy_persist::feed_meta::boot_position(dir, 0)?,
451            None => FeedPosition::new(1, 0),
452        };
453        let mut src = kevy_replicate::source::ReplicationSource::new(budget);
454        src.set_next_offset(at.offset);
455        Ok(Some(Arc::new(Mutex::new(FeedSource::new(at.generation, src)))))
456    }
457}