Skip to main content

laser_wire/filter/
read.rs

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/// The stream and topic a filtered read or preview addresses, by name.
18#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
19pub struct FilterSource {
20    pub stream: String,
21    pub topic: String,
22}
23
24/// Whose progress a filtered read follows: an independent consumer or a
25/// consumer group. Groups accept either a name or a numeric ID.
26#[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/// Which filter a read executes.
87#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum FilterRef {
90    /// The full filter in the request. Needs no catalog. A consumer group
91    /// still needs a binding with the same digest.
92    Inline(ConsumerFilter),
93    /// A saved revision from the catalog.
94    Revision { filter_id: u32, revision: u32 },
95    /// Whatever revision the consumer group is bound to. Groups only. An
96    /// unbound group is refused.
97    Bound,
98    /// Whatever policy the consumer group has. Groups only. A bound group is
99    /// filtered by its revision, an unbound group receives every record. Needs
100    /// a server that advertises `GROUP_POLICY_READS`.
101    Group,
102}
103
104/// Where a filtered read starts.
105#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case")]
107pub enum FilteredStart {
108    /// After the consumer's stored offset.
109    Next,
110    First,
111    Last,
112    Offset(u64),
113    /// Records at or after this broker timestamp, in microseconds.
114    Timestamp(u64),
115    /// Continue exactly where the previous page stopped.
116    Continue(Continuation),
117}
118
119/// Which replica serves the read.
120#[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    /// The partition primary, through an attached consumer session. The owner
125    /// proves the page came from the generation the reply names. Required for
126    /// acknowledgments.
127    #[default]
128    Primary,
129    /// Whatever replica the connection reaches. It may lag without bound, so it
130    /// serves diagnostics and previews, never acknowledgments.
131    Local,
132}
133
134impl ReadMode {
135    pub const fn is_primary(&self) -> bool {
136        matches!(self, Self::Primary)
137    }
138}
139
140/// How the server selected the records of a page. `Unfiltered` is the explicit
141/// policy of a group without a binding, never a fallback: it carries no digest
142/// and the page holds every record the owner read returned.
143#[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
160/// A filtered policy names its digest and an unfiltered one has none.
161fn 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/// The exact source history a page was read from. A purge or a recreated
178/// stream, topic, or partition changes it, so a continuation or acknowledgment
179/// from an older history is rejected rather than applied to new records.
180#[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/// The resume point a page hands to the next request.
192#[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    /// The digest of the filter the page ran, absent for an unfiltered page.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub digest: Option<Digest32>,
201    /// The route the page was read on. A continuation from a local page never
202    /// resumes a primary read, because a local replica can lag a purge.
203    #[serde(default, skip_serializing_if = "ReadMode::is_primary")]
204    pub read_mode: ReadMode,
205    #[serde(default)]
206    pub mode: ExecutionMode,
207    /// The group's policy generation the page ran under, zero for an
208    /// independent consumer.
209    #[serde(default)]
210    pub policy_generation: u64,
211}
212
213/// Read one bounded page of matching records from one partition.
214#[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    /// Most matching records to return.
223    pub count: u32,
224    /// Most record bytes to return. The server may lower it.
225    pub max_reply_bytes: u32,
226    #[serde(default)]
227    pub read_mode: ReadMode,
228    /// Most source records to examine. The server lowers it to its own
229    /// budget. Absent means the server budget alone. A normal consumer sets
230    /// it to `count`, so a batch of 100 examines at most 100 records.
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub max_examined: Option<u32>,
233    /// Serve only once the catalog has been examined through this position,
234    /// so a reader sees the configuration its application just made.
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub min_catalog_position: Option<CatalogPosition>,
237}
238
239/// Why a page stopped scanning.
240#[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    /// The requested number of matches was reached.
245    Filled,
246    /// A record, byte, or round budget was spent. More records may follow.
247    Budget,
248    /// No more records are visible on the serving replica right now.
249    EndOfVisible,
250    /// A record failed to decode under the `stop` fault policy.
251    Fault,
252    /// One matching record alone exceeds the reply cap.
253    OversizedRecord,
254}
255
256/// Why a record could not be evaluated.
257#[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    /// No valid uint32 agdx.sid header identifies the writer schema.
262    MissingSchema,
263    /// The record schema is not permitted by this filter.
264    SchemaNotAllowed,
265    /// The registered schema family differs from the payload codec.
266    SchemaMismatch,
267    /// The payload is not valid for the filter's codec.
268    Malformed,
269    /// The payload exceeds the decoder's size limit.
270    TooLarge,
271    /// The payload nests deeper than the decoder's depth limit.
272    TooDeep,
273    /// The record's agdx.ct header names another codec than the filter's.
274    ForeignCodec,
275    /// A predicate's value exists but has a type the predicate cannot compare.
276    TypeMismatch,
277}
278
279impl FaultReason {
280    /// Whether the record is in another format than the filter reads, which
281    /// follows the filter's foreign policy instead of its fault policy.
282    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/// One record the filter could not evaluate.
294#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
295pub struct RecordFault {
296    pub offset: u64,
297    pub reason: FaultReason,
298}
299
300/// Decoder limits applied by the server to unevaluated records in a page.
301#[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/// The filter a page actually executed.
308#[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    /// The digest of the filter that ran, absent for an unfiltered page.
313    #[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    /// The group's policy generation, zero for an independent consumer.
322    #[serde(default)]
323    pub policy_generation: u64,
324}
325
326impl AppliedPolicy {
327    /// The policy an independent consumer or a bound group ran: one filter.
328    #[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    /// The policy of a group without a binding: every record.
341    #[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/// One page of a filtered read.
372///
373/// `records` is a standard polled-messages body: the matching records keep
374/// their original offsets, ids, timestamps, headers, and payload bytes.
375/// `next_scan_offset` is the first record the server has not examined, `None`
376/// when nothing was examined and the start selector must be sent again.
377/// `safe_ack_offset` is the last examined offset. The reader may acknowledge it
378/// only after every returned record at or below it is fully handled.
379#[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    /// The serving replica's partition head when the page was read. Reported,
391    /// never used as scan progress.
392    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    /// Offsets of returned records the filter could not evaluate (the `pass`
399    /// fault policy).
400    #[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    /// The start of the next request: continue after this page, or repeat the
410    /// original selector when nothing was examined.
411    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/// Acknowledge a filtered page through its safe offset. The server stores the
428/// offset only while the source still has the generation the page was read
429/// from, and only while the consumer still owns the partition.
430#[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    /// The digest of the filter the page ran, absent for an unfiltered page.
440    #[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    /// The group's policy generation the page ran under. The server refuses
446    /// the acknowledgment when the group's generation moved on.
447    #[serde(default)]
448    pub policy_generation: u64,
449}
450
451/// A stored acknowledgment.
452#[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/// Preview a filter over stored records of one partition. Observational: it
460/// joins no group, stores no offset, and changes no consumer state.
461#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
462pub struct FilterPreviewRequest {
463    pub v: u32,
464    pub source: FilterSource,
465    pub partition_id: u32,
466    /// `inline` or `revision`. `bound` is rejected.
467    pub filter: FilterRef,
468    pub from_offset: u64,
469    pub max_examined: u32,
470    pub max_records: u32,
471    /// Include a per-predicate explanation for every returned record.
472    #[serde(default)]
473    pub explain: bool,
474}
475
476/// How the filter judged one previewed record.
477#[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/// One previewed record: identity, verdict, and a bounded text view.
487#[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    /// The payload as lossy UTF-8, cut at the preview cap.
495    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/// A preview of one partition range.
503#[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    /// Selected records first, capped at `max_records`. Rejected records are
517    /// listed only when `explain` was requested.
518    pub records: Vec<PreviewRecord>,
519}
520
521/// A typed user header value, as the evaluator reads it. Iggy integer kinds up
522/// to 64 bits map to `int` or `uint`. 128-bit integers arrive as `raw` bytes.
523#[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/// One user header of a record.
535#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
536pub struct FilterHeader {
537    pub key: String,
538    pub value: HeaderScalar,
539}
540
541/// Evaluate a filter against one supplied record. Nothing is read from a
542/// stream and nothing is stored.
543#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
544pub struct FilterTestRequest {
545    pub v: u32,
546    /// `inline` or `revision`. `bound` is rejected.
547    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/// Three-valued comparison truth.
555#[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/// One node of an explanation tree: what it tested and how it resolved.
565/// `truth` is absent when the payload could not be decoded for this node.
566#[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/// Why a record was or was not selected.
576#[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/// The result of a sample test.
585#[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/// A filter that validated and compiled.
593#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
594pub struct FilterValidation {
595    pub v: u32,
596    pub digest: Digest32,
597    /// Whether evaluating the filter can decode the payload.
598    pub reads_payload: bool,
599    /// Whether evaluating the filter can read user headers.
600    pub reads_headers: bool,
601}
602
603/// The typed cause of a consumer-filter failure. Each maps to one shared
604/// [`ResultCode`], so a generic client can still classify it.
605#[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    /// The filter or request is malformed or out of range.
611    InvalidRequest,
612    /// The server or its backend does not serve this operation.
613    Unsupported,
614    /// The wire version is not accepted.
615    VersionSkew,
616    /// The source, filter, revision, binding, or operation does not exist.
617    NotFound,
618    /// A policy or mutation precondition does not hold (a different digest is
619    /// bound, a name is taken, an expected revision moved).
620    Conflict,
621    /// The source history changed (purge or recreation) since the page or
622    /// continuation was issued.
623    SourceChanged,
624    /// The consumer no longer owns the partition, or its session ended.
625    MembershipStale,
626    /// This node is not the partition primary. Resolve the route again.
627    NotPrimary,
628    /// The catalog cannot confirm the policy right now.
629    CatalogUnavailable,
630    /// The saved revision is paused. Pending records may still be acknowledged.
631    RevisionDisabled,
632    /// The request is too large.
633    TooLarge,
634    Unauthenticated,
635    Forbidden,
636    /// A transient failure. The same request may succeed later.
637    Unavailable,
638    /// A configured catalog resource limit is reached.
639    CapacityExhausted,
640    /// An unexpected failure the caller cannot retry away.
641    Backend,
642    /// A reason this build does not know. The error's `code` still classifies
643    /// it.
644    #[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/// A consumer-filter failure.
671#[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    /// Refuse a request of another filter op version as `version_skew`, before
693    /// its shape is validated, so a client of another build can tell a version
694    /// gap from a malformed request.
695    ///
696    /// # Errors
697    ///
698    /// `version_skew` when `v` is not [`FILTER_OP_VERSION`].
699    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/// A successful reply of a server-native consumer-filter command.
705#[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/// The reply to every server-native consumer-filter command.
716#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
717#[serde(rename_all = "snake_case")]
718// Keep successful pages inline without another allocation on every read.
719#[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}