use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use cbh_diag::{Reporter, ReporterExt, count_noun};
use super::{Storage, StorageError, cache_epoch_key, project_objects_prefix};
const GENESIS_EPOCH: &[u8] = b"genesis";
#[derive(Clone, Debug)]
pub struct CachingStorage<Inner, Cache> {
inner: Inner,
cache: Cache,
hits: Arc<AtomicUsize>,
misses: Arc<AtomicUsize>,
}
impl<Inner, Cache> CachingStorage<Inner, Cache>
where
Inner: Storage,
Cache: Storage,
{
pub(crate) fn new(inner: Inner, cache: Cache) -> Self {
Self {
inner,
cache,
hits: Arc::new(AtomicUsize::new(0)),
misses: Arc::new(AtomicUsize::new(0)),
}
}
pub(crate) fn inner(&self) -> &Inner {
&self.inner
}
pub(crate) async fn synchronize(
&self,
project: &str,
reporter: &dyn Reporter,
) -> Result<(), StorageError> {
let marker_key = cache_epoch_key(project);
let cloud_epoch = match self.inner.get(&marker_key).await {
Ok(bytes) => bytes,
Err(StorageError::NotFound { .. }) => GENESIS_EPOCH.to_vec(),
Err(other) => return Err(other),
};
let recorded_epoch = match self.cache.get(&marker_key).await {
Ok(bytes) => Some(bytes),
Err(StorageError::NotFound { .. }) => None,
Err(other) => return Err(other),
};
let reuse = recorded_epoch.as_deref() == Some(cloud_epoch.as_slice());
reporter.if_enabled(|notes| {
notes.note(&format!(
"cache: cloud invalidation marker {marker_key} reads epoch {}",
String::from_utf8_lossy(&cloud_epoch)
));
match &recorded_epoch {
Some(recorded) => notes.note(&format!(
"cache: local mirror last synchronized at epoch {}",
String::from_utf8_lossy(recorded)
)),
None => notes
.note("cache: local mirror has no recorded epoch, so it is treated as cold"),
}
if reuse {
notes.note(
"cache: epochs match, so the mirror is reused without re-downloading the \
history",
);
} else {
notes.note(
"cache: epochs differ, so this project's mirrored objects are wiped and the \
cloud epoch re-recorded before reloading",
);
}
});
if reuse {
return Ok(());
}
self.wipe_objects(project).await?;
self.cache.put_overwrite(&marker_key, &cloud_epoch).await?;
Ok(())
}
pub(crate) fn report_tally(&self, reporter: &dyn Reporter) {
reporter.note_with(|| {
let hits = self.hits.load(Ordering::Relaxed);
let misses = self.misses.load(Ordering::Relaxed);
format!(
"cache: served {} from the local mirror and fetched {} from the cloud this load",
count_noun(hits, "object"),
count_noun(misses, "object"),
)
});
}
async fn wipe_objects(&self, project: &str) -> Result<(), StorageError> {
let prefix = project_objects_prefix(project);
for key in self.cache.list(&prefix).await? {
self.cache.delete(&key).await?;
}
Ok(())
}
}
impl<Inner, Cache> Storage for CachingStorage<Inner, Cache>
where
Inner: Storage,
Cache: Storage,
{
async fn put(&self, key: &str, bytes: &[u8]) -> Result<(), StorageError> {
self.inner.put(key, bytes).await
}
async fn put_overwrite(&self, key: &str, bytes: &[u8]) -> Result<(), StorageError> {
self.inner.put_overwrite(key, bytes).await
}
async fn get(&self, key: &str) -> Result<Vec<u8>, StorageError> {
match self.cache.get(key).await {
Ok(bytes) => {
self.hits.fetch_add(1, Ordering::Relaxed);
Ok(bytes)
}
Err(StorageError::NotFound { .. }) => {
let bytes = self.inner.get(key).await?;
self.cache.put_overwrite(key, &bytes).await?;
self.misses.fetch_add(1, Ordering::Relaxed);
Ok(bytes)
}
Err(other) => Err(other),
}
}
async fn list(&self, prefix: &str) -> Result<Vec<String>, StorageError> {
self.inner.list(prefix).await
}
async fn delete(&self, key: &str) -> Result<(), StorageError> {
self.inner.delete(key).await
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use cbh_diag::RecordingReporter;
use futures::executor::block_on;
use super::*;
use crate::MemoryStorage;
fn caching() -> CachingStorage<MemoryStorage, MemoryStorage> {
CachingStorage::new(MemoryStorage::new(), MemoryStorage::new())
}
#[test]
fn get_populates_the_mirror_on_a_miss() {
let storage = caching();
block_on(
storage
.inner()
.put("v1/p/objects/criterion/abc.json", b"body"),
)
.unwrap();
let bytes = block_on(storage.get("v1/p/objects/criterion/abc.json")).unwrap();
assert_eq!(bytes, b"body");
assert_eq!(
storage.cache.keys(),
vec!["v1/p/objects/criterion/abc.json".to_owned()],
"the fetched object is mirrored"
);
}
#[test]
fn get_serves_a_hit_from_the_mirror_even_when_the_cloud_diverges() {
let storage = caching();
block_on(
storage
.inner()
.put("v1/p/objects/criterion/abc.json", b"body"),
)
.unwrap();
block_on(storage.get("v1/p/objects/criterion/abc.json")).unwrap();
block_on(
storage
.inner()
.put_overwrite("v1/p/objects/criterion/abc.json", b"newer"),
)
.unwrap();
let bytes = block_on(storage.get("v1/p/objects/criterion/abc.json")).unwrap();
assert_eq!(bytes, b"body", "a cache hit serves the mirrored bytes");
}
#[test]
fn list_reflects_objects_added_to_the_cloud_after_the_mirror_was_populated() {
let storage = caching();
block_on(storage.inner().put("v1/p/objects/criterion/a.json", b"a")).unwrap();
block_on(storage.get("v1/p/objects/criterion/a.json")).unwrap();
block_on(storage.inner().put("v1/p/objects/criterion/b.json", b"b")).unwrap();
let keys = block_on(storage.list("v1/p/objects/criterion/")).unwrap();
assert_eq!(
keys,
vec![
"v1/p/objects/criterion/a.json".to_owned(),
"v1/p/objects/criterion/b.json".to_owned()
]
);
}
#[test]
fn synchronize_reuses_the_mirror_when_the_epoch_matches() {
let storage = caching();
let reporter = RecordingReporter::new();
block_on(storage.synchronize("p", &reporter)).unwrap();
block_on(storage.inner().put("v1/p/objects/criterion/a.json", b"a")).unwrap();
block_on(storage.get("v1/p/objects/criterion/a.json")).unwrap();
block_on(storage.synchronize("p", &reporter)).unwrap();
assert!(
storage
.cache
.keys()
.contains(&"v1/p/objects/criterion/a.json".to_owned()),
"a matching epoch leaves the mirror intact"
);
assert!(reporter.contains("epochs match"));
}
#[test]
fn synchronize_wipes_the_mirror_when_the_epoch_changes() {
let storage = caching();
let reporter = RecordingReporter::new();
block_on(storage.inner().put("v1/p/objects/criterion/a.json", b"a")).unwrap();
block_on(storage.get("v1/p/objects/criterion/a.json")).unwrap();
block_on(storage.synchronize("p", &reporter)).unwrap();
block_on(
storage
.inner()
.put_overwrite(&cache_epoch_key("p"), b"2024-01-01T00:00:00Z"),
)
.unwrap();
block_on(storage.synchronize("p", &reporter)).unwrap();
assert_eq!(
storage.cache.keys(),
vec![cache_epoch_key("p")],
"the mirror is wiped down to the freshly recorded epoch"
);
assert!(reporter.contains("epochs differ"));
}
#[test]
fn synchronize_treats_an_absent_marker_as_a_stable_genesis_epoch() {
let storage = caching();
let reporter = RecordingReporter::new();
block_on(storage.synchronize("p", &reporter)).unwrap();
let recorded = block_on(storage.cache.get(&cache_epoch_key("p"))).unwrap();
assert_eq!(recorded, GENESIS_EPOCH);
}
#[test]
fn report_tally_counts_hits_and_misses() {
let storage = caching();
let reporter = RecordingReporter::new();
block_on(storage.inner().put("v1/p/objects/criterion/a.json", b"a")).unwrap();
block_on(storage.get("v1/p/objects/criterion/a.json")).unwrap();
block_on(storage.get("v1/p/objects/criterion/a.json")).unwrap();
storage.report_tally(&reporter);
assert!(
reporter.contains("served 1 object from the local mirror and fetched 1 object"),
"{:?}",
reporter.notes()
);
}
#[derive(Debug)]
struct GetFailsStorage;
impl Storage for GetFailsStorage {
async fn put(&self, _key: &str, _bytes: &[u8]) -> Result<(), StorageError> {
Ok(())
}
async fn put_overwrite(&self, _key: &str, _bytes: &[u8]) -> Result<(), StorageError> {
Ok(())
}
async fn get(&self, _key: &str) -> Result<Vec<u8>, StorageError> {
Err(StorageError::Io(std::io::Error::other(
"backend unavailable",
)))
}
async fn list(&self, _prefix: &str) -> Result<Vec<String>, StorageError> {
Ok(Vec::new())
}
async fn delete(&self, _key: &str) -> Result<(), StorageError> {
Ok(())
}
}
#[test]
fn put_targets_the_cloud_and_not_the_mirror() {
let storage = caching();
block_on(storage.put("v1/p/objects/criterion/a.json", b"body")).unwrap();
assert_eq!(
block_on(storage.inner().get("v1/p/objects/criterion/a.json")).unwrap(),
b"body"
);
assert!(
storage.cache.keys().is_empty(),
"{:?}",
storage.cache.keys()
);
}
#[test]
fn put_overwrite_targets_the_cloud_and_not_the_mirror() {
let storage = caching();
block_on(storage.put_overwrite("v1/p/objects/criterion/a.json", b"body")).unwrap();
assert_eq!(
block_on(storage.inner().get("v1/p/objects/criterion/a.json")).unwrap(),
b"body"
);
assert!(
storage.cache.keys().is_empty(),
"{:?}",
storage.cache.keys()
);
}
#[test]
fn delete_removes_from_the_cloud() {
let storage = caching();
block_on(
storage
.inner()
.put("v1/p/objects/criterion/a.json", b"body"),
)
.unwrap();
block_on(storage.delete("v1/p/objects/criterion/a.json")).unwrap();
let error = block_on(storage.inner().get("v1/p/objects/criterion/a.json")).unwrap_err();
assert!(matches!(error, StorageError::NotFound { .. }), "{error:?}");
}
#[test]
fn get_propagates_a_mirror_read_error() {
let storage = CachingStorage::new(MemoryStorage::new(), GetFailsStorage);
let error = block_on(storage.get("v1/p/objects/criterion/a.json")).unwrap_err();
assert!(matches!(error, StorageError::Io(_)), "{error:?}");
}
#[test]
fn synchronize_propagates_a_marker_read_error() {
let storage = CachingStorage::new(GetFailsStorage, MemoryStorage::new());
let reporter = RecordingReporter::new();
let error = block_on(storage.synchronize("p", &reporter)).unwrap_err();
assert!(matches!(error, StorageError::Io(_)), "{error:?}");
}
#[test]
fn synchronize_propagates_a_recorded_epoch_read_error() {
let storage = CachingStorage::new(MemoryStorage::new(), GetFailsStorage);
let reporter = RecordingReporter::new();
let error = block_on(storage.synchronize("p", &reporter)).unwrap_err();
assert!(matches!(error, StorageError::Io(_)), "{error:?}");
}
}