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}