1use anyhow::{Context, Result, anyhow};
2use async_stream::stream as gen_stream;
3use async_trait::async_trait;
4use bytes::{Buf, BufMut, Bytes, BytesMut};
5use futures::stream::TryStreamExt;
6use futures::stream::{self, BoxStream, StreamExt};
7use micromegas_tracing::prelude::*;
8use object_store::{
9 Attributes, CopyOptions, GetOptions, GetResult, GetResultPayload, ListResult, MultipartUpload,
10 ObjectMeta, ObjectStore, PutMultipartOptions, PutOptions, PutPayload, PutResult, path::Path,
11};
12use reqwest::Client;
13use serde_json::json;
14use std::ops::Range;
15use std::sync::Arc;
16use std::time::{Duration, Instant};
17
18use crate::prefetch::{ObjectPrefetch, PrefetchItem, PrefetchResponse};
19
20const CACHE_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
23const CACHE_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
26
27#[derive(Debug)]
28pub struct CacheClientStore {
29 http: Client,
30 cache_base_url: String,
31 api_key: Option<String>,
32 direct: Arc<dyn ObjectStore>,
33}
34
35impl CacheClientStore {
36 pub fn new(
37 cache_base_url: String,
38 api_key: Option<String>,
39 direct: Arc<dyn ObjectStore>,
40 ) -> Self {
41 let http = Client::builder()
42 .connect_timeout(CACHE_CONNECT_TIMEOUT)
43 .timeout(CACHE_REQUEST_TIMEOUT)
44 .build()
45 .expect("building reqwest client");
46 Self {
47 http,
48 cache_base_url,
49 api_key,
50 direct,
51 }
52 }
53
54 fn obj_url(&self, location: &Path) -> String {
55 format!(
56 "{}/obj/{}",
57 self.cache_base_url.trim_end_matches('/'),
58 location.as_ref()
59 )
60 }
61
62 fn add_auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
63 if let Some(key) = &self.api_key {
64 req.bearer_auth(key)
65 } else {
66 req
67 }
68 }
69
70 async fn get_range_stream(
88 &self,
89 location: &Path,
90 start: u64,
91 end: Option<u64>,
92 options: GetOptions,
93 ) -> Result<GetResult> {
94 let round_trip_start = Instant::now();
95 let url = self.obj_url(location);
96 let range_header = match end {
97 Some(end) => format!("bytes={}-{}", start, end.saturating_sub(1)),
98 None => format!("bytes={start}-"),
99 };
100 let resp = self
101 .add_auth(self.http.get(&url))
102 .header("Range", range_header)
103 .send()
104 .await
105 .with_context(|| "sending GET to cache")?;
106 if !resp.status().is_success() {
107 return Err(anyhow!("cache GET {url} status {}", resp.status()));
108 }
109
110 if let Some((served_range, object_size)) = parse_content_range(resp.headers()) {
111 let raw = resp
112 .bytes_stream()
113 .map_err(|e| object_store::Error::Generic {
114 store: "CacheClientStore",
115 source: Box::new(e),
116 })
117 .boxed();
118 let body =
119 full_stream_with_fallback(self.direct.clone(), location.clone(), options, raw);
120 fmetric!(
125 "range_cache_client_roundtrip_ms",
126 "ms",
127 round_trip_start.elapsed().as_secs_f64() * 1000.0
128 );
129 return Ok(stream_get_result(location, body, served_range, object_size));
130 }
131
132 let size = self.head_size(location).await?;
138 fmetric!(
139 "range_cache_client_roundtrip_ms",
140 "ms",
141 round_trip_start.elapsed().as_secs_f64() * 1000.0
142 );
143 Ok(build_get_result(location, Bytes::new(), 0..0, size))
144 }
145
146 async fn get_full_stream(&self, location: &Path, options: GetOptions) -> Result<GetResult> {
159 let round_trip_start = Instant::now();
160 let url = self.obj_url(location);
161 let resp = self
162 .add_auth(self.http.get(&url))
163 .send()
164 .await
165 .with_context(|| "sending GET to cache")?;
166 if !resp.status().is_success() {
167 return Err(anyhow!("cache GET {url} status {}", resp.status()));
168 }
169 let size = resp
170 .content_length()
171 .ok_or_else(|| anyhow!("missing Content-Length in GET response"))?;
172 let raw = resp
173 .bytes_stream()
174 .map_err(|e| object_store::Error::Generic {
175 store: "CacheClientStore",
176 source: Box::new(e),
177 })
178 .boxed();
179 let body = full_stream_with_fallback(self.direct.clone(), location.clone(), options, raw);
180 fmetric!(
183 "range_cache_client_roundtrip_ms",
184 "ms",
185 round_trip_start.elapsed().as_secs_f64() * 1000.0
186 );
187 Ok(stream_get_result(location, body, 0..size, size))
188 }
189
190 async fn head_size(&self, location: &Path) -> Result<u64> {
191 let url = self.obj_url(location);
192 let resp = self
193 .add_auth(self.http.head(&url))
194 .send()
195 .await
196 .with_context(|| "sending HEAD to cache")?;
197 if !resp.status().is_success() {
198 return Err(anyhow!("cache HEAD {url} status {}", resp.status()));
199 }
200 resp.headers()
201 .get("content-length")
202 .and_then(|v| v.to_str().ok())
203 .and_then(|s| s.parse::<u64>().ok())
204 .ok_or_else(|| anyhow!("missing Content-Length in HEAD response"))
205 }
206
207 pub async fn prefetch(&self, items: Vec<PrefetchItem>) -> Result<PrefetchResponse> {
212 let url = format!("{}/prefetch", self.cache_base_url.trim_end_matches('/'));
213 let mut body = Vec::new();
214 for item in &items {
215 serde_json::to_writer(&mut body, item).with_context(|| "serializing PrefetchItem")?;
216 body.push(b'\n');
217 }
218
219 let result: Result<PrefetchResponse> = async {
220 let resp = self
221 .add_auth(
222 self.http
223 .post(&url)
224 .header("Content-Type", "application/x-ndjson")
225 .body(body),
226 )
227 .send()
228 .await
229 .with_context(|| "sending POST to cache prefetch")?;
230 if !resp.status().is_success() {
231 return Err(anyhow!("cache prefetch {url} status {}", resp.status()));
232 }
233 resp.json::<PrefetchResponse>()
234 .await
235 .with_context(|| "reading prefetch response")
236 }
237 .await;
238
239 if let Err(e) = &result {
240 imetric!("range_cache_client_prefetch_error", "count", 1_u64);
241 debug!("prefetch request to {url} failed: {e}");
242 }
243 result
244 }
245}
246
247#[async_trait]
248impl ObjectPrefetch for CacheClientStore {
249 async fn prefetch(&self, items: Vec<PrefetchItem>) -> Result<PrefetchResponse> {
250 CacheClientStore::prefetch(self, items).await
251 }
252}
253
254fn parse_content_range(headers: &reqwest::header::HeaderMap) -> Option<(Range<u64>, u64)> {
260 let value = headers.get(reqwest::header::CONTENT_RANGE)?.to_str().ok()?;
261 let value = value.strip_prefix("bytes ")?;
262 let (span, size) = value.split_once('/')?;
263 let size: u64 = size.trim().parse().ok()?;
264 let (start, end) = span.split_once('-')?;
265 let start: u64 = start.trim().parse().ok()?;
266 let end: u64 = end.trim().parse().ok()?;
267 Some((start..end.saturating_add(1), size))
268}
269
270impl std::fmt::Display for CacheClientStore {
271 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
272 write!(f, "CacheClientStore({})", self.cache_base_url)
273 }
274}
275
276fn stream_get_result(
282 location: &Path,
283 body: BoxStream<'static, object_store::Result<Bytes>>,
284 range: Range<u64>,
285 object_size: u64,
286) -> GetResult {
287 let meta = ObjectMeta {
288 location: location.clone(),
289 last_modified: chrono::Utc::now(),
290 size: object_size,
291 e_tag: None,
292 version: None,
293 };
294 GetResult {
295 payload: GetResultPayload::Stream(body),
296 meta,
297 range,
298 attributes: Attributes::default(),
299 }
300}
301
302fn full_stream_with_fallback(
310 direct: Arc<dyn ObjectStore>,
311 location: Path,
312 options: GetOptions,
313 mut first: BoxStream<'static, object_store::Result<Bytes>>,
314) -> BoxStream<'static, object_store::Result<Bytes>> {
315 gen_stream! {
316 match first.next().await {
317 None => {}
318 Some(Ok(chunk)) => {
319 yield Ok(chunk);
320 while let Some(item) = first.next().await {
321 yield item;
322 }
323 }
324 Some(Err(e)) => {
325 imetric!("range_cache_client_fallback", "count", 1_u64);
326 debug!(
327 "cache GET stream for {location} failed before the first chunk, \
328 falling back to direct: {e}"
329 );
330 let direct_start = Instant::now();
331 let direct_result = direct.get_opts(&location, options).await;
332 fmetric!(
333 "range_cache_client_direct_ms",
334 "ms",
335 direct_start.elapsed().as_secs_f64() * 1000.0
336 );
337 match direct_result {
338 Ok(result) => {
339 let mut body = result.into_stream();
340 while let Some(item) = body.next().await {
341 yield item;
342 }
343 }
344 Err(direct_err) => yield Err(direct_err),
345 }
346 }
347 }
348 }
349 .boxed()
350}
351
352fn build_get_result(
358 location: &Path,
359 data: Bytes,
360 range: Range<u64>,
361 object_size: u64,
362) -> GetResult {
363 let meta = ObjectMeta {
364 location: location.clone(),
365 last_modified: chrono::Utc::now(),
366 size: object_size,
367 e_tag: None,
368 version: None,
369 };
370 let payload = GetResultPayload::Stream(Box::pin(stream::once(async move { Ok(data) })));
371 GetResult {
372 payload,
373 meta,
374 range,
375 attributes: Attributes::default(),
376 }
377}
378
379enum RangesReadError {
384 Transport(reqwest::Error),
385 Truncated,
386}
387
388impl From<reqwest::Error> for RangesReadError {
389 fn from(e: reqwest::Error) -> Self {
390 RangesReadError::Transport(e)
391 }
392}
393
394async fn read_framed_ranges(
401 mut stream: BoxStream<'static, reqwest::Result<Bytes>>,
402 count: usize,
403) -> Result<Vec<Bytes>, RangesReadError> {
404 let mut pending: Option<Bytes> = None;
405 let mut results = Vec::with_capacity(count);
406 for _ in 0..count {
407 let mut prefix = pull_exact(&mut stream, &mut pending, 8).await?;
408 let len = prefix.get_u64_le() as usize;
409 let data = pull_exact(&mut stream, &mut pending, len).await?;
410 results.push(data);
411 }
412 Ok(results)
413}
414
415async fn pull_exact(
420 stream: &mut BoxStream<'static, reqwest::Result<Bytes>>,
421 pending: &mut Option<Bytes>,
422 need: usize,
423) -> Result<Bytes, RangesReadError> {
424 let mut collected = BytesMut::with_capacity(need);
425 while collected.len() < need {
426 let chunk = match pending.take() {
427 Some(c) => c,
428 None => match stream.next().await {
429 Some(Ok(c)) => c,
430 Some(Err(e)) => return Err(e.into()),
431 None => return Err(RangesReadError::Truncated),
432 },
433 };
434 let remaining = need - collected.len();
435 if chunk.len() > remaining {
436 collected.put_slice(&chunk[..remaining]);
437 *pending = Some(chunk.slice(remaining..));
438 } else {
439 collected.put_slice(&chunk);
440 }
441 }
442 Ok(collected.freeze())
443}
444
445#[async_trait]
446impl ObjectStore for CacheClientStore {
447 async fn put_opts(
448 &self,
449 location: &Path,
450 payload: PutPayload,
451 opts: PutOptions,
452 ) -> object_store::Result<PutResult> {
453 self.direct.put_opts(location, payload, opts).await
454 }
455
456 async fn put_multipart_opts(
457 &self,
458 location: &Path,
459 opts: PutMultipartOptions,
460 ) -> object_store::Result<Box<dyn MultipartUpload>> {
461 self.direct.put_multipart_opts(location, opts).await
462 }
463
464 async fn get_opts(
465 &self,
466 location: &Path,
467 options: GetOptions,
468 ) -> object_store::Result<GetResult> {
469 use object_store::GetRange;
470
471 if options.if_match.is_some()
475 || options.if_none_match.is_some()
476 || options.if_modified_since.is_some()
477 || options.if_unmodified_since.is_some()
478 || options.version.is_some()
479 {
480 return self.direct.get_opts(location, options).await;
481 }
482
483 if options.head {
486 let result: Result<GetResult> = match self.head_size(location).await {
487 Ok(size) => Ok(build_get_result(location, Bytes::new(), 0..0, size)),
488 Err(e) => Err(e),
489 };
490 return match result {
491 Ok(r) => Ok(r),
492 Err(e) => {
493 imetric!("range_cache_client_fallback", "count", 1_u64);
499 debug!("cache miss for {location} (head), falling back to direct: {e}");
500 let direct_start = Instant::now();
501 let direct_result = self.direct.get_opts(location, options).await;
502 fmetric!(
503 "range_cache_client_direct_ms",
504 "ms",
505 direct_start.elapsed().as_secs_f64() * 1000.0
506 );
507 direct_result
508 }
509 };
510 }
511
512 let result: Result<GetResult> = match &options.range {
513 None => self.get_full_stream(location, options.clone()).await,
514 Some(GetRange::Bounded(r)) => {
519 self.get_range_stream(location, r.start, Some(r.end), options.clone())
520 .await
521 }
522 Some(GetRange::Offset(offset)) => {
525 self.get_range_stream(location, *offset, None, options.clone())
526 .await
527 }
528 Some(GetRange::Suffix(suffix)) => match self.head_size(location).await {
532 Ok(size) => {
533 let start = size.saturating_sub(*suffix);
534 self.get_range_stream(location, start, Some(size), options.clone())
535 .await
536 }
537 Err(e) => Err(e),
538 },
539 };
540
541 match result {
542 Ok(r) => Ok(r),
543 Err(e) => {
544 imetric!("range_cache_client_fallback", "count", 1_u64);
545 debug!("cache miss for {location}, falling back to direct: {e}");
546 let direct_start = Instant::now();
547 let direct_result = self.direct.get_opts(location, options).await;
548 fmetric!(
549 "range_cache_client_direct_ms",
550 "ms",
551 direct_start.elapsed().as_secs_f64() * 1000.0
552 );
553 direct_result
554 }
555 }
556 }
557
558 async fn get_ranges(
559 &self,
560 location: &Path,
561 ranges: &[Range<u64>],
562 ) -> object_store::Result<Vec<Bytes>> {
563 if ranges.is_empty() {
564 return Ok(vec![]);
565 }
566
567 let round_trip_start = Instant::now();
568 let url = format!(
569 "{}/ranges/{}",
570 self.cache_base_url.trim_end_matches('/'),
571 location.as_ref()
572 );
573 let ranges_json: Vec<[u64; 2]> = ranges.iter().map(|r| [r.start, r.end]).collect();
574 let body = json!({ "ranges": ranges_json }).to_string();
575
576 let resp = match self
577 .add_auth(
578 self.http
579 .post(&url)
580 .header("Content-Type", "application/json")
581 .body(body),
582 )
583 .send()
584 .await
585 {
586 Ok(r) if r.status().is_success() => r,
587 Ok(r) => {
588 imetric!("range_cache_client_fallback", "count", 1_u64);
589 debug!("cache ranges {url} status {}, falling back", r.status());
590 let direct_start = Instant::now();
591 let result = self.direct.get_ranges(location, ranges).await;
592 fmetric!(
593 "range_cache_client_direct_ms",
594 "ms",
595 direct_start.elapsed().as_secs_f64() * 1000.0
596 );
597 return result;
598 }
599 Err(e) => {
600 imetric!("range_cache_client_fallback", "count", 1_u64);
601 debug!("cache ranges request failed: {e}, falling back to direct");
602 let direct_start = Instant::now();
603 let result = self.direct.get_ranges(location, ranges).await;
604 fmetric!(
605 "range_cache_client_direct_ms",
606 "ms",
607 direct_start.elapsed().as_secs_f64() * 1000.0
608 );
609 return result;
610 }
611 };
612
613 match read_framed_ranges(resp.bytes_stream().boxed(), ranges.len()).await {
625 Ok(results) => {
626 fmetric!(
634 "range_cache_client_ranges_ms",
635 "ms",
636 round_trip_start.elapsed().as_secs_f64() * 1000.0
637 );
638 Ok(results)
639 }
640 Err(RangesReadError::Transport(e)) => {
641 imetric!("range_cache_client_fallback", "count", 1_u64);
642 debug!("reading ranges response failed: {e}, falling back to direct");
643 let direct_start = Instant::now();
644 let result = self.direct.get_ranges(location, ranges).await;
645 fmetric!(
646 "range_cache_client_direct_ms",
647 "ms",
648 direct_start.elapsed().as_secs_f64() * 1000.0
649 );
650 result
651 }
652 Err(RangesReadError::Truncated) => {
653 imetric!("range_cache_client_fallback", "count", 1_u64);
657 warn!("truncated ranges response, falling back to direct");
658 let direct_start = Instant::now();
659 let result = self.direct.get_ranges(location, ranges).await;
660 fmetric!(
661 "range_cache_client_direct_ms",
662 "ms",
663 direct_start.elapsed().as_secs_f64() * 1000.0
664 );
665 result
666 }
667 }
668 }
669
670 fn delete_stream(
671 &self,
672 locations: BoxStream<'static, object_store::Result<Path>>,
673 ) -> BoxStream<'static, object_store::Result<Path>> {
674 self.direct.delete_stream(locations)
675 }
676
677 fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, object_store::Result<ObjectMeta>> {
678 self.direct.list(prefix)
679 }
680
681 async fn list_with_delimiter(&self, prefix: Option<&Path>) -> object_store::Result<ListResult> {
682 self.direct.list_with_delimiter(prefix).await
683 }
684
685 async fn copy_opts(
686 &self,
687 from: &Path,
688 to: &Path,
689 options: CopyOptions,
690 ) -> object_store::Result<()> {
691 self.direct.copy_opts(from, to, options).await
692 }
693}