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}