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}