Skip to main content

krabka_restore/
bound.rs

1//! The restore bound: every predicate an operator gave, and what it decides.
2//!
3//! This module owns the point in time the restore stops at. It compiles the
4//! `--to-offset`, `--to-timestamp`, `--exclude-key`, `--exclude-header`,
5//! `--exclude-producer-id`, and `--exclude-offset` flags into one predicate
6//! set, and answers two questions about the archived bytes. For a batch it
7//! answers whether the batch passes through untouched, must be replaced with
8//! a bare header because every record is excluded, or has to be re-encoded
9//! because only some of its records survive; the borrowed batch view makes
10//! that decision without an owned decode of the batches that pass. Dropping a
11//! batch's bytes is never the same as dropping its offset range: the target
12//! log permits compacted offset gaps, but a rewrite must preserve the archived
13//! batch span: it also determines the producer's last sequence and the
14//! partition's exclusive end. A batch whose records are all excluded therefore
15//! still claims its original offsets as a bare header. For one record inside
16//! a batch that must be re-encoded, it answers whether the record survives. The exclude
17//! patterns match the raw key and header bytes. This module decodes no
18//! payload and knows no schema.
19
20use {
21    crate::{
22        args::{HeaderPattern, PartitionRef},
23        error::RestoreError,
24    },
25    krabka_ids::{Offset, ProducerId},
26    krabka_protocol::records::{
27        Attributes, RecordBatchBorrowed, RecordBatchHeader, RecordBorrowed, RecordsError,
28        TimestampType,
29    },
30    regex::Regex,
31    std::{
32        collections::{HashMap, HashSet},
33        str,
34    },
35};
36
37mod compile;
38
39#[cfg(test)]
40mod test_support;
41#[cfg(test)]
42mod tests;
43
44/// What the bound decides about one archived batch.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum BatchDecision {
47    /// No predicate touches the batch. It is written verbatim, so its bytes
48    /// stay byte-identical to the archived copy.
49    Keep,
50    /// Every record in the batch is excluded. The batch's offset range is
51    /// still written, as a bare header with zero records:
52    /// [`krabka_log::filter_batch`] calls this outcome
53    /// [`krabka_log::FilteredBatch::Empty`] and leaves the caller to decide
54    /// what an emptied batch becomes, and for a restore that must preserve
55    /// offsets, the bare header preserves the original sequence span and
56    /// exclusive log end even when no record survives. The bare header keeps
57    /// the archived `base_offset` and `last_offset_delta` unchanged, exactly as
58    /// krabka's own log cleaner does on its `RETAIN_EMPTY` path for the same reason.
59    Empty,
60    /// Some records survive. The batch is re-encoded from the records that
61    /// [`Predicates::decide_record`] keeps, so its bytes differ from the
62    /// archived copy. [`krabka_log::filter_batch`] keeps the batch's
63    /// `base_offset` and the survivors' deltas. The materializer restores the
64    /// archived `last_offset_delta` after filtering, preserving both record
65    /// coordinates and the producer sequence span.
66    Filter,
67}
68
69/// What the bound decides about one record inside a filtered batch.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum RecordDecision {
72    /// The record is written.
73    Keep,
74    /// The record is not written.
75    Drop,
76}
77
78/// The compiled predicate set.
79#[derive(Debug)]
80pub struct Predicates {
81    /// The last offset kept per partition, from `--to-offset`.
82    to_offset: HashMap<PartitionRef, Offset>,
83    /// The global `--to-timestamp` bound, in epoch milliseconds. Unlike
84    /// `to_offset`, this applies to every partition alike.
85    to_timestamp: Option<i64>,
86    /// `--exclude-key` patterns. A record is dropped when any one matches.
87    exclude_key: Vec<Regex>,
88    /// `--exclude-header` patterns. A record is dropped when any one matches
89    /// a header by name and value.
90    exclude_header: Vec<HeaderPattern>,
91    /// `--exclude-producer-id` values.
92    exclude_producer_id: HashSet<ProducerId>,
93    /// `--exclude-offset` ranges, grouped per partition as half-open
94    /// `[start, end_exclusive)` ranges.
95    exclude_offset: HashMap<PartitionRef, Vec<ExcludedOffsets>>,
96}
97
98impl Predicates {
99    /// The highest offset the restore keeps in `partition`, when
100    /// `--to-offset` names it.
101    ///
102    /// `--to-timestamp` gives no equivalent lookup here, because it is a
103    /// global bound that applies to every partition alike, while
104    /// `--to-offset` is named per partition. Turning a timestamp bound into
105    /// an offset a caller could look up the same way would need a batch's
106    /// own timestamp, and the only place that reads one is
107    /// [`Self::decide_batch`]; there is no offset this method can hand back
108    /// for a timestamp bound without decoding a batch first.
109    #[must_use]
110    pub fn offset_bound(&self, partition: &PartitionRef) -> Option<Offset> {
111        self.to_offset.get(partition).copied()
112    }
113
114    /// Whether this batch and every later ordered batch start past the
115    /// partition's inclusive offset bound.
116    #[must_use]
117    pub fn batch_past_offset_bound(&self, partition: &PartitionRef, batch_base: Offset) -> bool {
118        krabka_verified::restore_batch_past_offset_bound(
119            batch_base.0,
120            self.offset_bound(partition).map(|bound| bound.0),
121        )
122    }
123
124    /// Decide the fate of one archived batch.
125    ///
126    /// The caller applies [`Self::batch_past_offset_bound`] first and stops
127    /// walking the partition once a batch's base offset is past it. A batch
128    /// that straddles the inclusive offset bound is still decoded here so only
129    /// its records at or below the bound survive.
130    ///
131    /// # Errors
132    ///
133    /// While evaluating an applicable record predicate, returns an integrity
134    /// error if a record fails to parse, lies outside the batch's declared
135    /// offset range, or its absolute offset or timestamp cannot be represented.
136    pub fn decide_batch(
137        &self,
138        partition: &PartitionRef,
139        batch: &RecordBatchBorrowed<'_>,
140    ) -> Result<BatchDecision, RestoreError> {
141        // A control batch carries a transaction commit or abort marker, not
142        // operator data -- no exclude predicate is about transaction
143        // bookkeeping, and an operator writing `--exclude-producer-id` for a
144        // producer's data batches has no way to know, or reason to expect,
145        // that the same id also names the marker that closes out that
146        // producer's transaction. Filtering or emptying a control batch would
147        // silently corrupt the restored partition's transaction state, which
148        // is a worse outcome than retaining marker metadata past a timestamp
149        // bound. The caller still stops before a control batch whose base is
150        // past `--to-offset`; a valid Kafka control batch carries its one
151        // marker at that base.
152        if batch.attributes().is_control_batch() {
153            return Ok(BatchDecision::Keep);
154        }
155
156        if self.keeps_everything_in(partition) {
157            return Ok(BatchDecision::Keep);
158        }
159
160        let header = batch.header();
161        let producer_id = ProducerId(header.producer_id.get());
162
163        let mut saw_keep = false;
164        let mut saw_drop = false;
165        for parsed in batch {
166            let record = parsed?;
167            let RecordCoordinates {
168                offset,
169                timestamp_ms,
170            } = record_coordinates(header, &record)?;
171            match self.decide_record(partition, offset, timestamp_ms, producer_id, &record) {
172                RecordDecision::Keep => saw_keep = true,
173                RecordDecision::Drop => saw_drop = true,
174            }
175            // Once both are seen the answer is Filter regardless of what the
176            // rest of the batch holds, and `decide_record` runs again per
177            // record during the actual rewrite either way, so nothing here is
178            // worth precomputing.
179            if saw_keep && saw_drop {
180                return Ok(batch_decision(saw_keep, saw_drop));
181            }
182        }
183        // No drops, including an empty batch, stays byte-identical.
184        Ok(batch_decision(saw_keep, saw_drop))
185    }
186
187    /// Whether no exclude predicate could possibly touch a record in
188    /// `partition`, so [`Self::decide_batch`] can answer
189    /// [`BatchDecision::Keep`] without decoding a single record. This is the
190    /// common case for most restores, which run with no exclude flags at
191    /// all.
192    pub(crate) fn keeps_everything_in(&self, partition: &PartitionRef) -> bool {
193        self.to_timestamp.is_none()
194            && !self.to_offset.contains_key(partition)
195            && self.exclude_key.is_empty()
196            && self.exclude_header.is_empty()
197            && self.exclude_producer_id.is_empty()
198            && !self.exclude_offset.contains_key(partition)
199    }
200
201    /// Decide the fate of one record inside a batch that must be re-encoded.
202    ///
203    /// The record is dropped when any predicate matches, OR-combined: its
204    /// absolute `offset` falls in an `--exclude-offset` range for
205    /// `partition`, `producer_id` is named by `--exclude-producer-id`,
206    /// `record.key` matches an `--exclude-key` pattern, or one of `record`'s
207    /// headers matches an `--exclude-header NAME=REGEX` pattern by name and
208    /// value. A record is also dropped when it is above the partition's
209    /// inclusive `--to-offset` bound or at or after the global exclusive
210    /// `--to-timestamp` bound. A record with `key: None` never matches a key
211    /// pattern, and a header whose value is absent never matches a header
212    /// pattern; neither is treated as matching an empty string.
213    ///
214    /// `--exclude-key` and `--exclude-header` match the raw bytes only when
215    /// those bytes are valid UTF-8. `regex::Regex::is_match` takes `&str`,
216    /// and a byte string an operator's regex cannot even be checked against
217    /// must not silently match it merely because it fails to decode, so
218    /// invalid UTF-8 never matches.
219    #[must_use]
220    pub fn decide_record(
221        &self,
222        partition: &PartitionRef,
223        offset: Offset,
224        timestamp_ms: i64,
225        producer_id: ProducerId,
226        record: &RecordBorrowed<'_>,
227    ) -> RecordDecision {
228        let exclusions = krabka_verified::RestoreExclusions {
229            producer: self.exclude_producer_id.contains(&producer_id),
230            offset: self.exclude_offset.get(partition).is_some_and(|ranges| {
231                ranges
232                    .iter()
233                    .any(|range| offset >= range.start && offset < range.end_exclusive)
234            }),
235            content: krabka_verified::RestoreContentExclusions {
236                key: record.key.is_some_and(|key| {
237                    self.exclude_key
238                        .iter()
239                        .any(|pattern| matches_utf8(pattern, key))
240                }),
241                header: record.headers.iter().any(|header| {
242                    self.exclude_header.iter().any(|candidate| {
243                        candidate.name == header.key
244                            && header
245                                .value
246                                .is_some_and(|value| matches_utf8(&candidate.pattern, value))
247                    })
248                }),
249            },
250        };
251        if krabka_verified::restore_record_selected(
252            offset.0,
253            self.offset_bound(partition).map(|bound| bound.0),
254            timestamp_ms,
255            self.to_timestamp,
256            exclusions,
257        ) {
258            RecordDecision::Keep
259        } else {
260            RecordDecision::Drop
261        }
262    }
263}
264
265fn batch_decision(saw_keep: bool, saw_drop: bool) -> BatchDecision {
266    match krabka_verified::restore_batch_filter_decision(saw_keep, saw_drop) {
267        krabka_verified::RestoreFilterDecision::Keep => BatchDecision::Keep,
268        krabka_verified::RestoreFilterDecision::Empty => BatchDecision::Empty,
269        krabka_verified::RestoreFilterDecision::Filter => BatchDecision::Filter,
270    }
271}
272
273/// The kernel's view of a batch header's offset and timestamp placement.
274fn batch_frame(header: &RecordBatchHeader) -> krabka_verified::RestoreBatchFrame {
275    krabka_verified::RestoreBatchFrame {
276        base_offset: header.base_offset.get(),
277        last_offset_delta: header.last_offset_delta.get(),
278        timestamp_type: timestamp_type(Attributes(header.attributes.get())),
279        base_timestamp: header.base_timestamp.get(),
280        max_timestamp: header.max_timestamp.get(),
281    }
282}
283
284/// Map the attributes' timestamp-type bit onto the kernel's enum.
285pub fn timestamp_type(attributes: Attributes) -> krabka_verified::RestoreTimestampType {
286    match attributes.timestamp_type() {
287        TimestampType::CreateTime => krabka_verified::RestoreTimestampType::CreateTime,
288        TimestampType::LogAppendTime => krabka_verified::RestoreTimestampType::LogAppendTime,
289    }
290}
291
292/// One decoded record's absolute offset and the timestamp Kafka reports for it.
293pub struct RecordCoordinates {
294    /// The record's absolute offset.
295    pub(crate) offset: Offset,
296    /// The record's timestamp, in milliseconds.
297    pub(crate) timestamp_ms: i64,
298}
299
300/// A half-open `--exclude-offset` range within one partition.
301#[derive(Debug, Clone, Copy)]
302struct ExcludedOffsets {
303    start: Offset,
304    end_exclusive: Offset,
305}
306
307/// Validate and adapt one decoded record's absolute offset and the timestamp
308/// Kafka reports for it at the verified boundary: `base_timestamp +
309/// timestamp_delta` under `CreateTime`, the batch `max_timestamp` under
310/// `LogAppendTime`.
311pub fn record_coordinates(
312    header: &RecordBatchHeader,
313    record: &RecordBorrowed<'_>,
314) -> Result<RecordCoordinates, RestoreError> {
315    let frame = batch_frame(header);
316    krabka_verified::restore_record_coordinates(frame, krabka_verified::RestoreRecordDeltas {
317        offset_delta: record.offset_delta,
318        timestamp_delta: record.timestamp_delta,
319    })
320    .map(|(offset, timestamp_ms)| RecordCoordinates {
321        offset: Offset(offset),
322        timestamp_ms,
323    })
324    .ok_or_else(|| {
325        RecordsError::RecordParse(format!(
326            "record coordinates are outside the batch: {frame:?}, record offset delta {}, \
327             record timestamp delta {}",
328            record.offset_delta, record.timestamp_delta,
329        ))
330        .into()
331    })
332}
333
334/// Whether `bytes` is valid UTF-8 and `pattern` matches the decoded text.
335fn matches_utf8(pattern: &Regex, bytes: &[u8]) -> bool {
336    str::from_utf8(bytes).is_ok_and(|text| pattern.is_match(text))
337}