Skip to main content

laser_sdk/stream/
consumer_group.rs

1use crate::error::LaserError;
2use crate::filters::FilterPreviewBuilder;
3use crate::filters::reader::{FilteredReaderBuilder, SourceIncarnation};
4use crate::laser::Laser;
5use crate::stream::Topic;
6use crate::stream::transport::ConsumerBuilder;
7use iggy::prelude::{ConsumerGroupClient, ConsumerGroupDetails, Identifier, IggyError};
8use laser_wire::authz::scoped_resource;
9use laser_wire::filter::{
10    CatalogPosition, ConsumerFilter, FilterBinding, FilterConsumer, FilterError, FilterErrorReason,
11    FilterGroupIdentity, FilterGroupRef, FilterHeader, FilterMutation, FilterRef,
12    FilterRevisionPage, FilterRevisionRef, FilterSource, FilterTestResult, GroupFilterSpec,
13};
14use laser_wire::validate::Validate;
15use std::sync::{Arc, Mutex};
16
17// Filters whose name contains a group's own filter name: the name is unique,
18// so the first page holds it when it exists.
19const GROUP_FILTER_LOOKUP_PAGE: u32 = 8;
20
21/// A consumer group by name or by its native numeric id.
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub(crate) enum GroupTarget {
24    Name(String),
25    Id(u64),
26}
27
28/// One consumer group of a topic. The group owns its filter policy: readers
29/// built from this handle run whatever the group is configured with, bound
30/// or unbound, and never name a filter themselves. Build it with
31/// [`Topic::consumer_group`] or [`Topic::consumer_group_id`]. Handles are
32/// free to construct and clone.
33#[derive(Clone)]
34pub struct ConsumerGroup {
35    topic: Topic,
36    target: GroupTarget,
37    // The control-log position of the last configuration this handle, or a
38    // clone of it, performed. Every read built from the handle carries it, so
39    // the application never observes its own configuration as absent through
40    // another node.
41    configured_at: Arc<Mutex<Option<CatalogPosition>>>,
42}
43
44/// A consumer group as the server knows it after [`CreateConsumerGroup::build`]
45/// or [`ConsumerGroup::info`].
46#[derive(Clone, Debug, PartialEq, Eq)]
47pub struct ConsumerGroupInfo {
48    /// The native numeric group id.
49    pub id: u32,
50    pub name: String,
51    /// The exact group incarnation inside its stream and topic incarnations.
52    /// A recreated group is another identity and inherits no policy.
53    pub identity: FilterGroupIdentity,
54    /// The active policy, `None` for an unbound group whose readers receive
55    /// every record.
56    pub filter: Option<FilterBinding>,
57}
58
59impl ConsumerGroup {
60    pub(crate) fn new(topic: Topic, target: GroupTarget) -> Self {
61        Self {
62            topic,
63            target,
64            configured_at: Arc::new(Mutex::new(None)),
65        }
66    }
67
68    /// The group name, `None` for a handle addressed by numeric id.
69    pub fn name(&self) -> Option<&str> {
70        match &self.target {
71            GroupTarget::Name(name) => Some(name),
72            GroupTarget::Id(_) => None,
73        }
74    }
75
76    /// The native numeric id, `None` for a handle addressed by name.
77    pub const fn id(&self) -> Option<u64> {
78        match self.target {
79            GroupTarget::Id(id) => Some(id),
80            GroupTarget::Name(_) => None,
81        }
82    }
83
84    pub const fn topic(&self) -> &Topic {
85        &self.topic
86    }
87
88    /// Create the group, with an optional filter policy configured in the
89    /// same call. Idempotent: an existing group is kept, and a repeated
90    /// policy with the same digest keeps its binding.
91    pub fn create(&self) -> CreateConsumerGroup {
92        CreateConsumerGroup {
93            group: self.clone(),
94            policy: None,
95            operation_id: None,
96        }
97    }
98
99    /// The group's filter policy: configure it, inspect it, draft and pause
100    /// revisions, release it, preview and sample-test it.
101    pub fn filter(&self) -> GroupFilter {
102        GroupFilter {
103            group: self.clone(),
104        }
105    }
106
107    /// Build a live, load-balanced consumer of this group with server-backed
108    /// offsets. On a server that resolves group policies the consumer runs
109    /// the group's policy, filtered or unfiltered, and acknowledges through
110    /// the fenced group contract. On Apache Iggy it is the native group
111    /// consumer. The built consumer implements `futures::Stream`.
112    pub fn consumer(&self) -> ConsumerBuilder {
113        ConsumerBuilder::group(self.clone())
114    }
115
116    /// The match-oriented reader of this group's filter policy: pages of
117    /// records with their original offsets, an independent scan budget, and
118    /// explicit acknowledgments. An unbound group receives every record.
119    ///
120    /// # Errors
121    ///
122    /// [`LaserError::NoStream`] when the topic has no stream.
123    pub fn reader(&self) -> Result<FilteredReaderBuilder<'_>, LaserError> {
124        self.reader_with(FilterRef::Group)
125    }
126
127    /// The group as the server knows it: its id, exact identity and active
128    /// policy.
129    pub async fn info(&self) -> Result<ConsumerGroupInfo, LaserError> {
130        let details = self.native().await?;
131        let identity = self.identity_of(details.id).await?;
132        let filter = if self.laser().capabilities().await.filters.catalog {
133            self.filter().get().await?
134        } else {
135            None
136        };
137        if filter
138            .as_ref()
139            .is_some_and(|binding| binding.identity != identity)
140        {
141            return Err(FilterError::new(
142                FilterErrorReason::SourceChanged,
143                "the group incarnation changed while its policy was inspected",
144            )
145            .into());
146        }
147        Ok(ConsumerGroupInfo {
148            id: details.id,
149            name: details.name,
150            identity,
151            filter,
152        })
153    }
154
155    pub(crate) fn reader_with(
156        &self,
157        filter: FilterRef,
158    ) -> Result<FilteredReaderBuilder<'_>, LaserError> {
159        Ok(FilteredReaderBuilder::new(
160            self.laser(),
161            self.source()?,
162            self.selector(),
163            filter,
164            self.catalog_position(),
165        ))
166    }
167
168    /// Create the native group by name without touching its policy.
169    pub(crate) async fn ensure_native(&self) -> Result<(), LaserError> {
170        let GroupTarget::Name(name) = &self.target else {
171            return Err(LaserError::Invalid(
172                "a consumer group is created by name, address it with consumer_group(name)"
173                    .to_owned(),
174            ));
175        };
176        let stream = Identifier::named(self.topic.stream()?)?;
177        let topic = Identifier::named(self.topic.name())?;
178        match self
179            .laser()
180            .client()
181            .create_consumer_group(&stream, &topic, name)
182            .await
183        {
184            Ok(_) | Err(IggyError::ConsumerGroupNameAlreadyExists(..)) => Ok(()),
185            Err(error) => Err(error.into()),
186        }
187    }
188
189    pub(crate) fn laser(&self) -> &Laser {
190        self.topic.laser()
191    }
192
193    pub(crate) fn selector(&self) -> FilterConsumer {
194        match &self.target {
195            GroupTarget::Name(name) => FilterConsumer::Group(name.clone()),
196            GroupTarget::Id(id) => FilterConsumer::GroupId(*id),
197        }
198    }
199
200    pub(crate) fn source(&self) -> Result<FilterSource, LaserError> {
201        Ok(FilterSource {
202            stream: self.topic.stream()?.to_owned(),
203            topic: self.topic.name().to_owned(),
204        })
205    }
206
207    pub(crate) fn catalog_position(&self) -> Option<CatalogPosition> {
208        *self
209            .configured_at
210            .lock()
211            .unwrap_or_else(std::sync::PoisonError::into_inner)
212    }
213
214    pub(crate) async fn native_name(&self) -> Result<String, LaserError> {
215        match &self.target {
216            GroupTarget::Name(name) => Ok(name.clone()),
217            GroupTarget::Id(_) => Ok(self.native().await?.name),
218        }
219    }
220
221    fn remember_position(&self, position: Option<CatalogPosition>) {
222        let Some(position) = position else {
223            return;
224        };
225        let mut configured_at = self
226            .configured_at
227            .lock()
228            .unwrap_or_else(std::sync::PoisonError::into_inner);
229        if configured_at.is_none_or(|known| {
230            position.operation_id.is_some() && known.operation_id != position.operation_id
231                || known.partition_id != position.partition_id
232                || known.offset < position.offset
233        }) {
234            *configured_at = Some(position);
235        }
236    }
237
238    // The names the catalog addresses the group by. A numeric handle reads
239    // them from the server.
240    async fn group_ref(&self) -> Result<FilterGroupRef, LaserError> {
241        let source = self.source()?;
242        let group = match &self.target {
243            GroupTarget::Name(name) => name.clone(),
244            GroupTarget::Id(_) => self.native().await?.name,
245        };
246        Ok(FilterGroupRef {
247            stream: source.stream,
248            topic: source.topic,
249            group,
250        })
251    }
252
253    async fn native(&self) -> Result<ConsumerGroupDetails, LaserError> {
254        let stream = Identifier::named(self.topic.stream()?)?;
255        let topic = Identifier::named(self.topic.name())?;
256        let group = match &self.target {
257            GroupTarget::Name(name) => Identifier::named(name)?,
258            GroupTarget::Id(id) => Identifier::numeric(
259                u32::try_from(*id)
260                    .map_err(|_| LaserError::Config("consumer group id exceeds 32 bits"))?,
261            )?,
262        };
263        self.laser()
264            .client()
265            .get_consumer_group(&stream, &topic, &group)
266            .await?
267            .ok_or_else(|| {
268                FilterError::new(
269                    FilterErrorReason::NotFound,
270                    format!("consumer group {} does not exist", self.selector()),
271                )
272                .into()
273            })
274    }
275
276    async fn identity_of(&self, group_id: u32) -> Result<FilterGroupIdentity, LaserError> {
277        let source = self.source()?;
278        Ok(SourceIncarnation::read(&self.laser().client(), &source)
279            .await?
280            .identity(u64::from(group_id)))
281    }
282}
283
284/// Creates a consumer group. Build it with [`ConsumerGroup::create`].
285pub struct CreateConsumerGroup {
286    group: ConsumerGroup,
287    policy: Option<GroupFilterSpec>,
288    operation_id: Option<u128>,
289}
290
291impl CreateConsumerGroup {
292    /// Configure the group with `filter` once it exists. The definition is
293    /// saved as the group's own filter.
294    #[must_use]
295    pub fn filter(mut self, filter: ConsumerFilter) -> Self {
296        self.policy = Some(GroupFilterSpec::Definition(filter));
297        self
298    }
299
300    /// Configure the group with a definition or one of its own revisions.
301    #[must_use]
302    pub fn policy(mut self, policy: GroupFilterSpec) -> Self {
303        self.policy = Some(policy);
304        self
305    }
306
307    /// The idempotency key of the policy configuration. Record it first to
308    /// resume the same configuration after a crash and read its first
309    /// outcome.
310    #[must_use]
311    pub const fn operation_id(mut self, operation_id: u128) -> Self {
312        self.operation_id = Some(operation_id);
313        self
314    }
315
316    /// Create the group, then configure its policy when one was given. A
317    /// policy is preflighted against the server's capabilities before the
318    /// native group is created. A group that exists with the same policy is
319    /// returned unchanged, one that runs another policy is a typed conflict.
320    ///
321    /// # Errors
322    ///
323    /// [`LaserError::ConsumerGroupSetup`] when the group exists but its
324    /// policy could not be configured. The group is left as it is.
325    pub async fn build(self) -> Result<ConsumerGroupInfo, LaserError> {
326        let group = self.group;
327        if let Some(policy) = &self.policy {
328            FilterMutation::ConfigureGroup {
329                group: group.group_ref().await?,
330                policy: policy.clone(),
331                expected_identity: None,
332            }
333            .validate()?;
334            group.laser().filters().catalog_capabilities().await?;
335        }
336        group.ensure_native().await?;
337        let details = group.native().await?;
338        let identity = group.identity_of(details.id).await?;
339        let filter = match self.policy {
340            Some(policy) => match group
341                .filter()
342                .configure_scoped_as(self.operation_id, policy, identity)
343                .await
344            {
345                Ok(binding) => Some(binding),
346                Err(source) => {
347                    return Err(LaserError::ConsumerGroupSetup {
348                        group_id: details.id,
349                        name: details.name,
350                        identity,
351                        source: Box::new(source),
352                    });
353                }
354            },
355            None if group.laser().capabilities().await.filters.catalog => {
356                group.filter().get().await?
357            }
358            None => None,
359        };
360        Ok(ConsumerGroupInfo {
361            id: details.id,
362            name: details.name,
363            identity,
364            filter,
365        })
366    }
367}
368
369/// A consumer group's filter policy. Build it with [`ConsumerGroup::filter`].
370/// Every verb needs a managed backend that serves the filter catalog.
371pub struct GroupFilter {
372    group: ConsumerGroup,
373}
374
375impl GroupFilter {
376    /// Give the group `filter` as its policy. The definition is saved as the
377    /// group's own filter and the group is bound to it in one catalog
378    /// transaction. The same digest again keeps the binding. Another digest
379    /// on a group that runs a policy is a typed conflict: create a new group
380    /// for another policy.
381    pub async fn configure(&self, filter: ConsumerFilter) -> Result<FilterBinding, LaserError> {
382        self.configure_as(None, GroupFilterSpec::Definition(filter))
383            .await
384    }
385
386    /// [`configure`](Self::configure) with a definition or one of the group's
387    /// own revisions.
388    pub async fn configure_with(
389        &self,
390        policy: GroupFilterSpec,
391    ) -> Result<FilterBinding, LaserError> {
392        self.configure_as(None, policy).await
393    }
394
395    /// [`configure_with`](Self::configure_with) under a caller-chosen
396    /// operation id, so a caller that records the id first resumes the same
397    /// configuration after a crash and reads its first outcome.
398    pub async fn configure_as(
399        &self,
400        operation_id: Option<u128>,
401        policy: GroupFilterSpec,
402    ) -> Result<FilterBinding, LaserError> {
403        let details = self.group.native().await?;
404        let identity = self.group.identity_of(details.id).await?;
405        self.configure_scoped_as(operation_id, policy, identity)
406            .await
407    }
408
409    async fn configure_scoped_as(
410        &self,
411        operation_id: Option<u128>,
412        policy: GroupFilterSpec,
413        identity: FilterGroupIdentity,
414    ) -> Result<FilterBinding, LaserError> {
415        let group = self.group.group_ref().await?;
416        let (binding, position) = self
417            .group
418            .laser()
419            .filters()
420            .configure_group(group, policy, operation_id, identity)
421            .await?;
422        if binding.identity != identity {
423            return Err(FilterError::new(
424                FilterErrorReason::SourceChanged,
425                "configured binding identifies another group incarnation",
426            )
427            .into());
428        }
429        self.group.remember_position(position);
430        Ok(binding)
431    }
432
433    /// The active policy, `None` for an unbound group.
434    pub async fn get(&self) -> Result<Option<FilterBinding>, LaserError> {
435        self.group.laser().filters().catalog_capabilities().await?;
436        let details = self.group.native().await?;
437        let identity = self.group.identity_of(details.id).await?;
438        let group = self.group.group_ref().await?;
439        match self.group.laser().filters().binding(group).await {
440            Ok(binding) if binding.identity == identity => Ok(Some(binding)),
441            Ok(_) => Err(FilterError::new(
442                FilterErrorReason::SourceChanged,
443                "the group incarnation changed while its policy was inspected",
444            )
445            .into()),
446            Err(error) if error.is_not_found() => Ok(None),
447            Err(error) => Err(error),
448        }
449    }
450
451    /// One page of the group's own filter revisions, newest first.
452    pub async fn revisions(
453        &self,
454        page: u32,
455        page_size: u32,
456    ) -> Result<FilterRevisionPage, LaserError> {
457        let active = self.active().await?;
458        self.group
459            .laser()
460            .filters()
461            .revisions(active.filter_id, page, page_size)
462            .await
463    }
464
465    /// Draft a revision of the group's own filter. Readers keep running the
466    /// active revision. A different digest requires a separate group.
467    /// `expected_revision` must still be the latest.
468    pub async fn revise(
469        &self,
470        expected_revision: u32,
471        filter: ConsumerFilter,
472    ) -> Result<FilterRevisionRef, LaserError> {
473        let active = self.active().await?;
474        self.group
475            .laser()
476            .filters()
477            .revise(active.filter_id, expected_revision, filter)
478            .await
479    }
480
481    /// Pause or resume a revision of the group's own filter. A paused active
482    /// revision refuses new reads, while records already delivered can still
483    /// be acknowledged.
484    pub async fn set_revision_enabled(
485        &self,
486        revision: u32,
487        enabled: bool,
488    ) -> Result<(), LaserError> {
489        let active = self.active().await?;
490        self.group
491            .laser()
492            .filters()
493            .set_revision_enabled(active.filter_id, revision, enabled)
494            .await
495    }
496
497    /// Delete the group's own filter with every revision. A bound group is
498    /// released first, so its consumers receive every record from their next
499    /// poll. Nothing of the filter stays in the catalog. `Ok(false)` when the
500    /// group has no filter of its own.
501    pub async fn delete(&self) -> Result<bool, LaserError> {
502        self.group.laser().filters().catalog_capabilities().await?;
503        let details = self.group.native().await?;
504        let identity = self.group.identity_of(details.id).await?;
505        let name = group_filter_name(&identity);
506        let scoped_name = scoped_resource(&self.group.source()?.stream, &name);
507        let filters = self.group.laser().filters();
508        let mut before_id = None;
509        loop {
510            let page = filters
511                .list_before(&name, before_id, GROUP_FILTER_LOOKUP_PAGE)
512                .await?;
513            let next = page.items.last().map(|summary| summary.id);
514            let count = page.items.len();
515            if let Some(own) = page
516                .items
517                .into_iter()
518                .find(|summary| summary.name == name || summary.name == scoped_name)
519            {
520                let position = filters.drop_filter(own.id).await?;
521                self.group.remember_position(position);
522                return Ok(true);
523            }
524            let Some(next) = next else {
525                return Ok(false);
526            };
527            if before_id.is_some_and(|before| next >= before) {
528                return Err(FilterError::new(
529                    FilterErrorReason::CatalogUnavailable,
530                    "the catalog lookup cursor did not advance",
531                )
532                .into());
533            }
534            if count as u64 >= u64::from(page.total) {
535                return Ok(false);
536            }
537            before_id = Some(next);
538        }
539    }
540
541    /// Release the group's policy. Its readers then receive every record. A
542    /// released group may only be configured with the digest it ran.
543    pub async fn release(&self) -> Result<FilterBinding, LaserError> {
544        let active = self.active().await?;
545        let (released, position) = self.group.laser().filters().release_group(active).await?;
546        self.group.remember_position(position);
547        Ok(released)
548    }
549
550    /// Preview the active policy over the stored records of one partition. A
551    /// preview joins no group, stores no offset, and changes no consumer
552    /// state.
553    pub async fn preview(&self, partition_id: u32) -> Result<FilterPreviewBuilder<'_>, LaserError> {
554        let active = self.active().await?;
555        let source = self.group.source()?;
556        Ok(self.group.laser().filters().preview(
557            source.stream,
558            source.topic,
559            partition_id,
560            FilterRef::Revision {
561                filter_id: active.filter_id,
562                revision: active.revision,
563            },
564        ))
565    }
566
567    /// Evaluate the active policy against one supplied record and explain the
568    /// verdict. Nothing is read from a stream and nothing is stored.
569    pub async fn test(
570        &self,
571        payload: impl Into<Vec<u8>>,
572        headers: Vec<FilterHeader>,
573    ) -> Result<FilterTestResult, LaserError> {
574        let active = self.active().await?;
575        self.group
576            .laser()
577            .filters()
578            .test(
579                FilterRef::Revision {
580                    filter_id: active.filter_id,
581                    revision: active.revision,
582                },
583                payload,
584                headers,
585            )
586            .await
587    }
588
589    async fn active(&self) -> Result<FilterBinding, LaserError> {
590        self.get().await?.ok_or_else(|| {
591            FilterError::new(
592                FilterErrorReason::NotFound,
593                "the consumer group has no filter policy",
594            )
595            .into()
596        })
597    }
598}
599
600// The catalog names a group's own filter by the ids of the group incarnation
601// it was configured for. A catalog that scopes names to streams prefixes it
602// with the group's stream.
603fn group_filter_name(identity: &FilterGroupIdentity) -> String {
604    format!(
605        "group:{}:{}:{}:{}:{}",
606        identity.stream_id,
607        identity.stream_created_at_micros,
608        identity.topic_id,
609        identity.topic_created_at_micros,
610        identity.group_id
611    )
612}