1use crate::codes::FILTER_OP_VERSION;
2use crate::error::InvalidError;
3use crate::filter::catalog::CatalogPosition;
4use crate::filter::expr::ConsumerFilter;
5use crate::limits::{
6 MAX_FILTER_PREVIEW_EXAMINED, MAX_FILTER_PREVIEW_RECORDS, MAX_FILTER_SAMPLE_BYTES,
7 MAX_FILTER_SAMPLE_HEADERS, MAX_FILTER_SOURCE_NAME_BYTES, MAX_FILTERED_PAGE_BYTES,
8 MAX_FILTERED_PAGE_EXAMINED, MAX_FILTERED_PAGE_RECORDS,
9};
10use crate::result::ResultCode;
11use crate::schema::Digest32;
12use crate::validate::Validate;
13use serde::ser::SerializeStruct;
14use serde::{Deserialize, Deserializer, Serialize, Serializer};
15use std::fmt;
16
17#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
19pub struct FilterSource {
20 pub stream: String,
21 pub topic: String,
22}
23
24#[derive(Clone, Debug, PartialEq, Eq)]
27pub enum FilterConsumer {
28 Consumer(String),
29 Group(String),
30 GroupId(u64),
31}
32
33impl fmt::Display for FilterConsumer {
34 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
35 match self {
36 Self::Consumer(name) | Self::Group(name) => formatter.write_str(name),
37 Self::GroupId(id) => write!(formatter, "{id}"),
38 }
39 }
40}
41
42impl FilterConsumer {
43 pub const fn is_group(&self) -> bool {
44 matches!(self, Self::Group(_) | Self::GroupId(_))
45 }
46}
47
48impl Serialize for FilterConsumer {
49 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
50 let mut state = serializer.serialize_struct("FilterConsumer", 2)?;
51 match self {
52 Self::Consumer(name) => {
53 state.serialize_field("kind", "consumer")?;
54 state.serialize_field("name", name)?;
55 }
56 Self::Group(name) => {
57 state.serialize_field("kind", "group")?;
58 state.serialize_field("name", name)?;
59 }
60 Self::GroupId(id) => {
61 state.serialize_field("kind", "group_id")?;
62 state.serialize_field("id", id)?;
63 }
64 }
65 state.end()
66 }
67}
68
69impl<'de> Deserialize<'de> for FilterConsumer {
70 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
71 #[derive(Deserialize)]
72 #[serde(tag = "kind", rename_all = "snake_case")]
73 enum Selector {
74 Consumer { name: String },
75 Group { name: String },
76 GroupId { id: u64 },
77 }
78 Ok(match Selector::deserialize(deserializer)? {
79 Selector::Consumer { name } => Self::Consumer(name),
80 Selector::Group { name } => Self::Group(name),
81 Selector::GroupId { id } => Self::GroupId(id),
82 })
83 }
84}
85
86#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum FilterRef {
90 Inline(ConsumerFilter),
93 Revision { filter_id: u32, revision: u32 },
95 Bound,
98 Group,
102}
103
104#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case")]
107pub enum FilteredStart {
108 Next,
110 First,
111 Last,
112 Offset(u64),
113 Timestamp(u64),
115 Continue(Continuation),
117}
118
119#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, strum::Display)]
121#[serde(rename_all = "snake_case")]
122#[strum(serialize_all = "snake_case")]
123pub enum ReadMode {
124 #[default]
128 Primary,
129 Local,
132}
133
134impl ReadMode {
135 pub const fn is_primary(&self) -> bool {
136 matches!(self, Self::Primary)
137 }
138}
139
140#[derive(
144 Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize, strum::Display,
145)]
146#[serde(rename_all = "snake_case")]
147#[strum(serialize_all = "snake_case")]
148pub enum ExecutionMode {
149 #[default]
150 Filtered,
151 Unfiltered,
152}
153
154impl ExecutionMode {
155 pub const fn is_filtered(&self) -> bool {
156 matches!(self, Self::Filtered)
157 }
158}
159
160fn validate_policy_identity(
162 mode: ExecutionMode,
163 digest: Option<&Digest32>,
164) -> Result<(), InvalidError> {
165 match (mode, digest) {
166 (ExecutionMode::Filtered, Some(digest)) => digest.validate(),
167 (ExecutionMode::Filtered, None) => {
168 Err(InvalidError::new("a filtered policy names its digest"))
169 }
170 (ExecutionMode::Unfiltered, None) => Ok(()),
171 (ExecutionMode::Unfiltered, Some(_)) => {
172 Err(InvalidError::new("an unfiltered policy carries no digest"))
173 }
174 }
175}
176
177#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
181pub struct SourceGeneration {
182 pub stream_id: u32,
183 pub stream_created_at_micros: u64,
184 pub topic_id: u32,
185 pub topic_created_at_micros: u64,
186 pub partition_id: u32,
187 pub partition_created_revision: u64,
188 pub purge_generation: u64,
189}
190
191#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
193pub struct Continuation {
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub group_id: Option<u64>,
196 pub next_scan_offset: u64,
197 pub generation: SourceGeneration,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub digest: Option<Digest32>,
201 #[serde(default, skip_serializing_if = "ReadMode::is_primary")]
204 pub read_mode: ReadMode,
205 #[serde(default)]
206 pub mode: ExecutionMode,
207 #[serde(default)]
210 pub policy_generation: u64,
211}
212
213#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
215pub struct FilteredPollRequest {
216 pub v: u32,
217 pub source: FilterSource,
218 pub partition_id: u32,
219 pub consumer: FilterConsumer,
220 pub filter: FilterRef,
221 pub start: FilteredStart,
222 pub count: u32,
224 pub max_reply_bytes: u32,
226 #[serde(default)]
227 pub read_mode: ReadMode,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub max_examined: Option<u32>,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub min_catalog_position: Option<CatalogPosition>,
237}
238
239#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, strum::Display)]
241#[serde(rename_all = "snake_case")]
242#[strum(serialize_all = "snake_case")]
243pub enum StopReason {
244 Filled,
246 Budget,
248 EndOfVisible,
250 Fault,
252 OversizedRecord,
254}
255
256#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, strum::Display)]
258#[serde(rename_all = "snake_case")]
259#[strum(serialize_all = "snake_case")]
260pub enum FaultReason {
261 MissingSchema,
263 SchemaNotAllowed,
265 SchemaMismatch,
267 Malformed,
269 TooLarge,
271 TooDeep,
273 ForeignCodec,
275 TypeMismatch,
277}
278
279impl FaultReason {
280 pub const fn is_foreign(self) -> bool {
283 matches!(
284 self,
285 FaultReason::ForeignCodec
286 | FaultReason::MissingSchema
287 | FaultReason::SchemaNotAllowed
288 | FaultReason::SchemaMismatch
289 )
290 }
291}
292
293#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
295pub struct RecordFault {
296 pub offset: u64,
297 pub reason: FaultReason,
298}
299
300#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
302pub struct FilterDecodeLimits {
303 pub max_payload_bytes: u32,
304 pub max_depth: u32,
305}
306
307#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
309pub struct AppliedPolicy {
310 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub group_id: Option<u64>,
312 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub digest: Option<Digest32>,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub filter_id: Option<u32>,
317 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub revision: Option<u32>,
319 #[serde(default)]
320 pub mode: ExecutionMode,
321 #[serde(default)]
323 pub policy_generation: u64,
324}
325
326impl AppliedPolicy {
327 #[must_use]
329 pub fn filtered(group_id: Option<u64>, digest: Digest32) -> Self {
330 Self {
331 group_id,
332 digest: Some(digest),
333 filter_id: None,
334 revision: None,
335 mode: ExecutionMode::Filtered,
336 policy_generation: 0,
337 }
338 }
339
340 #[must_use]
342 pub const fn unfiltered(group_id: u64, policy_generation: u64) -> Self {
343 Self {
344 group_id: Some(group_id),
345 digest: None,
346 filter_id: None,
347 revision: None,
348 mode: ExecutionMode::Unfiltered,
349 policy_generation,
350 }
351 }
352}
353
354impl Validate for AppliedPolicy {
355 fn validate(&self) -> Result<(), InvalidError> {
356 validate_policy_identity(self.mode, self.digest.as_ref())?;
357 if self.mode == ExecutionMode::Unfiltered {
358 if self.group_id.is_none() {
359 return Err(InvalidError::new("only a consumer group runs unfiltered"));
360 }
361 if self.filter_id.is_some() || self.revision.is_some() {
362 return Err(InvalidError::new(
363 "an unfiltered policy has no filter reference",
364 ));
365 }
366 }
367 Ok(())
368 }
369}
370
371#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
380pub struct FilteredPage {
381 pub v: u32,
382 pub partition_id: u32,
383 pub policy: AppliedPolicy,
384 pub generation: SourceGeneration,
385 pub read_mode: ReadMode,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub next_scan_offset: Option<u64>,
388 #[serde(default, skip_serializing_if = "Option::is_none")]
389 pub safe_ack_offset: Option<u64>,
390 pub frontier: u64,
393 pub examined: u32,
394 pub matched: u32,
395 pub stop: StopReason,
396 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub fault: Option<RecordFault>,
398 #[serde(default, skip_serializing_if = "Vec::is_empty")]
401 pub unevaluated: Vec<u64>,
402 #[serde(default, skip_serializing_if = "Option::is_none")]
403 pub evaluation_limits: Option<Box<FilterDecodeLimits>>,
404 #[serde(with = "crate::encoding::bin_bytes")]
405 pub records: Vec<u8>,
406}
407
408impl FilteredPage {
409 pub fn next_start(&self, original: &FilteredStart) -> FilteredStart {
412 match self.next_scan_offset {
413 Some(next_scan_offset) => FilteredStart::Continue(Continuation {
414 group_id: self.policy.group_id,
415 next_scan_offset,
416 generation: self.generation,
417 digest: self.policy.digest.clone(),
418 read_mode: self.read_mode,
419 mode: self.policy.mode,
420 policy_generation: self.policy.policy_generation,
421 }),
422 None => original.clone(),
423 }
424 }
425}
426
427#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
431pub struct FilteredAck {
432 #[serde(default, skip_serializing_if = "Option::is_none")]
433 pub group_id: Option<u64>,
434 pub v: u32,
435 pub source: FilterSource,
436 pub partition_id: u32,
437 pub consumer: FilterConsumer,
438 pub generation: SourceGeneration,
439 #[serde(default, skip_serializing_if = "Option::is_none")]
441 pub digest: Option<Digest32>,
442 pub offset: u64,
443 #[serde(default)]
444 pub mode: ExecutionMode,
445 #[serde(default)]
448 pub policy_generation: u64,
449}
450
451#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
453pub struct AckReceipt {
454 pub partition_id: u32,
455 pub offset: u64,
456 pub generation: SourceGeneration,
457}
458
459#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
462pub struct FilterPreviewRequest {
463 pub v: u32,
464 pub source: FilterSource,
465 pub partition_id: u32,
466 pub filter: FilterRef,
468 pub from_offset: u64,
469 pub max_examined: u32,
470 pub max_records: u32,
471 #[serde(default)]
473 pub explain: bool,
474}
475
476#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, strum::Display)]
478#[serde(rename_all = "snake_case")]
479#[strum(serialize_all = "snake_case")]
480pub enum Verdict {
481 Selected,
482 Rejected,
483 Fault,
484}
485
486#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
488pub struct PreviewRecord {
489 pub offset: u64,
490 pub timestamp_micros: u64,
491 pub verdict: Verdict,
492 #[serde(default, skip_serializing_if = "Option::is_none")]
493 pub fault: Option<FaultReason>,
494 pub payload_text: String,
496 #[serde(default)]
497 pub payload_truncated: bool,
498 #[serde(default, skip_serializing_if = "Option::is_none")]
499 pub explanation: Option<FilterExplanation>,
500}
501
502#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
504pub struct FilterPreview {
505 pub v: u32,
506 pub partition_id: u32,
507 pub policy: AppliedPolicy,
508 pub read_mode: ReadMode,
509 pub examined: u32,
510 pub matched: u32,
511 pub faults: u32,
512 pub stop: StopReason,
513 #[serde(default, skip_serializing_if = "Option::is_none")]
514 pub next_offset: Option<u64>,
515 pub frontier: u64,
516 pub records: Vec<PreviewRecord>,
519}
520
521#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
524#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
525pub enum HeaderScalar {
526 Bool(bool),
527 Int(i64),
528 Uint(u64),
529 Float(f64),
530 String(String),
531 Raw(#[serde(with = "crate::encoding::bin_bytes")] Vec<u8>),
532}
533
534#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
536pub struct FilterHeader {
537 pub key: String,
538 pub value: HeaderScalar,
539}
540
541#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
544pub struct FilterTestRequest {
545 pub v: u32,
546 pub filter: FilterRef,
548 #[serde(with = "crate::encoding::bin_bytes")]
549 pub payload: Vec<u8>,
550 #[serde(default, skip_serializing_if = "Vec::is_empty")]
551 pub headers: Vec<FilterHeader>,
552}
553
554#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, strum::Display)]
556#[serde(rename_all = "snake_case")]
557#[strum(serialize_all = "snake_case")]
558pub enum Truth {
559 Match,
560 NoMatch,
561 Unknown,
562}
563
564#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
567pub struct ExplainNode {
568 pub label: String,
569 #[serde(default, skip_serializing_if = "Option::is_none")]
570 pub truth: Option<Truth>,
571 #[serde(default, skip_serializing_if = "Vec::is_empty")]
572 pub children: Vec<ExplainNode>,
573}
574
575#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
577pub struct FilterExplanation {
578 pub verdict: Verdict,
579 #[serde(default, skip_serializing_if = "Option::is_none")]
580 pub fault: Option<FaultReason>,
581 pub root: ExplainNode,
582}
583
584#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
586pub struct FilterTestResult {
587 pub v: u32,
588 pub policy: AppliedPolicy,
589 pub explanation: FilterExplanation,
590}
591
592#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
594pub struct FilterValidation {
595 pub v: u32,
596 pub digest: Digest32,
597 pub reads_payload: bool,
599 pub reads_headers: bool,
601}
602
603#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, strum::Display)]
606#[serde(rename_all = "snake_case")]
607#[strum(serialize_all = "snake_case")]
608#[non_exhaustive]
609pub enum FilterErrorReason {
610 InvalidRequest,
612 Unsupported,
614 VersionSkew,
616 NotFound,
618 Conflict,
621 SourceChanged,
624 MembershipStale,
626 NotPrimary,
628 CatalogUnavailable,
630 RevisionDisabled,
632 TooLarge,
634 Unauthenticated,
635 Forbidden,
636 Unavailable,
638 CapacityExhausted,
640 Backend,
642 #[serde(other)]
645 Unknown,
646}
647
648impl FilterErrorReason {
649 pub const fn code(self) -> ResultCode {
650 match self {
651 Self::InvalidRequest => ResultCode::InvalidArgument,
652 Self::Unsupported => ResultCode::Unsupported,
653 Self::VersionSkew => ResultCode::VersionSkew,
654 Self::NotFound => ResultCode::NotFound,
655 Self::Conflict => ResultCode::Conflict,
656 Self::SourceChanged => ResultCode::StaleGeneration,
657 Self::MembershipStale | Self::NotPrimary => ResultCode::Stale,
658 Self::CatalogUnavailable | Self::Unavailable | Self::RevisionDisabled => {
659 ResultCode::Unavailable
660 }
661 Self::TooLarge => ResultCode::TooLarge,
662 Self::Unauthenticated => ResultCode::Unauthenticated,
663 Self::Forbidden => ResultCode::Forbidden,
664 Self::CapacityExhausted => ResultCode::ResourceLimit,
665 Self::Backend | Self::Unknown => ResultCode::Backend,
666 }
667 }
668}
669
670#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
672#[error("{reason}: {message}")]
673pub struct FilterError {
674 pub code: ResultCode,
675 pub reason: FilterErrorReason,
676 pub message: String,
677}
678
679impl FilterError {
680 pub fn new(reason: FilterErrorReason, message: impl Into<String>) -> Self {
681 Self {
682 code: reason.code(),
683 reason,
684 message: message.into(),
685 }
686 }
687
688 pub fn invalid(error: InvalidError) -> Self {
689 Self::new(FilterErrorReason::InvalidRequest, error.0)
690 }
691
692 pub fn check_version(v: u32) -> Result<(), Self> {
700 validate_version(v).map_err(|error| Self::new(FilterErrorReason::VersionSkew, error.0))
701 }
702}
703
704#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
706#[serde(rename_all = "snake_case")]
707pub enum FilterOutcome {
708 Page(FilteredPage),
709 Acknowledged(AckReceipt),
710 Preview(FilterPreview),
711 Tested(FilterTestResult),
712 Validated(FilterValidation),
713}
714
715#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
717#[serde(rename_all = "snake_case")]
718#[allow(clippy::large_enum_variant)]
720pub enum FilterReply {
721 Ok(FilterOutcome),
722 Err(FilterError),
723}
724
725fn validate_version(v: u32) -> Result<(), InvalidError> {
726 if v == FILTER_OP_VERSION {
727 Ok(())
728 } else {
729 Err(InvalidError::new(format!(
730 "filter request version {v} is not supported, expected {FILTER_OP_VERSION}"
731 )))
732 }
733}
734
735pub(crate) fn validate_name(label: &str, name: &str) -> Result<(), InvalidError> {
736 if name.is_empty() || name.len() > MAX_FILTER_SOURCE_NAME_BYTES {
737 return Err(InvalidError::new(format!(
738 "{label} must be 1..={MAX_FILTER_SOURCE_NAME_BYTES} bytes"
739 )));
740 }
741 Ok(())
742}
743
744impl Validate for FilterSource {
745 fn validate(&self) -> Result<(), InvalidError> {
746 validate_name("stream", &self.stream)?;
747 validate_name("topic", &self.topic)
748 }
749}
750
751impl Validate for FilterConsumer {
752 fn validate(&self) -> Result<(), InvalidError> {
753 match self {
754 Self::Consumer(name) => validate_name("consumer", name),
755 Self::Group(name) => validate_name("consumer group", name),
756 Self::GroupId(id) => u32::try_from(*id).map(|_| ()).map_err(|_| {
757 InvalidError::new("consumer group id exceeds the native offset-key range")
758 }),
759 }
760 }
761}
762
763impl Validate for FilterRef {
764 fn validate(&self) -> Result<(), InvalidError> {
765 match self {
766 Self::Inline(filter) => filter.validate(),
767 Self::Revision { .. } | Self::Bound | Self::Group => Ok(()),
768 }
769 }
770}
771
772impl Validate for FilteredPollRequest {
773 fn validate(&self) -> Result<(), InvalidError> {
774 validate_version(self.v)?;
775 self.source.validate()?;
776 self.consumer.validate()?;
777 self.filter.validate()?;
778 if matches!(self.filter, FilterRef::Bound | FilterRef::Group) && !self.consumer.is_group() {
779 return Err(InvalidError::new(
780 "a `bound` or `group` filter needs a consumer group",
781 ));
782 }
783 if self.count == 0 || self.count > MAX_FILTERED_PAGE_RECORDS {
784 return Err(InvalidError::new(format!(
785 "count must be 1..={MAX_FILTERED_PAGE_RECORDS}"
786 )));
787 }
788 if let Some(max_examined) = self.max_examined
789 && (max_examined == 0 || max_examined > MAX_FILTERED_PAGE_EXAMINED)
790 {
791 return Err(InvalidError::new(format!(
792 "max_examined must be 1..={MAX_FILTERED_PAGE_EXAMINED}"
793 )));
794 }
795 if self.max_reply_bytes == 0 || self.max_reply_bytes > MAX_FILTERED_PAGE_BYTES {
796 return Err(InvalidError::new(format!(
797 "max_reply_bytes must be 1..={MAX_FILTERED_PAGE_BYTES}"
798 )));
799 }
800 if let FilteredStart::Continue(continuation) = &self.start {
801 validate_policy_identity(continuation.mode, continuation.digest.as_ref())?;
802 if continuation.mode == ExecutionMode::Unfiltered && !self.consumer.is_group() {
803 return Err(InvalidError::new("only a consumer group runs unfiltered"));
804 }
805 if self.consumer.is_group() != continuation.group_id.is_some() {
806 return Err(InvalidError::new(
807 "a group continuation must retain its group identity",
808 ));
809 }
810 if continuation.generation.partition_id != self.partition_id {
811 return Err(InvalidError::new(
812 "a continuation belongs to another partition",
813 ));
814 }
815 }
816 Ok(())
817 }
818}
819
820impl Validate for FilteredAck {
821 fn validate(&self) -> Result<(), InvalidError> {
822 validate_version(self.v)?;
823 self.source.validate()?;
824 self.consumer.validate()?;
825 validate_policy_identity(self.mode, self.digest.as_ref())?;
826 if self.mode == ExecutionMode::Unfiltered && !self.consumer.is_group() {
827 return Err(InvalidError::new("only a consumer group runs unfiltered"));
828 }
829 if self.consumer.is_group() != self.group_id.is_some() {
830 return Err(InvalidError::new(
831 "a group acknowledgment must retain its group identity",
832 ));
833 }
834 if self.generation.partition_id != self.partition_id {
835 return Err(InvalidError::new(
836 "an acknowledgment names another partition's generation",
837 ));
838 }
839 Ok(())
840 }
841}
842
843impl Validate for FilterPreviewRequest {
844 fn validate(&self) -> Result<(), InvalidError> {
845 validate_version(self.v)?;
846 self.source.validate()?;
847 self.filter.validate()?;
848 if matches!(self.filter, FilterRef::Bound | FilterRef::Group) {
849 return Err(InvalidError::new(
850 "a preview executes an inline filter or a saved revision",
851 ));
852 }
853 if self.max_examined == 0 || self.max_examined > MAX_FILTER_PREVIEW_EXAMINED {
854 return Err(InvalidError::new(format!(
855 "max_examined must be 1..={MAX_FILTER_PREVIEW_EXAMINED}"
856 )));
857 }
858 if self.max_records == 0 || self.max_records > MAX_FILTER_PREVIEW_RECORDS {
859 return Err(InvalidError::new(format!(
860 "max_records must be 1..={MAX_FILTER_PREVIEW_RECORDS}"
861 )));
862 }
863 Ok(())
864 }
865}
866
867impl Validate for FilterTestRequest {
868 fn validate(&self) -> Result<(), InvalidError> {
869 validate_version(self.v)?;
870 self.filter.validate()?;
871 if matches!(self.filter, FilterRef::Bound | FilterRef::Group) {
872 return Err(InvalidError::new(
873 "a sample test executes an inline filter or a saved revision",
874 ));
875 }
876 if self.payload.len() > MAX_FILTER_SAMPLE_BYTES {
877 return Err(InvalidError::new(format!(
878 "a sample payload exceeds {MAX_FILTER_SAMPLE_BYTES}B"
879 )));
880 }
881 if self.headers.len() > MAX_FILTER_SAMPLE_HEADERS {
882 return Err(InvalidError::new(format!(
883 "a sample carries more than {MAX_FILTER_SAMPLE_HEADERS} headers"
884 )));
885 }
886 for header in &self.headers {
887 validate_name("sample header key", &header.key)?;
888 let bytes = match &header.value {
889 HeaderScalar::String(value) => value.len(),
890 HeaderScalar::Raw(value) => value.len(),
891 _ => 0,
892 };
893 if bytes > crate::limits::MAX_FILTER_STRING_BYTES {
894 return Err(InvalidError::new(
895 "a sample header value exceeds its byte limit",
896 ));
897 }
898 }
899 Ok(())
900 }
901}
902
903#[cfg(test)]
904mod tests {
905 use super::*;
906 use crate::filter::expr::FilterExpr;
907 use crate::query::CmpOp;
908
909 fn generation() -> SourceGeneration {
910 SourceGeneration {
911 stream_id: 1,
912 stream_created_at_micros: 10,
913 topic_id: 2,
914 topic_created_at_micros: 20,
915 partition_id: 0,
916 partition_created_revision: 30,
917 purge_generation: 0,
918 }
919 }
920
921 fn request() -> FilteredPollRequest {
922 FilteredPollRequest {
923 v: FILTER_OP_VERSION,
924 source: FilterSource {
925 stream: "orbit".to_owned(),
926 topic: "fleet_changes".to_owned(),
927 },
928 partition_id: 0,
929 consumer: FilterConsumer::Group("anomaly-desk".to_owned()),
930 filter: FilterRef::Bound,
931 start: FilteredStart::Next,
932 count: 100,
933 max_reply_bytes: 1024 * 1024,
934 read_mode: ReadMode::Primary,
935 max_examined: None,
936 min_catalog_position: None,
937 }
938 }
939
940 #[test]
941 fn given_a_group_read_when_validated_then_should_pass() {
942 request().validate().expect("valid");
943 }
944
945 #[test]
946 fn given_out_of_range_reads_when_validated_then_should_be_rejected() {
947 let mut bound_consumer = request();
948 bound_consumer.consumer = FilterConsumer::Consumer("one".to_owned());
949 let mut zero = request();
950 zero.count = 0;
951 let mut huge = request();
952 huge.max_reply_bytes = MAX_FILTERED_PAGE_BYTES + 1;
953 let mut other_partition = request();
954 other_partition.start = FilteredStart::Continue(Continuation {
955 group_id: None,
956 next_scan_offset: 7,
957 generation: SourceGeneration {
958 partition_id: 5,
959 ..generation()
960 },
961 digest: Some(Digest32::new([1; 32])),
962 read_mode: ReadMode::Primary,
963 mode: ExecutionMode::Filtered,
964 policy_generation: 0,
965 });
966 let mut group_consumer = request();
967 group_consumer.filter = FilterRef::Group;
968 group_consumer.consumer = FilterConsumer::Consumer("one".to_owned());
969 let mut over_examined = request();
970 over_examined.max_examined = Some(MAX_FILTERED_PAGE_EXAMINED + 1);
971 let mut zero_examined = request();
972 zero_examined.max_examined = Some(0);
973 let mut unfiltered_with_digest = request();
974 unfiltered_with_digest.start = FilteredStart::Continue(Continuation {
975 group_id: Some(3),
976 next_scan_offset: 7,
977 generation: generation(),
978 digest: Some(Digest32::new([1; 32])),
979 read_mode: ReadMode::Primary,
980 mode: ExecutionMode::Unfiltered,
981 policy_generation: 1,
982 });
983 for request in [
984 bound_consumer,
985 zero,
986 huge,
987 other_partition,
988 group_consumer,
989 over_examined,
990 zero_examined,
991 unfiltered_with_digest,
992 ] {
993 assert!(request.validate().is_err(), "{request:?} must be rejected");
994 }
995 }
996
997 #[test]
998 fn given_an_automatic_group_read_with_an_examined_bound_when_validated_then_should_pass() {
999 let mut automatic = request();
1000 automatic.filter = FilterRef::Group;
1001 automatic.max_examined = Some(100);
1002 automatic.min_catalog_position = Some(CatalogPosition {
1003 partition_id: 0,
1004 offset: 41,
1005 operation_id: Some(11),
1006 });
1007 automatic.validate().expect("valid");
1008 }
1009
1010 #[test]
1011 fn given_an_unfiltered_policy_when_validated_then_should_need_a_group_and_no_digest() {
1012 AppliedPolicy::unfiltered(3, 1).validate().expect("valid");
1013 assert!(
1014 AppliedPolicy::filtered(Some(3), Digest32::new([1; 32]))
1015 .validate()
1016 .is_ok()
1017 );
1018 let mut no_group = AppliedPolicy::unfiltered(3, 1);
1019 no_group.group_id = None;
1020 assert!(no_group.validate().is_err());
1021 let mut with_digest = AppliedPolicy::unfiltered(3, 1);
1022 with_digest.digest = Some(Digest32::new([1; 32]));
1023 assert!(with_digest.validate().is_err());
1024 let mut with_reference = AppliedPolicy::unfiltered(3, 1);
1025 with_reference.filter_id = Some(1);
1026 assert!(with_reference.validate().is_err());
1027 with_reference.filter_id = None;
1028 with_reference.revision = Some(1);
1029 assert!(with_reference.validate().is_err());
1030 let mut without_digest = AppliedPolicy::filtered(None, Digest32::new([1; 32]));
1031 without_digest.digest = None;
1032 assert!(without_digest.validate().is_err());
1033 }
1034
1035 #[test]
1036 fn given_a_page_when_asked_for_the_next_start_then_should_continue_or_repeat() {
1037 let page = FilteredPage {
1038 v: FILTER_OP_VERSION,
1039 partition_id: 0,
1040 policy: AppliedPolicy {
1041 group_id: None,
1042 digest: Some(Digest32::new([9; 32])),
1043 filter_id: Some(3),
1044 revision: Some(1),
1045 mode: ExecutionMode::Filtered,
1046 policy_generation: 0,
1047 },
1048 generation: generation(),
1049 read_mode: ReadMode::Primary,
1050 next_scan_offset: Some(1397),
1051 safe_ack_offset: Some(1396),
1052 frontier: 2000,
1053 examined: 397,
1054 matched: 3,
1055 stop: StopReason::Filled,
1056 fault: None,
1057 unevaluated: Vec::new(),
1058 evaluation_limits: None,
1059 records: Vec::new(),
1060 };
1061 assert_eq!(
1062 page.next_start(&FilteredStart::Next),
1063 FilteredStart::Continue(Continuation {
1064 group_id: None,
1065 next_scan_offset: 1397,
1066 generation: generation(),
1067 digest: Some(Digest32::new([9; 32])),
1068 read_mode: ReadMode::Primary,
1069 mode: ExecutionMode::Filtered,
1070 policy_generation: 0,
1071 })
1072 );
1073 let empty = FilteredPage {
1074 next_scan_offset: None,
1075 safe_ack_offset: None,
1076 ..page
1077 };
1078 assert_eq!(empty.next_start(&FilteredStart::Last), FilteredStart::Last);
1079 }
1080
1081 #[test]
1082 fn given_error_reasons_when_classified_then_should_map_to_shared_codes() {
1083 assert_eq!(
1084 FilterError::new(FilterErrorReason::SourceChanged, "purged").code,
1085 ResultCode::StaleGeneration
1086 );
1087 assert_eq!(FilterErrorReason::NotPrimary.code(), ResultCode::Stale);
1088 assert_eq!(FilterErrorReason::Conflict.code(), ResultCode::Conflict);
1089 assert_eq!(
1090 FilterErrorReason::CatalogUnavailable.code(),
1091 ResultCode::Unavailable
1092 );
1093 }
1094
1095 #[test]
1096 fn given_samples_and_previews_when_validated_then_should_reject_group_selectors() {
1097 let inline = FilterRef::Inline(ConsumerFilter::json(FilterExpr::pred(
1098 "kind",
1099 CmpOp::Eq,
1100 "satellites",
1101 )));
1102 let test = FilterTestRequest {
1103 v: FILTER_OP_VERSION,
1104 filter: inline.clone(),
1105 payload: br#"{"kind":"satellites"}"#.to_vec(),
1106 headers: Vec::new(),
1107 };
1108 test.validate().expect("valid sample");
1109 let mut preview = FilterPreviewRequest {
1110 v: FILTER_OP_VERSION,
1111 source: request().source,
1112 partition_id: 0,
1113 filter: inline,
1114 from_offset: 0,
1115 max_examined: 100,
1116 max_records: 10,
1117 explain: false,
1118 };
1119 preview.validate().expect("valid preview");
1120 for selector in [FilterRef::Bound, FilterRef::Group] {
1121 let sample = FilterTestRequest {
1122 filter: selector.clone(),
1123 ..test.clone()
1124 };
1125 assert!(sample.validate().is_err());
1126 preview.filter = selector;
1127 assert!(preview.validate().is_err());
1128 }
1129 }
1130
1131 #[test]
1132 fn given_oversized_sample_headers_when_validated_then_should_reject_before_evaluation() {
1133 let mut request = FilterTestRequest {
1134 v: FILTER_OP_VERSION,
1135 filter: FilterRef::Inline(ConsumerFilter::headers_only(FilterExpr::header(
1136 "priority",
1137 CmpOp::Eq,
1138 "critical",
1139 ))),
1140 payload: Vec::new(),
1141 headers: vec![FilterHeader {
1142 key: "priority".to_owned(),
1143 value: HeaderScalar::Raw(vec![0; crate::limits::MAX_FILTER_STRING_BYTES + 1]),
1144 }],
1145 };
1146 assert!(request.validate().is_err());
1147 request.headers[0].value = HeaderScalar::String("critical".to_owned());
1148 request.validate().expect("bounded header");
1149 request.headers[0].key.clear();
1150 assert!(request.validate().is_err());
1151 }
1152}