Skip to main content

krabka_restore/materialize/
segment.rs

1//! Writing one verified segment into its target partition, under the bound.
2//!
3//! This module owns the log side of a restore: it opens the target partition's
4//! [`Log`], aligns its end offset to the segment's base offset, walks the
5//! archived batches in order, and appends what
6//! [`prepare_batch`](super::prepare::prepare_batch) prepared for each one. It
7//! also defines [`SegmentOutcome`], the per-segment tally the report renders.
8//! Under `--dry-run` it does the same work and opens no log at all.
9
10use {
11    super::prepare::{BatchTally, PrepareBatchSetup, PreparedBatch, prepare_batch},
12    crate::{
13        args::{PartitionRef, RestoreArgs},
14        bound::Predicates,
15        error::RestoreError,
16        verify::VerifiedSegment,
17    },
18    krabka_ids::Offset,
19    krabka_log::Log,
20    krabka_protocol::records::RecordBatchBorrowed,
21    krabka_remote_storage::TopicIdPartition,
22    serde::Serialize,
23    uuid::Uuid,
24};
25
26/// What writing one segment into the target produced.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
28pub struct SegmentOutcome {
29    /// The per-copy segment id in the archive.
30    pub segment_id: Uuid,
31    /// First offset the written segment holds.
32    pub base_offset: Offset,
33    /// Last offset the written segment holds.
34    pub end_offset: Offset,
35    /// Batches written unchanged.
36    pub batches_kept: u64,
37    /// Batches re-encoded because the bound dropped some of their records.
38    pub batches_rewritten: u64,
39    /// Batches written as a bare header because the bound excluded every
40    /// record. The offset range stays claimed; see
41    /// [`crate::bound::BatchDecision::Empty`].
42    pub batches_emptied: u64,
43    /// Records written.
44    pub records_kept: u64,
45    /// Records the bound dropped.
46    pub records_dropped: u64,
47    /// Bytes written into the target segment.
48    pub bytes_written: u64,
49}
50
51/// Write one verified segment into its target partition, under the bound.
52///
53/// # Errors
54///
55/// Returns [`RestoreError::Records`] when a batch will not re-encode, and
56/// [`RestoreError::Log`] or [`RestoreError::Io`] when the target rejects the
57/// write.
58pub async fn write_segment(
59    args: &RestoreArgs,
60    partition: &TopicIdPartition,
61    segment: &VerifiedSegment,
62    predicates: &Predicates,
63) -> Result<SegmentOutcome, RestoreError> {
64    write_segment_after_skip(args, partition, segment, predicates, false).await
65}
66
67/// Write a segment, allowing a forward offset gap only after discovery skipped a corrupt predecessor.
68pub async fn write_segment_after_skip(
69    args: &RestoreArgs,
70    partition: &TopicIdPartition,
71    segment: &VerifiedSegment,
72    predicates: &Predicates,
73    skipped_predecessor: bool,
74) -> Result<SegmentOutcome, RestoreError> {
75    let partition_ref = PartitionRef {
76        topic: partition.topic.clone(),
77        partition: partition.partition,
78    };
79    let mut outcome = SegmentOutcome {
80        segment_id: segment.facts.segment_id,
81        base_offset: segment.facts.base_offset,
82        end_offset: segment.facts.end_offset,
83        batches_kept: 0,
84        batches_rewritten: 0,
85        batches_emptied: 0,
86        records_kept: 0,
87        records_dropped: 0,
88        bytes_written: 0,
89    };
90
91    // Every batch in this segment starts past the keep bound: an earlier
92    // segment already ended the partition's restored history, so nothing
93    // here survives and the target log is never opened for it.
94    if predicates.batch_past_offset_bound(&partition_ref, segment.facts.base_offset) {
95        return Ok(outcome);
96    }
97
98    let mut log = super::open_partition_log(args, &partition.topic, partition.partition)?;
99    if let Some(log) = log.as_mut() {
100        align_log_to_segment(log, segment, partition, skipped_predecessor)?;
101    }
102
103    let raw: &[u8] = &segment.log;
104    let mut pos = 0usize;
105    while let Some(mut cursor) = raw.get(pos..).filter(|rest| !rest.is_empty()) {
106        let remaining_before = cursor.len();
107        // `<_>::default()` stands in for `RecordDecompressionPolicy::default()`.
108        // `krabka-compression`, which defines that type, is only a transitive
109        // dependency here (through `krabka-protocol`), so this crate has no
110        // path to name it; the default policy this crate would otherwise ask
111        // for by name is exactly what a bare `decode_borrow_with_policy`
112        // caller already gets through inference here.
113        let batch = RecordBatchBorrowed::decode_borrow_with_policy(&mut cursor, <_>::default())?;
114        let consumed = remaining_before - cursor.len();
115        let header = batch.header();
116        let batch_base_offset = Offset(header.base_offset.get());
117
118        if predicates.batch_past_offset_bound(&partition_ref, batch_base_offset) {
119            break;
120        }
121
122        let batch_bytes = segment.log.slice(pos..pos + consumed);
123        let records_in_batch = nonneg_u64(header.records_count.get());
124
125        let (mut prepared, tally) = prepare_batch(
126            PrepareBatchSetup::builder()
127                .partition_ref(&partition_ref)
128                .predicates(predicates)
129                .batch(&batch)
130                .batch_bytes(batch_bytes)
131                .records_in_batch(records_in_batch)
132                .build(),
133        )?;
134
135        let last_offset_delta = prepared.last_offset_delta();
136        outcome.bytes_written += bytes_len(prepared.encoded_len());
137        outcome.end_offset = batch_base_offset + i64::from(last_offset_delta);
138
139        if let Some(log) = log.as_mut() {
140            match &mut prepared {
141                PreparedBatch::Verbatim(verbatim) => {
142                    log.append_verbatim_at(verbatim, batch_base_offset)?;
143                }
144                PreparedBatch::Owned { batch, .. } => {
145                    log.append_at(batch, batch_base_offset)?;
146                }
147            }
148        }
149
150        match tally {
151            BatchTally::Kept => {
152                outcome.batches_kept += 1;
153                outcome.records_kept += records_in_batch;
154            }
155            BatchTally::Rewritten { kept, dropped } => {
156                outcome.batches_rewritten += 1;
157                outcome.records_kept += kept;
158                outcome.records_dropped += dropped;
159            }
160            BatchTally::Emptied => {
161                outcome.batches_emptied += 1;
162                outcome.records_dropped += records_in_batch;
163            }
164        }
165
166        pos += consumed;
167    }
168
169    if let Some(log) = log.as_mut() {
170        log.sync()?;
171    }
172
173    Ok(outcome)
174}
175
176/// Align `log`'s end offset to `segment`'s declared base offset before any of its batches are appended.
177///
178/// Ordinary segments must align. After `--continue-on-corrupt` skips a predecessor, a later segment may leave a forward hole: follower append preserves its original offsets without resetting the restored prefix. A fresh empty log alone may reset from zero to the first surviving base. Backward or unexplained gaps remain errors.
179///
180/// # Errors
181///
182/// Returns [`RestoreError::InvalidArgument`] when `log` already holds data that does not end where `segment` begins.
183fn align_log_to_segment(
184    log: &mut Log,
185    segment: &VerifiedSegment,
186    partition: &TopicIdPartition,
187    skipped_predecessor: bool,
188) -> Result<(), RestoreError> {
189    let leo = log.log_end_offset();
190    if leo == segment.facts.base_offset {
191        return Ok(());
192    }
193    if leo == Offset(0) {
194        log.reset_to(segment.facts.base_offset)?;
195        return Ok(());
196    }
197    if skipped_predecessor && leo < segment.facts.base_offset {
198        return Ok(());
199    }
200    Err(RestoreError::InvalidArgument(format!(
201        "segment {} of {}-{} starts at offset {}, but the target log already ends at {leo}; \
202         segments must be written in contiguous base-offset order",
203        segment.facts.segment_id, partition.topic, partition.partition, segment.facts.base_offset,
204    )))
205}
206
207/// Convert a header's `records_count` (or any small non-negative `i32` count) to `u64`, treating a corrupt-but-CRC-valid negative count as zero rather than panicking. [`crate::verify::verify_segment`] has already checked every batch's CRC by the time this module sees it.
208fn nonneg_u64(count: i32) -> u64 { u64::try_from(count.max(0)).unwrap_or(0) }
209
210/// Convert a byte length to `u64` for [`SegmentOutcome::bytes_written`].
211fn bytes_len(len: usize) -> u64 { u64::try_from(len).unwrap_or(u64::MAX) }
212
213#[cfg(test)]
214mod tests {
215    mod retry;
216
217    use {
218        super::*,
219        crate::materialize::test_support::{
220            args_from, batch, materialization_context, record, record_with_key, reopen_orders,
221            topic_id_partition, verified_segment,
222        },
223        assert2::check,
224        krabka_log::{LogConfig, name},
225        krabka_protocol::records::{Attributes, RecordBatch},
226        std::{fs, slice},
227    };
228
229    // Preserve each archived batch's span even when filtering empties it.
230    fn read_restored_pair(log: &Log) -> krabka_log::ReadOutput {
231        let read = read_from_start(log);
232        check!(read.batches.len() == 2);
233        check!(read.batches[0].base_offset == 0);
234        check!(read.batches[0].last_offset_delta == 2);
235        read
236    }
237
238    fn read_from_start(log: &Log) -> krabka_log::ReadOutput {
239        log.read(Offset(0), LogConfig::default().segment_size)
240            .expect("read back")
241    }
242
243    #[tokio::test]
244    async fn keep_only_batches_round_trip_verbatim_at_their_original_offsets() {
245        let (_target, args, partition, predicates) = materialization_context(&[]);
246
247        let batches = vec![
248            batch(0, vec![record(0, "a"), record(1, "b"), record(2, "c")]),
249            batch(3, vec![record(0, "d"), record(1, "e")]),
250        ];
251        let segment = verified_segment(0, &batches);
252
253        let outcome = write_segment(&args, &partition, &segment, &predicates)
254            .await
255            .expect("write_segment");
256
257        check!(outcome.batches_kept == 2);
258        check!(outcome.batches_rewritten == 0);
259        check!(outcome.batches_emptied == 0);
260        check!(outcome.records_kept == 5);
261        check!(outcome.records_dropped == 0);
262        check!(outcome.end_offset == Offset(4));
263
264        let log = reopen_orders(&args);
265        let read = read_from_start(&log);
266        check!(read.batches == batches);
267    }
268
269    #[tokio::test]
270    async fn an_emptied_batch_becomes_a_bare_header_and_the_next_batch_still_appends() {
271        let (_target, args, partition, predicates) =
272            materialization_context(&["--exclude-key", "^drop$"]);
273
274        let excluded = vec![
275            record_with_key(0, "drop"),
276            record_with_key(1, "drop"),
277            record_with_key(2, "drop"),
278        ];
279        let batch_a = batch(0, excluded);
280        let batch_b = batch(3, vec![record(0, "kept")]);
281        let segment = verified_segment(0, &[batch_a, batch_b]);
282
283        let outcome = write_segment(&args, &partition, &segment, &predicates)
284            .await
285            .expect("write_segment");
286
287        check!(outcome.batches_emptied == 1);
288        check!(outcome.batches_kept == 1);
289        check!(outcome.records_dropped == 3);
290        check!(outcome.records_kept == 1);
291        check!(outcome.end_offset == Offset(3));
292
293        let log = reopen_orders(&args);
294        // The bare header's full archived span (offsets 0..=2) is still
295        // claimed, so log_end_offset already accounts for batch_b at offset 3.
296        check!(log.log_end_offset() == Offset(4));
297
298        let read = read_restored_pair(&log);
299        check!(read.batches[0].records.is_empty());
300        check!(read.batches[1].base_offset == 3);
301    }
302
303    #[tokio::test]
304    async fn a_filtered_batch_keeps_the_survivors_original_absolute_offsets() {
305        let (_target, args, partition, predicates) =
306            materialization_context(&["--exclude-key", "^drop$"]);
307
308        let batch_a = batch(0, vec![
309            record(0, "keep0"),
310            record_with_key(1, "drop"),
311            record(2, "keep2"),
312        ]);
313        let segment = verified_segment(0, slice::from_ref(&batch_a));
314
315        let outcome = write_segment(&args, &partition, &segment, &predicates)
316            .await
317            .expect("write_segment");
318
319        check!(outcome.batches_rewritten == 1);
320        check!(outcome.batches_kept == 0);
321        check!(outcome.batches_emptied == 0);
322        check!(outcome.records_kept == 2);
323        check!(outcome.records_dropped == 1);
324        check!(outcome.end_offset == Offset(2));
325
326        let log = reopen_orders(&args);
327        let read = read_from_start(&log);
328        let expected = RecordBatch {
329            last_offset_delta: 2,
330            records: vec![record(0, "keep0"), record(2, "keep2")],
331            ..batch_a
332        };
333        check!(read.batches == vec![expected]);
334    }
335
336    #[tokio::test]
337    async fn a_filtered_batch_that_drops_its_trailing_record_does_not_strand_the_next_batch() {
338        // The archive is contiguous by construction: `batch_b`'s base offset
339        // (3) is exactly where `batch_a`'s ORIGINAL span (offsets 0..=2)
340        // ends. If `write_segment` used `filter_batch`'s recomputed
341        // `last_offset_delta` (which shrinks to the highest SURVIVING
342        // delta -- 1, once offset 2 is excluded) instead of the archived
343        // value, `batch_a` would claim only offsets 0..=1 and
344        // `log.log_end_offset()` after it would be 2, not 3, so the offset
345        // space of the archive would shrink. This is the regression a Filter
346        // decision that happens to exclude a batch's LAST record must not
347        // reintroduce.
348        let (_target, args, partition, predicates) =
349            materialization_context(&["--exclude-key", "^drop$"]);
350
351        let batch_a = batch(0, vec![record(0, "keep0"), record_with_key(2, "drop")]);
352        let batch_b = batch(3, vec![record(0, "keep3")]);
353        let segment = verified_segment(0, &[batch_a.clone(), batch_b.clone()]);
354
355        let outcome = write_segment(&args, &partition, &segment, &predicates)
356            .await
357            .expect("write_segment must not fail with an offset mismatch");
358
359        check!(outcome.batches_rewritten == 1);
360        check!(outcome.batches_kept == 1);
361        check!(outcome.records_kept == 2);
362        check!(outcome.records_dropped == 1);
363        check!(outcome.end_offset == Offset(3));
364
365        let log = reopen_orders(&args);
366        let read = read_restored_pair(&log);
367        check!(read.batches[0].records == vec![record(0, "keep0")]);
368        check!(read.batches[1].base_offset == 3);
369        check!(read.batches[1].records == vec![record(0, "keep3")]);
370    }
371
372    #[tokio::test]
373    async fn a_skipped_predecessor_preserves_filtered_prefix_and_compacted_suffix_offsets() {
374        let (_target, args, partition, predicates) =
375            materialization_context(&["--exclude-key", "^drop$"]);
376        let prefix = batch(0, vec![record(0, "head"), record_with_key(2, "drop")]);
377        write_segment(
378            &args,
379            &partition,
380            &verified_segment(0, slice::from_ref(&prefix)),
381            &predicates,
382        )
383        .await
384        .unwrap();
385        let suffix = batch(20, vec![record(2, "tail")]);
386        let segment = verified_segment(20, slice::from_ref(&suffix));
387        check!(
388            write_segment(&args, &partition, &segment, &predicates)
389                .await
390                .is_err()
391        );
392        write_segment_after_skip(&args, &partition, &segment, &predicates, true)
393            .await
394            .unwrap();
395        let restored = reopen_orders(&args);
396        check!(restored.log_end_offset() == Offset(23));
397        check!(
398            restored
399                .read(Offset(0), LogConfig::default().segment_size)
400                .unwrap()
401                .batches
402                == vec![
403                    RecordBatch {
404                        records: vec![record(0, "head")],
405                        ..prefix
406                    },
407                    suffix,
408                ]
409        );
410    }
411
412    #[tokio::test]
413    async fn a_second_segment_continues_without_resetting_the_first() {
414        let (_target, args, partition, predicates) = materialization_context(&[]);
415
416        let batch_1 = batch(0, vec![record(0, "a"), record(1, "b")]);
417        let segment_1 = verified_segment(0, slice::from_ref(&batch_1));
418        let outcome_1 = write_segment(&args, &partition, &segment_1, &predicates)
419            .await
420            .expect("segment 1");
421        check!(outcome_1.end_offset == Offset(1));
422
423        let batch_2 = batch(2, vec![record(0, "c")]);
424        let segment_2 = verified_segment(2, slice::from_ref(&batch_2));
425        let outcome_2 = write_segment(&args, &partition, &segment_2, &predicates)
426            .await
427            .expect("segment 2");
428        check!(outcome_2.end_offset == Offset(2));
429
430        let log = reopen_orders(&args);
431        let read = log
432            .read(Offset(0), LogConfig::default().segment_size)
433            .expect("read back");
434        // The first segment's data survives the second call: no destructive
435        // reset happened in between.
436        check!(read.batches == vec![batch_1, batch_2]);
437    }
438
439    #[tokio::test]
440    async fn the_first_segment_resets_an_empty_log_to_a_nonzero_base() {
441        let (_target, args, partition, predicates) = materialization_context(&[]);
442
443        let batches = vec![batch(1000, vec![record(0, "x"), record(1, "y")])];
444        let segment = verified_segment(1000, &batches);
445
446        let outcome = write_segment(&args, &partition, &segment, &predicates)
447            .await
448            .expect("write_segment");
449        check!(outcome.base_offset == Offset(1000));
450        check!(outcome.end_offset == Offset(1001));
451
452        let log = reopen_orders(&args);
453        check!(log.log_start_offset() == Offset(1000));
454        check!(log.log_end_offset() == Offset(1002));
455        let read = log
456            .read(Offset(1000), LogConfig::default().segment_size)
457            .expect("read back");
458        check!(read.batches == batches);
459    }
460
461    #[tokio::test]
462    async fn dry_run_matches_a_real_run_but_writes_nothing() {
463        let mut compressed = batch(0, vec![record(0, "a"), record_with_key(1, "drop")]);
464        // Kafka compression attribute 1 is gzip. A compressed rewrite's
465        // encoded size cannot be predicted by `RecordBatch::encoded_len`, so
466        // this pins the pre-encode path used by both real and dry runs.
467        compressed.attributes = Attributes(1);
468        let batches = vec![compressed, batch(2, vec![record(0, "c")])];
469        let partition = topic_id_partition("orders", 0);
470        // One shared fixture: `SegmentOutcome::segment_id` echoes
471        // `segment.facts.segment_id`, and that id is random per fixture, so
472        // both runs must see the exact same `VerifiedSegment` for their
473        // outcomes to compare equal on that field too.
474        let segment = verified_segment(0, &batches);
475
476        let real_target = tempfile::tempdir().expect("tempdir");
477        let real_args = args_from(&["--exclude-key", "^drop$"], real_target.path());
478        let real_predicates = Predicates::from_args(&real_args).expect("predicates");
479        let real_outcome = write_segment(&real_args, &partition, &segment, &real_predicates)
480            .await
481            .expect("real write");
482        let real_dir = name::partition_dir(&real_args.target.log_dir, "orders", 0);
483        let log_bytes: u64 = fs::read_dir(real_dir)
484            .expect("target partition")
485            .map(|entry| entry.expect("directory entry").path())
486            .filter(|path| path.extension().is_some_and(|extension| extension == "log"))
487            .map(|path| fs::metadata(path).expect("log metadata").len())
488            .sum();
489        check!(real_outcome.bytes_written == log_bytes);
490
491        let dry_target = tempfile::tempdir().expect("tempdir");
492        let mut dry_args = args_from(&["--exclude-key", "^drop$"], dry_target.path());
493        dry_args.dry_run = true;
494        let dry_predicates = Predicates::from_args(&dry_args).expect("predicates");
495        let dry_outcome = write_segment(&dry_args, &partition, &segment, &dry_predicates)
496            .await
497            .expect("dry write");
498
499        check!(dry_outcome == real_outcome);
500        check!(!dry_target.path().join("orders-0").exists());
501        check!(real_target.path().join("orders-0").exists());
502    }
503}