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;