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