Skip to main content

laser_sdk/filters/
reader.rs

1use crate::error::{LaserError, decode_managed_reply};
2use crate::filters::group::Membership;
3#[cfg(feature = "filters")]
4use crate::filters::guard::LocalGuard;
5use crate::filters::progress::{Blocked, PartitionProgress};
6use crate::filters::route::{RouteTo, Routes};
7use crate::iggy::prelude::{
8    Client, IggyClient, IggyClientBuilder, IggyError, IggyMessage, PolledMessages, StreamClient,
9    TopicClient,
10};
11use crate::laser::Laser;
12use bytes::Bytes;
13use iggy_binary_protocol::batch::{BatchIntegrity, decode_batch_slice_with};
14use iggy_common::Identifier;
15use laser_wire::codes::{AGDX_FILTERED_ACK_CODE, AGDX_FILTERED_POLL_CODE, FILTER_OP_VERSION};
16#[cfg(feature = "filters")]
17use laser_wire::filter::ConsumerFilter;
18use laser_wire::filter::{
19    AppliedPolicy, CatalogPosition, ExecutionMode, FilterConsumer, FilterError, FilterErrorReason,
20    FilterOutcome, FilterRef, FilterReply, FilterSource, FilteredAck, FilteredPage,
21    FilteredPollRequest, FilteredStart, ReadMode, SourceGeneration, StopReason,
22};
23use laser_wire::framing::encode_named;
24#[cfg(feature = "filters")]
25use laser_wire::limits::MAX_FILTER_CATALOG_PAGE;
26use laser_wire::validate::Validate;
27use serde::de::DeserializeOwned;
28use std::collections::{BTreeMap, BTreeSet, VecDeque};
29use std::sync::Arc;
30use std::time::Duration;
31use tokio::time::{Instant, sleep};
32
33const DEFAULT_COUNT: u32 = 100;
34const DEFAULT_MAX_REPLY_BYTES: u32 = 1024 * 1024;
35pub(crate) const DEFAULT_IDLE_INTERVAL: Duration = Duration::from_millis(250);
36const DEFAULT_MAX_UNACKED_PAGES: usize = 1024;
37
38/// Builds a [`FilteredReader`] over one consumer group. Start from
39/// [`ConsumerGroup::reader`](crate::stream::ConsumerGroup::reader).
40pub struct FilteredReaderBuilder<'a> {
41    laser: &'a Laser,
42    source: FilterSource,
43    consumer: FilterConsumer,
44    filter: FilterRef,
45    min_catalog_position: Option<CatalogPosition>,
46    start: FilteredStart,
47    count: u32,
48    max_examined: Option<u32>,
49    max_reply_bytes: u32,
50    read_mode: ReadMode,
51    #[cfg(feature = "filters")]
52    local_guard: bool,
53    idle_interval: Duration,
54    retry_interval: Option<Duration>,
55    max_unacked_pages: usize,
56    selected_partitions: BTreeSet<u32>,
57}
58
59/// Reads the records a consumer group's policy selects, with their original
60/// offsets, and stores progress through fenced acknowledgments.
61///
62/// Pages are read ahead of acknowledgments. An acknowledgment stores a page's
63/// safe offset only after that page and every earlier page of the partition
64/// are fully handled, so progress never skips an unhandled record. Pages that
65/// matched nothing are stored by the reader itself.
66///
67/// A partition that fails, faults, or blocks waits one idle interval before it
68/// is read again, while the other partitions keep reading. A purge or a new
69/// group binding restarts the partition from its stored offset once, and the
70/// error that reports it reaches the caller.
71///
72/// Every method is cancel-safe: a cancelled call leaves the position and the
73/// unstored progress as they were, and the next call repeats the work.
74pub struct FilteredReader {
75    owner: Arc<()>,
76    laser: Laser,
77    // The connection that holds the group membership and whose consumer
78    // session the data connections attach to. A group reader owns its own, so
79    // two readers are two members.
80    coordinator: Arc<IggyClient>,
81    consumer: FilterConsumer,
82    // The request every read sends, with the partition and the start set per
83    // read.
84    request: FilteredPollRequest,
85    idle_interval: Duration,
86    retry_interval: Duration,
87    max_unacked_pages: usize,
88    #[cfg(feature = "filters")]
89    guard: Option<LocalGuard>,
90    #[cfg(feature = "filters")]
91    guard_enabled: bool,
92    membership: Option<Membership>,
93    partitions: BTreeMap<u32, PartitionProgress>,
94    revoked: BTreeSet<u32>,
95    selected_partitions: BTreeSet<u32>,
96    routes: Routes,
97    // Data connections opened by routes this reader already retired on a rejoin.
98    retired_connections: u64,
99    cursor: usize,
100    sequence: u64,
101    examined_in_round: u64,
102    lost_routes: BTreeSet<u32>,
103    buffered: VecDeque<MatchedRecord>,
104    // An error a partition raised in a round that another partition's page
105    // ended, returned by the next round before any read, so a stopped
106    // partition is reported even while others keep matching.
107    deferred_error: Option<LaserError>,
108    // The stream and topic incarnation a numeric group id was first read
109    // under. Group ids restart at zero on a recreated topic, so a later page
110    // from another incarnation belongs to an unrelated group.
111    pinned_source: Option<SourceIncarnation>,
112}
113
114/// The stream and topic a numeric group id is scoped to.
115#[derive(Clone, Copy, Debug, PartialEq, Eq)]
116pub(crate) struct SourceIncarnation {
117    stream_id: u32,
118    stream_created_at_micros: u64,
119    topic_id: u32,
120    topic_created_at_micros: u64,
121}
122
123impl SourceIncarnation {
124    pub(crate) async fn read(
125        client: &IggyClient,
126        source: &FilterSource,
127    ) -> Result<Self, LaserError> {
128        let stream = client
129            .get_stream(&Identifier::named(&source.stream)?)
130            .await?
131            .ok_or_else(|| {
132                FilterError::new(FilterErrorReason::NotFound, "source stream does not exist")
133            })?;
134        let topic = client
135            .get_topic(
136                &Identifier::numeric(stream.id)?,
137                &Identifier::named(&source.topic)?,
138            )
139            .await?
140            .ok_or_else(|| {
141                FilterError::new(FilterErrorReason::NotFound, "source topic does not exist")
142            })?;
143        Ok(Self {
144            stream_id: stream.id,
145            stream_created_at_micros: stream.created_at.as_micros(),
146            topic_id: topic.id,
147            topic_created_at_micros: topic.created_at.as_micros(),
148        })
149    }
150}
151
152impl SourceIncarnation {
153    /// The exact identity of group `group_id` inside this incarnation.
154    pub(crate) const fn identity(self, group_id: u64) -> laser_wire::filter::FilterGroupIdentity {
155        laser_wire::filter::FilterGroupIdentity {
156            stream_id: self.stream_id,
157            stream_created_at_micros: self.stream_created_at_micros,
158            topic_id: self.topic_id,
159            topic_created_at_micros: self.topic_created_at_micros,
160            group_id,
161        }
162    }
163}
164
165impl From<SourceGeneration> for SourceIncarnation {
166    fn from(generation: SourceGeneration) -> Self {
167        Self {
168            stream_id: generation.stream_id,
169            stream_created_at_micros: generation.stream_created_at_micros,
170            topic_id: generation.topic_id,
171            topic_created_at_micros: generation.topic_created_at_micros,
172        }
173    }
174}
175
176/// One page of matching records from one partition.
177#[derive(Debug)]
178pub struct MatchedPage {
179    pub partition_id: u32,
180    pub records: Vec<MatchedRecord>,
181    /// The filter the server executed.
182    pub policy: AppliedPolicy,
183    /// The source history the page was read from.
184    pub generation: SourceGeneration,
185    pub stop: StopReason,
186    pub examined: u32,
187    /// The partition head when the page was read. Reported, not progress.
188    pub frontier: u64,
189    /// The offset acknowledging this page stores, once every earlier page is
190    /// handled too. Absent on a local page, which cannot be acknowledged.
191    pub safe_ack_offset: Option<u64>,
192    sequence: u64,
193    owner: Arc<()>,
194}
195
196/// One matching record, unchanged from the stream.
197#[derive(Debug)]
198pub struct MatchedRecord {
199    pub partition_id: u32,
200    pub offset: u64,
201    /// The partition head when the record was read.
202    pub frontier: u64,
203    /// `false` when a pass fault, foreign-record, or mismatch policy delivered
204    /// the record without evaluating it.
205    pub evaluated: bool,
206    pub message: IggyMessage,
207    sequence: u64,
208    owner: Arc<()>,
209}
210
211/// The handle a delivered record is acknowledged by: the partition read it
212/// came from and the reader membership that read it.
213#[derive(Clone, Debug)]
214pub(crate) struct Delivery {
215    pub(crate) partition_id: u32,
216    pub(crate) offset: u64,
217    sequence: u64,
218    owner: Arc<()>,
219}
220
221impl MatchedRecord {
222    pub(crate) fn delivery(&self) -> Delivery {
223        Delivery {
224            partition_id: self.partition_id,
225            offset: self.offset,
226            sequence: self.sequence,
227            owner: Arc::clone(&self.owner),
228        }
229    }
230
231    /// Decode the record's payload as JSON into `T`.
232    pub fn json<T: DeserializeOwned>(&self) -> Result<T, LaserError> {
233        serde_json::from_slice(&self.message.payload)
234            .map_err(|error| LaserError::Codec(error.to_string()))
235    }
236
237    /// Whether the record's header block fails to decode. The payload stays
238    /// readable, and `message.user_headers_map()` returns the decode error.
239    pub fn headers_malformed(&self) -> bool {
240        self.message.user_headers_map().is_err()
241    }
242}
243
244impl Delivery {
245    pub(crate) fn same(&self, other: &Self) -> bool {
246        self.partition_id == other.partition_id
247            && self.offset == other.offset
248            && self.sequence == other.sequence
249            && Arc::ptr_eq(&self.owner, &other.owner)
250    }
251}
252
253enum Read {
254    Page(MatchedPage),
255    /// Nothing matched. `more` when the page stopped before the visible end.
256    Empty {
257        more: bool,
258    },
259    Saturated,
260}
261
262enum Round {
263    Page(MatchedPage),
264    Busy,
265    Idle,
266}
267
268impl<'a> FilteredReaderBuilder<'a> {
269    pub(crate) fn new(
270        laser: &'a Laser,
271        source: FilterSource,
272        consumer: FilterConsumer,
273        filter: FilterRef,
274        min_catalog_position: Option<CatalogPosition>,
275    ) -> Self {
276        Self {
277            laser,
278            source,
279            consumer,
280            filter,
281            min_catalog_position,
282            start: FilteredStart::Next,
283            count: DEFAULT_COUNT,
284            max_examined: None,
285            max_reply_bytes: DEFAULT_MAX_REPLY_BYTES,
286            read_mode: ReadMode::Primary,
287            #[cfg(feature = "filters")]
288            local_guard: false,
289            idle_interval: DEFAULT_IDLE_INTERVAL,
290            retry_interval: None,
291            max_unacked_pages: DEFAULT_MAX_UNACKED_PAGES,
292            selected_partitions: BTreeSet::new(),
293        }
294    }
295
296    /// Where each partition read at the build starts. Defaults to after the
297    /// stored offset. A partition the group hands this reader later always
298    /// resumes after the group's stored offset, so the backlog the previous
299    /// owner left unacknowledged is read, not skipped.
300    #[must_use]
301    pub fn start(mut self, start: FilteredStart) -> Self {
302        self.start = start;
303        self
304    }
305
306    /// Restrict reads to this partition when the group assigns it to this
307    /// member. Repeat for several partitions. Native ownership stays unchanged.
308    #[must_use]
309    pub fn partition(mut self, partition_id: u32) -> Self {
310        self.selected_partitions.insert(partition_id);
311        self
312    }
313
314    /// Most matching records per page.
315    #[must_use]
316    pub fn count(mut self, count: u32) -> Self {
317        self.count = count;
318        self
319    }
320
321    /// Most source records one page examines, independent of
322    /// [`count`](Self::count). Without it the server's own budget bounds the
323    /// scan. A page that examines its budget without a match returns empty
324    /// and the reader continues from where it stopped.
325    #[must_use]
326    pub fn max_examined(mut self, records: u32) -> Self {
327        self.max_examined = Some(records);
328        self
329    }
330
331    /// Most record bytes per page. The server may lower it.
332    #[must_use]
333    pub fn max_reply_bytes(mut self, max_reply_bytes: u32) -> Self {
334        self.max_reply_bytes = max_reply_bytes;
335        self
336    }
337
338    /// `Primary` (the default) reads from each partition primary and can
339    /// acknowledge, and needs a Laser built from a connection string. `Local`
340    /// reads whatever replica this connection reaches, cannot acknowledge, and
341    /// keeps no pending pages.
342    #[must_use]
343    pub fn read_mode(mut self, read_mode: ReadMode) -> Self {
344        self.read_mode = read_mode;
345        self
346    }
347
348    /// Re-evaluate every returned record locally with the shared evaluator
349    /// and fail the page on any disagreement, before the caller sees it.
350    #[cfg(feature = "filters")]
351    #[must_use]
352    pub fn local_guard(mut self, enabled: bool) -> Self {
353        self.local_guard = enabled;
354        self
355    }
356
357    /// How long [`FilteredReader::next_page`] waits after a round that found
358    /// nothing new, and how long a blocked partition waits before it is read
359    /// again.
360    #[must_use]
361    pub fn idle_interval(mut self, interval: Duration) -> Self {
362        self.idle_interval = interval;
363        self
364    }
365
366    /// How long a partition whose read failed waits before it is read again.
367    /// Defaults to the idle interval.
368    #[must_use]
369    pub(crate) fn retry_interval(mut self, interval: Duration) -> Self {
370        self.retry_interval = Some(interval);
371        self
372    }
373
374    /// Most outstanding record-bearing pages per partition. Defaults to 1024.
375    /// A larger value permits more concurrent work and retains more offset metadata.
376    /// Acknowledge processed pages to release slots. Zero is not allowed.
377    #[must_use]
378    pub fn max_unacked_pages(mut self, pages: usize) -> Self {
379        self.max_unacked_pages = pages;
380        self
381    }
382
383    pub async fn build(self) -> Result<FilteredReader, LaserError> {
384        if self.max_unacked_pages == 0 {
385            return Err(LaserError::Config("max_unacked_pages must be positive"));
386        }
387        let capabilities = self.laser.capabilities().await;
388        if !matches!(self.filter, FilterRef::Group) && !capabilities.filters.native {
389            return Err(LaserError::unsupported(
390                "filters",
391                "consumer filters are not served by this server",
392            ));
393        }
394        if matches!(self.filter, FilterRef::Group) && !capabilities.filters.group_policy_reads {
395            return Err(LaserError::unsupported_feature(
396                "filters",
397                "group_policy_reads",
398                "this server serves consumer filters but not group-aware reads, upgrade it before reading groups through the Laser SDK",
399            ));
400        }
401        if self.read_mode == ReadMode::Primary && self.laser.connection_string().is_none() {
402            return Err(LaserError::Config(
403                "primary group reads open data connections, so they need a Laser built from a connection string. Read in ReadMode::Local for a bring-your-own client",
404            ));
405        }
406        let consumer = self.consumer;
407        let request = FilteredPollRequest {
408            v: FILTER_OP_VERSION,
409            source: self.source,
410            partition_id: 0,
411            consumer: consumer.clone(),
412            filter: self.filter,
413            start: self.start.clone(),
414            count: self.count,
415            max_reply_bytes: self.max_reply_bytes,
416            read_mode: self.read_mode,
417            max_examined: self.max_examined,
418            min_catalog_position: self.min_catalog_position,
419        };
420        request.validate()?;
421        let pinned_source = if matches!(consumer, FilterConsumer::GroupId(_)) {
422            Some(SourceIncarnation::read(&self.laser.client(), &request.source).await?)
423        } else {
424            None
425        };
426        // A group reader owns the connection that holds its membership, so
427        // two readers are two members. Without a connection string the
428        // shared client is the only one there is.
429        let coordinator = match self.laser.connection_string() {
430            Some(connection_string) => {
431                let client =
432                    IggyClientBuilder::from_connection_string(connection_string)?.build()?;
433                client.connect().await?;
434                Arc::new(client)
435            }
436            None => self.laser.client(),
437        };
438        let membership =
439            Membership::join(&coordinator, &request.source, &consumer, pinned_source).await?;
440        let partitions = membership
441            .partitions()
442            .iter()
443            .filter(|partition_id| {
444                self.selected_partitions.is_empty()
445                    || self.selected_partitions.contains(partition_id)
446            })
447            .map(|partition_id| (*partition_id, PartitionProgress::new(self.start.clone())))
448            .collect();
449        Ok(FilteredReader {
450            owner: Arc::new(()),
451            laser: self.laser.clone(),
452            coordinator,
453            consumer,
454            request,
455            idle_interval: self.idle_interval,
456            retry_interval: self.retry_interval.unwrap_or(self.idle_interval),
457            max_unacked_pages: self.max_unacked_pages,
458            #[cfg(feature = "filters")]
459            guard: None,
460            #[cfg(feature = "filters")]
461            guard_enabled: self.local_guard,
462            membership: Some(membership),
463            partitions,
464            revoked: BTreeSet::new(),
465            selected_partitions: self.selected_partitions,
466            routes: Routes::default(),
467            retired_connections: 0,
468            cursor: 0,
469            sequence: 0,
470            examined_in_round: 0,
471            lost_routes: BTreeSet::new(),
472            buffered: VecDeque::new(),
473            deferred_error: None,
474            pinned_source,
475        })
476    }
477}
478
479impl FilteredReader {
480    /// The next page with at least one matching record. Waits while every
481    /// partition has nothing new. Wrap it in a timeout to bound the wait.
482    pub async fn next_page(&mut self) -> Result<MatchedPage, LaserError> {
483        loop {
484            match self.round().await? {
485                Round::Page(page) => return Ok(page),
486                Round::Busy => {}
487                Round::Idle => sleep(self.idle_interval).await,
488            }
489        }
490    }
491
492    /// Read each partition in turn until a page matches or every partition
493    /// has nothing new, without the idle wait. `None` when nothing is new, so
494    /// a caller that drives its own loop waits [`idle_interval`](Self::idle_interval)
495    /// before it asks again.
496    pub async fn try_next_page(&mut self) -> Result<Option<MatchedPage>, LaserError> {
497        loop {
498            match self.round().await? {
499                Round::Page(page) => return Ok(Some(page)),
500                Round::Busy => {}
501                Round::Idle => return Ok(None),
502            }
503        }
504    }
505
506    /// Perform one bounded round without an idle wait. The flag means unmatched
507    /// work remains. Read [`examined_in_round`](Self::examined_in_round) for its
508    /// source-record count, including pages that matched nothing.
509    pub async fn read_round(&mut self) -> Result<(Option<MatchedPage>, bool), LaserError> {
510        Ok(match self.round().await? {
511            Round::Page(page) => (Some(page), false),
512            Round::Busy => (None, true),
513            Round::Idle => (None, false),
514        })
515    }
516
517    /// Source records examined by the last bounded round, including empty
518    /// pages that advance progress.
519    pub const fn examined_in_round(&self) -> u64 {
520        self.examined_in_round
521    }
522
523    /// How long this reader waits when nothing is new.
524    pub const fn idle_interval(&self) -> Duration {
525        self.idle_interval
526    }
527
528    /// The connection that holds this reader's group membership, for tests
529    /// that simulate the server dropping it.
530    #[doc(hidden)]
531    pub fn coordinator(&self) -> Arc<IggyClient> {
532        Arc::clone(&self.coordinator)
533    }
534
535    /// Whether `page` came from this reader in its current membership, so its
536    /// records can still be acknowledged. A rejoin retires every earlier page.
537    pub fn owns(&self, page: &MatchedPage) -> bool {
538        Arc::ptr_eq(&self.owner, &page.owner)
539            && self
540                .partitions
541                .get(&page.partition_id)
542                .is_some_and(|progress| progress.knows(page.sequence))
543    }
544
545    pub(crate) fn owns_record(&self, record: &MatchedRecord) -> bool {
546        Arc::ptr_eq(&self.owner, &record.owner)
547            && self
548                .partitions
549                .get(&record.partition_id)
550                .is_some_and(|progress| progress.knows(record.sequence))
551    }
552
553    /// The next matching record, one at a time over [`next_page`](Self::next_page).
554    pub async fn next_record(&mut self) -> Result<MatchedRecord, LaserError> {
555        loop {
556            if let Some(record) = self.buffered.pop_front() {
557                return Ok(record);
558            }
559            let page = self.next_page().await?;
560            self.buffered.extend(page.records);
561        }
562    }
563
564    /// Mark one record handled, and store the progress this completes.
565    pub async fn ack(&mut self, record: &MatchedRecord) -> Result<(), LaserError> {
566        self.require_acknowledgments()?;
567        self.require_owner(&record.owner)?;
568        self.require_sequence(record.partition_id, record.sequence)?;
569        self.progress(record.partition_id)?
570            .complete_record(record.sequence, record.offset);
571        self.flush(record.partition_id).await
572    }
573
574    /// Mark every record on this partition through `record` handled, and
575    /// store that completed prefix. Process all preceding records first.
576    /// Records after it remain pending, even inside the same page.
577    pub async fn ack_through(&mut self, record: &MatchedRecord) -> Result<(), LaserError> {
578        self.require_acknowledgments()?;
579        self.require_owner(&record.owner)?;
580        self.require_sequence(record.partition_id, record.sequence)?;
581        if !self
582            .progress(record.partition_id)?
583            .complete_through(record.sequence, record.offset)
584        {
585            return Err(LaserError::Invalid(
586                "the record is past the page acknowledgment boundary".to_owned(),
587            ));
588        }
589        self.flush(record.partition_id).await
590    }
591
592    /// Mark every record of `page` handled, and store the progress this
593    /// completes.
594    pub async fn ack_page(&mut self, page: &MatchedPage) -> Result<(), LaserError> {
595        self.require_acknowledgments()?;
596        self.require_owner(&page.owner)?;
597        self.require_sequence(page.partition_id, page.sequence)?;
598        self.progress(page.partition_id)?
599            .complete_page(page.sequence);
600        self.flush(page.partition_id).await
601    }
602
603    /// Mark the partition's records through `delivery` handled without
604    /// storing yet. The normal consumer yields in order, so every yield
605    /// completes the prefix through it, also inside a page, and its automatic
606    /// commit policies store that prefix on their own cadence through
607    /// [`flush_completed`](Self::flush_completed).
608    pub(crate) fn handled(&mut self, delivery: &Delivery) {
609        if !Arc::ptr_eq(&self.owner, &delivery.owner) {
610            return;
611        }
612        if let Some(progress) = self.partitions.get_mut(&delivery.partition_id) {
613            progress.complete_through(delivery.sequence, delivery.offset);
614        }
615    }
616
617    /// Acknowledge every record on the partition through `delivery`, the
618    /// normal consumer's manual commit.
619    pub(crate) async fn ack_through_delivery(
620        &mut self,
621        delivery: &Delivery,
622    ) -> Result<(), LaserError> {
623        self.require_acknowledgments()?;
624        self.require_owner(&delivery.owner)?;
625        self.require_sequence(delivery.partition_id, delivery.sequence)?;
626        if !self
627            .progress(delivery.partition_id)?
628            .complete_through(delivery.sequence, delivery.offset)
629        {
630            return Err(LaserError::Invalid(
631                "the record is past the page acknowledgment boundary".to_owned(),
632            ));
633        }
634        self.flush(delivery.partition_id).await
635    }
636
637    /// The newest offset this reader stored per partition.
638    pub(crate) fn stored_offsets(&self) -> BTreeMap<u32, u64> {
639        self.partitions
640            .iter()
641            .filter_map(|(partition_id, progress)| {
642                progress
643                    .stored_offset()
644                    .map(|offset| (*partition_id, offset))
645            })
646            .collect()
647    }
648
649    /// Data connections this reader opened to partition primaries. A healthy
650    /// reader opens one per node and keeps it: the count grows only when a
651    /// node stops being primary, a connection fails, or the group session
652    /// ends. A local reader opens none.
653    pub fn data_connections_opened(&self) -> u64 {
654        self.retired_connections + self.routes.opened()
655    }
656
657    /// The partitions this reader reads now.
658    pub fn partitions(&self) -> Vec<u32> {
659        self.partitions
660            .keys()
661            .filter(|partition_id| !self.revoked.contains(partition_id))
662            .copied()
663            .collect()
664    }
665
666    /// Flush completed targets while retaining the reader if a binding cancels the await.
667    #[doc(hidden)]
668    pub async fn flush_completed(&mut self) -> Result<(), LaserError> {
669        let mut outcome = Ok(());
670        if self.request.read_mode == ReadMode::Primary {
671            for partition_id in self.partitions.keys().copied().collect::<Vec<_>>() {
672                let flushed = self.flush(partition_id).await;
673                outcome = outcome.and(flushed);
674            }
675        }
676        outcome
677    }
678
679    /// Store completed progress, leave the group, and close the data
680    /// connections. Handled records that were not acknowledged are read again
681    /// by the next reader. A reader dropped without `close` leaves its group
682    /// when its own coordinator connection closes.
683    pub async fn close(mut self) -> Result<(), LaserError> {
684        let mut outcome = self.flush_completed().await;
685        if let Some(membership) = &self.membership {
686            outcome = outcome.and(membership.leave(&self.coordinator).await);
687        }
688        std::mem::take(&mut self.routes).close().await;
689        // A group reader owns the coordinator it joined over. Every other
690        // reader borrows the Laser's shared client.
691        if self.membership.is_some() && self.laser.connection_string().is_some() {
692            let _ = self.coordinator.shutdown().await;
693        }
694        outcome
695    }
696
697    // One read of every partition that is not waiting, in turn from where the
698    // last round stopped. A failing partition is deferred and the rest still
699    // read, so one partition never starves the others, and its error reaches
700    // the caller at most once per idle interval: at once when the round ends
701    // without a page, or first thing in the next round when a later
702    // partition's page ended this one.
703    async fn round(&mut self) -> Result<Round, LaserError> {
704        self.examined_in_round = 0;
705        if let Some(error) = self.deferred_error.take() {
706            return Err(error);
707        }
708        if self.membership.as_ref().is_some_and(Membership::is_due) {
709            self.refresh_membership().await?;
710        }
711        let readable = self.partitions();
712        if readable.is_empty() {
713            return Ok(Round::Idle);
714        }
715        let now = Instant::now();
716        let mut busy = false;
717        let mut failed = None;
718        let mut saturated = Vec::new();
719        for step in 0..readable.len() {
720            let index = (self.cursor + step) % readable.len();
721            let partition_id = readable[index];
722            if self.progress(partition_id)?.waiting(now) {
723                continue;
724            }
725            match self.read(partition_id).await {
726                Ok(Read::Page(page)) => {
727                    self.cursor = index + 1;
728                    self.deferred_error = failed;
729                    return Ok(Round::Page(page));
730                }
731                Ok(Read::Empty { more }) => busy |= more,
732                Ok(Read::Saturated) => saturated.push(partition_id),
733                Err(error) => {
734                    let retry_at = now + self.retry_interval;
735                    if let Ok(progress) = self.progress(partition_id) {
736                        progress.defer(retry_at);
737                    }
738                    failed.get_or_insert(error);
739                }
740            }
741        }
742        if let Some(error) = failed {
743            return Err(error);
744        }
745        if !saturated.is_empty() && !busy {
746            return Err(LaserError::Invalid(format!(
747                "partitions {saturated:?} each have {} unacknowledged pages. Acknowledge before reading more",
748                self.max_unacked_pages
749            )));
750        }
751        Ok(if busy { Round::Busy } else { Round::Idle })
752    }
753
754    async fn read(&mut self, partition_id: u32) -> Result<Read, LaserError> {
755        let maximum = self.max_unacked_pages;
756        let progress = self.progress(partition_id)?;
757        if progress.in_flight() >= maximum {
758            return Ok(Read::Saturated);
759        }
760        let start = progress.start().clone();
761        self.request.partition_id = partition_id;
762        self.request.start = start;
763        let payload = encode_named(&self.request)
764            .map_err(|error| LaserError::Codec(format!("encode filter request: {error}")))?;
765        let outcome = self
766            .exchange(partition_id, AGDX_FILTERED_POLL_CODE, payload)
767            .await;
768        let mut page = match outcome {
769            Ok(FilterOutcome::Page(page)) => {
770                self.lost_routes.remove(&partition_id);
771                page
772            }
773            Ok(_) => return Err(unexpected("filtered poll")),
774            Err(error) if is_route_lost(&error) => {
775                if !self.lost_routes.insert(partition_id) {
776                    return Err(error);
777                }
778                if let Some(membership) = self.membership.as_mut() {
779                    membership.expire();
780                }
781                return Ok(Read::Empty { more: false });
782            }
783            Err(error) => {
784                let error = self.restart_after(partition_id, error);
785                // The group's policy changed under this read. The partition
786                // restarted from its stored offset and is read again at
787                // once, now under the policy the group holds. Delivery goes
788                // on, only an explicit acknowledgment of a record read under
789                // the old policy reports the change.
790                if error.filter_reason() == Some(FilterErrorReason::Conflict) {
791                    tracing::warn!(target: "laser", partition_id, %error, "the group's policy changed, the partition continues from its stored offset (partition: {partition_id})");
792                    return Ok(Read::Empty { more: true });
793                }
794                return Err(error);
795            }
796        };
797        if page.records.len() > self.request.max_reply_bytes as usize + 16 {
798            return Err(LaserError::Protocol(
799                "filtered page exceeds the requested record byte limit".to_owned(),
800            ));
801        }
802        validate_record_body(&self.request, &page)?;
803        let polled = PolledMessages::from_bytes(Bytes::from(std::mem::take(&mut page.records)))?;
804        validate_page(&self.request, &page, &polled)?;
805        self.pin_source(&page)?;
806        let messages = polled.messages;
807        #[cfg(feature = "filters")]
808        if page.policy.mode.is_filtered() {
809            if self.guard_enabled && self.guard.is_none() {
810                self.guard = Some(
811                    LocalGuard::load(
812                        &self.laser,
813                        &bound_definition(&self.laser, &page, &self.request.source).await?,
814                    )
815                    .await?,
816                );
817            }
818            if let Some(guard) = &self.guard
819                && let Err(error) = guard.check(&page, &messages)
820            {
821                return Err(self.restart_after(partition_id, error));
822            }
823        }
824        self.sequence += 1;
825        let sequence = self.sequence;
826        let primary = self.request.read_mode == ReadMode::Primary;
827        let retry_at = Instant::now() + self.idle_interval;
828        let progress = self.progress(partition_id)?;
829        // A local page cannot be acknowledged, so its records are not pending
830        // work and never saturate the partition.
831        progress.record(
832            sequence,
833            &page,
834            messages
835                .iter()
836                .filter(|_| primary)
837                .map(|message| message.header.offset),
838        );
839        let blocked = progress.blocked();
840        if blocked.is_some() {
841            progress.defer(retry_at);
842        }
843        self.examined_in_round += u64::from(page.examined);
844        if messages.is_empty() {
845            if primary && let Err(error) = self.flush(partition_id).await {
846                tracing::debug!(target: "laser", partition_id, %error, "storing filtered progress failed, retried with the next acknowledgment");
847            }
848            if let Some(blocked) = blocked {
849                return Err(blocked_error(partition_id, blocked));
850            }
851            return Ok(Read::Empty {
852                more: matches!(page.stop, StopReason::Filled | StopReason::Budget),
853            });
854        }
855        let unevaluated: BTreeSet<u64> = page.unevaluated.iter().copied().collect();
856        let records = messages
857            .into_iter()
858            .map(|message| MatchedRecord {
859                partition_id,
860                offset: message.header.offset,
861                frontier: page.frontier,
862                evaluated: page.policy.mode.is_filtered()
863                    && !unevaluated.contains(&message.header.offset),
864                message,
865                sequence,
866                owner: Arc::clone(&self.owner),
867            })
868            .collect();
869        Ok(Read::Page(MatchedPage {
870            partition_id,
871            records,
872            policy: page.policy,
873            generation: page.generation,
874            stop: page.stop,
875            examined: page.examined,
876            frontier: page.frontier,
877            safe_ack_offset: page.safe_ack_offset,
878            sequence,
879            owner: Arc::clone(&self.owner),
880        }))
881    }
882
883    // A purge, a recreated group, or a new group binding ends the history or
884    // the policy the partition's continuation was issued for. The partition
885    // starts over from its stored offset, the guard reloads a rebound
886    // filter, and the error reaches the caller once.
887    fn restart_after(&mut self, partition_id: u32, error: LaserError) -> LaserError {
888        let reason = error.filter_reason();
889        if !matches!(
890            reason,
891            Some(FilterErrorReason::SourceChanged | FilterErrorReason::Conflict)
892        ) {
893            return error;
894        }
895        if let Ok(progress) = self.progress(partition_id) {
896            *progress = PartitionProgress::new(FilteredStart::Next);
897        }
898        // Buffered records of the partition belong to the dropped pages, so
899        // they are read again from the stored offset instead.
900        self.buffered
901            .retain(|record| record.partition_id != partition_id);
902        if reason == Some(FilterErrorReason::SourceChanged)
903            && let Some(membership) = self.membership.as_mut()
904        {
905            membership.expire();
906        }
907        #[cfg(feature = "filters")]
908        {
909            self.guard = None;
910        }
911        error
912    }
913
914    // Store the completed prefix of one partition. A lost reply keeps the
915    // target unstored, and the next store sends it again. Stores are
916    // monotone, so a repeat never moves progress back.
917    async fn flush(&mut self, partition_id: u32) -> Result<(), LaserError> {
918        let Some(target) = self.progress(partition_id)?.unstored().cloned() else {
919            self.retire(partition_id);
920            return Ok(());
921        };
922        let ack = FilteredAck {
923            group_id: target.group_id,
924            v: FILTER_OP_VERSION,
925            source: self.request.source.clone(),
926            partition_id,
927            consumer: self.consumer.clone(),
928            generation: target.generation,
929            digest: target.digest.clone(),
930            offset: target.offset,
931            mode: target.mode,
932            policy_generation: target.policy_generation,
933        };
934        let payload = encode_named(&ack)
935            .map_err(|error| LaserError::Codec(format!("encode filter request: {error}")))?;
936        let mut rerouted = false;
937        loop {
938            match self
939                .exchange(partition_id, AGDX_FILTERED_ACK_CODE, payload.clone())
940                .await
941            {
942                Ok(FilterOutcome::Acknowledged(receipt)) => {
943                    if receipt.partition_id != partition_id
944                        || receipt.offset != target.offset
945                        || receipt.generation != target.generation
946                    {
947                        return Err(LaserError::Protocol(
948                            "filtered acknowledgment does not match its request".to_owned(),
949                        ));
950                    }
951                    self.progress(partition_id)?.stored(&target);
952                    let revoked = self.revoked.contains(&partition_id);
953                    self.retire(partition_id);
954                    if revoked {
955                        self.routes.release(partition_id).await;
956                    }
957                    return Ok(());
958                }
959                Ok(_) => return Err(unexpected("filtered acknowledgment")),
960                Err(error) if is_route_lost(&error) && !rerouted => rerouted = true,
961                Err(error) => return Err(self.restart_after(partition_id, error)),
962            }
963        }
964    }
965
966    async fn exchange(
967        &mut self,
968        partition_id: u32,
969        code: u32,
970        payload: Vec<u8>,
971    ) -> Result<FilterOutcome, LaserError> {
972        let payload = Bytes::from(payload);
973        let reply = match self.request.read_mode {
974            ReadMode::Local => self.coordinator.send_binary_request(code, payload).await?,
975            ReadMode::Primary => {
976                let route_to = RouteTo {
977                    source: &self.request.source,
978                    consumer: &self.consumer,
979                };
980                let connection = self
981                    .routes
982                    .connection(
983                        &self.coordinator,
984                        self.laser.connection_string(),
985                        &route_to,
986                        partition_id,
987                        code == AGDX_FILTERED_ACK_CODE,
988                        self.laser.publish_options(),
989                    )
990                    .await?;
991                match connection.send_binary_request(code, payload).await {
992                    Ok(reply) => reply,
993                    Err(error) => {
994                        self.routes.invalidate(partition_id).await;
995                        return Err(error.into());
996                    }
997                }
998            }
999        };
1000        match decode_managed_reply::<FilterReply>(&reply)? {
1001            FilterReply::Ok(outcome) => Ok(outcome),
1002            FilterReply::Err(error) => {
1003                if matches!(
1004                    error.reason,
1005                    FilterErrorReason::NotPrimary | FilterErrorReason::MembershipStale
1006                ) {
1007                    self.routes.invalidate(partition_id).await;
1008                }
1009                Err(error.into())
1010            }
1011        }
1012    }
1013
1014    // Follow the group's current assignment. The whole change is applied to
1015    // the reader before anything awaits, so a cancelled call never loses it.
1016    // A partition that left the assignment stops being read but keeps its
1017    // in-flight pages, so records already handed out can still be
1018    // acknowledged while the server's offset fence allows it.
1019    async fn refresh_membership(&mut self) -> Result<(), LaserError> {
1020        let Some(membership) = self.membership.as_mut() else {
1021            return Ok(());
1022        };
1023        if !membership.sync(&self.coordinator).await? {
1024            return Ok(());
1025        }
1026        let assigned: BTreeSet<u32> = membership
1027            .partitions()
1028            .iter()
1029            .copied()
1030            .filter(|partition| {
1031                self.selected_partitions.is_empty() || self.selected_partitions.contains(partition)
1032            })
1033            .collect();
1034        let stale_routes = if membership.take_rejoined() {
1035            self.owner = Arc::new(());
1036            self.partitions.clear();
1037            self.revoked.clear();
1038            self.buffered.clear();
1039            Some(std::mem::take(&mut self.routes))
1040        } else {
1041            None
1042        };
1043        let left: Vec<u32> = self
1044            .partitions
1045            .keys()
1046            .filter(|partition_id| !assigned.contains(partition_id))
1047            .copied()
1048            .collect();
1049        for partition_id in &left {
1050            self.revoked.insert(*partition_id);
1051            self.retire(*partition_id);
1052        }
1053        // A partition gained on a rebalance resumes after the group's stored
1054        // offset, which holds what the previous owner acknowledged. One that
1055        // comes back before its revoked entry retired starts over the same
1056        // way, so it never replays another member's work from an old
1057        // continuation.
1058        for partition_id in assigned {
1059            if self.revoked.remove(&partition_id) || !self.partitions.contains_key(&partition_id) {
1060                self.buffered
1061                    .retain(|record| record.partition_id != partition_id);
1062                self.partitions
1063                    .insert(partition_id, PartitionProgress::new(FilteredStart::Next));
1064            }
1065        }
1066        // A rejoin can land in a recreated group with another binding.
1067        #[cfg(feature = "filters")]
1068        if stale_routes.is_some() {
1069            self.guard = None;
1070        }
1071        let retired: Vec<_> = left
1072            .into_iter()
1073            .filter_map(|partition| self.routes.forget(partition))
1074            .collect();
1075        if let Some(routes) = stale_routes {
1076            self.retired_connections += routes.opened();
1077            routes.close().await;
1078        }
1079        for client in retired {
1080            let _ = client.shutdown().await;
1081        }
1082        Ok(())
1083    }
1084
1085    // A numeric group id names a group inside one topic incarnation. The
1086    // first page pins it, and a page from a recreated stream or topic is
1087    // refused instead of reading an unrelated group under the same id.
1088    fn pin_source(&mut self, page: &FilteredPage) -> Result<(), LaserError> {
1089        let FilterConsumer::GroupId(id) = self.consumer else {
1090            return Ok(());
1091        };
1092        let incarnation = SourceIncarnation::from(page.generation);
1093        match self.pinned_source {
1094            None => {
1095                self.pinned_source = Some(incarnation);
1096                Ok(())
1097            }
1098            Some(pinned) if pinned == incarnation => Ok(()),
1099            Some(_) => Err(FilterError::new(
1100                FilterErrorReason::NotFound,
1101                format!(
1102                    "the numeric consumer group {id} belongs to a replaced stream or topic, build a new reader"
1103                ),
1104            )
1105            .into()),
1106        }
1107    }
1108
1109    // Forget a revoked partition once nothing of it is in flight.
1110    fn retire(&mut self, partition_id: u32) {
1111        if self.revoked.contains(&partition_id)
1112            && self
1113                .partitions
1114                .get(&partition_id)
1115                .is_some_and(|progress| progress.in_flight() == 0 && progress.unstored().is_none())
1116        {
1117            self.partitions.remove(&partition_id);
1118            self.revoked.remove(&partition_id);
1119        }
1120    }
1121
1122    fn progress(&mut self, partition_id: u32) -> Result<&mut PartitionProgress, LaserError> {
1123        self.partitions.get_mut(&partition_id).ok_or_else(|| {
1124            FilterError::new(
1125                FilterErrorReason::MembershipStale,
1126                format!(
1127                    "partition {partition_id} is no longer read by this reader. Its unacknowledged records are read again"
1128                ),
1129            )
1130            .into()
1131        })
1132    }
1133
1134    fn require_sequence(&self, partition: u32, sequence: u64) -> Result<(), LaserError> {
1135        if self
1136            .partitions
1137            .get(&partition)
1138            .is_some_and(|progress| progress.knows(sequence))
1139        {
1140            return Ok(());
1141        }
1142        Err(FilterError::new(
1143            FilterErrorReason::MembershipStale,
1144            "the acknowledgment belongs to a retired partition read",
1145        )
1146        .into())
1147    }
1148
1149    fn require_owner(&self, owner: &Arc<()>) -> Result<(), LaserError> {
1150        if !Arc::ptr_eq(&self.owner, owner) {
1151            return Err(LaserError::Invalid(
1152                "the page or record belongs to another filtered reader, or was read before this reader rejoined its group"
1153                    .to_owned(),
1154            ));
1155        }
1156        Ok(())
1157    }
1158
1159    fn require_acknowledgments(&self) -> Result<(), LaserError> {
1160        if self.request.read_mode == ReadMode::Local {
1161            return Err(LaserError::Invalid(
1162                "a ReadMode::Local reader cannot acknowledge: only the partition primary proves the history a page came from".to_owned(),
1163            ));
1164        }
1165        Ok(())
1166    }
1167}
1168
1169// The full definition a reader executes, for the local guard.
1170#[cfg(feature = "filters")]
1171async fn bound_definition(
1172    laser: &Laser,
1173    page: &FilteredPage,
1174    names: &FilterSource,
1175) -> Result<ConsumerFilter, LaserError> {
1176    let source = page.generation;
1177    for index in 0_u32.. {
1178        let bindings = laser
1179            .filters()
1180            .bindings(
1181                None,
1182                Some(&names.stream),
1183                Some(&names.topic),
1184                index,
1185                MAX_FILTER_CATALOG_PAGE,
1186            )
1187            .await?;
1188        if let Some(binding) = bindings.items.into_iter().find(|binding| {
1189            let id = binding.identity;
1190            Some(id.group_id) == page.policy.group_id
1191                && id.stream_id == source.stream_id
1192                && id.stream_created_at_micros == source.stream_created_at_micros
1193                && id.topic_id == source.topic_id
1194                && id.topic_created_at_micros == source.topic_created_at_micros
1195        }) {
1196            if Some(&binding.digest) != page.policy.digest.as_ref() {
1197                return Err(LaserError::Protocol(
1198                    "served policy differs from the group's catalog binding".to_owned(),
1199                ));
1200            }
1201            return definition(
1202                laser,
1203                &FilterRef::Revision {
1204                    filter_id: binding.filter_id,
1205                    revision: binding.revision,
1206                },
1207            )
1208            .await;
1209        }
1210        if (index + 1).saturating_mul(MAX_FILTER_CATALOG_PAGE) >= bindings.total {
1211            break;
1212        }
1213    }
1214    Err(FilterError::new(
1215        FilterErrorReason::NotFound,
1216        "no readable catalog binding matches this group identity",
1217    )
1218    .into())
1219}
1220
1221#[cfg(feature = "filters")]
1222async fn definition(laser: &Laser, filter: &FilterRef) -> Result<ConsumerFilter, LaserError> {
1223    let (filter_id, revision) = match filter {
1224        FilterRef::Inline(filter) => return Ok(filter.clone()),
1225        FilterRef::Revision {
1226            filter_id,
1227            revision,
1228        } => (*filter_id, *revision),
1229        FilterRef::Bound | FilterRef::Group => {
1230            return Err(LaserError::Config(
1231                "resolve a group guard from the served page",
1232            ));
1233        }
1234    };
1235    let mut page = 0;
1236    loop {
1237        let revisions = laser
1238            .filters()
1239            .revisions(filter_id, page, MAX_FILTER_CATALOG_PAGE)
1240            .await?;
1241        if let Some(found) = revisions
1242            .items
1243            .into_iter()
1244            .find(|info| info.revision == revision)
1245        {
1246            return Ok(found.filter);
1247        }
1248        page += 1;
1249        if page.saturating_mul(MAX_FILTER_CATALOG_PAGE) >= revisions.total {
1250            return Err(FilterError::new(
1251                FilterErrorReason::NotFound,
1252                format!("filter {filter_id} has no revision {revision}"),
1253            )
1254            .into());
1255        }
1256    }
1257}
1258
1259// The data connection no longer reaches the partition primary for this
1260// consumer: the primary moved, the attached session ended, or the group
1261// assignment changed. Reads are side-effect free, so the next one routes again.
1262fn is_route_lost(error: &LaserError) -> bool {
1263    matches!(
1264        error.filter_reason(),
1265        Some(FilterErrorReason::NotPrimary | FilterErrorReason::MembershipStale)
1266    ) || matches!(
1267        error,
1268        LaserError::Iggy(IggyError::ConsumerGroupPartitionNotOwned(..))
1269    )
1270}
1271
1272const fn blocked_error(partition_id: u32, blocked: Blocked) -> LaserError {
1273    match blocked {
1274        Blocked::Fault { offset, reason } => LaserError::FilterFault {
1275            partition_id,
1276            offset,
1277            reason,
1278        },
1279        Blocked::Oversized { offset } => LaserError::FilterOversizedRecord {
1280            partition_id,
1281            offset,
1282        },
1283    }
1284}
1285
1286fn unexpected(verb: &str) -> LaserError {
1287    LaserError::Protocol(format!("{verb}: unexpected reply variant"))
1288}
1289
1290fn validate_record_body(
1291    request: &FilteredPollRequest,
1292    page: &FilteredPage,
1293) -> Result<(), LaserError> {
1294    let invalid =
1295        || LaserError::Protocol("filtered record body has invalid framing or counts".to_owned());
1296    let head = page.records.get(..16).ok_or_else(invalid)?;
1297    let count = u32::from_le_bytes(head[12..16].try_into().map_err(|_| invalid())?);
1298    if count != page.matched || count > request.count {
1299        return Err(invalid());
1300    }
1301    let mut rest = &page.records[16..];
1302    let mut decoded = 0u32;
1303    while !rest.is_empty() {
1304        let batch =
1305            decode_batch_slice_with(rest, BatchIntegrity::LayoutOnly).map_err(|_| invalid())?;
1306        decoded = decoded
1307            .checked_add(batch.header.message_count)
1308            .ok_or_else(invalid)?;
1309        if decoded > count {
1310            return Err(invalid());
1311        }
1312        for frame in batch.iter() {
1313            batch
1314                .header
1315                .base_offset
1316                .checked_add(u64::from(frame.header.offset_delta))
1317                .ok_or_else(invalid)?;
1318            batch
1319                .header
1320                .origin_timestamp
1321                .checked_add(u64::from(frame.header.timestamp_delta))
1322                .ok_or_else(invalid)?;
1323        }
1324        rest = rest.get(batch.header.total_size()..).ok_or_else(invalid)?;
1325    }
1326    if decoded != count {
1327        return Err(invalid());
1328    }
1329    Ok(())
1330}
1331
1332fn validate_page(
1333    request: &FilteredPollRequest,
1334    page: &FilteredPage,
1335    polled: &PolledMessages,
1336) -> Result<(), LaserError> {
1337    let invalid = || {
1338        LaserError::Protocol("filtered page does not match its request or scan boundary".to_owned())
1339    };
1340    if page.v != FILTER_OP_VERSION
1341        || page.partition_id != request.partition_id
1342        || page.generation.partition_id != request.partition_id
1343        || polled.partition_id != request.partition_id
1344        || page.read_mode != request.read_mode
1345        || polled.current_offset != page.frontier
1346        || page.matched as usize != polled.messages.len()
1347        || page.matched > page.examined
1348        || request
1349            .max_examined
1350            .is_some_and(|maximum| page.examined > maximum)
1351        || page.policy.validate().is_err()
1352    {
1353        return Err(invalid());
1354    }
1355    // A strict group read runs a binding. An unfiltered page is the whole
1356    // examined range, so nothing was selected out of it.
1357    match page.policy.mode {
1358        ExecutionMode::Filtered => {}
1359        ExecutionMode::Unfiltered
1360            if matches!(request.filter, FilterRef::Bound)
1361                || page.matched != page.examined
1362                || page.policy.filter_id.is_some()
1363                || page.policy.revision.is_some() =>
1364        {
1365            return Err(invalid());
1366        }
1367        ExecutionMode::Unfiltered => {}
1368    }
1369    if let FilterConsumer::GroupId(id) = request.consumer
1370        && page.policy.group_id != Some(id)
1371    {
1372        return Err(LaserError::Protocol(
1373            "filtered page identifies another consumer group".to_owned(),
1374        ));
1375    }
1376    if request.consumer.is_group() != page.policy.group_id.is_some() {
1377        return Err(invalid());
1378    }
1379    let start = match &request.start {
1380        FilteredStart::Continue(cursor) => {
1381            if cursor.generation != page.generation
1382                || cursor.digest != page.policy.digest
1383                || cursor.group_id != page.policy.group_id
1384                || cursor.read_mode != request.read_mode
1385                || cursor.mode != page.policy.mode
1386                || cursor.policy_generation != page.policy.policy_generation
1387            {
1388                return Err(invalid());
1389            }
1390            Some(cursor.next_scan_offset)
1391        }
1392        FilteredStart::Offset(offset) => Some(*offset),
1393        FilteredStart::First => Some(0),
1394        _ => None,
1395    };
1396    // The last offset the page examined. A primary page offers it as its safe
1397    // acknowledgment offset, a local page offers none.
1398    let scanned_to = page
1399        .next_scan_offset
1400        .and_then(|offset| offset.checked_sub(1));
1401    if page.examined == 0 {
1402        if page.safe_ack_offset.is_some() || page.next_scan_offset != start {
1403            return Err(invalid());
1404        }
1405    } else if scanned_to.is_none()
1406        || match request.read_mode {
1407            ReadMode::Primary => page.safe_ack_offset != scanned_to,
1408            ReadMode::Local => page.safe_ack_offset.is_some(),
1409        }
1410    {
1411        return Err(invalid());
1412    }
1413    if scanned_to.is_some_and(|scanned| scanned > page.frontier) {
1414        return Err(invalid());
1415    }
1416    if start
1417        .zip(page.next_scan_offset)
1418        .is_some_and(|(start, next)| next < start)
1419    {
1420        return Err(invalid());
1421    }
1422    let mut previous = None;
1423    for message in &polled.messages {
1424        let offset = message.header.offset;
1425        if previous.is_some_and(|last| offset <= last)
1426            || start.is_some_and(|start| offset < start)
1427            || scanned_to.is_none_or(|scanned| offset > scanned)
1428            || offset > page.frontier
1429        {
1430            return Err(invalid());
1431        }
1432        previous = Some(offset);
1433    }
1434    // The returned offsets are strictly ascending, so a binary search proves
1435    // each unevaluated offset is one of them.
1436    let unevaluated: BTreeSet<_> = page.unevaluated.iter().copied().collect();
1437    if unevaluated.len() != page.unevaluated.len()
1438        || unevaluated.iter().any(|offset| {
1439            polled
1440                .messages
1441                .binary_search_by_key(offset, |message| message.header.offset)
1442                .is_err()
1443        })
1444    {
1445        return Err(invalid());
1446    }
1447    if matches!(page.stop, StopReason::Fault | StopReason::OversizedRecord) != page.fault.is_some()
1448    {
1449        return Err(invalid());
1450    }
1451    if let Some(fault) = page.fault
1452        && scanned_to.is_some_and(|scanned| scanned >= fault.offset)
1453    {
1454        return Err(invalid());
1455    }
1456    Ok(())
1457}
1458
1459#[cfg(test)]
1460mod tests {
1461    use super::*;
1462    use laser_wire::filter::{FilterExpr, FilteredPage};
1463
1464    fn sample() -> (FilteredPollRequest, FilteredPage, PolledMessages) {
1465        let filter = laser_wire::filter::ConsumerFilter::json(FilterExpr::present("mode"));
1466        let request = FilteredPollRequest {
1467            v: FILTER_OP_VERSION,
1468            source: FilterSource {
1469                stream: "orbit".to_owned(),
1470                topic: "fleet_changes".to_owned(),
1471            },
1472            partition_id: 0,
1473            consumer: FilterConsumer::Group("anomaly-desk".to_owned()),
1474            filter: FilterRef::Bound,
1475            start: FilteredStart::First,
1476            count: 10,
1477            max_reply_bytes: 1024,
1478            read_mode: ReadMode::Primary,
1479            max_examined: None,
1480            min_catalog_position: None,
1481        };
1482        let page = FilteredPage {
1483            v: FILTER_OP_VERSION,
1484            partition_id: 0,
1485            policy: AppliedPolicy {
1486                group_id: Some(9),
1487                digest: Some(filter.digest()),
1488                filter_id: Some(1),
1489                revision: Some(1),
1490                mode: ExecutionMode::Filtered,
1491                policy_generation: 1,
1492            },
1493            generation: SourceGeneration {
1494                stream_id: 1,
1495                stream_created_at_micros: 1,
1496                topic_id: 1,
1497                topic_created_at_micros: 1,
1498                partition_id: 0,
1499                partition_created_revision: 1,
1500                purge_generation: 0,
1501            },
1502            read_mode: ReadMode::Primary,
1503            next_scan_offset: Some(10),
1504            safe_ack_offset: Some(9),
1505            frontier: 9,
1506            examined: 10,
1507            matched: 0,
1508            stop: StopReason::EndOfVisible,
1509            fault: None,
1510            unevaluated: Vec::new(),
1511            evaluation_limits: None,
1512            records: Vec::new(),
1513        };
1514        let polled = PolledMessages {
1515            partition_id: 0,
1516            current_offset: 9,
1517            count: 0,
1518            messages: Vec::new(),
1519        };
1520        (request, page, polled)
1521    }
1522
1523    #[test]
1524    fn given_empty_scanned_pages_when_validated_then_should_accept_only_consistent_progress() {
1525        let (request, page, polled) = sample();
1526        validate_page(&request, &page, &polled).expect("valid scanned range");
1527        let mut malformed = page.clone();
1528        malformed.safe_ack_offset = Some(10);
1529        assert!(validate_page(&request, &malformed, &polled).is_err());
1530        malformed = page.clone();
1531        malformed.read_mode = ReadMode::Local;
1532        assert!(validate_page(&request, &malformed, &polled).is_err());
1533        malformed = page.clone();
1534        malformed.policy.digest = None;
1535        assert!(
1536            validate_page(&request, &malformed, &polled).is_err(),
1537            "a filtered page names the digest it ran"
1538        );
1539        malformed = page.clone();
1540        malformed.partition_id = 1;
1541        assert!(validate_page(&request, &malformed, &polled).is_err());
1542        malformed = page;
1543        malformed.examined = 0;
1544        assert!(validate_page(&request, &malformed, &polled).is_err());
1545    }
1546
1547    #[test]
1548    fn given_an_unfiltered_page_when_validated_then_should_need_a_group_read_without_a_digest() {
1549        let (mut request, mut page, polled) = sample();
1550        page.policy = AppliedPolicy::unfiltered(9, 0);
1551        page.matched = 0;
1552        page.examined = 0;
1553        page.safe_ack_offset = None;
1554        page.next_scan_offset = Some(0);
1555        assert!(
1556            validate_page(&request, &page, &polled).is_err(),
1557            "a strict read never accepts an unfiltered page"
1558        );
1559        request.filter = FilterRef::Group;
1560        validate_page(&request, &page, &polled).expect("an automatic read may run unfiltered");
1561        page.examined = 3;
1562        page.next_scan_offset = Some(3);
1563        page.safe_ack_offset = Some(2);
1564        assert!(
1565            validate_page(&request, &page, &polled).is_err(),
1566            "an unfiltered page delivers everything it examined"
1567        );
1568    }
1569
1570    #[test]
1571    fn given_a_continuation_when_the_reply_changes_history_or_policy_then_should_reject() {
1572        let (mut request, mut page, polled) = sample();
1573        let cursor = laser_wire::filter::Continuation {
1574            group_id: Some(9),
1575            next_scan_offset: 0,
1576            generation: page.generation,
1577            digest: page.policy.digest.clone(),
1578            read_mode: ReadMode::Primary,
1579            mode: ExecutionMode::Filtered,
1580            policy_generation: 1,
1581        };
1582        request.start = FilteredStart::Continue(cursor.clone());
1583        validate_page(&request, &page, &polled).expect("the same history and policy continue");
1584        page.generation.purge_generation = 1;
1585        assert!(validate_page(&request, &page, &polled).is_err());
1586        page.generation.purge_generation = 0;
1587        page.policy.policy_generation = 2;
1588        assert!(
1589            validate_page(&request, &page, &polled).is_err(),
1590            "a page under another policy generation does not continue this cursor"
1591        );
1592    }
1593
1594    #[test]
1595    fn given_an_examined_limit_when_a_reply_exceeds_it_then_should_refuse_the_page() {
1596        let (mut request, page, polled) = sample();
1597        request.max_examined = Some(page.examined);
1598        validate_page(&request, &page, &polled).expect("the scan reaches its limit");
1599        request.max_examined = Some(page.examined - 1);
1600        assert!(validate_page(&request, &page, &polled).is_err());
1601    }
1602
1603    #[test]
1604    fn given_a_forged_inner_count_when_decoding_then_should_reject_before_allocation() {
1605        let (request, mut page, _) = sample();
1606        page.records = vec![0; 16];
1607        page.records[12..16].copy_from_slice(&u32::MAX.to_le_bytes());
1608        assert!(validate_record_body(&request, &page).is_err());
1609    }
1610
1611    #[test]
1612    fn given_a_local_page_when_validated_then_should_carry_no_acknowledgment_offset() {
1613        let (mut request, mut page, polled) = sample();
1614        request.read_mode = ReadMode::Local;
1615        page.read_mode = ReadMode::Local;
1616        page.safe_ack_offset = None;
1617        validate_page(&request, &page, &polled).expect("a scanned local page");
1618        page.safe_ack_offset = Some(9);
1619        assert!(
1620            validate_page(&request, &page, &polled).is_err(),
1621            "a local page never offers an acknowledgment"
1622        );
1623    }
1624
1625    #[test]
1626    fn given_a_page_that_examined_nothing_when_its_scan_offset_moved_then_should_reject() {
1627        let (request, mut page, polled) = sample();
1628        page.examined = 0;
1629        page.safe_ack_offset = None;
1630        page.next_scan_offset = Some(0);
1631        validate_page(&request, &page, &polled).expect("an empty scan repeats its start");
1632        page.next_scan_offset = Some(5);
1633        assert!(validate_page(&request, &page, &polled).is_err());
1634    }
1635}