use std::ops::Range;
use std::sync::{Arc, OnceLock};
use async_trait::async_trait;
use bytes::Bytes;
use futures::stream::{self, BoxStream};
use micromegas_tracing::prelude::*;
use object_store::{
Attributes, CopyOptions, GetOptions, GetRange, GetResult, GetResultPayload, ListResult,
MultipartUpload, ObjectMeta, ObjectStore, PutMultipartOptions, PutOptions, PutPayload,
PutResult, path::Path,
};
use crate::backend::RangeCacheBackend;
use crate::bounded_memory_backend::BoundedMemoryBackend;
use crate::range_cache::{
DEFAULT_BLOCK_SIZE, DEFAULT_MAX_COALESCED_GET_BYTES, DEFAULT_PROMOTE_WHOLE_BATCH, RangeCache,
};
const ENV_OBJECT_CACHE_L1_MB: &str = "MICROMEGAS_OBJECT_CACHE_L1_MB";
const DEFAULT_OBJECT_CACHE_L1_MB: u64 = 200;
const L1_TOTAL_FETCH_PERMITS: usize = 16;
const L1_DEMAND_RESERVED_FETCH_PERMITS: usize = 4;
static SHARED_L1_BACKEND: OnceLock<Option<Arc<BoundedMemoryBackend>>> = OnceLock::new();
fn shared_l1_backend() -> Option<Arc<BoundedMemoryBackend>> {
SHARED_L1_BACKEND
.get_or_init(|| {
let mb = match std::env::var(ENV_OBJECT_CACHE_L1_MB) {
Ok(s) => s.parse::<u64>().unwrap_or_else(|_| {
warn!(
"Invalid {ENV_OBJECT_CACHE_L1_MB} value '{s}', using default {DEFAULT_OBJECT_CACHE_L1_MB} MB"
);
DEFAULT_OBJECT_CACHE_L1_MB
}),
Err(_) => DEFAULT_OBJECT_CACHE_L1_MB,
};
if mb == 0 {
info!("{ENV_OBJECT_CACHE_L1_MB}=0, in-process L1 cache disabled");
None
} else {
info!("in-process L1 cache enabled, budget={mb}MB");
Some(Arc::new(BoundedMemoryBackend::new(
(mb * 1024 * 1024) as usize,
)))
}
})
.clone()
}
pub fn l1_wrap(origin: Arc<dyn ObjectStore>, ns: &str) -> Arc<dyn ObjectStore> {
match shared_l1_backend() {
Some(backend) => Arc::new(L1CacheStore::new(origin, backend, ns.to_string())),
None => origin,
}
}
pub struct L1CacheStore {
cache: RangeCache,
origin: Arc<dyn ObjectStore>,
}
impl L1CacheStore {
pub fn new(
origin: Arc<dyn ObjectStore>,
backend: Arc<dyn RangeCacheBackend>,
ns: String,
) -> Self {
let cache = RangeCache::new(
origin.clone(),
backend,
DEFAULT_BLOCK_SIZE,
ns,
L1_TOTAL_FETCH_PERMITS,
L1_DEMAND_RESERVED_FETCH_PERMITS,
DEFAULT_MAX_COALESCED_GET_BYTES,
DEFAULT_PROMOTE_WHOLE_BATCH,
);
Self { cache, origin }
}
fn key(location: &Path) -> String {
location.as_ref().to_string()
}
async fn fallback_get_opts(
&self,
location: &Path,
options: GetOptions,
error: anyhow::Error,
) -> object_store::Result<GetResult> {
imetric!("l1_cache_fallback", "count", 1_u64);
debug!("L1 cache miss for {location}, falling back to origin: {error}");
self.origin.get_opts(location, options).await
}
}
impl std::fmt::Debug for L1CacheStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "L1CacheStore({})", self.origin)
}
}
impl std::fmt::Display for L1CacheStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "L1CacheStore({})", self.origin)
}
}
fn single_chunk_get_result(
data: Bytes,
range: Range<u64>,
object_size: u64,
location: &Path,
) -> GetResult {
let meta = ObjectMeta {
location: location.clone(),
last_modified: chrono::Utc::now(),
size: object_size,
e_tag: None,
version: None,
};
let payload = GetResultPayload::Stream(Box::pin(stream::once(async move { Ok(data) })));
GetResult {
payload,
meta,
range,
attributes: Attributes::default(),
}
}
#[async_trait]
impl ObjectStore for L1CacheStore {
async fn put_opts(
&self,
location: &Path,
payload: PutPayload,
opts: PutOptions,
) -> object_store::Result<PutResult> {
self.origin.put_opts(location, payload, opts).await
}
async fn put_multipart_opts(
&self,
location: &Path,
opts: PutMultipartOptions,
) -> object_store::Result<Box<dyn MultipartUpload>> {
self.origin.put_multipart_opts(location, opts).await
}
async fn get_opts(
&self,
location: &Path,
options: GetOptions,
) -> object_store::Result<GetResult> {
if options.if_match.is_some()
|| options.if_none_match.is_some()
|| options.if_modified_since.is_some()
|| options.if_unmodified_since.is_some()
|| options.version.is_some()
|| options.head
{
return self.origin.get_opts(location, options).await;
}
let key = Self::key(location);
let range = options.range.clone();
let resolved: anyhow::Result<(Bytes, Range<u64>, u64)> = async {
match &range {
Some(GetRange::Bounded(r)) => {
let r = r.clone();
let data = self.cache.get_range(&key, r.clone()).await?;
let size = self.cache.size(&key).await?;
Ok((data, r, size))
}
other => {
let size = self.cache.size(&key).await?;
let resolved_range = match other {
None => 0..size,
Some(GetRange::Offset(offset)) => *offset..size,
Some(GetRange::Suffix(suffix)) => size.saturating_sub(*suffix)..size,
Some(GetRange::Bounded(_)) => unreachable!("handled above"),
};
let data = self.cache.get_range(&key, resolved_range.clone()).await?;
Ok((data, resolved_range, size))
}
}
}
.await;
match resolved {
Ok((data, range, size)) => Ok(single_chunk_get_result(data, range, size, location)),
Err(e) => self.fallback_get_opts(location, options, e).await,
}
}
async fn get_ranges(
&self,
location: &Path,
ranges: &[Range<u64>],
) -> object_store::Result<Vec<Bytes>> {
if ranges.is_empty() {
return Ok(vec![]);
}
let key = Self::key(location);
match self.cache.get_ranges(&key, ranges).await {
Ok(results) => Ok(results),
Err(e) => {
imetric!("l1_cache_fallback", "count", 1_u64);
debug!("L1 cache miss for {location} (ranges), falling back to origin: {e}");
self.origin.get_ranges(location, ranges).await
}
}
}
fn delete_stream(
&self,
locations: BoxStream<'static, object_store::Result<Path>>,
) -> BoxStream<'static, object_store::Result<Path>> {
self.origin.delete_stream(locations)
}
fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, object_store::Result<ObjectMeta>> {
self.origin.list(prefix)
}
async fn list_with_delimiter(&self, prefix: Option<&Path>) -> object_store::Result<ListResult> {
self.origin.list_with_delimiter(prefix).await
}
async fn copy_opts(
&self,
from: &Path,
to: &Path,
options: CopyOptions,
) -> object_store::Result<()> {
self.origin.copy_opts(from, to, options).await
}
}