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/// How the server selected the records of a page. `Unfiltered` is the explicit
120/// policy of a group without a binding, never a fallback: it carries no digest
121/// and the page holds every record the owner read returned.
122#[derive(
123    Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize, strum::Display,
124)]
125#[serde(rename_all = "snake_case")]
126#[strum(serialize_all = "snake_case")]
127pub enum ExecutionMode {
128    #[default]
129    Filtered,
130    Unfiltered,
131}
132
133impl ExecutionMode {
134    pub const fn is_filtered(&self) -> bool {
135        matches!(self, Self::Filtered)
136    }
137}
138
139/// A filtered policy names its digest and an unfiltered one has none.
140fn validate_policy_identity(
141    mode: ExecutionMode,
142    digest: Option<&Digest32>,
143) -> Result<(), InvalidError> {
144    match (mode, digest) {
145        (ExecutionMode::Filtered, Some(digest)) => digest.validate(),
146        (ExecutionMode::Filtered, None) => {
147            Err(InvalidError::new("a filtered policy names its digest"))
148        }
149        (ExecutionMode::Unfiltered, None) => Ok(()),
150        (ExecutionMode::Unfiltered, Some(_)) => {
151            Err(InvalidError::new("an unfiltered policy carries no digest"))
152        }
153    }
154}
155
156/// The exact source history a page was read from. A recreated stream, topic,
157/// or partition changes it, so a continuation or acknowledgment from an older
158/// history is rejected rather than applied to new records. The partition is
159/// named by its incarnation, the value its route reports.
160#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
161pub struct SourceGeneration {
162    pub stream_id: u32,
163    pub stream_created_at_micros: u64,
164    pub topic_id: u32,
165    pub topic_created_at_micros: u64,
166    pub partition_id: u32,
167    pub partition_incarnation: u64,
168}
169
170/// The partition owner a page was read under. The reader stores the page's
171/// offset with it and the page's partition incarnation, so the store is refused
172/// once the partition was recreated or its consumer group assignment moved.
173#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
174pub struct AckContext {
175    /// The consumer group owner generation, zero for an independent consumer.
176    pub owner_generation: u64,
177    /// The metadata operation the serving node had applied when it read the page.
178    pub metadata_op: u64,
179}
180
181/// The resume point a page hands to the next request.
182#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
183pub struct Continuation {
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub group_id: Option<u64>,
186    pub next_scan_offset: u64,
187    pub generation: SourceGeneration,
188    /// The digest of the filter the page ran, absent for an unfiltered page.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub digest: Option<Digest32>,
191    #[serde(default)]
192    pub mode: ExecutionMode,
193    /// The group's policy generation the page ran under, zero for an
194    /// independent consumer.
195    #[serde(default)]
196    pub policy_generation: u64,
197}
198
199/// Read one bounded page of matching records from one partition.
200#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
201pub struct FilteredPollRequest {
202    pub v: u32,
203    pub source: FilterSource,
204    pub partition_id: u32,
205    pub consumer: FilterConsumer,
206    pub filter: FilterRef,
207    pub start: FilteredStart,
208    /// Most matching records to return.
209    pub count: u32,
210    /// Most record bytes to return. The server may lower it.
211    pub max_reply_bytes: u32,
212    /// Most source records to examine. The server lowers it to its own
213    /// budget. Absent means the server budget alone. A normal consumer sets
214    /// it to `count`, so a batch of 100 examines at most 100 records.
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub max_examined: Option<u32>,
217    /// Serve only once the catalog has been examined through this position,
218    /// so a reader sees the configuration its application just made.
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub min_catalog_position: Option<CatalogPosition>,
221}
222
223/// Why a page stopped scanning.
224#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, strum::Display)]
225#[serde(rename_all = "snake_case")]
226#[strum(serialize_all = "snake_case")]
227pub enum StopReason {
228    /// The requested number of matches was reached.
229    Filled,
230    /// A record, byte, or round budget was spent. More records may follow.
231    Budget,
232    /// No more records are visible on the serving replica right now.
233    EndOfVisible,
234    /// A record failed to decode under the `stop` fault policy.
235    Fault,
236    /// One matching record alone exceeds the reply cap.
237    OversizedRecord,
238}
239
240/// Why a record could not be evaluated.
241#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, strum::Display)]
242#[serde(rename_all = "snake_case")]
243#[strum(serialize_all = "snake_case")]
244pub enum FaultReason {
245    /// No valid uint32 agdx.sid header identifies the writer schema.
246    MissingSchema,
247    /// The record schema is not permitted by this filter.
248    SchemaNotAllowed,
249    /// The registered schema family differs from the payload codec.
250    SchemaMismatch,
251    /// The payload is not valid for the filter's codec.
252    Malformed,
253    /// The payload exceeds the decoder's size limit.
254    TooLarge,
255    /// The payload nests deeper than the decoder's depth limit.
256    TooDeep,
257    /// The record's agdx.ct header names another codec than the filter's.
258    ForeignCodec,
259    /// A predicate's value exists but has a type the predicate cannot compare.
260    TypeMismatch,
261}
262
263impl FaultReason {
264    /// Whether the record is in another format than the filter reads, which
265    /// follows the filter's foreign policy instead of its fault policy.
266    pub const fn is_foreign(self) -> bool {
267        matches!(
268            self,
269            FaultReason::ForeignCodec
270                | FaultReason::MissingSchema
271                | FaultReason::SchemaNotAllowed
272                | FaultReason::SchemaMismatch
273        )
274    }
275}
276
277/// One record the filter could not evaluate.
278#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
279pub struct RecordFault {
280    pub offset: u64,
281    pub reason: FaultReason,
282}
283
284/// Decoder limits applied by the server to unevaluated records in a page.
285#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
286pub struct FilterDecodeLimits {
287    pub max_payload_bytes: u32,
288    pub max_depth: u32,
289}
290
291/// The filter a page actually executed.
292#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
293pub struct AppliedPolicy {
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub group_id: Option<u64>,
296    /// The digest of the filter that ran, absent for an unfiltered page.
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub digest: Option<Digest32>,
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub filter_id: Option<u32>,
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub revision: Option<u32>,
303    #[serde(default)]
304    pub mode: ExecutionMode,
305    /// The group's policy generation, zero for an independent consumer.
306    #[serde(default)]
307    pub policy_generation: u64,
308}
309
310impl AppliedPolicy {
311    /// The policy an independent consumer or a bound group ran: one filter.
312    #[must_use]
313    pub fn filtered(group_id: Option<u64>, digest: Digest32) -> Self {
314        Self {
315            group_id,
316            digest: Some(digest),
317            filter_id: None,
318            revision: None,
319            mode: ExecutionMode::Filtered,
320            policy_generation: 0,
321        }
322    }
323
324    /// The policy of a group without a binding: every record.
325    #[must_use]
326    pub const fn unfiltered(group_id: u64, policy_generation: u64) -> Self {
327        Self {
328            group_id: Some(group_id),
329            digest: None,
330            filter_id: None,
331            revision: None,
332            mode: ExecutionMode::Unfiltered,
333            policy_generation,
334        }
335    }
336}
337
338impl Validate for AppliedPolicy {
339    fn validate(&self) -> Result<(), InvalidError> {
340        validate_policy_identity(self.mode, self.digest.as_ref())?;
341        if self.mode == ExecutionMode::Unfiltered {
342            if self.group_id.is_none() {
343                return Err(InvalidError::new("only a consumer group runs unfiltered"));
344            }
345            if self.filter_id.is_some() || self.revision.is_some() {
346                return Err(InvalidError::new(
347                    "an unfiltered policy has no filter reference",
348                ));
349            }
350        }
351        Ok(())
352    }
353}
354
355/// One page of a filtered read.
356///
357/// `records` is a standard polled-messages body: its head names the partition,
358/// the head offset, the record count and the partition context the page was
359/// served under, and the matching records keep their original offsets, ids,
360/// timestamps, headers, and payload bytes.
361/// `next_scan_offset` is the first record the server has not examined, `None`
362/// when nothing was examined and the start selector must be sent again.
363/// `safe_ack_offset` is the last examined offset. The reader may store it, with
364/// `ack_context`, only after every returned record at or below it is fully
365/// handled.
366#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
367pub struct FilteredPage {
368    pub v: u32,
369    pub partition_id: u32,
370    pub policy: AppliedPolicy,
371    pub generation: SourceGeneration,
372    pub ack_context: AckContext,
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub next_scan_offset: Option<u64>,
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    pub safe_ack_offset: Option<u64>,
377    /// The primary's partition head when the page was read. Reported, never
378    /// used as scan progress.
379    pub frontier: u64,
380    pub examined: u32,
381    pub matched: u32,
382    pub stop: StopReason,
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub fault: Option<RecordFault>,
385    /// Offsets of returned records the filter could not evaluate (the `pass`
386    /// fault policy).
387    #[serde(default, skip_serializing_if = "Vec::is_empty")]
388    pub unevaluated: Vec<u64>,
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub evaluation_limits: Option<Box<FilterDecodeLimits>>,
391    #[serde(with = "crate::encoding::bin_bytes")]
392    pub records: Vec<u8>,
393}
394
395impl FilteredPage {
396    /// The start of the next request: continue after this page, or repeat the
397    /// original selector when nothing was examined.
398    pub fn next_start(&self, original: &FilteredStart) -> FilteredStart {
399        match self.next_scan_offset {
400            Some(next_scan_offset) => FilteredStart::Continue(Continuation {
401                group_id: self.policy.group_id,
402                next_scan_offset,
403                generation: self.generation,
404                digest: self.policy.digest.clone(),
405                mode: self.policy.mode,
406                policy_generation: self.policy.policy_generation,
407            }),
408            None => original.clone(),
409        }
410    }
411}
412
413/// Acknowledge a filtered page through its safe offset. The server stores the
414/// offset only while the source still has the generation the page was read
415/// from, and only while the consumer still owns the partition.
416#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
417pub struct FilteredAck {
418    #[serde(default, skip_serializing_if = "Option::is_none")]
419    pub group_id: Option<u64>,
420    pub v: u32,
421    pub source: FilterSource,
422    pub partition_id: u32,
423    pub consumer: FilterConsumer,
424    pub generation: SourceGeneration,
425    pub ack_context: AckContext,
426    /// The digest of the filter the page ran, absent for an unfiltered page.
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    pub digest: Option<Digest32>,
429    pub offset: u64,
430    #[serde(default)]
431    pub mode: ExecutionMode,
432    /// The group's policy generation the page ran under. The server refuses
433    /// the acknowledgment when the group's generation moved on.
434    #[serde(default)]
435    pub policy_generation: u64,
436}
437
438/// A stored acknowledgment.
439#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
440pub struct AckReceipt {
441    pub partition_id: u32,
442    pub offset: u64,
443    pub generation: SourceGeneration,
444}
445
446/// Preview a filter over stored records of one partition. Observational: it
447/// joins no group, stores no offset, and changes no consumer state.
448#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
449pub struct FilterPreviewRequest {
450    pub v: u32,
451    pub source: FilterSource,
452    pub partition_id: u32,
453    /// `inline` or `revision`. `bound` is rejected.
454    pub filter: FilterRef,
455    pub from_offset: u64,
456    pub max_examined: u32,
457    pub max_records: u32,
458    /// Include a per-predicate explanation for every returned record.
459    #[serde(default)]
460    pub explain: bool,
461}
462
463/// How the filter judged one previewed record.
464#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, strum::Display)]
465#[serde(rename_all = "snake_case")]
466#[strum(serialize_all = "snake_case")]
467pub enum Verdict {
468    Selected,
469    Rejected,
470    Fault,
471}
472
473/// One previewed record: identity, verdict, and a bounded text view.
474#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
475pub struct PreviewRecord {
476    pub offset: u64,
477    pub timestamp_micros: u64,
478    pub verdict: Verdict,
479    #[serde(default, skip_serializing_if = "Option::is_none")]
480    pub fault: Option<FaultReason>,
481    /// The payload as lossy UTF-8, cut at the preview cap.
482    pub payload_text: String,
483    #[serde(default)]
484    pub payload_truncated: bool,
485    #[serde(default, skip_serializing_if = "Option::is_none")]
486    pub explanation: Option<FilterExplanation>,
487}
488
489/// A preview of one partition range.
490#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
491pub struct FilterPreview {
492    pub v: u32,
493    pub partition_id: u32,
494    pub policy: AppliedPolicy,
495    pub examined: u32,
496    pub matched: u32,
497    pub faults: u32,
498    pub stop: StopReason,
499    #[serde(default, skip_serializing_if = "Option::is_none")]
500    pub next_offset: Option<u64>,
501    pub frontier: u64,
502    /// Selected records first, capped at `max_records`. Rejected records are
503    /// listed only when `explain` was requested.
504    pub records: Vec<PreviewRecord>,
505}
506
507/// A typed user header value, as the evaluator reads it. Iggy integer kinds up
508/// to 64 bits map to `int` or `uint`. 128-bit integers arrive as `raw` bytes.
509#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
510#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
511pub enum HeaderScalar {
512    Bool(bool),
513    Int(i64),
514    Uint(u64),
515    Float(f64),
516    String(String),
517    Raw(#[serde(with = "crate::encoding::bin_bytes")] Vec<u8>),
518}
519
520/// One user header of a record.
521#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
522pub struct FilterHeader {
523    pub key: String,
524    pub value: HeaderScalar,
525}
526
527/// Evaluate a filter against one supplied record. Nothing is read from a
528/// stream and nothing is stored.
529#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
530pub struct FilterTestRequest {
531    pub v: u32,
532    /// `inline` or `revision`. `bound` is rejected.
533    pub filter: FilterRef,
534    #[serde(with = "crate::encoding::bin_bytes")]
535    pub payload: Vec<u8>,
536    #[serde(default, skip_serializing_if = "Vec::is_empty")]
537    pub headers: Vec<FilterHeader>,
538}
539
540/// Three-valued comparison truth.
541#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, strum::Display)]
542#[serde(rename_all = "snake_case")]
543#[strum(serialize_all = "snake_case")]
544pub enum Truth {
545    Match,
546    NoMatch,
547    Unknown,
548}
549
550/// One node of an explanation tree: what it tested and how it resolved.
551/// `truth` is absent when the payload could not be decoded for this node.
552#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
553pub struct ExplainNode {
554    pub label: String,
555    #[serde(default, skip_serializing_if = "Option::is_none")]
556    pub truth: Option<Truth>,
557    #[serde(default, skip_serializing_if = "Vec::is_empty")]
558    pub children: Vec<ExplainNode>,
559}
560
561/// Why a record was or was not selected.
562#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
563pub struct FilterExplanation {
564    pub verdict: Verdict,
565    #[serde(default, skip_serializing_if = "Option::is_none")]
566    pub fault: Option<FaultReason>,
567    pub root: ExplainNode,
568}
569
570/// The result of a sample test.
571#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
572pub struct FilterTestResult {
573    pub v: u32,
574    pub policy: AppliedPolicy,
575    pub explanation: FilterExplanation,
576}
577
578/// A filter that validated and compiled.
579#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
580pub struct FilterValidation {
581    pub v: u32,
582    pub digest: Digest32,
583    /// Whether evaluating the filter can decode the payload.
584    pub reads_payload: bool,
585    /// Whether evaluating the filter can read user headers.
586    pub reads_headers: bool,
587}
588
589/// The typed cause of a consumer-filter failure. Each maps to one shared
590/// [`ResultCode`], so a generic client can still classify it.
591#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, strum::Display)]
592#[serde(rename_all = "snake_case")]
593#[strum(serialize_all = "snake_case")]
594#[non_exhaustive]
595pub enum FilterErrorReason {
596    /// The filter or request is malformed or out of range.
597    InvalidRequest,
598    /// The server or its backend does not serve this operation.
599    Unsupported,
600    /// The wire version is not accepted.
601    VersionSkew,
602    /// The source, filter, revision, binding, or operation does not exist.
603    NotFound,
604    /// A policy or mutation precondition does not hold (a different digest is
605    /// bound, a name is taken, an expected revision moved).
606    Conflict,
607    /// The source history changed (recreation) since the page or continuation
608    /// was issued.
609    SourceChanged,
610    /// The consumer no longer owns the partition, or its session ended.
611    MembershipStale,
612    /// The catalog cannot confirm the policy right now.
613    CatalogUnavailable,
614    /// The saved revision is paused. Pending records may still be acknowledged.
615    RevisionDisabled,
616    /// The request is too large.
617    TooLarge,
618    Unauthenticated,
619    Forbidden,
620    /// A transient failure. The same request may succeed later.
621    Unavailable,
622    /// A configured catalog resource limit is reached.
623    CapacityExhausted,
624    /// An unexpected failure the caller cannot retry away.
625    Backend,
626    /// A reason this build does not know. The error's `code` still classifies
627    /// it.
628    #[serde(other)]
629    Unknown,
630}
631
632impl FilterErrorReason {
633    pub const fn code(self) -> ResultCode {
634        match self {
635            Self::InvalidRequest => ResultCode::InvalidArgument,
636            Self::Unsupported => ResultCode::Unsupported,
637            Self::VersionSkew => ResultCode::VersionSkew,
638            Self::NotFound => ResultCode::NotFound,
639            Self::Conflict => ResultCode::Conflict,
640            Self::SourceChanged => ResultCode::StaleGeneration,
641            Self::MembershipStale => ResultCode::Stale,
642            Self::CatalogUnavailable | Self::Unavailable | Self::RevisionDisabled => {
643                ResultCode::Unavailable
644            }
645            Self::TooLarge => ResultCode::TooLarge,
646            Self::Unauthenticated => ResultCode::Unauthenticated,
647            Self::Forbidden => ResultCode::Forbidden,
648            Self::CapacityExhausted => ResultCode::ResourceLimit,
649            Self::Backend | Self::Unknown => ResultCode::Backend,
650        }
651    }
652}
653
654/// A consumer-filter failure.
655#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
656#[error("{reason}: {message}")]
657pub struct FilterError {
658    pub code: ResultCode,
659    pub reason: FilterErrorReason,
660    pub message: String,
661}
662
663impl FilterError {
664    pub fn new(reason: FilterErrorReason, message: impl Into<String>) -> Self {
665        Self {
666            code: reason.code(),
667            reason,
668            message: message.into(),
669        }
670    }
671
672    pub fn invalid(error: InvalidError) -> Self {
673        Self::new(FilterErrorReason::InvalidRequest, error.0)
674    }
675
676    /// Refuse a request of another filter op version as `version_skew`, before
677    /// its shape is validated, so a client of another build can tell a version
678    /// gap from a malformed request.
679    ///
680    /// # Errors
681    ///
682    /// `version_skew` when `v` is not [`FILTER_OP_VERSION`].
683    pub fn check_version(v: u32) -> Result<(), Self> {
684        validate_version(v).map_err(|error| Self::new(FilterErrorReason::VersionSkew, error.0))
685    }
686}
687
688/// A successful reply of a server-native consumer-filter command.
689#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
690#[serde(rename_all = "snake_case")]
691pub enum FilterOutcome {
692    Page(FilteredPage),
693    Acknowledged(AckReceipt),
694    Preview(FilterPreview),
695    Tested(FilterTestResult),
696    Validated(FilterValidation),
697}
698
699/// The reply to every server-native consumer-filter command.
700#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
701#[serde(rename_all = "snake_case")]
702// Keep successful pages inline without another allocation on every read.
703#[allow(clippy::large_enum_variant)]
704pub enum FilterReply {
705    Ok(FilterOutcome),
706    Err(FilterError),
707}
708
709fn validate_version(v: u32) -> Result<(), InvalidError> {
710    if v == FILTER_OP_VERSION {
711        Ok(())
712    } else {
713        Err(InvalidError::new(format!(
714            "filter request version {v} is not supported, expected {FILTER_OP_VERSION}"
715        )))
716    }
717}
718
719pub(crate) fn validate_name(label: &str, name: &str) -> Result<(), InvalidError> {
720    if name.is_empty() || name.len() > MAX_FILTER_SOURCE_NAME_BYTES {
721        return Err(InvalidError::new(format!(
722            "{label} must be 1..={MAX_FILTER_SOURCE_NAME_BYTES} bytes"
723        )));
724    }
725    Ok(())
726}
727
728impl Validate for FilterSource {
729    fn validate(&self) -> Result<(), InvalidError> {
730        validate_name("stream", &self.stream)?;
731        validate_name("topic", &self.topic)
732    }
733}
734
735impl Validate for FilterConsumer {
736    fn validate(&self) -> Result<(), InvalidError> {
737        match self {
738            Self::Consumer(name) => validate_name("consumer", name),
739            Self::Group(name) => validate_name("consumer group", name),
740            Self::GroupId(id) => u32::try_from(*id).map(|_| ()).map_err(|_| {
741                InvalidError::new("consumer group id exceeds the native offset-key range")
742            }),
743        }
744    }
745}
746
747impl Validate for FilterRef {
748    fn validate(&self) -> Result<(), InvalidError> {
749        match self {
750            Self::Inline(filter) => filter.validate(),
751            Self::Revision { .. } | Self::Bound | Self::Group => Ok(()),
752        }
753    }
754}
755
756impl Validate for FilteredPollRequest {
757    fn validate(&self) -> Result<(), InvalidError> {
758        validate_version(self.v)?;
759        self.source.validate()?;
760        self.consumer.validate()?;
761        self.filter.validate()?;
762        if matches!(self.filter, FilterRef::Bound | FilterRef::Group) && !self.consumer.is_group() {
763            return Err(InvalidError::new(
764                "a `bound` or `group` filter needs a consumer group",
765            ));
766        }
767        if self.count == 0 || self.count > MAX_FILTERED_PAGE_RECORDS {
768            return Err(InvalidError::new(format!(
769                "count must be 1..={MAX_FILTERED_PAGE_RECORDS}"
770            )));
771        }
772        if let Some(max_examined) = self.max_examined
773            && (max_examined == 0 || max_examined > MAX_FILTERED_PAGE_EXAMINED)
774        {
775            return Err(InvalidError::new(format!(
776                "max_examined must be 1..={MAX_FILTERED_PAGE_EXAMINED}"
777            )));
778        }
779        if self.max_reply_bytes == 0 || self.max_reply_bytes > MAX_FILTERED_PAGE_BYTES {
780            return Err(InvalidError::new(format!(
781                "max_reply_bytes must be 1..={MAX_FILTERED_PAGE_BYTES}"
782            )));
783        }
784        if let FilteredStart::Continue(continuation) = &self.start {
785            validate_policy_identity(continuation.mode, continuation.digest.as_ref())?;
786            if continuation.mode == ExecutionMode::Unfiltered && !self.consumer.is_group() {
787                return Err(InvalidError::new("only a consumer group runs unfiltered"));
788            }
789            if self.consumer.is_group() != continuation.group_id.is_some() {
790                return Err(InvalidError::new(
791                    "a group continuation must retain its group identity",
792                ));
793            }
794            if continuation.generation.partition_id != self.partition_id {
795                return Err(InvalidError::new(
796                    "a continuation belongs to another partition",
797                ));
798            }
799        }
800        Ok(())
801    }
802}
803
804impl Validate for FilteredAck {
805    fn validate(&self) -> Result<(), InvalidError> {
806        validate_version(self.v)?;
807        self.source.validate()?;
808        self.consumer.validate()?;
809        validate_policy_identity(self.mode, self.digest.as_ref())?;
810        if self.mode == ExecutionMode::Unfiltered && !self.consumer.is_group() {
811            return Err(InvalidError::new("only a consumer group runs unfiltered"));
812        }
813        if self.consumer.is_group() != self.group_id.is_some() {
814            return Err(InvalidError::new(
815                "a group acknowledgment must retain its group identity",
816            ));
817        }
818        if self.generation.partition_id != self.partition_id {
819            return Err(InvalidError::new(
820                "an acknowledgment names another partition's generation",
821            ));
822        }
823        Ok(())
824    }
825}
826
827impl Validate for FilterPreviewRequest {
828    fn validate(&self) -> Result<(), InvalidError> {
829        validate_version(self.v)?;
830        self.source.validate()?;
831        self.filter.validate()?;
832        if matches!(self.filter, FilterRef::Bound | FilterRef::Group) {
833            return Err(InvalidError::new(
834                "a preview executes an inline filter or a saved revision",
835            ));
836        }
837        if self.max_examined == 0 || self.max_examined > MAX_FILTER_PREVIEW_EXAMINED {
838            return Err(InvalidError::new(format!(
839                "max_examined must be 1..={MAX_FILTER_PREVIEW_EXAMINED}"
840            )));
841        }
842        if self.max_records == 0 || self.max_records > MAX_FILTER_PREVIEW_RECORDS {
843            return Err(InvalidError::new(format!(
844                "max_records must be 1..={MAX_FILTER_PREVIEW_RECORDS}"
845            )));
846        }
847        Ok(())
848    }
849}
850
851impl Validate for FilterTestRequest {
852    fn validate(&self) -> Result<(), InvalidError> {
853        validate_version(self.v)?;
854        self.filter.validate()?;
855        if matches!(self.filter, FilterRef::Bound | FilterRef::Group) {
856            return Err(InvalidError::new(
857                "a sample test executes an inline filter or a saved revision",
858            ));
859        }
860        if self.payload.len() > MAX_FILTER_SAMPLE_BYTES {
861            return Err(InvalidError::new(format!(
862                "a sample payload exceeds {MAX_FILTER_SAMPLE_BYTES}B"
863            )));
864        }
865        if self.headers.len() > MAX_FILTER_SAMPLE_HEADERS {
866            return Err(InvalidError::new(format!(
867                "a sample carries more than {MAX_FILTER_SAMPLE_HEADERS} headers"
868            )));
869        }
870        for header in &self.headers {
871            validate_name("sample header key", &header.key)?;
872            let bytes = match &header.value {
873                HeaderScalar::String(value) => value.len(),
874                HeaderScalar::Raw(value) => value.len(),
875                _ => 0,
876            };
877            if bytes > crate::limits::MAX_FILTER_STRING_BYTES {
878                return Err(InvalidError::new(
879                    "a sample header value exceeds its byte limit",
880                ));
881            }
882        }
883        Ok(())
884    }
885}
886
887#[cfg(test)]
888mod tests {
889    use super::*;
890    use crate::filter::expr::FilterExpr;
891    use crate::query::CmpOp;
892
893    fn generation() -> SourceGeneration {
894        SourceGeneration {
895            stream_id: 1,
896            stream_created_at_micros: 10,
897            topic_id: 2,
898            topic_created_at_micros: 20,
899            partition_id: 0,
900            partition_incarnation: 30,
901        }
902    }
903
904    fn request() -> FilteredPollRequest {
905        FilteredPollRequest {
906            v: FILTER_OP_VERSION,
907            source: FilterSource {
908                stream: "orbit".to_owned(),
909                topic: "fleet_changes".to_owned(),
910            },
911            partition_id: 0,
912            consumer: FilterConsumer::Group("anomaly-desk".to_owned()),
913            filter: FilterRef::Bound,
914            start: FilteredStart::Next,
915            count: 100,
916            max_reply_bytes: 1024 * 1024,
917            max_examined: None,
918            min_catalog_position: None,
919        }
920    }
921
922    #[test]
923    fn given_a_group_read_when_validated_then_should_pass() {
924        request().validate().expect("valid");
925    }
926
927    #[test]
928    fn given_out_of_range_reads_when_validated_then_should_be_rejected() {
929        let mut bound_consumer = request();
930        bound_consumer.consumer = FilterConsumer::Consumer("one".to_owned());
931        let mut zero = request();
932        zero.count = 0;
933        let mut huge = request();
934        huge.max_reply_bytes = MAX_FILTERED_PAGE_BYTES + 1;
935        let mut other_partition = request();
936        other_partition.start = FilteredStart::Continue(Continuation {
937            group_id: None,
938            next_scan_offset: 7,
939            generation: SourceGeneration {
940                partition_id: 5,
941                ..generation()
942            },
943            digest: Some(Digest32::new([1; 32])),
944            mode: ExecutionMode::Filtered,
945            policy_generation: 0,
946        });
947        let mut group_consumer = request();
948        group_consumer.filter = FilterRef::Group;
949        group_consumer.consumer = FilterConsumer::Consumer("one".to_owned());
950        let mut over_examined = request();
951        over_examined.max_examined = Some(MAX_FILTERED_PAGE_EXAMINED + 1);
952        let mut zero_examined = request();
953        zero_examined.max_examined = Some(0);
954        let mut unfiltered_with_digest = request();
955        unfiltered_with_digest.start = FilteredStart::Continue(Continuation {
956            group_id: Some(3),
957            next_scan_offset: 7,
958            generation: generation(),
959            digest: Some(Digest32::new([1; 32])),
960            mode: ExecutionMode::Unfiltered,
961            policy_generation: 1,
962        });
963        for request in [
964            bound_consumer,
965            zero,
966            huge,
967            other_partition,
968            group_consumer,
969            over_examined,
970            zero_examined,
971            unfiltered_with_digest,
972        ] {
973            assert!(request.validate().is_err(), "{request:?} must be rejected");
974        }
975    }
976
977    #[test]
978    fn given_an_automatic_group_read_with_an_examined_bound_when_validated_then_should_pass() {
979        let mut automatic = request();
980        automatic.filter = FilterRef::Group;
981        automatic.max_examined = Some(100);
982        automatic.min_catalog_position = Some(CatalogPosition {
983            partition_id: 0,
984            offset: 41,
985            operation_id: Some(11),
986        });
987        automatic.validate().expect("valid");
988    }
989
990    #[test]
991    fn given_an_unfiltered_policy_when_validated_then_should_need_a_group_and_no_digest() {
992        AppliedPolicy::unfiltered(3, 1).validate().expect("valid");
993        assert!(
994            AppliedPolicy::filtered(Some(3), Digest32::new([1; 32]))
995                .validate()
996                .is_ok()
997        );
998        let mut no_group = AppliedPolicy::unfiltered(3, 1);
999        no_group.group_id = None;
1000        assert!(no_group.validate().is_err());
1001        let mut with_digest = AppliedPolicy::unfiltered(3, 1);
1002        with_digest.digest = Some(Digest32::new([1; 32]));
1003        assert!(with_digest.validate().is_err());
1004        let mut with_reference = AppliedPolicy::unfiltered(3, 1);
1005        with_reference.filter_id = Some(1);
1006        assert!(with_reference.validate().is_err());
1007        with_reference.filter_id = None;
1008        with_reference.revision = Some(1);
1009        assert!(with_reference.validate().is_err());
1010        let mut without_digest = AppliedPolicy::filtered(None, Digest32::new([1; 32]));
1011        without_digest.digest = None;
1012        assert!(without_digest.validate().is_err());
1013    }
1014
1015    #[test]
1016    fn given_a_page_when_asked_for_the_next_start_then_should_continue_or_repeat() {
1017        let page = FilteredPage {
1018            v: FILTER_OP_VERSION,
1019            partition_id: 0,
1020            policy: AppliedPolicy {
1021                group_id: None,
1022                digest: Some(Digest32::new([9; 32])),
1023                filter_id: Some(3),
1024                revision: Some(1),
1025                mode: ExecutionMode::Filtered,
1026                policy_generation: 0,
1027            },
1028            generation: generation(),
1029            ack_context: AckContext {
1030                owner_generation: 4,
1031                metadata_op: 40,
1032            },
1033            next_scan_offset: Some(1397),
1034            safe_ack_offset: Some(1396),
1035            frontier: 2000,
1036            examined: 397,
1037            matched: 3,
1038            stop: StopReason::Filled,
1039            fault: None,
1040            unevaluated: Vec::new(),
1041            evaluation_limits: None,
1042            records: Vec::new(),
1043        };
1044        assert_eq!(
1045            page.next_start(&FilteredStart::Next),
1046            FilteredStart::Continue(Continuation {
1047                group_id: None,
1048                next_scan_offset: 1397,
1049                generation: generation(),
1050                digest: Some(Digest32::new([9; 32])),
1051                mode: ExecutionMode::Filtered,
1052                policy_generation: 0,
1053            })
1054        );
1055        let empty = FilteredPage {
1056            next_scan_offset: None,
1057            safe_ack_offset: None,
1058            ..page
1059        };
1060        assert_eq!(empty.next_start(&FilteredStart::Last), FilteredStart::Last);
1061    }
1062
1063    #[test]
1064    fn given_error_reasons_when_classified_then_should_map_to_shared_codes() {
1065        assert_eq!(
1066            FilterError::new(FilterErrorReason::SourceChanged, "recreated").code,
1067            ResultCode::StaleGeneration
1068        );
1069        assert_eq!(FilterErrorReason::MembershipStale.code(), ResultCode::Stale);
1070        assert_eq!(FilterErrorReason::Conflict.code(), ResultCode::Conflict);
1071        assert_eq!(
1072            FilterErrorReason::CatalogUnavailable.code(),
1073            ResultCode::Unavailable
1074        );
1075    }
1076
1077    #[test]
1078    fn given_samples_and_previews_when_validated_then_should_reject_group_selectors() {
1079        let inline = FilterRef::Inline(ConsumerFilter::json(FilterExpr::pred(
1080            "kind",
1081            CmpOp::Eq,
1082            "satellites",
1083        )));
1084        let test = FilterTestRequest {
1085            v: FILTER_OP_VERSION,
1086            filter: inline.clone(),
1087            payload: br#"{"kind":"satellites"}"#.to_vec(),
1088            headers: Vec::new(),
1089        };
1090        test.validate().expect("valid sample");
1091        let mut preview = FilterPreviewRequest {
1092            v: FILTER_OP_VERSION,
1093            source: request().source,
1094            partition_id: 0,
1095            filter: inline,
1096            from_offset: 0,
1097            max_examined: 100,
1098            max_records: 10,
1099            explain: false,
1100        };
1101        preview.validate().expect("valid preview");
1102        for selector in [FilterRef::Bound, FilterRef::Group] {
1103            let sample = FilterTestRequest {
1104                filter: selector.clone(),
1105                ..test.clone()
1106            };
1107            assert!(sample.validate().is_err());
1108            preview.filter = selector;
1109            assert!(preview.validate().is_err());
1110        }
1111    }
1112
1113    #[test]
1114    fn given_oversized_sample_headers_when_validated_then_should_reject_before_evaluation() {
1115        let mut request = FilterTestRequest {
1116            v: FILTER_OP_VERSION,
1117            filter: FilterRef::Inline(ConsumerFilter::headers_only(FilterExpr::header(
1118                "priority",
1119                CmpOp::Eq,
1120                "critical",
1121            ))),
1122            payload: Vec::new(),
1123            headers: vec![FilterHeader {
1124                key: "priority".to_owned(),
1125                value: HeaderScalar::Raw(vec![0; crate::limits::MAX_FILTER_STRING_BYTES + 1]),
1126            }],
1127        };
1128        assert!(request.validate().is_err());
1129        request.headers[0].value = HeaderScalar::String("critical".to_owned());
1130        request.validate().expect("bounded header");
1131        request.headers[0].key.clear();
1132        assert!(request.validate().is_err());
1133    }
1134}