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}