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