ifc_lite_server/services/
cache.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Disk-based cache service using cacache.
6
7use super::cache_remove::classify_index_walk_error;
8use crate::error::ApiError;
9use serde::{de::DeserializeOwned, Serialize};
10use sha2::{Digest, Sha256};
11use std::path::PathBuf;
12use std::sync::Arc;
13use tokio::sync::{RwLock, Semaphore};
14
15/// Content-addressable disk cache.
16#[derive(Debug, Clone)]
17pub struct DiskCache {
18    pub(super) cache_dir: PathBuf,
19    // Excludes a `set`/`set_bytes` write from `remove_by_key_prefix`'s GC
20    // pass, and nothing else. `cacache::write` commits in two steps -- the
21    // content blob is finalized first, the index entry inserted second
22    // (`Writer::commit` in the vendored cacache) -- so a write and a GC pass
23    // CAN interleave: the GC pass's "list index, drop blobs nothing
24    // references" scan can run between those two halves of a concurrent
25    // write, see the blob but no index entry pointing at it yet, and unlink
26    // it out from under the writer that is about to insert that very entry.
27    // The blob is content-addressed, so this only bites when the concurrent
28    // write's bytes hash to a blob the GC pass just unreferenced -- exactly
29    // the case `remove_by_key_prefix`'s own docstring calls out (two source
30    // files sharing one output blob) -- but when it does, the surviving
31    // entry then reads as corrupt on every later `get`/`get_bytes`, which is
32    // the one failure mode that method's ordering was written to prevent.
33    // `read()` here is shared among concurrent writers (they may still race
34    // each other inside `cacache::write`, which is safe on its own); only
35    // the BLOB-RECLAIM half of `remove_by_key_prefix` takes `write()`, so it
36    // runs with no write in flight and no write can start until it finishes.
37    // Its index-entry removals need no exclusion at all -- removing an index
38    // entry can only orphan a blob, never unlink one out from under a
39    // writer -- and are deliberately left outside the lock.
40    pub(super) write_gc_lock: Arc<RwLock<()>>,
41    /// One `remove_by_key_prefix` at a time, process-wide.
42    ///
43    /// The removal walks the whole index twice, and the second walk holds
44    /// `write_gc_lock.write_owned()` for its duration. tokio's `RwLock` is
45    /// write-preferring, so overlapping removals starve every cache write in
46    /// the process, and a walk costs exactly the same for a key nothing was
47    /// ever stored under. Lives on the cache, not on `Admission`, so every
48    /// caller gets the bound without learning it exists, and the permit is
49    /// carried INTO both blocking closures the same way the GC guard is: a
50    /// permit held by the async fn would be released when a client drops the
51    /// request mid-walk, while the walk itself runs on.
52    pub(crate) index_walk: Arc<Semaphore>,
53}
54
55impl DiskCache {
56    /// Create a new cache in the specified directory.
57    pub async fn new(cache_dir: &str) -> Self {
58        let path = PathBuf::from(cache_dir);
59
60        // Create cache directory if it doesn't exist
61        if let Err(e) = tokio::fs::create_dir_all(&path).await {
62            tracing::warn!(
63                error = %e,
64                path = %path.display(),
65                "Failed to create cache directory"
66            );
67        }
68
69        Self {
70            cache_dir: path,
71            write_gc_lock: Arc::new(RwLock::new(())),
72            index_walk: Arc::new(Semaphore::new(1)),
73        }
74    }
75
76    /// Generate a cache key from file content (SHA256 hash).
77    pub fn generate_key(data: &[u8]) -> String {
78        let mut hasher = Sha256::new();
79        hasher.update(data);
80        hex::encode(hasher.finalize())
81    }
82
83    /// Get a cached value by key.
84    pub async fn get<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, ApiError> {
85        match cacache::read(&self.cache_dir, key).await {
86            Ok(data) => {
87                let value: T = serde_json::from_slice(&data)?;
88                Ok(Some(value))
89            }
90            Err(cacache::Error::EntryNotFound(_, _)) => Ok(None),
91            Err(e) => Err(ApiError::Cache(e.to_string())),
92        }
93    }
94
95    /// Set a cached value.
96    ///
97    /// Serializes and then delegates to [`Self::set_bytes`], so there is
98    /// exactly one body that takes the `write_gc_lock` read guard around a
99    /// `cacache::write` -- the invariant cannot drift between the two entry
100    /// points.
101    pub async fn set<T: Serialize>(&self, key: &str, value: &T) -> Result<(), ApiError> {
102        let data = serde_json::to_vec(value)?;
103        self.set_bytes(key, &data).await
104    }
105
106    /// Whether the index holds an entry for `key`. An index lookup only: the
107    /// content is not read, so this is the cheap pre-filter, never the
108    /// decision a reader acts on.
109    ///
110    /// `Ok(false)` is "absent"; a lookup that FAILS is an error, not an
111    /// absence, so a caller answering `404` on `false` still answers a broken
112    /// cache store with `500 CACHE_ERROR` (#5750 review). `cacache::metadata`
113    /// answers `Ok(None)` for a key it has no entry for.
114    pub async fn has(&self, key: &str) -> Result<bool, ApiError> {
115        match cacache::metadata(&self.cache_dir, key).await {
116            Ok(entry) => Ok(entry.is_some()),
117            Err(e) => Err(ApiError::Cache(e.to_string())),
118        }
119    }
120
121    /// Remove a cached entry.
122    #[allow(dead_code)]
123    pub async fn remove(&self, key: &str) -> Result<(), ApiError> {
124        cacache::remove(&self.cache_dir, key).await?;
125        Ok(())
126    }
127
128    /// Clear all cached entries.
129    #[allow(dead_code)]
130    pub async fn clear(&self) -> Result<(), ApiError> {
131        cacache::clear(&self.cache_dir).await?;
132        Ok(())
133    }
134
135    /// Get raw bytes from cache (for Parquet responses).
136    pub async fn get_bytes(&self, key: &str) -> Result<Option<Vec<u8>>, ApiError> {
137        match cacache::read(&self.cache_dir, key).await {
138            Ok(data) => Ok(Some(data)),
139            Err(cacache::Error::EntryNotFound(_, _)) => Ok(None),
140            Err(e) => Err(ApiError::Cache(e.to_string())),
141        }
142    }
143
144    /// Set raw bytes in cache.
145    pub async fn set_bytes(&self, key: &str, data: &[u8]) -> Result<(), ApiError> {
146        // Excludes `remove_by_key_prefix`'s GC pass, not other concurrent
147        // writers -- see `write_gc_lock`'s docstring.
148        let _guard = self.write_gc_lock.read().await;
149        cacache::write(&self.cache_dir, key, data).await?;
150        tracing::debug!(key = %key, size = data.len(), "Cached raw bytes");
151        Ok(())
152    }
153
154    /// Cache-wide entry count and total content size in bytes, for the
155    /// `/api/v1/metrics` gauges (issue #3636). Sums `Metadata::size` across
156    /// every index entry; entries sharing a deduplicated content blob are
157    /// each counted once (as stored), matching what `remove_by_key_prefix`
158    /// treats as "still referenced".
159    pub async fn stats(&self) -> Result<CacheStats, ApiError> {
160        let cache_dir = self.cache_dir.clone();
161        tokio::task::spawn_blocking(move || {
162            let mut stats = CacheStats::default();
163            for entry in cacache::list_sync(&cache_dir) {
164                // Never `flatten()` here: a walk that cannot complete (an
165                // unmounted or permission-denied `CACHE_DIR`) would then be
166                // indistinguishable from an empty cache, and the gauges would
167                // report `entries=0 bytes=0` as if the cache were healthy.
168                let meta = match entry {
169                    Ok(meta) => meta,
170                    Err(e) => match classify_index_walk_error(&cache_dir, e) {
171                        Some(err) => return Err(err),
172                        None => break,
173                    },
174                };
175                stats.entries += 1;
176                stats.bytes += meta.size as u64;
177            }
178            Ok::<_, ApiError>(stats)
179        })
180        .await?
181    }
182}
183
184/// Cache-wide totals reported by [`DiskCache::stats`].
185#[derive(Debug, Clone, Copy, Default)]
186pub struct CacheStats {
187    pub entries: u64,
188    pub bytes: u64,
189}
190
191#[cfg(test)]
192#[path = "cache_tests.rs"]
193mod cache_tests;