Skip to main content

krabka_restore/
discover.rs

1//! The archive inventory: what the archive holds, before anything is read.
2//!
3//! This module owns the scan. It streams the archive listing under the
4//! operator's prefix, decodes every key with the KIP-405 archive key codec
5//! (`parse_partition_dir_name` and `parse_segment_file_name`), and groups
6//! the result into one [`SegmentInventory`] per archived segment, with the
7//! object key and size of each artifact that is present. The listing is
8//! consumed as it arrives, so the scan holds one entry per archived segment
9//! rather than one per object: an archive of tens of millions of keys is
10//! exactly the archive a restore runs against. It keeps the keys it cannot
11//! attribute rather than discarding them, because an unrecognized key is the
12//! first sign that `--archive-prefix` points at the wrong tree, and it keeps a
13//! bounded sample with a count of the rest, because a wrong prefix makes every
14//! key in the bucket unrecognized. When
15//! `--rlmm-snapshot` is given, this module reconciles the scan against it and
16//! reports a disagreement instead of choosing a winner; without the snapshot a
17//! segment the old cluster had marked for deletion looks exactly like a live
18//! one. This module decides nothing about completeness or integrity. It
19//! reports presence, and the verify stage judges it.
20//!
21//! This file holds the scan itself and the shapes it produces. The
22//! snapshot comparison, which is the only part with a second source of truth to
23//! weigh, lives beside it in [`self::reconcile`].
24
25use {
26    futures_util::stream::TryStreamExt as _,
27    krabka_ids::Offset,
28    krabka_remote_storage::{
29        IndexType, LOG_FILE_SUFFIX, TopicIdPartition, parse_partition_dir_name,
30        parse_segment_file_name,
31    },
32    object_store::{ObjectMeta, path::Path},
33    std::collections::HashMap,
34    uuid::Uuid,
35};
36
37mod reconcile;
38
39#[cfg(test)]
40mod test_support;
41#[cfg(test)]
42mod tests;
43
44pub use self::reconcile::{load_snapshot, reconcile_authenticated_snapshot};
45use {
46    self::reconcile::reconcile_with_snapshot,
47    crate::{
48        args::{RestoreArgs, offset_partitions},
49        backend::ArchiveStore,
50        error::RestoreError,
51    },
52};
53
54/// One artifact of one archived segment, as the scan found it.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct ArchiveObject {
57    /// The full object key, prefix included.
58    pub key: Path,
59    /// The object size the store reports. It stays the store's own `u64`,
60    /// because it is compared against byte positions inside the object.
61    pub size: u64,
62}
63
64/// One archived segment and the artifacts the archive holds for it.
65///
66/// Kafka copies the log, the offset index, the time index, the producer
67/// snapshot, and the leader-epoch checkpoint together. The transaction index
68/// is absent for a segment with no aborted transaction, so its absence is not
69/// by itself a torn copy.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct SegmentInventory {
72    /// The per-copy segment id the archive names the artifacts with.
73    pub segment_id: Uuid,
74    /// First offset the segment holds, as the key encodes it.
75    pub base_offset: Offset,
76    /// The segment data.
77    pub log: Option<ArchiveObject>,
78    /// The sparse offset index.
79    pub offset_index: Option<ArchiveObject>,
80    /// The sparse time index.
81    pub time_index: Option<ArchiveObject>,
82    /// The producer id snapshot.
83    pub producer_snapshot: Option<ArchiveObject>,
84    /// The leader-epoch checkpoint.
85    pub leader_epoch: Option<ArchiveObject>,
86    /// The aborted-transaction index. It is optional.
87    pub transaction_index: Option<ArchiveObject>,
88}
89
90impl SegmentInventory {
91    /// Present artifacts in Kafka's log, offset, time, producer, epoch and transaction order.
92    pub(crate) fn artifacts(&self) -> impl Iterator<Item = &ArchiveObject> {
93        [
94            self.log.as_ref(),
95            self.offset_index.as_ref(),
96            self.time_index.as_ref(),
97            self.producer_snapshot.as_ref(),
98            self.leader_epoch.as_ref(),
99            self.transaction_index.as_ref(),
100        ]
101        .into_iter()
102        .flatten()
103    }
104}
105
106/// One partition of the archive.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct PartitionInventory {
109    /// The partition the archive directory names.
110    pub partition: TopicIdPartition,
111    /// Every segment found for the partition, in base-offset order.
112    pub segments: Vec<SegmentInventory>,
113}
114
115impl PartitionInventory {
116    /// Order partitions by topic name and index, preserving scan order for equal keys.
117    pub(crate) fn sort_by_topic_and_partition(partitions: &mut [Self]) {
118        partitions.sort_by(|a, b| {
119            (a.partition.topic.as_str(), a.partition.partition)
120                .cmp(&(b.partition.topic.as_str(), b.partition.partition))
121        });
122    }
123}
124
125/// How many unattributable keys the scan keeps.
126///
127/// Past this many the scan counts and drops, because the sample is diagnostic:
128/// an operator who pointed `--archive-prefix` at the wrong tree learns it from
129/// the first handful of keys and from the total, not from the millionth key.
130pub const UNRECOGNIZED_SAMPLE_LIMIT: usize = 64;
131
132/// The keys the scan could not attribute to a segment.
133///
134/// Bounded on purpose: the count is the whole truth, and the sample is the
135/// part worth printing.
136#[derive(Debug, Clone, Default, PartialEq, Eq)]
137pub struct UnrecognizedKeys {
138    /// The first [`UNRECOGNIZED_SAMPLE_LIMIT`] such keys, in listing order.
139    pub sample: Vec<Path>,
140    /// Keys past the sample, counted but not kept.
141    pub omitted: u64,
142}
143
144impl UnrecognizedKeys {
145    /// Whether the scan attributed every key it saw.
146    #[must_use]
147    pub fn is_empty(&self) -> bool { self.sample.is_empty() && self.omitted == 0 }
148
149    /// Every unattributable key the scan saw, sample and omitted together.
150    #[must_use]
151    pub fn total(&self) -> u64 { self.sample.len() as u64 + self.omitted }
152
153    /// Record one key, keeping it only while the sample has room.
154    fn record(&mut self, key: Path) {
155        if self.sample.len() < UNRECOGNIZED_SAMPLE_LIMIT {
156            self.sample.push(key);
157        } else {
158            self.omitted += 1;
159        }
160    }
161}
162
163/// Everything the scan found.
164#[derive(Debug, Clone, Default, PartialEq, Eq)]
165pub struct ArchiveInventory {
166    /// The selected partitions, ordered by topic then partition index.
167    pub partitions: Vec<PartitionInventory>,
168    /// Keys the codec could not attribute to a segment.
169    pub unrecognized: UnrecognizedKeys,
170}
171
172impl ArchiveInventory {
173    /// Whether the scan found `topic`-`partition`.
174    ///
175    /// Compared by topic name and partition index, the pair a bound is
176    /// written against, not by the archive's internal topic id.
177    #[must_use]
178    pub fn holds(&self, topic: &str, partition: i32) -> bool {
179        holds_partition(&self.partitions, topic, partition)
180    }
181}
182
183/// Whether `partitions` names `topic`-`partition`, by topic name and
184/// partition index rather than by the archive's internal topic id.
185///
186/// Shared by [`ArchiveInventory::holds`] and by [`inventory`]'s own bound
187/// check, which runs against the partitions built so far before they are
188/// wrapped in an [`ArchiveInventory`].
189fn holds_partition(partitions: &[PartitionInventory], topic: &str, partition: i32) -> bool {
190    partitions
191        .iter()
192        .any(|entry| entry.partition.topic == topic && entry.partition.partition == partition)
193}
194
195/// One artifact of a segment, as [`inventory`] is still accumulating it.
196///
197/// Distinguishes the segment's `.log` data from its indexes so the artifact a
198/// key names can be assigned to the right [`SegmentInventory`] field without
199/// an intermediate `unrecognized` entry for a key whose suffix is simply not
200/// yet known at the point the accumulator slot is chosen.
201#[derive(Debug, Clone, Copy)]
202enum Artifact {
203    /// The segment's `.log` data.
204    Log,
205    /// One of the segment's indexes.
206    Index(IndexType),
207}
208
209/// Identifies a partition while [`inventory`] accumulates it: the topic id and
210/// the partition index, without the topic name that [`PartitionAccum`] holds.
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
212struct PartitionIdentity {
213    topic_id: Uuid,
214    partition: i32,
215}
216
217/// Segments accumulated so far for one partition, keyed by segment id so an
218/// artifact found later for a segment already seen joins the same entry.
219#[derive(Debug, Default)]
220struct PartitionAccum {
221    /// The topic name, as the partition directory names it.
222    topic: String,
223    /// Segments seen so far, keyed by segment id.
224    segments: HashMap<Uuid, SegmentInventory>,
225}
226
227/// A [`SegmentInventory`] with every artifact absent, the accumulator's
228/// starting point for a segment id seen for the first time.
229fn empty_segment(segment_id: Uuid, base_offset: Offset) -> SegmentInventory {
230    SegmentInventory {
231        segment_id,
232        base_offset,
233        log: None,
234        offset_index: None,
235        time_index: None,
236        producer_snapshot: None,
237        leader_epoch: None,
238        transaction_index: None,
239    }
240}
241
242/// Fold one listed object into the accumulator, or record its key as
243/// unattributable.
244///
245/// This is the whole per-object step of [`inventory`]. It is a function rather
246/// than a loop body so the scan's state is exactly its arguments: everything
247/// that survives one object is either partition-shaped or a bounded sample.
248fn absorb(
249    object: ObjectMeta,
250    root: &Path,
251    args: &RestoreArgs,
252    accum: &mut HashMap<PartitionIdentity, PartitionAccum>,
253    unrecognized: &mut UnrecognizedKeys,
254) {
255    // Collected to owned `String`s immediately, so the parsed pieces
256    // below never borrow `object.location` and it stays free to move
257    // into `unrecognized` or clone into an `ArchiveObject` right after.
258    let Some(relative) = object.location.prefix_match(root) else {
259        unrecognized.record(object.location);
260        return;
261    };
262    let relative: Vec<String> = relative.map(|part| part.as_ref().to_owned()).collect();
263    let [partition_dir, segment_file] = relative.as_slice() else {
264        unrecognized.record(object.location);
265        return;
266    };
267
268    let Some(dir) = parse_partition_dir_name(partition_dir) else {
269        unrecognized.record(object.location);
270        return;
271    };
272    let Some(file) = parse_segment_file_name(segment_file) else {
273        unrecognized.record(object.location);
274        return;
275    };
276
277    if !args.selects_topic(&dir.topic) {
278        unrecognized.record(object.location);
279        return;
280    }
281
282    let artifact = if file.suffix == LOG_FILE_SUFFIX {
283        Artifact::Log
284    } else if let Some(index_type) = IndexType::from_suffix(file.suffix) {
285        Artifact::Index(index_type)
286    } else {
287        unrecognized.record(object.location);
288        return;
289    };
290
291    let archive_object = ArchiveObject {
292        key: object.location.clone(),
293        size: object.size,
294    };
295
296    let partition_accum = accum
297        .entry(PartitionIdentity {
298            topic_id: dir.topic_id,
299            partition: dir.partition,
300        })
301        .or_insert_with(|| PartitionAccum {
302            topic: dir.topic.clone(),
303            segments: HashMap::new(),
304        });
305    let segment_accum = partition_accum
306        .segments
307        .entry(file.segment_id)
308        .or_insert_with(|| empty_segment(file.segment_id, Offset(file.base_offset)));
309
310    match artifact {
311        Artifact::Log => segment_accum.log = Some(archive_object),
312        Artifact::Index(IndexType::Offset) => segment_accum.offset_index = Some(archive_object),
313        Artifact::Index(IndexType::Timestamp) => {
314            segment_accum.time_index = Some(archive_object);
315        }
316        Artifact::Index(IndexType::ProducerSnapshot) => {
317            segment_accum.producer_snapshot = Some(archive_object);
318        }
319        Artifact::Index(IndexType::LeaderEpoch) => {
320            segment_accum.leader_epoch = Some(archive_object);
321        }
322        Artifact::Index(IndexType::Transaction) => {
323            segment_accum.transaction_index = Some(archive_object);
324        }
325    }
326}
327
328/// Scan `store` and build the inventory of the partitions `args` selects.
329///
330/// The listing is consumed as it arrives, so what the scan holds is the
331/// archive's segments and a bounded sample of its unattributable keys, never
332/// its objects.
333///
334/// # Errors
335///
336/// Returns [`RestoreError::ObjectStore`] when the listing fails,
337/// [`RestoreError::UnknownPartition`] when a `--to-offset` or
338/// `--exclude-offset` bound names a topic partition the scan did not find,
339/// [`RestoreError::EmptyArchive`] when no selected topic has a segment, and
340/// [`RestoreError::MetadataDisagreement`] when `--rlmm-snapshot` contradicts
341/// the scan.
342pub async fn inventory(
343    store: &ArchiveStore,
344    args: &RestoreArgs,
345) -> Result<ArchiveInventory, RestoreError> {
346    let root = store.root().unwrap_or(Path::ROOT);
347
348    let mut unrecognized = UnrecognizedKeys::default();
349    let mut accum: HashMap<PartitionIdentity, PartitionAccum> = HashMap::new();
350
351    let mut listing = store.ops().list_stream(store.root());
352    while let Some(object) = listing.try_next().await? {
353        absorb(object, &root, args, &mut accum, &mut unrecognized);
354    }
355    if unrecognized.omitted > 0 {
356        tracing::warn!(
357            total = unrecognized.total(),
358            kept = unrecognized.sample.len(),
359            "the archive holds keys this restore cannot attribute to a segment"
360        );
361    }
362
363    let mut partitions: Vec<PartitionInventory> = accum
364        .into_iter()
365        .map(|(key, partition_accum)| {
366            let mut segments: Vec<SegmentInventory> =
367                partition_accum.segments.into_values().collect();
368            segments.sort_by_key(|segment| segment.base_offset);
369            PartitionInventory {
370                partition: TopicIdPartition::new(
371                    key.topic_id,
372                    partition_accum.topic,
373                    key.partition,
374                ),
375                segments,
376            }
377        })
378        .collect();
379    PartitionInventory::sort_by_topic_and_partition(&mut partitions);
380
381    // Checked against the raw scan before RLMM reconciliation. When a diskless
382    // capture is present, `diskless::add_partitions` performs this check after
383    // merging its partitions with this inventory.
384    if args.archive.diskless_wal_capture.is_none() {
385        for partition in offset_partitions(args) {
386            if !holds_partition(&partitions, &partition.topic, partition.partition) {
387                return Err(RestoreError::UnknownPartition {
388                    topic: partition.topic.clone(),
389                    partition: partition.partition,
390                });
391            }
392        }
393    }
394
395    if !args.archive.worm_key_id.is_empty() && args.archive.rlmm_snapshot.is_some() {
396        tracing::info!(
397            "deferring RLMM reconciliation until the snapshot's signed digest is authenticated"
398        );
399    } else if let Some(snapshot_path) = &args.archive.rlmm_snapshot {
400        reconcile_with_snapshot(&mut partitions, args, snapshot_path)?;
401    }
402
403    if partitions.is_empty() && args.archive.diskless_wal_capture.is_none() {
404        return Err(RestoreError::EmptyArchive {
405            prefix: store.prefix().unwrap_or("").to_owned(),
406        });
407    }
408
409    Ok(ArchiveInventory {
410        partitions,
411        unrecognized,
412    })
413}