rskit_cache/adapters/fs/
store.rs1use std::{
2 path::{Path, PathBuf},
3 sync::Arc,
4 time::{Duration, SystemTime, UNIX_EPOCH},
5};
6
7use rskit_errors::{AppError, AppResult, ErrorCode};
8use serde::{Deserialize, Serialize};
9use tokio::sync::Mutex;
10
11use crate::CacheStore;
12
13use super::FileCacheConfig;
14
15pub struct FileCache {
17 config: FileCacheConfig,
18 mutation_lock: Arc<Mutex<()>>,
19}
20
21impl FileCache {
22 #[must_use]
24 pub fn new(config: FileCacheConfig) -> Self {
25 Self {
26 config,
27 mutation_lock: Arc::new(Mutex::new(())),
28 }
29 }
30
31 pub(crate) fn prefixed_key(&self, key: &str) -> String {
32 self.config
33 .key_prefix
34 .as_ref()
35 .map_or_else(|| key.to_owned(), |prefix| format!("{prefix}:{key}"))
36 }
37
38 pub(crate) fn entry_path(&self, key: &str) -> PathBuf {
39 let hash = rskit_util::hash::hash_hex(key.as_bytes());
40 self.config.root.join(&hash[..2]).join(hash)
41 }
42
43 async fn read_entry(&self, path: &Path, expected_key: &str) -> AppResult<Option<Entry>> {
44 let Some(entry) = self.read_entry_file(path).await? else {
45 return Ok(None);
46 };
47 if entry.key != expected_key {
48 return Err(AppError::new(
49 ErrorCode::Conflict,
50 format!("cache key collision for '{}'", path.display()),
51 ));
52 }
53 if entry.is_expired()? {
54 return Ok(None);
55 }
56 Ok(Some(entry))
57 }
58
59 async fn read_entry_file(&self, path: &Path) -> AppResult<Option<Entry>> {
60 let bytes =
61 match rskit_fs::async_io::file::read_bounded(path, self.config.max_entry_bytes).await {
62 Ok(bytes) => bytes,
63 Err(error) if is_not_found_error(&error) => return Ok(None),
64 Err(error) => return Err(error),
65 };
66 let entry: Entry = serde_json::from_slice(&bytes).map_err(|error| {
67 AppError::new(
68 ErrorCode::Internal,
69 format!("failed to decode cache entry '{}'", path.display()),
70 )
71 .with_cause(error)
72 })?;
73 Ok(Some(entry))
74 }
75
76 pub async fn cleanup_expired(&self, max_entries: usize) -> AppResult<usize> {
82 if max_entries == 0 {
83 return Ok(0);
84 }
85
86 let _guard = self.mutation_lock.lock().await;
87 let mut checked = 0;
88 let mut removed = 0;
89 if !rskit_fs::async_io::dir::exists(&self.config.root).await? {
90 return Ok(0);
91 }
92 let shard_dirs = rskit_fs::async_io::dir::list(&self.config.root).await?;
93
94 for shard in shard_dirs.into_iter().filter(|entry| entry.is_dir) {
95 let entries = match rskit_fs::async_io::dir::list(&shard.path).await {
96 Ok(entries) => entries,
97 Err(error) if is_not_found_error(&error) => continue,
98 Err(error) => return Err(error),
99 };
100 for entry in entries.into_iter().filter(|entry| entry.is_file) {
101 if checked == max_entries {
102 return Ok(removed);
103 }
104 checked += 1;
105 let Some(cache_entry) = self.read_entry_file(&entry.path).await? else {
106 continue;
107 };
108 if cache_entry.is_expired()? {
109 match tokio::fs::remove_file(&entry.path).await {
110 Ok(()) => removed += 1,
111 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
112 Err(error) => {
113 return Err(AppError::new(
114 ErrorCode::Internal,
115 format!(
116 "failed to delete expired cache entry '{}'",
117 entry.path.display()
118 ),
119 )
120 .with_cause(error));
121 }
122 }
123 }
124 }
125 }
126
127 Ok(removed)
128 }
129}
130
131#[async_trait::async_trait]
132impl CacheStore for FileCache {
133 async fn get(&self, key: &str) -> AppResult<Option<String>> {
134 let key = self.prefixed_key(key);
135 let path = self.entry_path(&key);
136 Ok(self.read_entry(&path, &key).await?.map(|entry| entry.value))
137 }
138
139 async fn set(&self, key: &str, val: &str, ttl: Option<Duration>) -> AppResult<()> {
140 if ttl.is_some_and(|ttl| ttl.is_zero()) {
141 return Err(AppError::new(
142 ErrorCode::InvalidInput,
143 "cache TTL must be greater than zero",
144 ));
145 }
146 let key = self.prefixed_key(key);
147 let path = self.entry_path(&key);
148 let entry = Entry {
149 key,
150 value: val.to_owned(),
151 expires_at_millis: expires_at_millis(ttl)?,
152 };
153 let json = serde_json::to_vec(&entry).map_err(|error| {
154 AppError::new(ErrorCode::Internal, "failed to encode cache entry").with_cause(error)
155 })?;
156 if json.len() as u64 > self.config.max_entry_bytes {
157 return Err(cache_entry_too_large_error(
158 &path,
159 json.len() as u64,
160 self.config.max_entry_bytes,
161 ));
162 }
163 let _guard = self.mutation_lock.lock().await;
164 rskit_fs::async_io::file::write_atomic_replace(&path, json, "rskit-cache").await
165 }
166
167 async fn delete(&self, key: &str) -> AppResult<bool> {
168 let key = self.prefixed_key(key);
169 let path = self.entry_path(&key);
170 let _guard = self.mutation_lock.lock().await;
171 match tokio::fs::remove_file(&path).await {
172 Ok(()) => Ok(true),
173 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
174 Err(error) => Err(AppError::new(
175 ErrorCode::Internal,
176 format!("failed to delete cache entry '{}'", path.display()),
177 )
178 .with_cause(error)),
179 }
180 }
181
182 async fn exists(&self, key: &str) -> AppResult<bool> {
183 self.get(key).await.map(|value| value.is_some())
184 }
185}
186
187#[derive(Deserialize, Serialize)]
188pub(crate) struct Entry {
189 pub(crate) key: String,
190 pub(crate) value: String,
191 pub(crate) expires_at_millis: Option<u128>,
192}
193
194impl Entry {
195 fn is_expired(&self) -> AppResult<bool> {
196 self.expires_at_millis
197 .map(|expires_at| now_millis().map(|now| expires_at <= now))
198 .transpose()
199 .map(|expired| expired.unwrap_or(false))
200 }
201}
202
203fn expires_at_millis(ttl: Option<Duration>) -> AppResult<Option<u128>> {
204 ttl.map(ttl_millis)
205 .transpose()?
206 .map(|ttl| now_millis().and_then(|now| now.checked_add(ttl).ok_or_else(ttl_error)))
207 .transpose()
208}
209
210pub(crate) fn ttl_millis(ttl: Duration) -> AppResult<u128> {
211 if ttl.is_zero() {
212 return Err(AppError::new(
213 ErrorCode::InvalidInput,
214 "cache TTL must be greater than zero",
215 ));
216 }
217 Ok(ttl.as_millis().max(1))
218}
219
220pub(crate) fn now_millis() -> AppResult<u128> {
221 SystemTime::now()
222 .duration_since(UNIX_EPOCH)
223 .map(|duration| duration.as_millis())
224 .map_err(|error| {
225 AppError::new(ErrorCode::Internal, "system clock is before UNIX_EPOCH")
226 .with_cause(error)
227 })
228}
229
230fn ttl_error() -> AppError {
231 AppError::new(
232 ErrorCode::InvalidInput,
233 "cache TTL is too large to represent safely for filesystem cache",
234 )
235}
236
237fn is_not_found_error(error: &AppError) -> bool {
238 error
239 .cause()
240 .and_then(|cause| cause.downcast_ref::<std::io::Error>())
241 .is_some_and(|cause| cause.kind() == std::io::ErrorKind::NotFound)
242}
243
244fn cache_entry_too_large_error(path: &Path, actual: u64, limit: u64) -> AppError {
245 AppError::new(
246 ErrorCode::InvalidInput,
247 format!(
248 "cache entry '{}' is {actual} bytes, exceeding limit {limit} bytes",
249 path.display()
250 ),
251 )
252 .with_detail("rskit_cache_error", "entry_too_large")
253 .with_detail("actual_bytes", actual)
254 .with_detail("limit_bytes", limit)
255}