use anyhow::{Context, Result, anyhow};
use async_stream::stream as gen_stream;
use async_trait::async_trait;
use bytes::{Buf, BufMut, Bytes, BytesMut};
use futures::stream::TryStreamExt;
use futures::stream::{self, BoxStream, StreamExt};
use micromegas_tracing::prelude::*;
use object_store::{
Attributes, CopyOptions, GetOptions, GetResult, GetResultPayload, ListResult, MultipartUpload,
ObjectMeta, ObjectStore, PutMultipartOptions, PutOptions, PutPayload, PutResult, path::Path,
};
use reqwest::Client;
use serde_json::json;
use std::ops::Range;
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::prefetch::{ObjectPrefetch, PrefetchItem, PrefetchResponse};
const CACHE_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
const CACHE_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
#[derive(Debug)]
pub struct CacheClientStore {
http: Client,
cache_base_url: String,
api_key: Option<String>,
direct: Arc<dyn ObjectStore>,
}
impl CacheClientStore {
pub fn new(
cache_base_url: String,
api_key: Option<String>,
direct: Arc<dyn ObjectStore>,
) -> Self {
let http = Client::builder()
.connect_timeout(CACHE_CONNECT_TIMEOUT)
.timeout(CACHE_REQUEST_TIMEOUT)
.build()
.expect("building reqwest client");
Self {
http,
cache_base_url,
api_key,
direct,
}
}
fn obj_url(&self, location: &Path) -> String {
format!(
"{}/obj/{}",
self.cache_base_url.trim_end_matches('/'),
location.as_ref()
)
}
fn add_auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if let Some(key) = &self.api_key {
req.bearer_auth(key)
} else {
req
}
}
async fn get_range_stream(
&self,
location: &Path,
start: u64,
end: Option<u64>,
options: GetOptions,
) -> Result<GetResult> {
let round_trip_start = Instant::now();
let url = self.obj_url(location);
let range_header = match end {
Some(end) => format!("bytes={}-{}", start, end.saturating_sub(1)),
None => format!("bytes={start}-"),
};
let resp = self
.add_auth(self.http.get(&url))
.header("Range", range_header)
.send()
.await
.with_context(|| "sending GET to cache")?;
if !resp.status().is_success() {
return Err(anyhow!("cache GET {url} status {}", resp.status()));
}
if let Some((served_range, object_size)) = parse_content_range(resp.headers()) {
let raw = resp
.bytes_stream()
.map_err(|e| object_store::Error::Generic {
store: "CacheClientStore",
source: Box::new(e),
})
.boxed();
let body =
full_stream_with_fallback(self.direct.clone(), location.clone(), options, raw);
fmetric!(
"range_cache_client_roundtrip_ms",
"ms",
round_trip_start.elapsed().as_secs_f64() * 1000.0
);
return Ok(stream_get_result(location, body, served_range, object_size));
}
let size = self.head_size(location).await?;
fmetric!(
"range_cache_client_roundtrip_ms",
"ms",
round_trip_start.elapsed().as_secs_f64() * 1000.0
);
Ok(build_get_result(location, Bytes::new(), 0..0, size))
}
async fn get_full_stream(&self, location: &Path, options: GetOptions) -> Result<GetResult> {
let round_trip_start = Instant::now();
let url = self.obj_url(location);
let resp = self
.add_auth(self.http.get(&url))
.send()
.await
.with_context(|| "sending GET to cache")?;
if !resp.status().is_success() {
return Err(anyhow!("cache GET {url} status {}", resp.status()));
}
let size = resp
.content_length()
.ok_or_else(|| anyhow!("missing Content-Length in GET response"))?;
let raw = resp
.bytes_stream()
.map_err(|e| object_store::Error::Generic {
store: "CacheClientStore",
source: Box::new(e),
})
.boxed();
let body = full_stream_with_fallback(self.direct.clone(), location.clone(), options, raw);
fmetric!(
"range_cache_client_roundtrip_ms",
"ms",
round_trip_start.elapsed().as_secs_f64() * 1000.0
);
Ok(stream_get_result(location, body, 0..size, size))
}
async fn head_size(&self, location: &Path) -> Result<u64> {
let url = self.obj_url(location);
let resp = self
.add_auth(self.http.head(&url))
.send()
.await
.with_context(|| "sending HEAD to cache")?;
if !resp.status().is_success() {
return Err(anyhow!("cache HEAD {url} status {}", resp.status()));
}
resp.headers()
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.ok_or_else(|| anyhow!("missing Content-Length in HEAD response"))
}
pub async fn prefetch(&self, items: Vec<PrefetchItem>) -> Result<PrefetchResponse> {
let url = format!("{}/prefetch", self.cache_base_url.trim_end_matches('/'));
let mut body = Vec::new();
for item in &items {
serde_json::to_writer(&mut body, item).with_context(|| "serializing PrefetchItem")?;
body.push(b'\n');
}
let result: Result<PrefetchResponse> = async {
let resp = self
.add_auth(
self.http
.post(&url)
.header("Content-Type", "application/x-ndjson")
.body(body),
)
.send()
.await
.with_context(|| "sending POST to cache prefetch")?;
if !resp.status().is_success() {
return Err(anyhow!("cache prefetch {url} status {}", resp.status()));
}
resp.json::<PrefetchResponse>()
.await
.with_context(|| "reading prefetch response")
}
.await;
if let Err(e) = &result {
imetric!("range_cache_client_prefetch_error", "count", 1_u64);
debug!("prefetch request to {url} failed: {e}");
}
result
}
}
#[async_trait]
impl ObjectPrefetch for CacheClientStore {
async fn prefetch(&self, items: Vec<PrefetchItem>) -> Result<PrefetchResponse> {
CacheClientStore::prefetch(self, items).await
}
}
fn parse_content_range(headers: &reqwest::header::HeaderMap) -> Option<(Range<u64>, u64)> {
let value = headers.get(reqwest::header::CONTENT_RANGE)?.to_str().ok()?;
let value = value.strip_prefix("bytes ")?;
let (span, size) = value.split_once('/')?;
let size: u64 = size.trim().parse().ok()?;
let (start, end) = span.split_once('-')?;
let start: u64 = start.trim().parse().ok()?;
let end: u64 = end.trim().parse().ok()?;
Some((start..end.saturating_add(1), size))
}
impl std::fmt::Display for CacheClientStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "CacheClientStore({})", self.cache_base_url)
}
}
fn stream_get_result(
location: &Path,
body: BoxStream<'static, object_store::Result<Bytes>>,
range: Range<u64>,
object_size: u64,
) -> GetResult {
let meta = ObjectMeta {
location: location.clone(),
last_modified: chrono::Utc::now(),
size: object_size,
e_tag: None,
version: None,
};
GetResult {
payload: GetResultPayload::Stream(body),
meta,
range,
attributes: Attributes::default(),
}
}
fn full_stream_with_fallback(
direct: Arc<dyn ObjectStore>,
location: Path,
options: GetOptions,
mut first: BoxStream<'static, object_store::Result<Bytes>>,
) -> BoxStream<'static, object_store::Result<Bytes>> {
gen_stream! {
match first.next().await {
None => {}
Some(Ok(chunk)) => {
yield Ok(chunk);
while let Some(item) = first.next().await {
yield item;
}
}
Some(Err(e)) => {
imetric!("range_cache_client_fallback", "count", 1_u64);
debug!(
"cache GET stream for {location} failed before the first chunk, \
falling back to direct: {e}"
);
let direct_start = Instant::now();
let direct_result = direct.get_opts(&location, options).await;
fmetric!(
"range_cache_client_direct_ms",
"ms",
direct_start.elapsed().as_secs_f64() * 1000.0
);
match direct_result {
Ok(result) => {
let mut body = result.into_stream();
while let Some(item) = body.next().await {
yield item;
}
}
Err(direct_err) => yield Err(direct_err),
}
}
}
}
.boxed()
}
fn build_get_result(
location: &Path,
data: Bytes,
range: Range<u64>,
object_size: u64,
) -> 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(),
}
}
enum RangesReadError {
Transport(reqwest::Error),
Truncated,
}
impl From<reqwest::Error> for RangesReadError {
fn from(e: reqwest::Error) -> Self {
RangesReadError::Transport(e)
}
}
async fn read_framed_ranges(
mut stream: BoxStream<'static, reqwest::Result<Bytes>>,
count: usize,
) -> Result<Vec<Bytes>, RangesReadError> {
let mut pending: Option<Bytes> = None;
let mut results = Vec::with_capacity(count);
for _ in 0..count {
let mut prefix = pull_exact(&mut stream, &mut pending, 8).await?;
let len = prefix.get_u64_le() as usize;
let data = pull_exact(&mut stream, &mut pending, len).await?;
results.push(data);
}
Ok(results)
}
async fn pull_exact(
stream: &mut BoxStream<'static, reqwest::Result<Bytes>>,
pending: &mut Option<Bytes>,
need: usize,
) -> Result<Bytes, RangesReadError> {
let mut collected = BytesMut::with_capacity(need);
while collected.len() < need {
let chunk = match pending.take() {
Some(c) => c,
None => match stream.next().await {
Some(Ok(c)) => c,
Some(Err(e)) => return Err(e.into()),
None => return Err(RangesReadError::Truncated),
},
};
let remaining = need - collected.len();
if chunk.len() > remaining {
collected.put_slice(&chunk[..remaining]);
*pending = Some(chunk.slice(remaining..));
} else {
collected.put_slice(&chunk);
}
}
Ok(collected.freeze())
}
#[async_trait]
impl ObjectStore for CacheClientStore {
async fn put_opts(
&self,
location: &Path,
payload: PutPayload,
opts: PutOptions,
) -> object_store::Result<PutResult> {
self.direct.put_opts(location, payload, opts).await
}
async fn put_multipart_opts(
&self,
location: &Path,
opts: PutMultipartOptions,
) -> object_store::Result<Box<dyn MultipartUpload>> {
self.direct.put_multipart_opts(location, opts).await
}
async fn get_opts(
&self,
location: &Path,
options: GetOptions,
) -> object_store::Result<GetResult> {
use object_store::GetRange;
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()
{
return self.direct.get_opts(location, options).await;
}
if options.head {
let result: Result<GetResult> = match self.head_size(location).await {
Ok(size) => Ok(build_get_result(location, Bytes::new(), 0..0, size)),
Err(e) => Err(e),
};
return match result {
Ok(r) => Ok(r),
Err(e) => {
imetric!("range_cache_client_fallback", "count", 1_u64);
debug!("cache miss for {location} (head), falling back to direct: {e}");
let direct_start = Instant::now();
let direct_result = self.direct.get_opts(location, options).await;
fmetric!(
"range_cache_client_direct_ms",
"ms",
direct_start.elapsed().as_secs_f64() * 1000.0
);
direct_result
}
};
}
let result: Result<GetResult> = match &options.range {
None => self.get_full_stream(location, options.clone()).await,
Some(GetRange::Bounded(r)) => {
self.get_range_stream(location, r.start, Some(r.end), options.clone())
.await
}
Some(GetRange::Offset(offset)) => {
self.get_range_stream(location, *offset, None, options.clone())
.await
}
Some(GetRange::Suffix(suffix)) => match self.head_size(location).await {
Ok(size) => {
let start = size.saturating_sub(*suffix);
self.get_range_stream(location, start, Some(size), options.clone())
.await
}
Err(e) => Err(e),
},
};
match result {
Ok(r) => Ok(r),
Err(e) => {
imetric!("range_cache_client_fallback", "count", 1_u64);
debug!("cache miss for {location}, falling back to direct: {e}");
let direct_start = Instant::now();
let direct_result = self.direct.get_opts(location, options).await;
fmetric!(
"range_cache_client_direct_ms",
"ms",
direct_start.elapsed().as_secs_f64() * 1000.0
);
direct_result
}
}
}
async fn get_ranges(
&self,
location: &Path,
ranges: &[Range<u64>],
) -> object_store::Result<Vec<Bytes>> {
if ranges.is_empty() {
return Ok(vec![]);
}
let round_trip_start = Instant::now();
let url = format!(
"{}/ranges/{}",
self.cache_base_url.trim_end_matches('/'),
location.as_ref()
);
let ranges_json: Vec<[u64; 2]> = ranges.iter().map(|r| [r.start, r.end]).collect();
let body = json!({ "ranges": ranges_json }).to_string();
let resp = match self
.add_auth(
self.http
.post(&url)
.header("Content-Type", "application/json")
.body(body),
)
.send()
.await
{
Ok(r) if r.status().is_success() => r,
Ok(r) => {
imetric!("range_cache_client_fallback", "count", 1_u64);
debug!("cache ranges {url} status {}, falling back", r.status());
let direct_start = Instant::now();
let result = self.direct.get_ranges(location, ranges).await;
fmetric!(
"range_cache_client_direct_ms",
"ms",
direct_start.elapsed().as_secs_f64() * 1000.0
);
return result;
}
Err(e) => {
imetric!("range_cache_client_fallback", "count", 1_u64);
debug!("cache ranges request failed: {e}, falling back to direct");
let direct_start = Instant::now();
let result = self.direct.get_ranges(location, ranges).await;
fmetric!(
"range_cache_client_direct_ms",
"ms",
direct_start.elapsed().as_secs_f64() * 1000.0
);
return result;
}
};
match read_framed_ranges(resp.bytes_stream().boxed(), ranges.len()).await {
Ok(results) => {
fmetric!(
"range_cache_client_ranges_ms",
"ms",
round_trip_start.elapsed().as_secs_f64() * 1000.0
);
Ok(results)
}
Err(RangesReadError::Transport(e)) => {
imetric!("range_cache_client_fallback", "count", 1_u64);
debug!("reading ranges response failed: {e}, falling back to direct");
let direct_start = Instant::now();
let result = self.direct.get_ranges(location, ranges).await;
fmetric!(
"range_cache_client_direct_ms",
"ms",
direct_start.elapsed().as_secs_f64() * 1000.0
);
result
}
Err(RangesReadError::Truncated) => {
imetric!("range_cache_client_fallback", "count", 1_u64);
warn!("truncated ranges response, falling back to direct");
let direct_start = Instant::now();
let result = self.direct.get_ranges(location, ranges).await;
fmetric!(
"range_cache_client_direct_ms",
"ms",
direct_start.elapsed().as_secs_f64() * 1000.0
);
result
}
}
}
fn delete_stream(
&self,
locations: BoxStream<'static, object_store::Result<Path>>,
) -> BoxStream<'static, object_store::Result<Path>> {
self.direct.delete_stream(locations)
}
fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, object_store::Result<ObjectMeta>> {
self.direct.list(prefix)
}
async fn list_with_delimiter(&self, prefix: Option<&Path>) -> object_store::Result<ListResult> {
self.direct.list_with_delimiter(prefix).await
}
async fn copy_opts(
&self,
from: &Path,
to: &Path,
options: CopyOptions,
) -> object_store::Result<()> {
self.direct.copy_opts(from, to, options).await
}
}