Skip to main content

kevy_store/stream/
group.rs

1//! Consumer groups for v2-7 streams (sprint B). The group state lives
2//! inside its parent [`crate::stream::StreamData`] so XADD / XDEL can
3//! see the group map without an extra lookup. This file owns the
4//! types + the in-stream operations; the `Store`-side wrappers live in
5//! `stream/store.rs` next to the rest of the public API.
6
7#[cfg(not(feature = "std"))]
8use crate::nostd_prelude::*;
9use alloc::collections::BTreeMap;
10
11use kevy_map::KevyMap;
12
13pub(super) use super::claim::AutoclaimResult;
14use super::{EntryBatch, StreamData, StreamId};
15use crate::StoreError;
16use crate::value::SmallBytes;
17
18/// One consumer group's state. Sorted PEL plus a map of known
19/// consumers (with cached pel_count for O(1) XINFO answers).
20#[derive(Debug, Clone)]
21pub struct ConsumerGroup {
22    /// Highest ID delivered to any consumer in this group. Bumped by
23    /// XREADGROUP with `>`; settable via XGROUP SETID.
24    pub last_delivered_id: StreamId,
25    /// Pending-Entries List: every ID delivered but not yet ACKed.
26    /// Sorted by ID for `XPENDING start end` range queries.
27    pub pel: BTreeMap<StreamId, PelEntry>,
28    /// Consumers known to this group (by name).
29    pub consumers: KevyMap<SmallBytes, Box<ConsumerState>>,
30}
31
32impl ConsumerGroup {
33    /// Highest ID delivered by this group — for `XINFO GROUPS`.
34    pub fn last_delivered_id(&self) -> StreamId {
35        self.last_delivered_id
36    }
37    /// Total pending entries — `XINFO GROUPS`'s `pending`.
38    pub fn pending_count(&self) -> usize {
39        self.pel.len()
40    }
41    /// Known consumer count — `XINFO GROUPS`'s `consumers`.
42    pub fn consumer_count(&self) -> usize {
43        self.consumers.len()
44    }
45    /// Iterate `(consumer_name, consumer)` pairs — `XINFO CONSUMERS`.
46    pub fn consumers_iter(&self) -> impl Iterator<Item = (&[u8], &ConsumerState)> {
47        self.consumers.iter().map(|(k, v)| (k.as_slice(), v.as_ref()))
48    }
49}
50
51impl ConsumerState {
52    /// `XINFO CONSUMERS`' `pending` field.
53    pub fn pending_count(&self) -> usize {
54        self.pel_count
55    }
56    /// Last unix-ms this consumer interacted with the group.
57    pub fn last_seen_ms(&self) -> u64 {
58        self.last_seen_ms
59    }
60}
61
62impl Default for ConsumerGroup {
63    fn default() -> Self {
64        Self {
65            last_delivered_id: StreamId::MIN,
66            pel: BTreeMap::new(),
67            consumers: KevyMap::default(),
68        }
69    }
70}
71
72/// One pending entry: who got it, when, and how many times.
73#[derive(Clone, Debug)]
74pub struct PelEntry {
75    /// Owning consumer's name. Used by XPENDING's `consumer` filter
76    /// and XCLAIM's ownership transfer.
77    pub consumer: SmallBytes,
78    /// Last delivery wall-clock (unix-ms). XCLAIM compares idle =
79    /// `now - delivery_time_ms` against its `min-idle-ms` arg.
80    pub delivery_time_ms: u64,
81    /// Number of times this entry has been delivered (=1 on first
82    /// XREADGROUP, +=1 on each XCLAIM that doesn't have JUSTID).
83    pub delivery_count: u32,
84}
85
86/// Per-consumer cached counters so `XINFO CONSUMERS` answers in O(1).
87#[derive(Clone, Debug)]
88#[allow(dead_code)]
89pub struct ConsumerState {
90    /// Consumer name. Read by XINFO CONSUMERS (sprint C).
91    pub name: SmallBytes,
92    /// Last wall-clock (unix-ms) the consumer interacted with the
93    /// group (any XREADGROUP / XACK / XCLAIM touch).
94    pub last_seen_ms: u64,
95    /// Cached size of this consumer's slice of the PEL.
96    pub pel_count: usize,
97}
98
99/// `XGROUP CREATE` ID argument: either an explicit ID or `$`
100/// (= current stream's `last_id`, resolved by the caller).
101#[derive(Clone, Copy, Debug, PartialEq, Eq)]
102pub enum GroupCreateMode {
103    /// `<ms>-<seq>` literal — the group's `last_delivered_id` starts here.
104    AtId(StreamId),
105    /// `$` — resolve to the stream's current `last_id` at create time.
106    AtCurrent,
107}
108
109/// Summary form of `XPENDING key group` (only 3 args): total pending,
110/// min/max IDs across the PEL, and per-consumer aggregate counts.
111#[derive(Debug)]
112pub struct PendingSummary {
113    /// Total pending entries across all consumers.
114    pub total: u64,
115    /// Smallest and largest pending IDs, or `None` if the PEL is empty.
116    pub id_range: Option<(StreamId, StreamId)>,
117    /// `(consumer, count)` pairs in arbitrary order.
118    pub by_consumer: Vec<(Vec<u8>, u64)>,
119}
120
121/// Extended form of `XPENDING key group [IDLE ms] start end count
122/// [consumer]`: one row per matching PEL entry.
123#[derive(Debug)]
124pub struct PendingExtended {
125    /// Per-entry rows in ID-ascending order.
126    pub rows: Vec<PendingExtendedRow>,
127}
128
129/// One row of the extended XPENDING reply.
130#[derive(Debug)]
131pub struct PendingExtendedRow {
132    /// Entry ID.
133    pub id: StreamId,
134    /// Owning consumer's name.
135    pub consumer: Vec<u8>,
136    /// Idle time in milliseconds (now - delivery_time_ms).
137    pub idle_ms: u64,
138    /// Delivery count.
139    pub delivery_count: u32,
140}
141
142/// Knobs for [`crate::StreamData`]'s `xclaim`: `min-idle-ms` plus the
143/// `IDLE`/`TIME`/`RETRYCOUNT`/`FORCE`/`JUSTID` flag tail.
144#[derive(Debug)]
145pub struct XClaimOpts {
146    /// Only claim entries idle for at least this many ms.
147    pub min_idle_ms: u64,
148    /// Override post-claim idle to this many ms (else 0 — XCLAIM resets
149    /// the clock so the new owner has the full idle window).
150    pub idle_override_ms: Option<u64>,
151    /// Override post-claim delivery_time_ms to this absolute unix-ms.
152    /// Takes precedence over `idle_override_ms` if both set.
153    pub time_override_ms: Option<u64>,
154    /// Override post-claim `delivery_count` (else +=1).
155    pub retrycount_override: Option<u32>,
156    /// `FORCE`: claim even if the entry isn't in the PEL yet (creates
157    /// a fresh PEL row with delivery_count=1).
158    pub force: bool,
159    /// `JUSTID`: skip the +=1 on `delivery_count` (used by tools that
160    /// don't intend a real redelivery).
161    pub justid: bool,
162}
163
164impl StreamData {
165    /// `XGROUP CREATE key group <id|$> [MKSTREAM]`. Returns `true` if
166    /// a new group was created; `false` if the group already existed
167    /// (caller should report Redis's `-BUSYGROUP` error in that case).
168    pub fn group_create(&mut self, name: &[u8], mode: GroupCreateMode) -> Result<bool, StoreError> {
169        if self.groups.contains_key(name) {
170            return Ok(false);
171        }
172        let last_delivered_id = match mode {
173            GroupCreateMode::AtId(id) => id,
174            GroupCreateMode::AtCurrent => self.last_id,
175        };
176        self.groups.insert(
177            SmallBytes::from_slice(name),
178            Box::new(ConsumerGroup {
179                last_delivered_id,
180                pel: BTreeMap::new(),
181                consumers: KevyMap::default(),
182            }),
183        );
184        Ok(true)
185    }
186
187    /// `XGROUP DESTROY key group`. Returns `true` if a group was dropped.
188    pub fn group_destroy(&mut self, name: &[u8]) -> bool {
189        self.groups.remove(name).is_some()
190    }
191
192    /// `XGROUP SETID key group <id|$>`. Returns `false` if the group
193    /// doesn't exist.
194    pub fn group_setid(&mut self, name: &[u8], mode: GroupCreateMode) -> bool {
195        let Some(g) = self.groups.get_mut(name) else {
196            return false;
197        };
198        g.last_delivered_id = match mode {
199            GroupCreateMode::AtId(id) => id,
200            GroupCreateMode::AtCurrent => self.last_id,
201        };
202        true
203    }
204
205    /// `XGROUP CREATECONSUMER key group consumer`. Returns `true` if a
206    /// new consumer was inserted, `false` if it already existed or the
207    /// group is missing.
208    pub fn group_create_consumer(&mut self, group: &[u8], consumer: &[u8], now_ms: u64) -> bool {
209        let Some(g) = self.groups.get_mut(group) else {
210            return false;
211        };
212        if g.consumers.contains_key(consumer) {
213            return false;
214        }
215        g.consumers.insert(
216            SmallBytes::from_slice(consumer),
217            Box::new(ConsumerState {
218                name: SmallBytes::from_slice(consumer),
219                last_seen_ms: now_ms,
220                pel_count: 0,
221            }),
222        );
223        true
224    }
225
226    /// `XGROUP DELCONSUMER key group consumer`. Returns the number of
227    /// PEL entries dropped along with the consumer (matches Redis).
228    pub fn group_del_consumer(&mut self, group: &[u8], consumer: &[u8]) -> u64 {
229        let Some(g) = self.groups.get_mut(group) else {
230            return 0;
231        };
232        let dropped = g.pel.len();
233        g.pel.retain(|_, p| p.consumer.as_slice() != consumer);
234        let dropped = dropped - g.pel.len();
235        g.consumers.remove(consumer);
236        dropped as u64
237    }
238
239    /// `XREADGROUP GROUP g c [COUNT n] STREAMS key id`. ID `>` →
240    /// "new entries since last_delivered_id" (updates last_delivered);
241    /// ID `<x>` → "PEL entries for this consumer with id > x" (does
242    /// NOT update last_delivered, used for replay).
243    pub fn readgroup(
244        &mut self,
245        group: &[u8],
246        consumer: &[u8],
247        last_seen_arg: ReadGroupId,
248        count: Option<usize>,
249        noack: bool,
250        now_ms: u64,
251    ) -> Result<EntryBatch, StoreError> {
252        let Some(g) = self.groups.get_mut(group) else {
253            return Err(StoreError::NoSuchKey);
254        };
255        let consumer_smb = SmallBytes::from_slice(consumer);
256        ensure_consumer(g, &consumer_smb, now_ms);
257        if let Some(cs) = g.consumers.get_mut(consumer_smb.as_slice()) {
258            cs.last_seen_ms = now_ms;
259        }
260        match last_seen_arg {
261            ReadGroupId::New => {
262                let start = g.last_delivered_id.next();
263                let entries: Vec<(StreamId, &[(SmallBytes, SmallBytes)])> = self
264                    .entries
265                    .range(start..=StreamId::MAX)
266                    .map(|(id, fv)| (*id, fv.as_slice()))
267                    .collect();
268                let take = match count {
269                    Some(n) => entries.into_iter().take(n).collect::<Vec<_>>(),
270                    None => entries,
271                };
272                if take.is_empty() {
273                    return Ok(Vec::new());
274                }
275                if !noack {
276                    record_deliveries(g, &consumer_smb, &take, now_ms);
277                }
278                let g_mut = self.groups.get_mut(group).expect("present");
279                if let Some((last_id, _)) = take.last() {
280                    g_mut.last_delivered_id = *last_id;
281                }
282                Ok(super::clone_entries(take))
283            }
284            ReadGroupId::ReplayAfter(after) => {
285                Ok(replay_pel_entries(g, &self.entries, &consumer_smb, after, count))
286            }
287        }
288    }
289
290    /// `XACK key group id [...]`. Returns count of PEL entries removed.
291    pub fn ack(&mut self, group: &[u8], ids: &[StreamId]) -> u64 {
292        let Some(g) = self.groups.get_mut(group) else {
293            return 0;
294        };
295        let mut n = 0u64;
296        for id in ids {
297            if let Some(p) = g.pel.remove(id) {
298                if let Some(cs) = g.consumers.get_mut(p.consumer.as_slice()) {
299                    cs.pel_count = cs.pel_count.saturating_sub(1);
300                }
301                n += 1;
302            }
303        }
304        n
305    }
306
307    /// `XPENDING key group` — the summary form (4-tuple).
308    pub fn pending_summary(&self, group: &[u8]) -> Option<PendingSummary> {
309        let g = self.groups.get(group)?;
310        let total = g.pel.len() as u64;
311        let id_range = match (g.pel.keys().next(), g.pel.keys().next_back()) {
312            (Some(lo), Some(hi)) => Some((*lo, *hi)),
313            _ => None,
314        };
315        let mut counts: Vec<(Vec<u8>, u64)> = Vec::new();
316        for p in g.pel.values() {
317            if let Some((_, n)) = counts.iter_mut().find(|(name, _)| name == p.consumer.as_slice())
318            {
319                *n += 1;
320            } else {
321                counts.push((p.consumer.to_vec(), 1));
322            }
323        }
324        Some(PendingSummary { total, id_range, by_consumer: counts })
325    }
326
327    /// `XPENDING key group [IDLE ms] start end count [consumer]`.
328    #[allow(clippy::too_many_arguments)]
329    pub fn pending_extended(
330        &self,
331        group: &[u8],
332        idle_min_ms: Option<u64>,
333        start: StreamId,
334        end: StreamId,
335        count: usize,
336        consumer_filter: Option<&[u8]>,
337        now_ms: u64,
338    ) -> Option<PendingExtended> {
339        let g = self.groups.get(group)?;
340        let mut rows = Vec::with_capacity(count.min(g.pel.len()));
341        for (id, p) in g.pel.range(start..=end) {
342            if rows.len() >= count {
343                break;
344            }
345            let idle = now_ms.saturating_sub(p.delivery_time_ms);
346            if let Some(min) = idle_min_ms
347                && idle < min
348            {
349                continue;
350            }
351            if let Some(c) = consumer_filter
352                && p.consumer.as_slice() != c
353            {
354                continue;
355            }
356            rows.push(PendingExtendedRow {
357                id: *id,
358                consumer: p.consumer.to_vec(),
359                idle_ms: idle,
360                delivery_count: p.delivery_count,
361            });
362        }
363        Some(PendingExtended { rows })
364    }
365}
366
367/// XREADGROUP's per-stream ID: either `>` (= new entries) or an explicit
368/// "after this id" for PEL replay.
369#[derive(Clone, Copy, Debug, PartialEq, Eq)]
370pub enum ReadGroupId {
371    /// `>` — new entries only.
372    New,
373    /// `<id>` — replay PEL entries strictly after this id.
374    ReplayAfter(StreamId),
375}
376
377/// Idempotent insert: ensure the named consumer exists in this group's
378/// roster so subsequent `pel_count`/`last_seen_ms` updates have a slot.
379/// The `XREADGROUP … <id>` replay arm: PEL entries owned by `consumer`
380/// with id strictly after `after`, joined against the live entry map
381/// (XDEL'd tombstones are skipped), capped at `count`.
382fn replay_pel_entries(
383    g: &ConsumerGroup,
384    entries: &alloc::collections::BTreeMap<StreamId, Vec<(SmallBytes, SmallBytes)>>,
385    consumer: &SmallBytes,
386    after: StreamId,
387    count: Option<usize>,
388) -> EntryBatch {
389    let mut hit: Vec<(StreamId, Vec<(SmallBytes, SmallBytes)>)> = Vec::new();
390    for (id, pel_entry) in g.pel.range(after.next()..=StreamId::MAX) {
391        if pel_entry.consumer != *consumer {
392            continue;
393        }
394        if let Some(fv) = entries.get(id) {
395            hit.push((*id, fv.clone()));
396        }
397        if let Some(n) = count
398            && hit.len() >= n
399        {
400            break;
401        }
402    }
403    hit.into_iter()
404        .map(|(id, fv)| (id, fv.iter().map(|(f, v)| (f.to_vec(), v.to_vec())).collect()))
405        .collect()
406}
407
408pub(super) fn ensure_consumer(g: &mut ConsumerGroup, name: &SmallBytes, now_ms: u64) {
409    if g.consumers.get(name.as_slice()).is_none() {
410        g.consumers.insert(
411            name.clone(),
412            Box::new(ConsumerState { name: name.clone(), last_seen_ms: now_ms, pel_count: 0 }),
413        );
414    }
415}
416
417fn record_deliveries(
418    g: &mut ConsumerGroup,
419    consumer: &SmallBytes,
420    entries: &[(StreamId, &[(SmallBytes, SmallBytes)])],
421    now_ms: u64,
422) {
423    let mut new_for_consumer = 0usize;
424    for (id, _) in entries {
425        let entry = g.pel.entry(*id).or_insert_with(|| {
426            new_for_consumer += 1;
427            PelEntry { consumer: consumer.clone(), delivery_time_ms: now_ms, delivery_count: 0 }
428        });
429        if entry.consumer != *consumer {
430            // Ownership transfer via the read path is unusual; Redis
431            // does it on `>` reads only when the PEL already had an
432            // entry from a previous owner — treat as XCLAIM-style.
433            if let Some(prev) = g.consumers.get_mut(entry.consumer.as_slice()) {
434                prev.pel_count = prev.pel_count.saturating_sub(1);
435            }
436            entry.consumer = consumer.clone();
437            new_for_consumer += 1;
438        }
439        entry.delivery_time_ms = now_ms;
440        entry.delivery_count = entry.delivery_count.saturating_add(1);
441    }
442    if let Some(cs) = g.consumers.get_mut(consumer.as_slice()) {
443        cs.pel_count = cs.pel_count.saturating_add(new_for_consumer);
444    }
445}