krabka_remote_storage/index_cache.rs
1//! `RemoteIndexCache` keeps a bounded on-disk copy of the index objects a
2//! remote segment carries, so a consumer reading through a cold segment
3//! downloads each index once instead of once per `Fetch`.
4//!
5//! Mirrors Kafka's `RemoteIndexCache`
6//! (`org.apache.kafka.storage.internals.log.RemoteIndexCache`): entries live
7//! under `<log_dir>/remote-log-index-cache`, are keyed by the segment's
8//! [`RemoteLogSegmentId`](crate::RemoteLogSegmentId) together with the
9//! [`IndexType`], and the cache is sized by a total-byte budget
10//! (Kafka's `remote.log.index.file.cache.total.size.bytes`, 1 GiB by default)
11//! rather than by entry count, because a `.txnindex` and a `.timeindex` differ
12//! in size by orders of magnitude. Reaching the budget evicts the
13//! least-recently-used entries until the new one fits.
14//!
15//! The cache directory is emptied on construction. This is a cache of bytes
16//! that the remote tier still holds, so a restart re-downloading them costs
17//! latency and nothing else, whereas trusting files written by an earlier
18//! process would mean trusting a size budget and a segment lineage that the
19//! new process cannot check. Kafka reloads its directory; krabka does not,
20//! because krabka has no on-disk entry header to validate one against.
21//!
22//! [`RemoteIndexCache::disabled`] is the pass-through the broker's in-process
23//! tests and the [`RemoteReader`]-equivalent unbounded path use: every lookup
24//! is a miss and nothing is written.
25//!
26//! [`RemoteIndexCache::resize`] changes the budget of a live cache, as Kafka's
27//! `RemoteIndexCache.resizeCacheSize` does when an operator alters
28//! `remote.log.index.file.cache.total.size.bytes`: a shrink evicts the
29//! least-recently-used entries until the held bytes fit the new budget.
30//!
31//! Eviction is also driven from the segment lifecycle:
32//! [`RemoteIndexCache::remove_segment`] drops every index of one segment when
33//! the `RemoteLogManager` moves it to
34//! [`DeleteSegmentStarted`](crate::RemoteLogSegmentState::DeleteSegmentStarted),
35//! so a deleted segment's bytes do not hold the budget against live segments.
36//!
37//! [`RemoteReader`]: https://docs.rs/krabka-broker
38
39use {
40 crate::{error::RemoteStorageError, storage_manager::IndexType},
41 std::{
42 collections::{BTreeMap, HashMap},
43 fs,
44 io::ErrorKind,
45 path::{Path, PathBuf},
46 sync::{
47 Mutex, MutexGuard, PoisonError,
48 atomic::{AtomicU64, Ordering},
49 },
50 },
51 uuid::Uuid,
52};
53
54/// The directory, relative to the broker's log directory, that holds the
55/// cached index files. The name is Kafka's.
56pub const REMOTE_INDEX_CACHE_DIR: &str = "remote-log-index-cache";
57
58/// Kafka's `remote.log.index.file.cache.total.size.bytes` default: 1 GiB.
59pub const DEFAULT_INDEX_CACHE_TOTAL_SIZE_BYTES: u64 = 1024 * 1024 * 1024;
60
61/// What a lookup did, so the caller can count hits and misses without
62/// reaching into the cache's internals.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum IndexCacheOutcome {
65 /// The bytes came from the cache directory; no remote fetch ran.
66 Hit,
67 /// The bytes came from the fetcher and were written to the cache.
68 Miss,
69 /// The cache is disabled, so the fetcher ran and nothing was stored.
70 Disabled,
71}
72
73/// A point-in-time reading of the cache's counters, for the broker's metrics.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
75pub struct IndexCacheStats {
76 /// Lookups answered from the cache directory.
77 pub hits: u64,
78 /// Lookups that had to download the index object.
79 pub misses: u64,
80 /// Entries dropped to stay inside the byte budget.
81 pub evictions: u64,
82 /// Entries currently held.
83 pub entries: u64,
84 /// Bytes currently held.
85 pub bytes: u64,
86}
87
88/// One index object of one segment.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
90struct CacheKey {
91 segment_id: Uuid,
92 index_type: IndexType,
93}
94
95/// The bookkeeping for one cached file.
96#[derive(Debug)]
97struct CacheEntry {
98 /// Size on disk, which is what the budget counts.
99 size: u64,
100 /// The `clock` reading of this entry's last use; its key in `order`.
101 last_used: u64,
102}
103
104/// The mutable half of the cache, behind one lock. The lock is held across the
105/// small filesystem operations that keep the map and the directory in step;
106/// holding it across the *remote* fetch would serialize every cold read, so
107/// the fetcher runs outside it and the result is inserted afterwards.
108#[derive(Debug, Default)]
109struct CacheState {
110 entries: HashMap<CacheKey, CacheEntry>,
111 /// `last_used` → key, so the least-recently-used entry is the first one.
112 order: BTreeMap<u64, CacheKey>,
113 clock: u64,
114 total_bytes: u64,
115}
116
117impl CacheState {
118 /// Records a use of `key`, moving it to the most-recently-used end.
119 fn touch(&mut self, key: CacheKey) {
120 self.clock += 1;
121 let clock = self.clock;
122 if let Some(entry) = self.entries.get_mut(&key) {
123 self.order.remove(&entry.last_used);
124 entry.last_used = clock;
125 self.order.insert(clock, key);
126 }
127 }
128
129 /// Drops one entry from the bookkeeping and returns the bytes it held.
130 fn forget(&mut self, key: CacheKey) -> Option<u64> {
131 let entry = self.entries.remove(&key)?;
132 self.order.remove(&entry.last_used);
133 self.total_bytes = self.total_bytes.saturating_sub(entry.size);
134 Some(entry.size)
135 }
136
137 /// The least-recently-used key, or `None` when the cache is empty.
138 fn lru(&self) -> Option<CacheKey> { self.order.first_key_value().map(|(_, key)| *key) }
139
140 /// Forget least-recently-used entries while `over` holds for the bytes
141 /// still held, and hand back the keys forgotten.
142 fn evict_while(&mut self, over: impl Fn(u64) -> bool) -> Vec<CacheKey> {
143 let mut evicted = Vec::new();
144 while over(self.total_bytes) {
145 let Some(victim) = self.lru() else { break };
146 self.forget(victim);
147 evicted.push(victim);
148 }
149 evicted
150 }
151}
152
153/// A bounded on-disk cache of remote segment index objects.
154#[derive(Debug)]
155pub struct RemoteIndexCache {
156 /// The cache directory, or `None` when the cache is disabled.
157 root: Option<PathBuf>,
158 /// The total-byte budget the entries must fit inside. It is only changed
159 /// under the `state` lock, by [`Self::resize`].
160 max_bytes: AtomicU64,
161 state: Mutex<CacheState>,
162 hits: AtomicU64,
163 misses: AtomicU64,
164 evictions: AtomicU64,
165}
166
167impl RemoteIndexCache {
168 /// Opens (and empties) `<log_dir>/remote-log-index-cache` and returns a
169 /// cache bounded by `max_bytes`.
170 ///
171 /// # Errors
172 ///
173 /// Returns [`RemoteStorageError::Io`] when the directory cannot be
174 /// emptied or created.
175 pub fn new(log_dir: &Path, max_bytes: u64) -> Result<Self, RemoteStorageError> {
176 let root = log_dir.join(REMOTE_INDEX_CACHE_DIR);
177 if let Err(e) = fs::remove_dir_all(&root)
178 && e.kind() != ErrorKind::NotFound
179 {
180 return Err(RemoteStorageError::Io(e));
181 }
182 fs::create_dir_all(&root).map_err(RemoteStorageError::Io)?;
183 Ok(Self::empty(Some(root), max_bytes))
184 }
185
186 /// A cache that stores nothing: every lookup runs the fetcher and reports
187 /// [`IndexCacheOutcome::Disabled`].
188 #[must_use]
189 pub fn disabled() -> Self { Self::empty(None, 0) }
190
191 fn empty(root: Option<PathBuf>, max_bytes: u64) -> Self {
192 Self {
193 root,
194 max_bytes: AtomicU64::new(max_bytes),
195 state: Mutex::new(CacheState::default()),
196 hits: AtomicU64::new(0),
197 misses: AtomicU64::new(0),
198 evictions: AtomicU64::new(0),
199 }
200 }
201
202 /// Whether this cache stores anything.
203 #[must_use]
204 pub fn is_enabled(&self) -> bool { self.root.is_some() }
205
206 /// The current total-byte budget.
207 #[must_use]
208 pub fn max_bytes(&self) -> u64 { self.max_bytes.load(Ordering::Relaxed) }
209
210 /// Sets the total-byte budget to `max_bytes`, evicting the
211 /// least-recently-used entries until the held bytes fit it.
212 ///
213 /// This is Kafka's `RemoteIndexCache.resizeCacheSize`, which sets the
214 /// Caffeine maximum weight and so evicts down to the new size at once. A
215 /// grow evicts nothing. A disabled cache only records the new budget.
216 pub fn resize(&self, max_bytes: u64) {
217 let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
218 self.max_bytes.store(max_bytes, Ordering::Relaxed);
219 let evicted = state.evict_while(|held| held > max_bytes);
220 self.discard(state, &evicted);
221 }
222
223 /// Delete the files of the `evicted` entries while `state` is still
224 /// held, so no new entry can take a path being deleted, then count them.
225 fn discard(&self, state: MutexGuard<'_, CacheState>, evicted: &[CacheKey]) {
226 if let Some(root) = self.root.as_ref() {
227 for victim in evicted {
228 remove_quietly(&entry_path(root, *victim));
229 }
230 }
231 drop(state);
232 self.evictions.fetch_add(
233 u64::try_from(evicted.len()).unwrap_or(u64::MAX),
234 Ordering::Relaxed,
235 );
236 }
237
238 /// The counters the broker publishes.
239 #[must_use]
240 pub fn stats(&self) -> IndexCacheStats {
241 let state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
242 IndexCacheStats {
243 hits: self.hits.load(Ordering::Relaxed),
244 misses: self.misses.load(Ordering::Relaxed),
245 evictions: self.evictions.load(Ordering::Relaxed),
246 entries: u64::try_from(state.entries.len()).unwrap_or(u64::MAX),
247 bytes: state.total_bytes,
248 }
249 }
250
251 /// Returns `segment_id`'s `index_type` bytes, running `fetch` only when
252 /// the cache does not already hold them.
253 ///
254 /// `fetch` is called outside the cache lock, so several partitions can
255 /// miss concurrently. Two concurrent misses on the same key both download
256 /// and the second write replaces the first with identical bytes; a remote
257 /// index object is immutable once its segment is finished, so the race
258 /// costs one extra download and never returns the wrong bytes.
259 ///
260 /// # Errors
261 ///
262 /// Returns whatever `fetch` returns on a miss. A cache-directory failure
263 /// is not an error: the fetched bytes are returned and the entry is
264 /// simply not stored.
265 pub fn get_or_fetch<F>(
266 &self,
267 segment_id: Uuid,
268 index_type: IndexType,
269 fetch: F,
270 ) -> Result<(Vec<u8>, IndexCacheOutcome), RemoteStorageError>
271 where
272 F: FnOnce() -> Result<Vec<u8>, RemoteStorageError>,
273 {
274 let Some(root) = self.root.as_ref() else {
275 return Ok((fetch()?, IndexCacheOutcome::Disabled));
276 };
277 let key = CacheKey {
278 segment_id,
279 index_type,
280 };
281 let path = entry_path(root, key);
282 if self.claim_hit(key) {
283 match fs::read(&path) {
284 Ok(bytes) => {
285 self.hits.fetch_add(1, Ordering::Relaxed);
286 return Ok((bytes, IndexCacheOutcome::Hit));
287 }
288 Err(error) => {
289 // The file went missing under us. Drop the bookkeeping and
290 // fall through to a download rather than failing a read
291 // the remote tier can still answer.
292 tracing::debug!(
293 error = %error,
294 path = %path.display(),
295 "remote index cache: entry unreadable; re-fetching"
296 );
297 self.drop_entry(key, &path);
298 }
299 }
300 }
301 self.misses.fetch_add(1, Ordering::Relaxed);
302 let bytes = fetch()?;
303 self.store(key, &path, &bytes);
304 Ok((bytes, IndexCacheOutcome::Miss))
305 }
306
307 /// Drops every cached index of `segment_id`.
308 ///
309 /// The `RemoteLogManager` calls this when a segment enters
310 /// `DeleteSegmentStarted`, so the deleted segment's bytes stop counting
311 /// against the budget.
312 pub fn remove_segment(&self, segment_id: Uuid) {
313 let Some(root) = self.root.as_ref() else {
314 return;
315 };
316 for index_type in IndexType::ALL {
317 let key = CacheKey {
318 segment_id,
319 index_type,
320 };
321 self.drop_entry(key, &entry_path(root, key));
322 }
323 }
324
325 /// Whether the cache currently claims to hold `key`; bumps its recency
326 /// when it does. The read that follows is what settles whether the file is
327 /// really there.
328 fn claim_hit(&self, key: CacheKey) -> bool {
329 let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
330 if !state.entries.contains_key(&key) {
331 return false;
332 }
333 state.touch(key);
334 true
335 }
336
337 /// Removes one entry from the map and from the directory.
338 fn drop_entry(&self, key: CacheKey, path: &Path) {
339 let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
340 if state.forget(key).is_some() {
341 remove_quietly(path);
342 }
343 }
344
345 /// Writes `bytes` into the cache directory and makes room for them.
346 ///
347 /// An entry larger than the whole budget is fetched and returned but never
348 /// stored: caching it would evict everything else and then evict itself.
349 fn store(&self, key: CacheKey, path: &Path, bytes: &[u8]) {
350 let size = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
351 if size > self.max_bytes() {
352 return;
353 }
354 // Publish a completed file, never truncate a path that a cache hit
355 // may already be reading. Keep publication and eviction serialized
356 // so an old eviction cannot unlink a newly inserted entry.
357 let temporary = path.with_extension(format!("{}.tmp", Uuid::new_v4()));
358 if let Err(error) = fs::write(&temporary, bytes) {
359 remove_quietly(&temporary);
360 tracing::debug!(error = %error, path = %path.display(),
361 "remote index cache: temporary entry not stored");
362 return;
363 }
364 let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
365 let published = fs::rename(&temporary, path);
366 if let Err(error) = published {
367 remove_quietly(&temporary);
368 tracing::debug!(
369 error = %error,
370 path = %path.display(),
371 "remote index cache: entry not stored"
372 );
373 return;
374 }
375 // Re-read the budget under the lock: a concurrent `resize` may have
376 // shrunk it since the unlocked check above.
377 let max_bytes = self.max_bytes();
378 if size > max_bytes {
379 state.forget(key);
380 remove_quietly(path);
381 drop(state);
382 return;
383 }
384 let evicted = {
385 state.forget(key);
386 let evicted = state.evict_while(|held| held + size > max_bytes);
387 state.clock += 1;
388 let clock = state.clock;
389 state.entries.insert(key, CacheEntry {
390 size,
391 last_used: clock,
392 });
393 state.order.insert(clock, key);
394 state.total_bytes += size;
395 evicted
396 };
397 self.discard(state, &evicted);
398 }
399}
400
401/// The cache file for one index of one segment. Segment ids are unique across
402/// partitions, so the id and the Kafka suffix name the file on their own.
403fn entry_path(root: &Path, key: CacheKey) -> PathBuf {
404 root.join(format!(
405 "{}{}",
406 key.segment_id.as_hyphenated(),
407 key.index_type.suffix()
408 ))
409}
410
411/// Deletes a cache file, treating a failure as nothing worth failing a read
412/// over: the entry is already gone from the map, so the file is at worst
413/// stale bytes that no lookup can reach.
414fn remove_quietly(path: &Path) {
415 match fs::remove_file(path) {
416 Ok(()) => {}
417 Err(error) if error.kind() == ErrorKind::NotFound => {}
418 Err(error) => tracing::debug!(
419 error = %error,
420 path = %path.display(),
421 "remote index cache: stale entry not removed"
422 ),
423 }
424}
425
426#[cfg(test)]
427mod tests;