snix_castore/blobstore/combinators/
cache.rs1use std::sync::Arc;
2
3use tonic::async_trait;
4use tracing::{instrument, trace};
5
6use crate::{
7 B3Digest,
8 blobstore::{self, BlobMeta, BlobStore},
9 composition::{CompositionContext, ServiceBuilder},
10};
11
12pub struct Cache<CN, CF> {
17 instance_name: String,
18 near: CN,
19 far: CF,
20}
21
22impl<BN, BF> Cache<BN, BF> {
23 pub fn new(instance_name: String, near: BN, far: BF) -> Self {
25 Self {
26 instance_name,
27 near,
28 far,
29 }
30 }
31}
32
33#[async_trait]
34impl<BN, BF> BlobStore for Cache<BN, BF>
35where
36 BN: BlobStore,
37 BF: BlobStore,
38{
39 #[instrument(skip(self, digest), fields(blob.digest=%digest, instance_name=%self.instance_name))]
40 async fn get(&self, digest: &B3Digest) -> Result<Option<BlobMeta>, blobstore::Error> {
41 if let Some(blob) = self.near.get(digest).await.map_err(Error::NearGet)? {
42 trace!("serving from cache");
43 return Ok(Some(blob));
44 }
45
46 trace!("not found in near, asking remoteā¦");
47
48 match self.far.get(digest).await.map_err(Error::FarGet)? {
49 None => Ok(None),
51 Some(blob_meta) => {
52 self.near
54 .put(digest, blob_meta.clone())
55 .await
56 .map_err(Error::NearPut)?;
57
58 Ok(Some(blob_meta))
59 }
60 }
61 }
62
63 #[instrument(skip_all, fields(Blob.digest=%digest, instance_name=%self.instance_name))]
64 async fn has(&self, digest: &B3Digest) -> Result<bool, blobstore::Error> {
65 Ok(self.near.has(digest).await.map_err(Error::NearGet)?
66 || self.far.has(digest).await.map_err(Error::FarGet)?)
67 }
68
69 #[instrument(skip_all, fields(instance_name=%self.instance_name))]
71 async fn put(&self, digest: &B3Digest, blob_meta: BlobMeta) -> Result<(), blobstore::Error> {
72 Ok(self
73 .near
74 .put(digest, blob_meta)
75 .await
76 .map_err(Error::NearPut)?)
77 }
78}
79
80#[derive(thiserror::Error, Debug)]
82pub enum Error {
83 #[error("wrong arguments: {0}")]
84 WrongConfig(&'static str),
85
86 #[error("getting from near: {0}")]
87 NearGet(#[source] blobstore::Error),
88 #[error("putting into near: {0}")]
89 NearPut(#[source] blobstore::Error),
90 #[error("getting from far: {0}")]
91 FarGet(#[source] blobstore::Error),
92}
93
94impl From<Error> for blobstore::Error {
95 fn from(value: Error) -> Self {
96 Self(Box::new(value))
97 }
98}
99
100#[derive(serde::Deserialize, Debug)]
102#[serde(deny_unknown_fields)]
103pub struct CacheConfig {
104 near: String,
105 far: String,
106}
107
108impl TryFrom<url::Url> for CacheConfig {
109 type Error = Box<dyn std::error::Error + Send + Sync>;
110 fn try_from(url: url::Url) -> Result<Self, Self::Error> {
111 if url.has_authority() || !url.path().is_empty() {
113 return Err(Error::WrongConfig("no authority or path allowed").into());
114 }
115 Ok(serde_qs::from_str(url.query().unwrap_or_default())?)
116 }
117}
118
119#[async_trait]
120impl ServiceBuilder for CacheConfig {
121 type Output = dyn BlobStore;
122 async fn build<'a>(
123 &'a self,
124 instance_name: &str,
125 context: &CompositionContext,
126 ) -> Result<Arc<Self::Output>, Box<dyn std::error::Error + Send + Sync>> {
127 let (near, far) = futures::join!(
128 context.resolve::<Self::Output>(&self.near),
129 context.resolve::<Self::Output>(&self.far)
130 );
131 Ok(Arc::new(Cache {
132 instance_name: instance_name.to_string(),
133 near: near?,
134 far: far?,
135 }))
136 }
137}
138
139#[cfg(test)]
140mod test {
141 use std::sync::LazyLock;
142
143 use crate::{
144 blobstore::{BlobMeta, BlobStore, MockBlobStore, memory::MemoryBlobStore},
145 chunkstore::Chunk,
146 };
147
148 use super::Cache;
149
150 const CHUNK: Chunk = Chunk::from_static(b"foo");
151 static BLOB_META: LazyLock<BlobMeta> =
152 LazyLock::new(|| BlobMeta::from_digests_and_sizes([(CHUNK.digest(), CHUNK.len() as u64)]));
153
154 #[tokio::test]
156 async fn get_near_only() {
157 let near = MemoryBlobStore::default();
158 near.put(&CHUNK.digest(), BLOB_META.clone())
159 .await
160 .expect("to succeed");
161 let mut far = MockBlobStore::new();
162 far.expect_get().never();
163
164 let uut = Cache::new("uut".to_string(), near, far);
165
166 assert_eq!(
167 Some(BLOB_META.clone()),
168 uut.get(&CHUNK.digest()).await.expect("to succeed")
169 );
170 }
171
172 #[tokio::test]
174 async fn get_populates_near() {
175 let near = MemoryBlobStore::default();
176 let far_with_chunk = MemoryBlobStore::default();
177 far_with_chunk
178 .put(&CHUNK.digest(), BLOB_META.clone())
179 .await
180 .expect("to succeed");
181
182 let uut = Cache::new("uut".to_string(), &near, far_with_chunk);
183
184 assert!(
186 !near.has(&CHUNK.digest()).await.expect("to succeed"),
187 "near should be empty"
188 );
189
190 uut.get(&CHUNK.digest()).await.expect("to succeed");
192
193 assert!(
195 near.has(&CHUNK.digest()).await.expect("to succeed"),
196 "near should be populated now"
197 );
198 }
199
200 #[tokio::test]
202 async fn get_not_found() {
203 let near = MemoryBlobStore::default();
204 let far = MemoryBlobStore::default();
205
206 let uut = Cache::new("uut".to_string(), near, far);
207
208 assert_eq!(None, uut.get(&CHUNK.digest()).await.expect("to succeed"));
209 }
210
211 #[tokio::test]
213 async fn has_near_or_far() {
214 let cs_without = MemoryBlobStore::default();
215 let cs_with = MemoryBlobStore::default();
216 cs_with
217 .put(&CHUNK.digest(), BLOB_META.clone())
218 .await
219 .expect("to succeed");
220
221 let uut = Cache::new("uut".to_string(), &cs_without, &cs_with);
222 assert!(uut.has(&CHUNK.digest()).await.expect("to succeed"));
223 let uut = Cache::new("uut".to_string(), &cs_with, &cs_without);
224 assert!(uut.has(&CHUNK.digest()).await.expect("to succeed"));
225 }
226
227 #[tokio::test]
229 async fn put_writes_to_near() {
230 let near = MemoryBlobStore::default();
231 let far = MemoryBlobStore::default();
232
233 let uut = Cache::new("uut".to_string(), near.clone(), far.clone());
234
235 uut.put(&CHUNK.digest(), BLOB_META.clone())
236 .await
237 .expect("to succeed");
238
239 assert!(near.has(&CHUNK.digest()).await.expect("to succeed"));
240 assert!(!far.has(&CHUNK.digest()).await.expect("to succeed"));
241 }
242}