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}