Skip to main content

tantivy_cache/
lib.rs

1use std::{
2    io,
3    ops::Range,
4    path::{Path, PathBuf},
5    sync::Arc,
6};
7
8use async_trait::async_trait;
9use block_on_place::HandleExt;
10use deadpool_redis::{Connection, Pool, redis::AsyncCommands};
11use eyre::{OptionExt, Result};
12use gxhash::GxBuildHasher;
13use scc::HashIndex;
14use tantivy::{
15    Directory, HasLen,
16    directory::{
17        DirectoryLock, FileHandle, FileSlice, Lock, WatchCallback, WatchHandle, WritePtr,
18        error::{DeleteError, LockError, OpenReadError, OpenWriteError},
19    },
20};
21use tantivy_common::OwnedBytes;
22use tokio::runtime::Handle;
23
24use self::{error::WrapIoErrorExt, footer::Footer};
25
26pub use self::warmer::Warmer;
27
28mod error;
29mod footer;
30mod keys;
31mod warmer;
32
33#[cfg(test)]
34mod tests;
35
36/// A [`Directory`] implementation wrapping another one and caching some data using
37/// [`deadpool_redis`].
38#[derive(Clone, Debug)]
39pub struct CachingDirectory<D> {
40    rt: Handle,
41    redis: Pool,
42    dir: D,
43
44    // In-memory cache used to not fetch footers more than once and to not hold more
45    // than one instance of a file's footer in-memory.
46    cache: Cache,
47}
48
49type Cache = Arc<HashIndex<PathBuf, Footer, GxBuildHasher>>;
50
51#[derive(Debug)]
52struct CachingHandle {
53    file: FileSlice,
54    footer: Footer,
55
56    rt: Handle,
57    redis: Pool,
58    path: PathBuf,
59}
60
61impl<D> CachingDirectory<D> {
62    /// Creates a new [`CachingDirectory`] from the given directory and pool.
63    ///
64    /// The provided [`Directory`] must return a [`FileHandle`] that implements
65    /// [`read_bytes_async()`][1] without panicking.
66    ///
67    /// This must be called from within a `tokio` context.
68    ///
69    /// [1]: FileHandle::read_bytes_async()
70    pub fn new(dir: D, redis: Pool) -> Self {
71        let rt = Handle::current();
72        let cache = Arc::default();
73
74        Self {
75            rt,
76            redis,
77            cache,
78            dir,
79        }
80    }
81
82    async fn open(&self, path: impl Into<PathBuf>, file: FileSlice) -> Result<CachingHandle> {
83        let path = path.into();
84
85        loop {
86            if let Some(footer) = self.cache.get_async(&path).await {
87                let footer = footer.get().clone();
88                let rt = self.rt.clone();
89                let redis = self.redis.clone();
90
91                return Ok(CachingHandle {
92                    file,
93                    footer,
94                    rt,
95                    redis,
96                    path,
97                });
98            }
99
100            let footer = if let Some(offset) = offset(&path, &self.redis).await {
101                Footer::with_offset(offset)
102            } else {
103                let footer = Footer::read(&path, &file).await?;
104                update(&path, &footer, &self.redis).await;
105
106                footer
107            };
108
109            if self
110                .cache
111                .insert_async(path.clone(), footer.clone())
112                .await
113                .is_err()
114            {
115                // A footer was inserted by another task – fetch it so that we don't end up with
116                // more than one footer living for the same file.
117                continue;
118            }
119
120            let rt = self.rt.clone();
121            let redis = self.redis.clone();
122
123            return Ok(CachingHandle {
124                file,
125                footer,
126                rt,
127                redis,
128                path,
129            });
130        }
131    }
132}
133
134impl<D: Directory + Clone> CachingDirectory<D> {
135    /// Creates a [`Warmer`] that allows warming this directory's cache.
136    ///
137    /// The warmer defaults to the host's available parallelism; configure it with
138    /// [`Warmer::with_concurrency`].
139    ///
140    /// [1]: tantivy::Index::open
141    pub fn warmer(&self) -> Warmer<D> {
142        Warmer::new(self.clone())
143    }
144
145    /// Warms the cache for a single file.
146    ///
147    /// Opens the file and loads its footer into both the in-memory cache and Redis, so
148    /// that a later read of the footer region is served entirely from cache instead of
149    /// from the underlying [`Directory`].
150    ///
151    /// Non-existent files are silently ignored.
152    pub(crate) async fn warm(&self, path: &Path) -> Result<()> {
153        let dir = self.dir.clone();
154        let owned = path.to_path_buf();
155
156        // `open_read` is blocking, so run it off the async runtime to avoid
157        // serialising concurrent warms on the runtime's worker threads.
158        let opened = self
159            .rt
160            .spawn_blocking(move || dir.open_read(&owned))
161            .await?;
162
163        let file = match opened {
164            Ok(file) => file,
165            Err(OpenReadError::FileDoesNotExist(_)) => return Ok(()),
166            Err(error) => return Err(error.into()),
167        };
168
169        // Caches the footer offset (in Redis and in-memory).
170        let handle = self.open(path, file).await?;
171
172        // Eagerly loads the footer data into memory (and Redis), so later reads are served
173        // without touching Redis or the directory.
174        handle.footer().await;
175
176        Ok(())
177    }
178}
179
180impl CachingHandle {
181    /// Fetches the file's footer.
182    ///
183    /// If the footer has already been fetched, this simply returns it. If it is stored
184    /// in Redis, it fetches it from there. Otherwsie, it reads it and stores it in
185    /// Redis.
186    async fn footer(&self) -> Option<OwnedBytes> {
187        let fetch = async {
188            if let Some(data) = footer(&self.path, &self.redis).await {
189                return Ok(OwnedBytes::new(data));
190            }
191
192            let footer = Footer::read(&self.path, &self.file).await?;
193            update(&self.path, &footer, &self.redis).await;
194
195            footer.data().ok_or_eyre("missing data")
196        };
197
198        match self.footer.get_or_fetch(fetch).await {
199            Ok(footer) => Some(footer),
200            Err(error) => {
201                tracing::warn!("Failed to fetch footer: {error:?}");
202
203                None
204            }
205        }
206    }
207}
208
209impl<D: Clone + Directory> Directory for CachingDirectory<D> {
210    #[inline]
211    fn get_file_handle(&self, path: &Path) -> Result<Arc<dyn FileHandle>, OpenReadError> {
212        let file = self.dir.open_read(path)?;
213        let handle = self
214            .rt
215            .block_on_place(self.open(path, file))
216            .map_err(OpenReadError::wrapper(path))?;
217
218        Ok(Arc::new(handle))
219    }
220
221    #[inline]
222    fn delete(&self, path: &Path) -> Result<(), DeleteError> {
223        // TODO(MLB): remove from cache
224        self.dir.delete(path)
225    }
226
227    #[inline]
228    fn exists(&self, path: &Path) -> Result<bool, OpenReadError> {
229        // TODO(MLB): check the cache
230        self.dir.exists(path)
231    }
232
233    #[inline]
234    fn open_read(&self, path: &Path) -> Result<FileSlice, OpenReadError> {
235        self.get_file_handle(path).map(FileSlice::new)
236    }
237
238    #[inline]
239    fn open_write(&self, path: &Path) -> Result<WritePtr, OpenWriteError> {
240        // TODO(MLB): wrap and insert into the cache
241        self.dir.open_write(path)
242    }
243
244    #[inline]
245    fn atomic_read(&self, path: &Path) -> Result<Vec<u8>, OpenReadError> {
246        // TODO(MLB): read from cache
247        self.dir.atomic_read(path)
248    }
249
250    #[inline]
251    fn atomic_write(&self, path: &Path, data: &[u8]) -> io::Result<()> {
252        // TODO(MLB): write to cache
253        self.dir.atomic_write(path, data)
254    }
255
256    #[inline]
257    fn sync_directory(&self) -> io::Result<()> {
258        self.dir.sync_directory()
259    }
260
261    #[inline]
262    fn watch(&self, cb: WatchCallback) -> tantivy::Result<WatchHandle> {
263        self.dir.watch(cb)
264    }
265
266    #[inline]
267    fn acquire_lock(&self, lock: &Lock) -> Result<DirectoryLock, LockError> {
268        self.dir.acquire_lock(lock)
269    }
270}
271
272#[async_trait]
273impl FileHandle for CachingHandle {
274    fn read_bytes(&self, range: Range<usize>) -> io::Result<OwnedBytes> {
275        if range.end <= self.footer.offset {
276            return self.file.read_bytes_slice(range);
277        }
278
279        let Some(footer) = self.rt.block_on_place(self.footer()) else {
280            return self.file.read_bytes_slice(range);
281        };
282
283        if range.start >= self.footer.offset {
284            let start = range.start - self.footer.offset;
285            let end = range.end - self.footer.offset;
286            let slice = footer.slice(start..end);
287
288            return Ok(slice);
289        }
290
291        let start = range.start;
292        let end = self.footer.offset;
293        let data = self.file.read_bytes_slice(start..end)?;
294
295        let start = 0;
296        let end = range.end - self.footer.offset;
297        let footer = footer.slice(start..end);
298
299        let mut combined = Vec::with_capacity(data.len() + footer.len());
300        combined.extend_from_slice(data.as_ref());
301        combined.extend_from_slice(footer.as_ref());
302
303        Ok(OwnedBytes::new(combined))
304    }
305
306    async fn read_bytes_async(&self, range: Range<usize>) -> io::Result<OwnedBytes> {
307        if range.end <= self.footer.offset {
308            return self.file.read_bytes_slice_async(range).await;
309        }
310
311        let Some(footer) = self.footer().await else {
312            return self.file.read_bytes_slice_async(range).await;
313        };
314
315        if range.start >= self.footer.offset {
316            let start = range.start - self.footer.offset;
317            let end = range.end - self.footer.offset;
318            let slice = footer.slice(start..end);
319
320            return Ok(slice);
321        }
322
323        let start = range.start;
324        let end = self.footer.offset;
325        let data = self.file.read_bytes_slice_async(start..end).await?;
326
327        let start = 0;
328        let end = range.end - self.footer.offset;
329        let footer = footer.slice(start..end);
330
331        let mut combined = Vec::with_capacity(data.len() + footer.len());
332        combined.extend_from_slice(data.as_ref());
333        combined.extend_from_slice(footer.as_ref());
334
335        Ok(OwnedBytes::new(combined))
336    }
337}
338
339impl HasLen for CachingHandle {
340    #[inline]
341    fn len(&self) -> usize {
342        self.file.len()
343    }
344
345    #[inline]
346    fn is_empty(&self) -> bool {
347        self.file.is_empty()
348    }
349}
350
351/// Gets a connection from the given Redis pool.
352///
353/// If an error occurs, this returns `None` and logs the error message.
354async fn conn(redis: &Pool) -> Option<Connection> {
355    match redis.get().await {
356        Ok(conn) => Some(conn),
357        Err(error) => {
358            tracing::warn!("Failed to get Redis connection: {error:?}");
359            None
360        }
361    }
362}
363
364/// Fetches the footer offset for the given path from Redis.
365///
366/// If an error occurs, this returns `None` and logs the error message.
367async fn offset(path: &Path, redis: &Pool) -> Option<usize> {
368    let key = keys::offset(path).ok()?;
369    let mut conn = conn(redis).await?;
370
371    match conn.get(&key).await {
372        Ok(offset) => offset,
373        Err(error) => {
374            tracing::warn!("Failed to fetch footer offset: {error:?}");
375            None
376        }
377    }
378}
379
380/// Fetches the footer data for the given path from Redis.
381///
382/// If an error occurs, this returns `None` and logs the error message.
383async fn footer(path: &Path, redis: &Pool) -> Option<Vec<u8>> {
384    let key = keys::footer(path).ok()?;
385    let mut conn = conn(redis).await?;
386
387    match conn.get(&key).await {
388        Ok(offset) => offset,
389        Err(error) => {
390            tracing::warn!("Failed to fetch footer: {error:?}");
391            None
392        }
393    }
394}
395
396/// Updates the cached data for the given footer in Redis.
397///
398/// If an error occurs, this returns `None` and logs the error message.
399async fn update(path: &Path, footer: &Footer, redis: &Pool) {
400    let Some(mut conn) = conn(redis).await else {
401        return;
402    };
403
404    let Ok(key) = keys::offset(path) else { return };
405    match conn.set(&key, footer.offset).await {
406        Ok(()) => (),
407        Err(error) => {
408            tracing::warn!("Failed to update footer offset: {error:?}");
409        }
410    }
411
412    let Ok(key) = keys::footer(path) else { return };
413    let Some(data) = footer.data() else { return };
414    match conn.set(&key, data.as_slice()).await {
415        Ok(()) => (),
416        Err(error) => {
417            tracing::warn!("Failed to update footer: {error:?}");
418        }
419    }
420}