Skip to main content

krabka_remote_storage/
storage_manager.rs

1//! The [`RemoteStorageManager`] SPI: copy / fetch / delete of segment data
2//! and indexes to and from the remote tier.
3
4use {
5    crate::{
6        error::RemoteStorageError,
7        metadata::{CustomMetadata, RemoteLogSegmentMetadata},
8    },
9    base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD},
10    bytes::Bytes,
11    std::{
12        iter,
13        path::{Path, PathBuf},
14    },
15    uuid::Uuid,
16};
17
18/// The kinds of index a segment carries alongside its `.log` data.
19///
20/// Mirrors Kafka's `RemoteStorageManager.IndexType`. A
21/// [`RemoteStorageManager`] copies all of these on
22/// [`RemoteStorageManager::copy_log_segment_data`] and serves any of them
23/// back on [`RemoteStorageManager::fetch_index`].
24///
25/// `suffix` is the Kafka `LocalTieredStorage` filename suffix of each index
26/// type. Its remote leader-epoch artifact uses `.leader_epoch_checkpoint`,
27/// distinct from a partition log's local `leader-epoch-checkpoint` file.
28/// `from_suffix` is its inverse. A reader that discovers an archive from the
29/// object store alone identifies each artifact by the suffix of its key, and
30/// every key that is not an index ends in [`LOG_FILE_SUFFIX`], so `None` means
31/// "not an index". `ALL` lists every index kind, so a caller that has to act
32/// on all of them -- such as dropping one segment's whole cache footprint --
33/// cannot silently miss one that is added later.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, krabka_macros::EnumStr)]
35#[enum_str(as_str = suffix, parse = from_suffix, all)]
36pub enum IndexType {
37    /// Sparse offset → byte-position index (`.index`).
38    #[enum_str(name = ".index")]
39    Offset,
40    /// Sparse timestamp → relative-offset index (`.timeindex`).
41    #[enum_str(name = ".timeindex")]
42    Timestamp,
43    /// Producer id snapshot (`.snapshot`).
44    #[enum_str(name = ".snapshot")]
45    ProducerSnapshot,
46    /// Leader-epoch checkpoint (`.leader_epoch_checkpoint` in Kafka's
47    /// `LocalTieredStorage`).
48    #[enum_str(name = ".leader_epoch_checkpoint")]
49    LeaderEpoch,
50    /// Aborted-transaction index (`.txnindex`). It is optional. A segment
51    /// with no aborted transactions has none.
52    #[enum_str(name = ".txnindex")]
53    Transaction,
54}
55
56/// Filename suffix of a segment's data, as distinct from one of its indexes.
57pub const LOG_FILE_SUFFIX: &str = ".log";
58
59/// Every artifact a segment copy can write, including optional indexes.
60pub fn segment_suffixes() -> impl Iterator<Item = &'static str> {
61    iter::once(LOG_FILE_SUFFIX).chain(IndexType::ALL.iter().map(|index| index.suffix()))
62}
63
64/// Character count of a Kafka Base64-rendered UUID.
65///
66/// The 16 raw bytes encode to 22 URL-safe unpadded Base64 characters. The
67/// width is fixed, which is what lets [`parse_partition_dir_name`] find the
68/// topic id in a name whose topic component may itself contain `-`.
69const KAFKA_UUID_LEN: usize = 22;
70
71/// Character count of the zero-padded base offset that starts a segment
72/// filename. `i64::MAX` is 19 digits, so the field never overflows its width.
73const BASE_OFFSET_LEN: usize = 20;
74
75/// Kafka renders UUIDs in URL-safe, unpadded Base64 for remote-tier paths.
76#[must_use]
77pub fn kafka_uuid(uuid: Uuid) -> String { URL_SAFE_NO_PAD.encode(uuid.as_bytes()) }
78
79/// Reads back a UUID that [`kafka_uuid`] rendered.
80///
81/// Returns `None` unless the input is URL-safe unpadded Base64 that decodes to
82/// exactly the 16 bytes of a UUID. The engine rejects a non-canonical encoding,
83/// so one UUID has one accepted spelling and the round trip is exact.
84#[must_use]
85pub fn decode_kafka_uuid(encoded: &str) -> Option<Uuid> {
86    let bytes: [u8; 16] = URL_SAFE_NO_PAD.decode(encoded).ok()?.try_into().ok()?;
87    Some(Uuid::from_bytes(bytes))
88}
89
90/// Directory name used by Kafka's `LocalTieredStorage` for a partition.
91#[must_use]
92pub fn partition_dir_name(metadata: &RemoteLogSegmentMetadata) -> String {
93    let tp = &metadata.remote_log_segment_id().topic_id_partition;
94    format!("{}-{}-{}", tp.topic, tp.partition, kafka_uuid(tp.topic_id))
95}
96
97/// The partition that a remote-tier directory name identifies.
98///
99/// A reader that discovers an archive from object storage alone has nothing but
100/// the keys, so the directory component of a key is where the partition comes
101/// from.
102#[derive(Debug, Clone, PartialEq, Eq, Hash)]
103pub struct PartitionDirName {
104    /// Topic name.
105    pub topic: String,
106    /// Partition index.
107    pub partition: i32,
108    /// Stable topic UUID, as assigned at topic creation.
109    pub topic_id: Uuid,
110}
111
112/// Reads back the partition that [`partition_dir_name`] rendered.
113///
114/// The name is parsed from the right, because a topic name may contain `-`
115/// while neither the partition index nor the fixed-width topic id can. Returns
116/// `None` when a component is absent or malformed, which includes a topic that
117/// is empty or that holds the `/` key separator.
118#[must_use]
119pub fn parse_partition_dir_name(name: &str) -> Option<PartitionDirName> {
120    let (head, topic_id) = name.split_at_checked(name.len().checked_sub(KAFKA_UUID_LEN)?)?;
121    let topic_id = decode_kafka_uuid(topic_id)?;
122    let (topic, partition) = head.strip_suffix('-')?.rsplit_once('-')?;
123    if topic.is_empty() || topic.contains('/') || !partition.bytes().all(|b| b.is_ascii_digit()) {
124        return None;
125    }
126    Some(PartitionDirName {
127        topic: topic.to_owned(),
128        partition: partition.parse().ok()?,
129        topic_id,
130    })
131}
132
133/// Filename used by Kafka's `LocalTieredStorage` for one segment artifact.
134#[must_use]
135pub fn segment_file_name(metadata: &RemoteLogSegmentMetadata, suffix: &str) -> String {
136    format!(
137        "{:020}-{}{}",
138        metadata.start_offset(),
139        kafka_uuid(metadata.remote_log_segment_id().id),
140        suffix
141    )
142}
143
144/// The segment artifact that a remote-tier filename identifies.
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
146pub struct SegmentFileName<'a> {
147    /// First offset the segment holds.
148    pub base_offset: i64,
149    /// Random per-segment UUID.
150    pub segment_id: Uuid,
151    /// Which artifact of the segment this is: [`LOG_FILE_SUFFIX`] for the data,
152    /// or an [`IndexType::suffix`] for one of the indexes.
153    pub suffix: &'a str,
154}
155
156/// Reads back the segment artifact that [`segment_file_name`] rendered.
157///
158/// Both leading components have a fixed width, so the name parses from the
159/// left even though the Base64 alphabet contains the `-` separator. Returns
160/// `None` when a component is absent or malformed.
161#[must_use]
162pub fn parse_segment_file_name(name: &str) -> Option<SegmentFileName<'_>> {
163    let (base_offset, rest) = name.split_at_checked(BASE_OFFSET_LEN)?;
164    if !base_offset.bytes().all(|b| b.is_ascii_digit()) {
165        return None;
166    }
167    let (segment_id, suffix) = rest.strip_prefix('-')?.split_at_checked(KAFKA_UUID_LEN)?;
168    Some(SegmentFileName {
169        base_offset: base_offset.parse().ok()?,
170        segment_id: decode_kafka_uuid(segment_id)?,
171        suffix,
172    })
173}
174
175/// The local files, and the in-memory leader-epoch bytes, that make up one
176/// log segment for copy to the remote tier.
177///
178/// Mirrors Kafka's `LogSegmentData`. `transaction_index` is optional; a
179/// segment with no aborted transactions has no `.txnindex` file.
180/// `producer_snapshot_index` is optional too so third-party callers can copy
181/// legacy segments that predate snapshots. krabka log exports provide one.
182/// The broker passes the leader-epoch index as bytes and not as a path,
183/// because it holds the relevant slice in memory at copy time.
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct LogSegmentData {
186    /// Path to the `.log` data file.
187    pub log_segment: PathBuf,
188    /// Path to the `.index` (offset index) file.
189    pub offset_index: PathBuf,
190    /// Path to the `.timeindex` file.
191    pub time_index: PathBuf,
192    /// Path to the `.txnindex` file, when present.
193    pub transaction_index: Option<PathBuf>,
194    /// Path to the producer-id `.snapshot` file, when present. Older segment
195    /// sources can omit it; krabka log exports always provide one.
196    pub producer_snapshot_index: Option<PathBuf>,
197    /// Serialized leader-epoch index bytes for this segment's offset range.
198    pub leader_epoch_index: Bytes,
199}
200
201/// One artifact of a segment copy: the key suffix it lands under and its source.
202pub struct SegmentArtifact<'a> {
203    pub suffix: &'static str,
204    pub body: ArtifactBody<'a>,
205}
206
207/// The source of one remote segment artifact.
208pub enum ArtifactBody<'a> {
209    File(&'a Path),
210    Memory(&'a Bytes),
211}
212
213impl LogSegmentData {
214    /// Walk the copy order shared by filesystem and object-store backends.
215    pub(crate) fn artifacts(&self) -> impl Iterator<Item = SegmentArtifact<'_>> {
216        [
217            Some(SegmentArtifact {
218                suffix: LOG_FILE_SUFFIX,
219                body: ArtifactBody::File(self.log_segment.as_path()),
220            }),
221            Some(SegmentArtifact {
222                suffix: IndexType::Offset.suffix(),
223                body: ArtifactBody::File(self.offset_index.as_path()),
224            }),
225            Some(SegmentArtifact {
226                suffix: IndexType::Timestamp.suffix(),
227                body: ArtifactBody::File(self.time_index.as_path()),
228            }),
229            self.producer_snapshot_index
230                .as_deref()
231                .map(|path| SegmentArtifact {
232                    suffix: IndexType::ProducerSnapshot.suffix(),
233                    body: ArtifactBody::File(path),
234                }),
235            Some(SegmentArtifact {
236                suffix: IndexType::LeaderEpoch.suffix(),
237                body: ArtifactBody::Memory(&self.leader_epoch_index),
238            }),
239            self.transaction_index
240                .as_deref()
241                .map(|path| SegmentArtifact {
242                    suffix: IndexType::Transaction.suffix(),
243                    body: ArtifactBody::File(path),
244                }),
245        ]
246        .into_iter()
247        .flatten()
248    }
249}
250
251/// SPI for the remote object store that holds offloaded segment data.
252///
253/// Implementations are **synchronous and blocking**. They mirror Kafka's
254/// `RemoteStorageManager`, which the broker drives from a dedicated thread
255/// pool, and the broker wraps these calls in `spawn_blocking`.
256/// Implementations must be `Send + Sync` so the broker can share one instance
257/// across tasks.
258pub trait RemoteStorageManager: Send + Sync {
259    /// Copies a segment's data and all of its indexes to the remote tier.
260    ///
261    /// Returns optional [`CustomMetadata`], for example an object-store key
262    /// or a version id. The broker records it on the segment and passes it
263    /// back on every later fetch or delete for that segment.
264    ///
265    /// # Errors
266    ///
267    /// Returns [`RemoteStorageError`] if any underlying store operation
268    /// fails.
269    fn copy_log_segment_data(
270        &self,
271        metadata: &RemoteLogSegmentMetadata,
272        data: &LogSegmentData,
273    ) -> Result<Option<CustomMetadata>, RemoteStorageError>;
274
275    /// Fetches a byte range of a segment's `.log` data.
276    ///
277    /// `start_position` is the inclusive starting byte offset within the
278    /// segment. `end_position`, when `Some`, is the inclusive last byte
279    /// offset; when `None`, the read runs to the end of the segment.
280    ///
281    /// # Errors
282    ///
283    /// Returns [`RemoteStorageError::SegmentNotFound`] if the segment is
284    /// not present, or [`RemoteStorageError::Io`] on a store failure.
285    fn fetch_log_segment(
286        &self,
287        metadata: &RemoteLogSegmentMetadata,
288        start_position: u32,
289        end_position: Option<u32>,
290    ) -> Result<Vec<u8>, RemoteStorageError>;
291
292    /// Fetches one of a segment's indexes in full.
293    ///
294    /// # Errors
295    ///
296    /// Returns [`RemoteStorageError::SegmentNotFound`] if the segment or the
297    /// requested index is not present, or [`RemoteStorageError::Io`] on a
298    /// store failure.
299    fn fetch_index(
300        &self,
301        metadata: &RemoteLogSegmentMetadata,
302        index_type: IndexType,
303    ) -> Result<Vec<u8>, RemoteStorageError>;
304
305    /// Deletes a segment's data and all of its indexes from the remote tier.
306    ///
307    /// Implementations must be idempotent: a delete of an absent segment
308    /// succeeds — or return an error, when the backend is an immutable
309    /// archive.
310    ///
311    /// # Errors
312    ///
313    /// Returns [`RemoteStorageError::Io`] on a store failure.
314    fn delete_log_segment_data(
315        &self,
316        metadata: &RemoteLogSegmentMetadata,
317    ) -> Result<(), RemoteStorageError>;
318}
319
320#[cfg(test)]
321mod tests;