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
38pub 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
59pub struct FilteredReader {
75 owner: Arc<()>,
76 laser: Laser,
77 coordinator: Arc<IggyClient>,
81 consumer: FilterConsumer,
82 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 retired_connections: u64,
99 cursor: usize,
100 sequence: u64,
101 examined_in_round: u64,
102 lost_routes: BTreeSet<u32>,
103 buffered: VecDeque<MatchedRecord>,
104 deferred_error: Option<LaserError>,
108 pinned_source: Option<SourceIncarnation>,
112}
113
114#[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 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#[derive(Debug)]
178pub struct MatchedPage {
179 pub partition_id: u32,
180 pub records: Vec<MatchedRecord>,
181 pub policy: AppliedPolicy,
183 pub generation: SourceGeneration,
185 pub stop: StopReason,
186 pub examined: u32,
187 pub frontier: u64,
189 pub safe_ack_offset: Option<u64>,
192 sequence: u64,
193 owner: Arc<()>,
194}
195
196#[derive(Debug)]
198pub struct MatchedRecord {
199 pub partition_id: u32,
200 pub offset: u64,
201 pub frontier: u64,
203 pub evaluated: bool,
206 pub message: IggyMessage,
207 sequence: u64,
208 owner: Arc<()>,
209}
210
211#[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 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 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 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 #[must_use]
301 pub fn start(mut self, start: FilteredStart) -> Self {
302 self.start = start;
303 self
304 }
305
306 #[must_use]
309 pub fn partition(mut self, partition_id: u32) -> Self {
310 self.selected_partitions.insert(partition_id);
311 self
312 }
313
314 #[must_use]
316 pub fn count(mut self, count: u32) -> Self {
317 self.count = count;
318 self
319 }
320
321 #[must_use]
326 pub fn max_examined(mut self, records: u32) -> Self {
327 self.max_examined = Some(records);
328 self
329 }
330
331 #[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 #[must_use]
343 pub fn read_mode(mut self, read_mode: ReadMode) -> Self {
344 self.read_mode = read_mode;
345 self
346 }
347
348 #[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 #[must_use]
361 pub fn idle_interval(mut self, interval: Duration) -> Self {
362 self.idle_interval = interval;
363 self
364 }
365
366 #[must_use]
369 pub(crate) fn retry_interval(mut self, interval: Duration) -> Self {
370 self.retry_interval = Some(interval);
371 self
372 }
373
374 #[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 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 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 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 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 pub const fn examined_in_round(&self) -> u64 {
520 self.examined_in_round
521 }
522
523 pub const fn idle_interval(&self) -> Duration {
525 self.idle_interval
526 }
527
528 #[doc(hidden)]
531 pub fn coordinator(&self) -> Arc<IggyClient> {
532 Arc::clone(&self.coordinator)
533 }
534
535 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 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 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 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 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 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 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 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 pub fn data_connections_opened(&self) -> u64 {
654 self.retired_connections + self.routes.opened()
655 }
656
657 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 #[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 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 if self.membership.is_some() && self.laser.connection_string().is_some() {
692 let _ = self.coordinator.shutdown().await;
693 }
694 outcome
695 }
696
697 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 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 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 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 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 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 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 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 #[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 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 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#[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
1259fn 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 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 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 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}