Skip to main content

micromegas_object_cache/
client.rs

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, GetRange, GetResult, GetResultPayload, ListResult,
10    MultipartUpload, ObjectMeta, ObjectStore, PutMultipartOptions, PutOptions, PutPayload,
11    PutResult, path::Path,
12};
13use reqwest::Client;
14use serde_json::json;
15use std::ops::Range;
16use std::sync::Arc;
17use std::time::{Duration, Instant};
18
19use crate::circuit_breaker::{Admission, CircuitBreaker, CircuitBreakerConfig, Transition};
20use crate::prefetch::{ObjectPrefetch, PrefetchItem, PrefetchResponse};
21
22/// Fail fast if the cache server can't be reached, so reads fall back to the
23/// direct store instead of stalling on a hung connection. Spans DNS
24/// resolution as well as the TCP/TLS handshake (reqwest wraps the whole
25/// connector service). Calibrated for the only deployment this client
26/// documents -- a private, intra-VPC `object-cache`; raise it via
27/// `MICROMEGAS_OBJECT_CACHE_CLIENT_CONNECT_TIMEOUT_MS` for TLS/cross-zone/
28/// clustered-DNS deployments.
29const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_millis(50);
30/// The direct-path race budget: every phase where the cache can lose (which,
31/// since mid-stream resume exists, is all of them) is bounded by this.
32/// Calibrated at the production origin-fetch latency distribution (p99
33/// ~311ms, max ~575ms).
34const DEFAULT_ABANDON_TIMEOUT: Duration = Duration::from_millis(500);
35/// Per-chunk bound on body reassembly (`get_ranges`' `pull_exact`, and --
36/// indirectly, via `read_timeout` -- the `GET /obj` response body). Looser
37/// than `abandon_timeout` because a mid-stream abandon re-reads the
38/// remainder not yet delivered: a throughput cost knob, not a correctness
39/// bound. Also reused, unmodified, as the breaker's `cooldown`. Not an env
40/// var.
41const DEFAULT_STALL_TIMEOUT: Duration = Duration::from_secs(3);
42/// Overall per-request deadline (`ClientBuilder::timeout`). A rare backstop
43/// behind the tighter bounds above for a healthy cache; still reports
44/// `record_unresponsive` on expiry. Not an env var.
45const DEFAULT_TOTAL_TIMEOUT: Duration = Duration::from_secs(15);
46/// Consecutive unresponsive requests that trip the circuit breaker. Not an
47/// env var by default value, but overridable; `0` disables the breaker.
48const DEFAULT_BREAKER_THRESHOLD: u32 = 5;
49
50const ENV_CONNECT_TIMEOUT_MS: &str = "MICROMEGAS_OBJECT_CACHE_CLIENT_CONNECT_TIMEOUT_MS";
51const ENV_ABANDON_TIMEOUT_MS: &str = "MICROMEGAS_OBJECT_CACHE_CLIENT_ABANDON_TIMEOUT_MS";
52const ENV_BREAKER_THRESHOLD: &str = "MICROMEGAS_OBJECT_CACHE_CLIENT_BREAKER_THRESHOLD";
53
54/// Tunables for `CacheClientStore`. `Default` carries the production values,
55/// `from_env` applies operator overrides, and tests construct one directly
56/// (short timeouts, near-zero or very long cooldown) instead of sleeping.
57#[derive(Debug, Clone)]
58pub struct CacheClientConfig {
59    pub connect_timeout: Duration,
60    /// The direct-path race budget: every phase where the cache can lose,
61    /// which after Phase 0 is all of them. Applied at the header (time-to-
62    /// headers) phase of the five `send()` sites.
63    pub abandon_timeout: Duration,
64    /// Per-chunk bound on `get_ranges`' `pull_exact` read. Looser than
65    /// `abandon_timeout` because a mid-stream abandon re-reads the ranges
66    /// not yet delivered -- a cost knob, not a correctness bound. Also backs
67    /// the `GET /obj` response body's per-frame bound, but *not* directly:
68    /// the client's `ClientBuilder::read_timeout` is set to `stall_timeout +
69    /// abandon_timeout` (looser by a margin), so `/ranges`' own explicit
70    /// `pull_exact` wrap -- set to `stall_timeout` exactly -- is always the
71    /// one that fires first on a real stall, never `read_timeout`. Reused as
72    /// the breaker's `cooldown`.
73    pub stall_timeout: Duration,
74    pub total_timeout: Duration,
75    pub breaker: CircuitBreakerConfig,
76}
77
78impl Default for CacheClientConfig {
79    fn default() -> Self {
80        Self {
81            connect_timeout: DEFAULT_CONNECT_TIMEOUT,
82            abandon_timeout: DEFAULT_ABANDON_TIMEOUT,
83            stall_timeout: DEFAULT_STALL_TIMEOUT,
84            total_timeout: DEFAULT_TOTAL_TIMEOUT,
85            breaker: CircuitBreakerConfig {
86                failure_threshold: DEFAULT_BREAKER_THRESHOLD,
87                // Reuse `stall_timeout` rather than adding a second knob --
88                // see "Why the cooldown is fixed". Kept in sync by
89                // `from_env` too; a test overriding `stall_timeout` sets the
90                // cooldown it wants explicitly.
91                cooldown: DEFAULT_STALL_TIMEOUT,
92            },
93        }
94    }
95}
96
97impl CacheClientConfig {
98    /// Apply `MICROMEGAS_OBJECT_CACHE_CLIENT_*` operator overrides on top of
99    /// `Default`. `stall_timeout`/`total_timeout` stay fixed named constants
100    /// (see "The constants that are not env vars"); only the connect budget,
101    /// abandon budget, and breaker threshold are env-overridable.
102    pub fn from_env() -> Self {
103        let connect_timeout = env_millis(ENV_CONNECT_TIMEOUT_MS, DEFAULT_CONNECT_TIMEOUT);
104        let abandon_timeout = env_millis(ENV_ABANDON_TIMEOUT_MS, DEFAULT_ABANDON_TIMEOUT);
105        let failure_threshold = env_u32(ENV_BREAKER_THRESHOLD, DEFAULT_BREAKER_THRESHOLD);
106        Self {
107            connect_timeout,
108            abandon_timeout,
109            breaker: CircuitBreakerConfig {
110                failure_threshold,
111                ..Self::default().breaker
112            },
113            ..Self::default()
114        }
115    }
116}
117
118/// Parse a millisecond duration from the environment, warning and falling
119/// back to `default` on an invalid (non-integer) value. Mirrors the
120/// warn-and-default pattern in `l1_store.rs`.
121fn env_millis(var: &str, default: Duration) -> Duration {
122    match std::env::var(var) {
123        Ok(s) => match s.parse::<u64>() {
124            Ok(ms) => Duration::from_millis(ms),
125            Err(_) => {
126                warn!("Invalid {var} value '{s}', using default {default:?}");
127                default
128            }
129        },
130        Err(_) => default,
131    }
132}
133
134/// Parse a `u32` from the environment, warning and falling back to `default`
135/// on an invalid value. Mirrors the warn-and-default pattern in `l1_store.rs`.
136fn env_u32(var: &str, default: u32) -> u32 {
137    match std::env::var(var) {
138        Ok(s) => match s.parse::<u32>() {
139            Ok(v) => v,
140            Err(_) => {
141                warn!("Invalid {var} value '{s}', using default {default}");
142                default
143            }
144        },
145        Err(_) => default,
146    }
147}
148
149#[derive(Debug)]
150pub struct CacheClientStore {
151    http: Client,
152    cache_base_url: String,
153    api_key: Option<String>,
154    direct: Arc<dyn ObjectStore>,
155    config: CacheClientConfig,
156    breaker: Arc<CircuitBreaker>,
157}
158
159impl CacheClientStore {
160    pub fn new(
161        cache_base_url: String,
162        api_key: Option<String>,
163        direct: Arc<dyn ObjectStore>,
164    ) -> Self {
165        Self::with_config(
166            cache_base_url,
167            api_key,
168            direct,
169            CacheClientConfig::from_env(),
170        )
171    }
172
173    /// Like `new`, but with an explicit `CacheClientConfig` instead of
174    /// reading the environment -- what tests use to install short timeouts
175    /// and a controllable cooldown.
176    pub fn with_config(
177        cache_base_url: String,
178        api_key: Option<String>,
179        direct: Arc<dyn ObjectStore>,
180        config: CacheClientConfig,
181    ) -> Self {
182        // One client for every endpoint. `read_timeout` gives the `/obj`
183        // response body (and `prefetch`'s `resp.bytes()`) its per-frame
184        // bound; it's set with a margin above `stall_timeout` so `/ranges`'
185        // own explicit `pull_exact` wrap is unambiguously the first of the
186        // two to fire on a real stall -- see "`ClientBuilder::read_timeout`
187        // on the one client".
188        let http = Client::builder()
189            .connect_timeout(config.connect_timeout)
190            .read_timeout(config.stall_timeout + config.abandon_timeout)
191            .timeout(config.total_timeout)
192            .build()
193            .expect("building reqwest client");
194        let breaker = Arc::new(CircuitBreaker::new(config.breaker.clone()));
195        Self {
196            http,
197            cache_base_url,
198            api_key,
199            direct,
200            config,
201            breaker,
202        }
203    }
204
205    fn obj_url(&self, location: &Path) -> String {
206        format!(
207            "{}/obj/{}",
208            self.cache_base_url.trim_end_matches('/'),
209            location.as_ref()
210        )
211    }
212
213    fn add_auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
214        if let Some(key) = &self.api_key {
215            req.bearer_auth(key)
216        } else {
217            req
218        }
219    }
220
221    /// Send a request to the cache, bounding time-to-headers with
222    /// `config.abandon_timeout` and reporting *failures* to the circuit
223    /// breaker. Success is deliberately NOT reported here: a single logical
224    /// read can issue two requests (Suffix does `head_size` then
225    /// `get_range_stream`), and reporting responsive at each time-to-headers
226    /// would zero `consecutive` so a later-phase failure could never trip
227    /// the breaker. See "One outcome per logical operation". Every call site
228    /// is recoverable -- dropping the future cancels the request and lands
229    /// in the existing fallback -- which is what makes one budget correct
230    /// for all of them.
231    async fn send(&self, req: reqwest::RequestBuilder, what: &str) -> Result<reqwest::Response> {
232        let budget = self.config.abandon_timeout;
233        match tokio::time::timeout(budget, req.send()).await {
234            Ok(Ok(resp)) => Ok(resp),
235            Ok(Err(e)) => {
236                self.report_unresponsive(what);
237                Err(e).with_context(|| format!("sending {what} to cache"))
238            }
239            Err(_) => {
240                self.report_abandoned(what);
241                Err(anyhow!(
242                    "cache {what} abandoned: no response headers within {budget:?}"
243                ))
244            }
245        }
246    }
247
248    /// The single success report for a logical operation: the resource
249    /// answered (any HTTP status), so the circuit is healthy.
250    fn report(&self) {
251        report_transition(self.breaker.record_responsive());
252    }
253
254    /// The resource is not answering at all: connect failure, transport
255    /// error, or a `stall_timeout` expiry (see "Abandon vs. unresponsive").
256    fn report_unresponsive(&self, what: &str) {
257        report_unresponsive(what, &self.breaker);
258    }
259
260    /// The cache lost the race against the direct path (an `abandon_timeout`
261    /// expiry). Called only from `send`.
262    fn report_abandoned(&self, what: &str) {
263        imetric!("range_cache_client_abandoned", "count", 1_u64);
264        debug!("cache {what} abandoned: no response headers within the abandon budget");
265        report_transition(self.breaker.record_unresponsive());
266    }
267
268    /// Fall back to the direct store for a whole `get_opts` operation,
269    /// bumping the shared `range_cache_client_fallback` counter and timing
270    /// `range_cache_client_direct_ms`, mirroring
271    /// `L1CacheStore::fallback_get_opts`.
272    async fn direct_get_opts(
273        &self,
274        location: &Path,
275        options: GetOptions,
276    ) -> object_store::Result<GetResult> {
277        direct_get_opts_with_metrics(&self.direct, location, options).await
278    }
279
280    /// Fall back to the direct store for a whole `get_ranges` operation; see
281    /// `direct_get_opts`.
282    async fn direct_get_ranges(
283        &self,
284        location: &Path,
285        ranges: &[Range<u64>],
286    ) -> object_store::Result<Vec<Bytes>> {
287        imetric!("range_cache_client_fallback", "count", 1_u64);
288        let direct_start = Instant::now();
289        let result = self.direct.get_ranges(location, ranges).await;
290        fmetric!(
291            "range_cache_client_direct_ms",
292            "ms",
293            direct_start.elapsed().as_secs_f64() * 1000.0
294        );
295        result
296    }
297
298    /// Issue a range GET and build a streaming `GetResult`, mirroring
299    /// `get_full_stream` but for ranged reads: the body is streamed rather
300    /// than buffered with `.bytes()`, which would otherwise materialize the
301    /// whole range (unbounded, since the server does not cap total requested
302    /// bytes) as one contiguous allocation before any of it is used. The
303    /// actual served byte range and the full object size both come
304    /// from the 206's `Content-Range: bytes {start}-{end}/{size}` header
305    /// rather than a buffered body length, avoiding a separate HEAD
306    /// round-trip in the common case.
307    ///
308    /// An open-ended `end` (`None`) requests `bytes={start}-`, i.e. from `start`
309    /// to the end of the object, which the cache server resolves against the
310    /// true object size.
311    ///
312    /// `options` (carrying the original requested range) is threaded through
313    /// so a stream error reaching the consumer can fall back to `self.direct`
314    /// for the remainder of the same range, via `full_stream_with_fallback`.
315    async fn get_range_stream(
316        &self,
317        location: &Path,
318        start: u64,
319        end: Option<u64>,
320        options: GetOptions,
321    ) -> Result<GetResult> {
322        let round_trip_start = Instant::now();
323        let url = self.obj_url(location);
324        let range_header = match end {
325            Some(end) => format!("bytes={}-{}", start, end.saturating_sub(1)),
326            None => format!("bytes={start}-"),
327        };
328        let resp = self
329            .send(
330                self.add_auth(self.http.get(&url))
331                    .header("Range", range_header),
332                "GET",
333            )
334            .await?;
335        if !resp.status().is_success() {
336            // A full HTTP response arrived cheaply -- see "Abandon vs.
337            // unresponsive" ("any HTTP response on a demand-read endpoint
338            // counts as responsive").
339            self.report();
340            return Err(anyhow!("cache GET {url} status {}", resp.status()));
341        }
342
343        if let Some((served_range, object_size)) = parse_content_range(resp.headers()) {
344            let raw = resp
345                .bytes_stream()
346                .map_err(|e| object_store::Error::Generic {
347                    store: "CacheClientStore",
348                    source: Box::new(e),
349                })
350                .boxed();
351            let body = full_stream_with_fallback(
352                self.direct.clone(),
353                self.breaker.clone(),
354                location.clone(),
355                options,
356                served_range.clone(),
357                raw,
358            );
359            // Measured at time-to-usable-stream, i.e. before any body bytes
360            // are read (the body streams lazily) — distinct from
361            // `range_cache_client_ranges_ms`, which covers the buffered
362            // `/ranges` path and is measured after the full body is read.
363            fmetric!(
364                "range_cache_client_roundtrip_ms",
365                "ms",
366                round_trip_start.elapsed().as_secs_f64() * 1000.0
367            );
368            return Ok(stream_get_result(location, body, served_range, object_size));
369        }
370
371        // No `Content-Range`: the server serves a zero-length range (an
372        // empty/zero-byte object, or an open-ended range starting exactly at
373        // EOF) as a plain 200 with an empty body rather than a 206 (see
374        // `get_range_handler`), so there's nothing to stream. The full object
375        // size still isn't known from this response; resolve it with a HEAD.
376        // `head_size` is the tail of this operation -- no stream follows --
377        // so this call site owns the single success report.
378        let size = self.head_size(location).await?;
379        self.report();
380        fmetric!(
381            "range_cache_client_roundtrip_ms",
382            "ms",
383            round_trip_start.elapsed().as_secs_f64() * 1000.0
384        );
385        Ok(build_get_result(location, Bytes::new(), 0..0, size))
386    }
387
388    /// Issue an unranged GET and build a streaming `GetResult`, mapping the
389    /// response body to `GetResultPayload::Stream` so the whole object is never
390    /// buffered in memory (matching how the direct store streams the body). The
391    /// object size comes from the `Content-Length` header, which is required to
392    /// populate `meta.size` and the `0..size` range without reading the body.
393    ///
394    /// The body is wrapped with `full_stream_with_fallback` so a stream error
395    /// reaching the consumer transparently resumes the remainder from
396    /// `self.direct`, at the `GetResult` level instead of the whole-response
397    /// level, since this path streams rather than buffers (the ranged path,
398    /// `get_range_stream`, uses the same helper for the same reason).
399    async fn get_full_stream(&self, location: &Path, options: GetOptions) -> Result<GetResult> {
400        let round_trip_start = Instant::now();
401        let url = self.obj_url(location);
402        let resp = self.send(self.add_auth(self.http.get(&url)), "GET").await?;
403        if !resp.status().is_success() {
404            self.report();
405            return Err(anyhow!("cache GET {url} status {}", resp.status()));
406        }
407        let size = match resp.content_length() {
408            Some(size) => size,
409            None => {
410                // A malformed response after a cheap 2xx is a protocol
411                // violation from our own cache, not a liveness signal -- see
412                // "What does not feed the breaker as a failure".
413                self.report();
414                return Err(anyhow!("missing Content-Length in GET response"));
415            }
416        };
417        let raw = resp
418            .bytes_stream()
419            .map_err(|e| object_store::Error::Generic {
420                store: "CacheClientStore",
421                source: Box::new(e),
422            })
423            .boxed();
424        let body = full_stream_with_fallback(
425            self.direct.clone(),
426            self.breaker.clone(),
427            location.clone(),
428            options,
429            0..size,
430            raw,
431        );
432        // Measured at time-to-usable-stream, before any body bytes are read
433        // (see the matching comment in `get_range_stream`).
434        fmetric!(
435            "range_cache_client_roundtrip_ms",
436            "ms",
437            round_trip_start.elapsed().as_secs_f64() * 1000.0
438        );
439        Ok(stream_get_result(location, body, 0..size, size))
440    }
441
442    /// Shared by three call sites (the head-only path, the no-`Content-Range`
443    /// path in `get_range_stream`, and the Suffix path). Reports
444    /// `record_responsive` internally on its own two after-2xx failure arms
445    /// (non-2xx status, malformed/missing `Content-Length`) -- both are
446    /// terminal for every caller, so those reports fire identically no matter
447    /// which site is calling. The *success* report is left to whichever call
448    /// site owns the whole logical operation (see "One outcome per logical
449    /// operation").
450    async fn head_size(&self, location: &Path) -> Result<u64> {
451        let url = self.obj_url(location);
452        let resp = self
453            .send(self.add_auth(self.http.head(&url)), "HEAD")
454            .await?;
455        if !resp.status().is_success() {
456            self.report();
457            return Err(anyhow!("cache HEAD {url} status {}", resp.status()));
458        }
459        match resp
460            .headers()
461            .get("content-length")
462            .and_then(|v| v.to_str().ok())
463            .and_then(|s| s.parse::<u64>().ok())
464        {
465            Some(size) => Ok(size),
466            None => {
467                self.report();
468                Err(anyhow!("missing Content-Length in HEAD response"))
469            }
470        }
471    }
472
473    /// POST a batch of keys to warm at the cache server's prefetch priority.
474    /// Best-effort: there is no demand read to fall back to, so callers should
475    /// treat an `Err` as "the warm didn't happen" and move on rather than
476    /// retrying inline.
477    ///
478    /// Gated on `admit_bypass_only()`, not `admit()`: prefetch never closes
479    /// the circuit and must not be able to consume the single per-cooldown
480    /// probe slot a demand read would otherwise receive -- see "Prefetch
481    /// does not close the circuit".
482    pub async fn prefetch(&self, items: Vec<PrefetchItem>) -> Result<PrefetchResponse> {
483        if matches!(self.breaker.admit_bypass_only(), Admission::Bypass) {
484            imetric!("range_cache_client_circuit_bypassed", "count", 1_u64);
485            debug!(
486                "cache circuit open, dropping {} prefetch item(s)",
487                items.len()
488            );
489            return Ok(PrefetchResponse {
490                accepted: 0,
491                rejected: 0,
492                dropped: items.len(),
493            });
494        }
495
496        let url = format!("{}/prefetch", self.cache_base_url.trim_end_matches('/'));
497        let mut body = Vec::new();
498        for item in &items {
499            serde_json::to_writer(&mut body, item).with_context(|| "serializing PrefetchItem")?;
500            body.push(b'\n');
501        }
502
503        let result: Result<PrefetchResponse> = async {
504            let resp = self
505                .send(
506                    self.add_auth(
507                        self.http
508                            .post(&url)
509                            .header("Content-Type", "application/x-ndjson")
510                            .body(body),
511                    ),
512                    "prefetch",
513                )
514                .await?;
515            if !resp.status().is_success() {
516                // Deliberately not covered by the "any HTTP response counts
517                // as responsive" rule, on any admission -- see "Prefetch
518                // does not close the circuit": a write-time warm's non-2xx
519                // must not hold a 503-ing cache's circuit closed.
520                return Err(anyhow!("cache prefetch {url} status {}", resp.status()));
521            }
522            // Read as two explicit steps rather than via the combined
523            // `resp.json()` helper: in reqwest 0.12.28 `json()` re-wraps
524            // every error from its internal body read as `Kind::Decode`, so
525            // `is_body()`/`is_decode()` can't distinguish a transport
526            // failure from a parse failure on that path. See "What does not
527            // feed the breaker as a failure".
528            let bytes = match resp.bytes().await {
529                Ok(bytes) => bytes,
530                Err(e) => {
531                    // A body/transport/timeout failure: a genuine liveness
532                    // signal.
533                    self.report_unresponsive("prefetch response body");
534                    return Err(e).with_context(|| "reading prefetch response body");
535                }
536            };
537            // The response arrived as a full, cheap 2xx; a parse failure
538            // here is a protocol violation from our own cache, not evidence
539            // the demand path is unresponsive -- reports nothing, exactly
540            // like `get_full_stream`'s/`head_size`'s malformed-response arms.
541            serde_json::from_slice::<PrefetchResponse>(&bytes)
542                .with_context(|| "parsing prefetch response")
543        }
544        .await;
545
546        if let Err(e) = &result {
547            imetric!("range_cache_client_prefetch_error", "count", 1_u64);
548            debug!("prefetch request to {url} failed: {e}");
549        }
550        result
551    }
552
553    /// Issue `POST /ranges` and reassemble the framed response body, keeping
554    /// four failure kinds distinct instead of folding them into one:
555    /// header-phase failure (`Send`, reusing `send`'s own `abandon_timeout`
556    /// wrap and reporting), a non-2xx `Status`, and each `RangesReadError`
557    /// body-failure kind (`Transport`/`Stalled`/`Truncated`). Only a `Send`
558    /// failure or a body `Transport`/`Stalled` counts as a breaker failure; a
559    /// non-2xx `Status` or a `Truncated` body means a full HTTP response
560    /// arrived cheaply, so both report `record_responsive` per "Abandon vs.
561    /// unresponsive". Success -- the framed body fully resolving -- is the
562    /// single success report for the whole `get_ranges` operation, made here
563    /// because `read_framed_ranges` exposes nothing to the caller until it
564    /// fully resolves.
565    pub async fn send_ranges(
566        &self,
567        location: &Path,
568        ranges: &[Range<u64>],
569    ) -> Result<Vec<Bytes>, RangesSendError> {
570        let url = format!(
571            "{}/ranges/{}",
572            self.cache_base_url.trim_end_matches('/'),
573            location.as_ref()
574        );
575        let ranges_json: Vec<[u64; 2]> = ranges.iter().map(|r| [r.start, r.end]).collect();
576        let body = json!({ "ranges": ranges_json }).to_string();
577
578        let resp = self
579            .send(
580                self.add_auth(
581                    self.http
582                        .post(&url)
583                        .header("Content-Type", "application/json")
584                        .body(body),
585                ),
586                "ranges request",
587            )
588            .await
589            .map_err(RangesSendError::Send)?;
590
591        if !resp.status().is_success() {
592            let status = resp.status();
593            self.report();
594            return Err(RangesSendError::Status(status));
595        }
596
597        // Stream the length-prefixed multi-range body (see the server's
598        // `frame_ranges_stream`) and reassemble each range's `Bytes` as its
599        // chunks arrive, instead of buffering the whole response with
600        // `.bytes()` into one contiguous allocation before any of it is
601        // used — the response can be arbitrarily large since the server does
602        // not cap total requested bytes. `pull_exact`'s per-chunk
603        // read is wrapped in `stall_timeout`, deliberately tighter than the
604        // client's `read_timeout` so this explicit wrap is the one that
605        // fires on a real stall -- see "`ClientBuilder::read_timeout` on the
606        // one client". `read_framed_ranges` is a plain `Future` (not a
607        // `Stream`) that only resolves once every range has been read, so
608        // nothing is ever exposed to the caller before completion.
609        match read_framed_ranges(
610            resp.bytes_stream().boxed(),
611            ranges.len(),
612            self.config.stall_timeout,
613        )
614        .await
615        {
616            Ok(results) => {
617                self.report();
618                Ok(results)
619            }
620            Err(RangesReadError::Transport(e)) => {
621                self.report_unresponsive("ranges body");
622                Err(RangesSendError::Body(RangesReadError::Transport(e)))
623            }
624            Err(RangesReadError::Stalled) => {
625                self.report_unresponsive("ranges body");
626                Err(RangesSendError::Body(RangesReadError::Stalled))
627            }
628            Err(RangesReadError::Truncated) => {
629                // A truncated/garbled framing from our own cache is a
630                // protocol violation (unexpected), not a liveness signal --
631                // see "What does not feed the breaker as a failure".
632                self.report();
633                Err(RangesSendError::Body(RangesReadError::Truncated))
634            }
635        }
636    }
637}
638
639#[async_trait]
640impl ObjectPrefetch for CacheClientStore {
641    async fn prefetch(&self, items: Vec<PrefetchItem>) -> Result<PrefetchResponse> {
642        CacheClientStore::prefetch(self, items).await
643    }
644}
645
646/// A state change worth reporting: emits its metric/log exactly once per
647/// transition (not once per request), so an outage doesn't flood.
648fn report_transition(t: Transition) {
649    match t {
650        Transition::None => {}
651        Transition::Opened { cooldown } => {
652            imetric!("range_cache_client_circuit_opened", "count", 1_u64);
653            warn!("cache circuit opened, cooling down for {cooldown:?}");
654        }
655        Transition::Closed => {
656            imetric!("range_cache_client_circuit_closed", "count", 1_u64);
657            info!("cache circuit closed");
658        }
659    }
660}
661
662/// Report an unresponsive cache to `breaker` and emit the resulting
663/// transition. Free-standing (rather than a `CacheClientStore` method) so
664/// `full_stream_with_fallback` -- itself a free function, since its `'static`
665/// stream can't borrow `&self` -- can report from its resume path, the one
666/// place a free function needs to report unresponsive at all.
667/// `CacheClientStore::report_unresponsive` is a thin wrapper over this.
668fn report_unresponsive(what: &str, breaker: &CircuitBreaker) {
669    imetric!("range_cache_client_unresponsive", "count", 1_u64);
670    debug!("cache {what} unresponsive");
671    report_transition(breaker.record_unresponsive());
672}
673
674/// Fall back to `direct` for a whole `get_opts` operation, bumping the
675/// shared `range_cache_client_fallback` counter and timing
676/// `range_cache_client_direct_ms`. Free-standing so `full_stream_with_fallback`
677/// (a free function) can share it for its resume read; `CacheClientStore::
678/// direct_get_opts` is a thin wrapper over this for every other call site.
679async fn direct_get_opts_with_metrics(
680    direct: &Arc<dyn ObjectStore>,
681    location: &Path,
682    options: GetOptions,
683) -> object_store::Result<GetResult> {
684    imetric!("range_cache_client_fallback", "count", 1_u64);
685    let direct_start = Instant::now();
686    let result = direct.get_opts(location, options).await;
687    fmetric!(
688        "range_cache_client_direct_ms",
689        "ms",
690        direct_start.elapsed().as_secs_f64() * 1000.0
691    );
692    result
693}
694
695/// Parse a `Content-Range: bytes {start}-{end}/{size}` response header,
696/// returning the actual byte range served (`start..end+1`) and the full
697/// object size. Returns `None` when the header is absent or not in the
698/// expected form (e.g. the unsatisfiable `bytes */size` form, or an
699/// unparseable value).
700fn parse_content_range(headers: &reqwest::header::HeaderMap) -> Option<(Range<u64>, u64)> {
701    let value = headers.get(reqwest::header::CONTENT_RANGE)?.to_str().ok()?;
702    let value = value.strip_prefix("bytes ")?;
703    let (span, size) = value.split_once('/')?;
704    let size: u64 = size.trim().parse().ok()?;
705    let (start, end) = span.split_once('-')?;
706    let start: u64 = start.trim().parse().ok()?;
707    let end: u64 = end.trim().parse().ok()?;
708    Some((start..end.saturating_add(1), size))
709}
710
711impl std::fmt::Display for CacheClientStore {
712    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
713        write!(f, "CacheClientStore({})", self.cache_base_url)
714    }
715}
716
717/// Build a streaming `GetResult` from an already-built byte stream (see
718/// `full_stream_with_fallback`), so the object/range is delivered in chunks
719/// rather than buffered whole. `range` is the slice actually being streamed
720/// (`0..object_size` for an unranged GET) and `object_size` is the full
721/// object size, per the `ObjectMeta` contract.
722fn stream_get_result(
723    location: &Path,
724    body: BoxStream<'static, object_store::Result<Bytes>>,
725    range: Range<u64>,
726    object_size: u64,
727) -> GetResult {
728    let meta = ObjectMeta {
729        location: location.clone(),
730        last_modified: chrono::Utc::now(),
731        size: object_size,
732        e_tag: None,
733        version: None,
734    };
735    GetResult {
736        payload: GetResultPayload::Stream(body),
737        meta,
738        range,
739        attributes: Attributes::default(),
740    }
741}
742
743/// Wrap the raw byte stream for a GET (full or ranged) so any stream error --
744/// or the stream ending cleanly short of the requested range -- transparently
745/// resumes the remainder from `direct` at the byte offset already delivered,
746/// instead of surfacing the cache's own error to the consumer or silently
747/// truncating. See "The fix: resume from the delivered offset".
748///
749/// `resolved_range` is the absolute byte range this stream is expected to
750/// deliver: `served_range` from `parse_content_range` on the ranged path, or
751/// `0..size` from `Content-Length` on the full path -- resolved by the caller
752/// before this helper is ever invoked.
753///
754/// This is also the single place the whole `get_opts` main-path operation
755/// reports its outcome to the breaker (see "One outcome per logical
756/// operation"): `record_responsive` when the stream ends -- with or without
757/// error -- with every requested byte already delivered (no resume occurs),
758/// or `record_unresponsive` (via the resume path only) when a resume occurs.
759/// Never both.
760fn full_stream_with_fallback(
761    direct: Arc<dyn ObjectStore>,
762    breaker: Arc<CircuitBreaker>,
763    location: Path,
764    options: GetOptions,
765    resolved_range: Range<u64>,
766    mut first: BoxStream<'static, object_store::Result<Bytes>>,
767) -> BoxStream<'static, object_store::Result<Bytes>> {
768    gen_stream! {
769        let mut bytes_yielded: u64 = 0;
770        let mut last_err: Option<String> = None;
771        loop {
772            match first.next().await {
773                Some(Ok(chunk)) => {
774                    bytes_yielded += chunk.len() as u64;
775                    yield Ok(chunk);
776                }
777                Some(Err(e)) => {
778                    last_err = Some(e.to_string());
779                    break;
780                }
781                None => break,
782            }
783        }
784
785        let resume_start = resolved_range.start.saturating_add(bytes_yielded);
786        if resume_start >= resolved_range.end {
787            // Every requested byte was already delivered (or the cache
788            // over-delivered past its own declared range): the operation
789            // succeeded even if the terminal event was an `Err`. Never issue
790            // an empty or inverted `direct.get_opts(Bounded(x..x))` call --
791            // `object_store` rejects that as a hard error. This is the
792            // single success report for this operation.
793            report_transition(breaker.record_responsive());
794            return;
795        }
796
797        // Bytes still owed: resume the remainder from `direct`, treating a
798        // clean end (`None`) with bytes owed identically to an `Err`.
799        let remainder = resume_start..resolved_range.end;
800        imetric!("range_cache_client_stream_resumed", "count", 1_u64);
801        match &last_err {
802            Some(e) => debug!(
803                "cache GET stream for {location} failed with {} bytes still owed, \
804                 resuming {}..{} from direct: {e}",
805                remainder.end - remainder.start,
806                remainder.start,
807                remainder.end
808            ),
809            None => debug!(
810                "cache GET stream for {location} ended short by {} bytes, \
811                 resuming {}..{} from direct",
812                remainder.end - remainder.start,
813                remainder.start,
814                remainder.end
815            ),
816        }
817        // The only report a resumed operation makes -- see "One outcome per
818        // logical operation".
819        report_unresponsive("stream", &breaker);
820
821        let mut resumed_options = options;
822        resumed_options.range = Some(GetRange::Bounded(remainder));
823        match direct_get_opts_with_metrics(&direct, &location, resumed_options).await {
824            Ok(result) => {
825                let mut body = result.into_stream();
826                while let Some(item) = body.next().await {
827                    yield item;
828                }
829            }
830            // A direct-store failure is a failure the invariant permits --
831            // it constrains cache failure modes, not the direct store's own.
832            Err(direct_err) => yield Err(direct_err),
833        }
834    }
835    .boxed()
836}
837
838/// Build a `GetResult` for an already-buffered, small ranged payload (used
839/// only for the zero-length-range edge cases in `get_range_stream` and the
840/// HEAD-only path in `get_opts`, where there is nothing worth streaming).
841/// `range` is the slice actually returned while `object_size` is the full
842/// object size, per the `ObjectMeta` contract.
843fn build_get_result(
844    location: &Path,
845    data: Bytes,
846    range: Range<u64>,
847    object_size: u64,
848) -> GetResult {
849    let meta = ObjectMeta {
850        location: location.clone(),
851        last_modified: chrono::Utc::now(),
852        size: object_size,
853        e_tag: None,
854        version: None,
855    };
856    let payload = GetResultPayload::Stream(Box::pin(stream::once(async move { Ok(data) })));
857    GetResult {
858        payload,
859        meta,
860        range,
861        attributes: Attributes::default(),
862    }
863}
864
865/// Why reading a streamed `/ranges` response body failed. Public API surface
866/// (re-exported from `lib.rs`) so the cross-crate integration test can
867/// assert on the exact failure kind directly (via `send_ranges`), rather than
868/// inspecting logs or metrics.
869#[derive(Debug, thiserror::Error)]
870pub enum RangesReadError {
871    #[error("transport error reading ranges response: {0}")]
872    Transport(#[from] reqwest::Error),
873    /// No data arrived on the response body for a full `stall_timeout`
874    /// window -- the per-chunk bound `pull_exact` wraps around each
875    /// `stream.next()`, deliberately tighter than the client's `read_timeout`
876    /// margin so this is the classification that actually fires on a real
877    /// stall. See "`ClientBuilder::read_timeout` on the one client".
878    #[error("ranges response body stalled (no data within the stall budget)")]
879    Stalled,
880    /// The framed body ended before every declared frame was fully read: a
881    /// protocol violation from our own cache, not a liveness signal.
882    #[error("truncated ranges response")]
883    Truncated,
884}
885
886/// Why `send_ranges` failed, keeping the header-phase failure, a non-2xx
887/// status, and each `RangesReadError` body-failure kind distinct rather than
888/// folding them into one. Public API surface (re-exported from `lib.rs`) for
889/// the same reason as `RangesReadError`.
890#[derive(Debug, thiserror::Error)]
891pub enum RangesSendError {
892    #[error("sending ranges request to cache: {0}")]
893    Send(anyhow::Error),
894    #[error("cache ranges request status {0}")]
895    Status(reqwest::StatusCode),
896    #[error(transparent)]
897    Body(#[from] RangesReadError),
898}
899
900/// Reassemble `count` length-prefixed frames (an 8-byte little-endian length
901/// followed by that many bytes, repeated once per requested range — see the
902/// server's `frame_ranges_stream`) from a streaming multi-range response
903/// body, mirroring `RangeCache::get_ranges`'s pending-chunk reassembly on the
904/// server side (`range_cache.rs`) instead of buffering the whole response
905/// with `resp.bytes().await` before parsing it.
906async fn read_framed_ranges(
907    mut stream: BoxStream<'static, reqwest::Result<Bytes>>,
908    count: usize,
909    stall_timeout: Duration,
910) -> Result<Vec<Bytes>, RangesReadError> {
911    let mut pending: Option<Bytes> = None;
912    let mut results = Vec::with_capacity(count);
913    for _ in 0..count {
914        let mut prefix = pull_exact(&mut stream, &mut pending, 8, stall_timeout).await?;
915        let len = prefix.get_u64_le() as usize;
916        let data = pull_exact(&mut stream, &mut pending, len, stall_timeout).await?;
917        results.push(data);
918    }
919    Ok(results)
920}
921
922/// Pull exactly `need` bytes out of `stream`, using `pending` as a one-chunk
923/// lookahead so a frame that straddles a network chunk boundary is
924/// reassembled correctly (mirrors `RangeCache::get_ranges`'s reassembly loop
925/// in `range_cache.rs`). Each `stream.next()` await is bounded by
926/// `stall_timeout` -- a per-chunk bound, since the whole body has no size cap.
927async fn pull_exact(
928    stream: &mut BoxStream<'static, reqwest::Result<Bytes>>,
929    pending: &mut Option<Bytes>,
930    need: usize,
931    stall_timeout: Duration,
932) -> Result<Bytes, RangesReadError> {
933    let mut collected = BytesMut::with_capacity(need);
934    while collected.len() < need {
935        let chunk = match pending.take() {
936            Some(c) => c,
937            None => match tokio::time::timeout(stall_timeout, stream.next()).await {
938                Ok(Some(Ok(c))) => c,
939                Ok(Some(Err(e))) => return Err(e.into()),
940                Ok(None) => return Err(RangesReadError::Truncated),
941                Err(_) => return Err(RangesReadError::Stalled),
942            },
943        };
944        let remaining = need - collected.len();
945        if chunk.len() > remaining {
946            collected.put_slice(&chunk[..remaining]);
947            *pending = Some(chunk.slice(remaining..));
948        } else {
949            collected.put_slice(&chunk);
950        }
951    }
952    Ok(collected.freeze())
953}
954
955#[async_trait]
956impl ObjectStore for CacheClientStore {
957    async fn put_opts(
958        &self,
959        location: &Path,
960        payload: PutPayload,
961        opts: PutOptions,
962    ) -> object_store::Result<PutResult> {
963        self.direct.put_opts(location, payload, opts).await
964    }
965
966    async fn put_multipart_opts(
967        &self,
968        location: &Path,
969        opts: PutMultipartOptions,
970    ) -> object_store::Result<Box<dyn MultipartUpload>> {
971        self.direct.put_multipart_opts(location, opts).await
972    }
973
974    async fn get_opts(
975        &self,
976        location: &Path,
977        options: GetOptions,
978    ) -> object_store::Result<GetResult> {
979        // The cache HTTP protocol can't convey conditional/version preconditions,
980        // so any such request must go straight to the direct store to preserve
981        // the expected 412/304 semantics. Never touches the cache, so no
982        // fallback bookkeeping.
983        if options.if_match.is_some()
984            || options.if_none_match.is_some()
985            || options.if_modified_since.is_some()
986            || options.if_unmodified_since.is_some()
987            || options.version.is_some()
988        {
989            return self.direct.get_opts(location, options).await;
990        }
991
992        // One admission gate per public entry point (see "The breaker"): a
993        // `Probe` admission behaves exactly like `Allow` below, so only
994        // `Bypass` needs handling here.
995        if matches!(self.breaker.admit(), Admission::Bypass) {
996            imetric!("range_cache_client_circuit_bypassed", "count", 1_u64);
997            debug!("cache circuit open, reading {location} direct");
998            return self.direct_get_opts(location, options).await;
999        }
1000
1001        // A head-only request needs metadata, not the body: return an empty
1002        // payload with the true object size instead of streaming the object.
1003        if options.head {
1004            let result: Result<GetResult> = match self.head_size(location).await {
1005                Ok(size) => {
1006                    // `head_size`'s completion *is* the whole operation
1007                    // here: the single success report.
1008                    self.report();
1009                    Ok(build_get_result(location, Bytes::new(), 0..0, size))
1010                }
1011                Err(e) => Err(e),
1012            };
1013            return match result {
1014                Ok(r) => Ok(r),
1015                Err(e) => {
1016                    // Falling back to the direct store is a by-design graceful
1017                    // degradation path (cache restarting/unreachable), not an
1018                    // error: keep it at debug and let the fallback metric (which
1019                    // is what dashboards alert on) carry the signal, so a cache
1020                    // outage doesn't flood logs with one warning per read.
1021                    debug!("cache miss for {location} (head), falling back to direct: {e}");
1022                    self.direct_get_opts(location, options).await
1023                }
1024            };
1025        }
1026
1027        let result: Result<GetResult> = match &options.range {
1028            None => self.get_full_stream(location, options.clone()).await,
1029            // Issue the range GET and stream the body; the actual served
1030            // range and the full object size come from the 206's
1031            // `Content-Range` header (see `get_range_stream`), avoiding a
1032            // preceding HEAD round-trip in the common case.
1033            Some(GetRange::Bounded(r)) => {
1034                self.get_range_stream(location, r.start, Some(r.end), options.clone())
1035                    .await
1036            }
1037            // Open-ended range: the server resolves `-` against the true
1038            // object size, returned to us via `Content-Range`.
1039            Some(GetRange::Offset(offset)) => {
1040                self.get_range_stream(location, *offset, None, options.clone())
1041                    .await
1042            }
1043            // Suffix reads need the object size up front to compute the start
1044            // offset, since the cache server's Range parser does not accept the
1045            // `bytes=-N` suffix form. A HEAD is unavoidable here. This is a
1046            // genuine intermediate step -- `get_range_stream`'s eventual
1047            // stream is what completes the operation -- so no success report
1048            // is made at this call site; `head_size`'s own failure-arm
1049            // reports still fire as usual.
1050            Some(GetRange::Suffix(suffix)) => match self.head_size(location).await {
1051                Ok(size) => {
1052                    let start = size.saturating_sub(*suffix);
1053                    self.get_range_stream(location, start, Some(size), options.clone())
1054                        .await
1055                }
1056                Err(e) => Err(e),
1057            },
1058        };
1059
1060        match result {
1061            Ok(r) => Ok(r),
1062            Err(e) => {
1063                debug!("cache miss for {location}, falling back to direct: {e}");
1064                self.direct_get_opts(location, options).await
1065            }
1066        }
1067    }
1068
1069    async fn get_ranges(
1070        &self,
1071        location: &Path,
1072        ranges: &[Range<u64>],
1073    ) -> object_store::Result<Vec<Bytes>> {
1074        if ranges.is_empty() {
1075            return Ok(vec![]);
1076        }
1077
1078        if matches!(self.breaker.admit(), Admission::Bypass) {
1079            imetric!("range_cache_client_circuit_bypassed", "count", 1_u64);
1080            debug!("cache circuit open, reading ranges for {location} direct");
1081            return self.direct_get_ranges(location, ranges).await;
1082        }
1083
1084        let round_trip_start = Instant::now();
1085        match self.send_ranges(location, ranges).await {
1086            Ok(results) => {
1087                // Distinct from `range_cache_client_roundtrip_ms`: that metric
1088                // covers the streaming GET paths and is measured at
1089                // time-to-headers (before any body bytes are read), while this
1090                // path buffers the full framed response body before emitting,
1091                // so it measures time-to-full-body. Keeping them under
1092                // separate names avoids conflating two different quantities
1093                // in one distribution.
1094                fmetric!(
1095                    "range_cache_client_ranges_ms",
1096                    "ms",
1097                    round_trip_start.elapsed().as_secs_f64() * 1000.0
1098                );
1099                Ok(results)
1100            }
1101            Err(RangesSendError::Send(e)) => {
1102                debug!("cache ranges request for {location} failed: {e}, falling back to direct");
1103                self.direct_get_ranges(location, ranges).await
1104            }
1105            Err(RangesSendError::Status(status)) => {
1106                debug!("cache ranges for {location} status {status}, falling back to direct");
1107                self.direct_get_ranges(location, ranges).await
1108            }
1109            Err(RangesSendError::Body(RangesReadError::Transport(e))) => {
1110                debug!(
1111                    "reading ranges response for {location} failed: {e}, falling back to direct"
1112                );
1113                self.direct_get_ranges(location, ranges).await
1114            }
1115            Err(RangesSendError::Body(RangesReadError::Stalled)) => {
1116                debug!("ranges response for {location} stalled, falling back to direct");
1117                self.direct_get_ranges(location, ranges).await
1118            }
1119            Err(RangesSendError::Body(RangesReadError::Truncated)) => {
1120                // A truncated/garbled framing from our own cache is a protocol
1121                // violation (unexpected), unlike the clean miss/outage paths
1122                // above — keep this at warn.
1123                warn!("truncated ranges response for {location}, falling back to direct");
1124                self.direct_get_ranges(location, ranges).await
1125            }
1126        }
1127    }
1128
1129    fn delete_stream(
1130        &self,
1131        locations: BoxStream<'static, object_store::Result<Path>>,
1132    ) -> BoxStream<'static, object_store::Result<Path>> {
1133        self.direct.delete_stream(locations)
1134    }
1135
1136    fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, object_store::Result<ObjectMeta>> {
1137        self.direct.list(prefix)
1138    }
1139
1140    async fn list_with_delimiter(&self, prefix: Option<&Path>) -> object_store::Result<ListResult> {
1141        self.direct.list_with_delimiter(prefix).await
1142    }
1143
1144    async fn copy_opts(
1145        &self,
1146        from: &Path,
1147        to: &Path,
1148        options: CopyOptions,
1149    ) -> object_store::Result<()> {
1150        self.direct.copy_opts(from, to, options).await
1151    }
1152}