Skip to main content

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;