1use std::{
2 fmt::{Debug, Formatter, Result as FmtResult},
3 future::Future,
4 num::{NonZeroU64, NonZeroUsize},
5 sync::{
6 Arc,
7 atomic::{AtomicUsize, Ordering},
8 },
9 time::{Duration, Instant},
10};
11
12use serde_json::{from_slice, to_vec};
13use shardline_cache::{
14 AsyncReconstructionCache, DisabledReconstructionCache, MemoryReconstructionCache,
15 ReconstructionCacheKey, RedisReconstructionCache,
16};
17use shardline_protocol::RepositoryScope;
18
19use crate::{
20 FileReconstructionResponse, LocalBackend, ServerConfig, ServerConfigError, ServerError,
21};
22
23type SharedReconstructionCache = Arc<dyn AsyncReconstructionCache>;
24const MAX_RECONSTRUCTION_CACHE_PAYLOAD_BYTES: u64 = 67_108_864;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct ReconstructionCacheBenchReport {
29 pub cold_load_micros: u64,
31 pub hot_load_micros: u64,
33 pub response_bytes: u64,
35 pub cache_hit: bool,
37}
38
39#[derive(Clone)]
41pub struct ReconstructionCacheService {
42 adapter_name: &'static str,
43 adapter: SharedReconstructionCache,
44}
45
46impl Debug for ReconstructionCacheService {
47 fn fmt(&self, formatter: &mut Formatter<'_>) -> FmtResult {
48 formatter
49 .debug_struct("ReconstructionCacheService")
50 .field("adapter_name", &self.adapter_name)
51 .finish()
52 }
53}
54
55impl ReconstructionCacheService {
56 #[must_use]
57 pub fn disabled() -> Self {
58 Self {
59 adapter_name: ReconstructionCacheAdapter::Disabled.as_str(),
60 adapter: Arc::new(DisabledReconstructionCache::new()),
61 }
62 }
63
64 pub fn from_config(config: &ServerConfig) -> Result<Self, ServerError> {
70 match config.reconstruction_cache_adapter() {
71 ReconstructionCacheAdapter::Disabled => Ok(Self::disabled()),
72 ReconstructionCacheAdapter::Memory => Ok(Self {
73 adapter_name: ReconstructionCacheAdapter::Memory.as_str(),
74 adapter: Arc::new(MemoryReconstructionCache::new(
75 config.reconstruction_cache_ttl_seconds(),
76 config.reconstruction_cache_memory_max_entries(),
77 )),
78 }),
79 ReconstructionCacheAdapter::Redis => {
80 let redis_url = config
81 .reconstruction_cache_redis_url()
82 .ok_or(ServerError::MissingReconstructionCacheRedisUrl)?;
83 let adapter = RedisReconstructionCache::new(
84 redis_url,
85 config.reconstruction_cache_ttl_seconds(),
86 )?;
87 Ok(Self {
88 adapter_name: ReconstructionCacheAdapter::Redis.as_str(),
89 adapter: Arc::new(adapter),
90 })
91 }
92 }
93 }
94
95 fn for_tests(adapter_name: &'static str, adapter: SharedReconstructionCache) -> Self {
96 Self {
97 adapter_name,
98 adapter,
99 }
100 }
101
102 pub(crate) const fn backend_name(&self) -> &'static str {
103 self.adapter_name
104 }
105
106 pub(crate) async fn ready(&self) -> Result<(), ServerError> {
107 self.adapter.ready().await.map_err(ServerError::from)
108 }
109
110 pub(crate) async fn get_or_load<Load, LoadFuture>(
111 &self,
112 key: &ReconstructionCacheKey,
113 load: Load,
114 ) -> Result<FileReconstructionResponse, ServerError>
115 where
116 Load: FnOnce() -> LoadFuture,
117 LoadFuture: Future<Output = Result<FileReconstructionResponse, ServerError>>,
118 {
119 let cached = self.adapter.get(key).await;
120 if let Ok(Some(payload)) = cached
121 && payload_within_bound(&payload)
122 {
123 let parsed = from_slice::<FileReconstructionResponse>(&payload);
124 if let Ok(response) = parsed {
125 shardline_metrics::record_reconstruction_cache_hit();
126 return Ok(response);
127 }
128 }
129
130 shardline_metrics::record_reconstruction_cache_miss();
131 let response = load().await?;
132 let payload = to_vec(&response)?;
133 if payload_within_bound(&payload) {
134 let _ignored = self.adapter.put(key, &payload).await;
135 }
136 Ok(response)
137 }
138
139 pub(crate) fn version_key(
140 file_id: &str,
141 content_hash: &str,
142 repository_scope: Option<&RepositoryScope>,
143 ) -> ReconstructionCacheKey {
144 ReconstructionCacheKey::version(file_id, content_hash, repository_scope)
145 }
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub(crate) enum ReconstructionCacheAdapter {
150 Disabled,
151 Memory,
152 Redis,
153}
154
155impl ReconstructionCacheAdapter {
156 pub(crate) fn parse(value: &str) -> Result<Self, ServerConfigError> {
157 match value {
158 "disabled" => Ok(Self::Disabled),
159 "memory" => Ok(Self::Memory),
160 "redis" => Ok(Self::Redis),
161 _ => Err(ServerConfigError::InvalidReconstructionCacheAdapter),
162 }
163 }
164
165 pub(crate) const fn as_str(self) -> &'static str {
166 match self {
167 Self::Disabled => "disabled",
168 Self::Memory => "memory",
169 Self::Redis => "redis",
170 }
171 }
172}
173
174pub(crate) const DEFAULT_RECONSTRUCTION_CACHE_TTL_SECONDS: NonZeroU64 = match NonZeroU64::new(30) {
175 Some(value) => value,
176 None => NonZeroU64::MIN,
177};
178
179pub(crate) const DEFAULT_RECONSTRUCTION_CACHE_MEMORY_MAX_ENTRIES: NonZeroUsize =
180 match NonZeroUsize::new(4096) {
181 Some(value) => value,
182 None => NonZeroUsize::MIN,
183 };
184
185fn payload_within_bound(payload: &[u8]) -> bool {
186 let observed_bytes = u64::try_from(payload.len()).unwrap_or(u64::MAX);
187 observed_bytes <= MAX_RECONSTRUCTION_CACHE_PAYLOAD_BYTES
188}
189
190pub(crate) async fn benchmark_memory_reconstruction_cache_with_loader<Load, LoadFuture>(
191 file_id: &str,
192 content_hash: &str,
193 repository_scope: Option<&RepositoryScope>,
194 load: Load,
195) -> Result<ReconstructionCacheBenchReport, ServerError>
196where
197 Load: Fn() -> LoadFuture,
198 LoadFuture: Future<Output = Result<FileReconstructionResponse, ServerError>>,
199{
200 let ttl_seconds = NonZeroU64::new(60).unwrap_or(NonZeroU64::MIN);
201 let max_entries = NonZeroUsize::new(8).unwrap_or(NonZeroUsize::MIN);
202 let adapter: SharedReconstructionCache =
203 Arc::new(MemoryReconstructionCache::new(ttl_seconds, max_entries));
204 let cache = ReconstructionCacheService::for_tests("memory-bench", adapter);
205 let key = ReconstructionCacheService::version_key(file_id, content_hash, repository_scope);
206 let loader_calls = AtomicUsize::new(0);
207
208 let cold_started = Instant::now();
209 let cold = cache
210 .get_or_load(&key, || {
211 loader_calls.fetch_add(1, Ordering::SeqCst);
212 load()
213 })
214 .await?;
215 let cold_load_micros = duration_micros(cold_started.elapsed())?;
216
217 let hot_started = Instant::now();
218 let hot = cache
219 .get_or_load(&key, || {
220 loader_calls.fetch_add(1, Ordering::SeqCst);
221 load()
222 })
223 .await?;
224 let hot_load_micros = duration_micros(hot_started.elapsed())?;
225
226 debug_assert_eq!(cold, hot);
227 let response_bytes = u64::try_from(to_vec(&hot)?.len())?;
228
229 Ok(ReconstructionCacheBenchReport {
230 cold_load_micros,
231 hot_load_micros,
232 response_bytes,
233 cache_hit: loader_calls.load(Ordering::SeqCst) == 1,
234 })
235}
236
237pub async fn benchmark_memory_reconstruction_cache(
243 backend: &LocalBackend,
244 file_id: &str,
245 content_hash: &str,
246 repository_scope: Option<&RepositoryScope>,
247) -> Result<ReconstructionCacheBenchReport, ServerError> {
248 benchmark_memory_reconstruction_cache_with_loader(
249 file_id,
250 content_hash,
251 repository_scope,
252 || async move {
253 backend
254 .reconstruction(file_id, Some(content_hash), None, repository_scope)
255 .await
256 },
257 )
258 .await
259}
260
261fn duration_micros(duration: Duration) -> Result<u64, ServerError> {
262 u64::try_from(duration.as_micros()).map_err(ServerError::from)
263}
264
265#[cfg(test)]
266mod tests {
267 use std::{
268 num::{NonZeroU64, NonZeroUsize},
269 sync::{
270 Arc,
271 atomic::{AtomicUsize, Ordering},
272 },
273 };
274
275 use shardline_cache::{
276 AsyncReconstructionCache, MemoryReconstructionCache, ReconstructionCacheError,
277 ReconstructionCacheFuture, ReconstructionCacheKey,
278 };
279 use tokio::sync::Mutex;
280
281 use super::{
282 MAX_RECONSTRUCTION_CACHE_PAYLOAD_BYTES, ReconstructionCacheService,
283 SharedReconstructionCache,
284 };
285 use crate::FileReconstructionResponse;
286 use crate::xet_adapter::{
287 ReconstructionChunkRange, ReconstructionFetchInfo, ReconstructionTerm,
288 ReconstructionUrlRange,
289 };
290
291 #[derive(Debug)]
292 struct BrokenCache;
293
294 #[derive(Debug)]
295 struct StaticCache {
296 payload: Option<Vec<u8>>,
297 put_calls: Arc<AtomicUsize>,
298 }
299
300 impl AsyncReconstructionCache for BrokenCache {
301 fn ready(&self) -> ReconstructionCacheFuture<'_, ()> {
302 Box::pin(async { Err(ReconstructionCacheError::Operation) })
303 }
304
305 fn get<'operation>(
306 &'operation self,
307 _key: &'operation ReconstructionCacheKey,
308 ) -> ReconstructionCacheFuture<'operation, Option<Vec<u8>>> {
309 Box::pin(async { Err(ReconstructionCacheError::Operation) })
310 }
311
312 fn put<'operation>(
313 &'operation self,
314 _key: &'operation ReconstructionCacheKey,
315 _payload: &'operation [u8],
316 ) -> ReconstructionCacheFuture<'operation, ()> {
317 Box::pin(async { Err(ReconstructionCacheError::Operation) })
318 }
319
320 fn delete<'operation>(
321 &'operation self,
322 _key: &'operation ReconstructionCacheKey,
323 ) -> ReconstructionCacheFuture<'operation, bool> {
324 Box::pin(async { Err(ReconstructionCacheError::Operation) })
325 }
326 }
327
328 impl AsyncReconstructionCache for StaticCache {
329 fn ready(&self) -> ReconstructionCacheFuture<'_, ()> {
330 Box::pin(async { Ok(()) })
331 }
332
333 fn get<'operation>(
334 &'operation self,
335 _key: &'operation ReconstructionCacheKey,
336 ) -> ReconstructionCacheFuture<'operation, Option<Vec<u8>>> {
337 let payload = self.payload.clone();
338 Box::pin(async move { Ok(payload) })
339 }
340
341 fn put<'operation>(
342 &'operation self,
343 _key: &'operation ReconstructionCacheKey,
344 _payload: &'operation [u8],
345 ) -> ReconstructionCacheFuture<'operation, ()> {
346 self.put_calls.fetch_add(1, Ordering::SeqCst);
347 Box::pin(async { Ok(()) })
348 }
349
350 fn delete<'operation>(
351 &'operation self,
352 _key: &'operation ReconstructionCacheKey,
353 ) -> ReconstructionCacheFuture<'operation, bool> {
354 Box::pin(async { Ok(false) })
355 }
356 }
357
358 #[derive(Debug)]
359 struct CaptureCache {
360 put_calls: Arc<AtomicUsize>,
361 stored_payloads: Arc<Mutex<Vec<Vec<u8>>>>,
362 }
363
364 impl AsyncReconstructionCache for CaptureCache {
365 fn ready(&self) -> ReconstructionCacheFuture<'_, ()> {
366 Box::pin(async { Ok(()) })
367 }
368
369 fn get<'operation>(
370 &'operation self,
371 _key: &'operation ReconstructionCacheKey,
372 ) -> ReconstructionCacheFuture<'operation, Option<Vec<u8>>> {
373 Box::pin(async { Ok(None) })
374 }
375
376 fn put<'operation>(
377 &'operation self,
378 _key: &'operation ReconstructionCacheKey,
379 payload: &'operation [u8],
380 ) -> ReconstructionCacheFuture<'operation, ()> {
381 let payload = payload.to_vec();
382 let stored_payloads = Arc::clone(&self.stored_payloads);
383 self.put_calls.fetch_add(1, Ordering::SeqCst);
384 Box::pin(async move {
385 stored_payloads.lock().await.push(payload);
386 Ok(())
387 })
388 }
389
390 fn delete<'operation>(
391 &'operation self,
392 _key: &'operation ReconstructionCacheKey,
393 ) -> ReconstructionCacheFuture<'operation, bool> {
394 Box::pin(async { Ok(false) })
395 }
396 }
397
398 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
399 async fn cache_service_uses_cached_payload_after_first_load() {
400 let ttl_seconds = NonZeroU64::new(60).unwrap_or(NonZeroU64::MIN);
401 let adapter: SharedReconstructionCache = Arc::new(MemoryReconstructionCache::new(
402 ttl_seconds,
403 NonZeroUsize::MIN,
404 ));
405 let cache = ReconstructionCacheService::for_tests("memory", adapter);
406 let key = ReconstructionCacheKey::latest("asset.bin", None);
407 let loader_calls = AtomicUsize::new(0);
408
409 let first = cache
410 .get_or_load(&key, || {
411 loader_calls.fetch_add(1, Ordering::SeqCst);
412 async { Ok(sample_response("chunk-1")) }
413 })
414 .await;
415 let second = cache
416 .get_or_load(&key, || {
417 loader_calls.fetch_add(1, Ordering::SeqCst);
418 async { Ok(sample_response("chunk-2")) }
419 })
420 .await;
421
422 assert!(first.is_ok());
423 assert!(second.is_ok());
424 assert_eq!(loader_calls.load(Ordering::SeqCst), 1);
425 assert_eq!(
426 second
427 .ok()
428 .and_then(|response| response.terms.first().map(|term| term.hash.clone())),
429 Some("chunk-1".to_owned())
430 );
431 }
432
433 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
434 async fn cache_service_falls_back_to_loader_when_cache_adapter_errors() {
435 let adapter: SharedReconstructionCache = Arc::new(BrokenCache);
436 let cache = ReconstructionCacheService::for_tests("broken", adapter);
437 let key = ReconstructionCacheKey::latest("asset.bin", None);
438
439 let response = cache
440 .get_or_load(&key, || async { Ok(sample_response("chunk-1")) })
441 .await;
442
443 assert!(response.is_ok());
444 assert_eq!(
445 response
446 .ok()
447 .and_then(|value| value.terms.first().map(|term| term.unpacked_length)),
448 Some(4)
449 );
450 }
451
452 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
453 async fn cache_service_falls_back_to_loader_when_cached_payload_exceeds_bound() {
454 let put_calls = Arc::new(AtomicUsize::new(0));
455 let adapter: SharedReconstructionCache = Arc::new(StaticCache {
456 payload: Some(vec![
457 b'{';
458 MAX_RECONSTRUCTION_CACHE_PAYLOAD_BYTES as usize + 1
459 ]),
460 put_calls: Arc::clone(&put_calls),
461 });
462 let cache = ReconstructionCacheService::for_tests("static", adapter);
463 let key = ReconstructionCacheKey::latest("asset.bin", None);
464 let loader_calls = AtomicUsize::new(0);
465
466 let response = cache
467 .get_or_load(&key, || {
468 loader_calls.fetch_add(1, Ordering::SeqCst);
469 async { Ok(sample_response("chunk-1")) }
470 })
471 .await;
472
473 assert!(response.is_ok());
474 assert_eq!(loader_calls.load(Ordering::SeqCst), 1);
475 assert_eq!(put_calls.load(Ordering::SeqCst), 1);
476 }
477
478 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
479 async fn cache_service_skips_put_when_loaded_payload_exceeds_bound() {
480 let put_calls = Arc::new(AtomicUsize::new(0));
481 let stored_payloads = Arc::new(Mutex::new(Vec::new()));
482 let adapter: SharedReconstructionCache = Arc::new(CaptureCache {
483 put_calls: Arc::clone(&put_calls),
484 stored_payloads: Arc::clone(&stored_payloads),
485 });
486 let cache = ReconstructionCacheService::for_tests("capture", adapter);
487 let key = ReconstructionCacheKey::latest("asset.bin", None);
488 let oversized_hash =
489 "h".repeat(usize::try_from(MAX_RECONSTRUCTION_CACHE_PAYLOAD_BYTES).unwrap_or(0));
490
491 let response = cache
492 .get_or_load(&key, || async { Ok(sample_response(&oversized_hash)) })
493 .await;
494
495 assert!(response.is_ok());
496 assert_eq!(put_calls.load(Ordering::SeqCst), 0);
497 assert_eq!(stored_payloads.lock().await.len(), 0);
498 }
499
500 fn sample_response(hash: &str) -> FileReconstructionResponse {
501 FileReconstructionResponse {
502 offset_into_first_range: 0,
503 terms: vec![ReconstructionTerm {
504 hash: hash.to_owned(),
505 unpacked_length: 4,
506 range: ReconstructionChunkRange { start: 0, end: 1 },
507 }],
508 fetch_info: [(
509 hash.to_owned(),
510 vec![ReconstructionFetchInfo {
511 range: ReconstructionChunkRange { start: 0, end: 1 },
512 url: format!("https://cas.example.test/{hash}"),
513 url_range: ReconstructionUrlRange { start: 0, end: 3 },
514 }],
515 )]
516 .into_iter()
517 .collect(),
518 }
519 }
520}