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