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;