micromegas_object_cache/range_cache/mod.rs
1use std::collections::{BTreeSet, HashMap};
2use std::ops::Range;
3use std::sync::Arc;
4
5use anyhow::Result;
6use async_stream::try_stream;
7use bytes::{BufMut, Bytes, BytesMut};
8use futures::stream::{self, Stream, StreamExt};
9use micromegas_tracing::prelude::*;
10use object_store::{ObjectStore, ObjectStoreExt, path::Path};
11
12use super::backend::{BackendDiskStats, FillHint, RangeCacheBackend};
13use super::blocks::{assemble_range, block_byte_range, blocks_for_range};
14use super::metric_tags::{self, PrefixTags};
15
16mod error;
17mod fetch;
18mod scheduler;
19
20pub use error::{RangeError, StreamRangesCaller};
21use scheduler::{
22 FetchScheduler, FulfillGuard, Ownership, Priority, decode_size, reconstruct_shared_error,
23};
24
25pub const DEFAULT_BLOCK_SIZE: u64 = 1024 * 1024;
26
27/// Number of blocks per streamed fetch window used by `stream_ranges`. At the
28/// default 1 MiB block size, 8 blocks (8 MiB) matches one coalesced origin
29/// GET run (`DEFAULT_MAX_COALESCED_GET_BYTES`), with `buffered(2)` giving
30/// modest pipeline overlap. This bounds peak in-flight memory per stream to
31/// roughly `2 * DEMAND_WINDOW_BLOCKS * block_size`, independent of how large
32/// the requested range is.
33pub const DEMAND_WINDOW_BLOCKS: u64 = 8;
34
35/// Default total number of origin GETs allowed to run concurrently. See
36/// `RangeCache::new`.
37pub const DEFAULT_TOTAL_FETCH_PERMITS: usize = 32;
38/// Default number of `DEFAULT_TOTAL_FETCH_PERMITS` slots reserved for demand
39/// reads (never consumed by prefetch).
40pub const DEFAULT_DEMAND_RESERVED_FETCH_PERMITS: usize = 8;
41/// Default max byte span of one coalesced run GET.
42pub const DEFAULT_MAX_COALESCED_GET_BYTES: u64 = 8 * 1024 * 1024;
43/// Default promotion granularity: promote only the run(s) covering a demanded
44/// block, not the whole prefetch batch.
45pub const DEFAULT_PROMOTE_WHOLE_BATCH: bool = false;
46
47/// Range-aware read cache over an origin object store.
48///
49/// # Cache invalidation
50///
51/// This cache assumes object keys are **write-once and content-addressed**: a
52/// given key always maps to the same bytes for the lifetime of the object. The
53/// size and block caches therefore carry no TTL, etag, or generation in their
54/// keys and are never invalidated. Overwriting an existing key with different
55/// content would cause stale size/block data to be served indefinitely. This is
56/// safe for micromegas lake objects (blocks, parquet) which are never
57/// overwritten; do not point this cache at a mutable namespace.
58///
59/// # In-flight map and priority
60///
61/// Concurrent fetches of the same block or size are collapsed via an
62/// in-flight map (`FetchScheduler`): the first caller to ask for a key
63/// becomes its owner and issues the origin request (spawned as a detached
64/// task, so a cancelled caller never strands the others waiting on it);
65/// every other concurrent caller joins and observes the same result.
66/// Contiguous missing blocks the owner controls are coalesced into one
67/// `origin.get_range` per run. Every origin GET is either `Demand` or
68/// `Prefetch` priority; a demand caller joining a prefetch-priority fetch
69/// promotes it (see `own_or_join`), so a late demand read is never stuck
70/// behind unrelated prefetch traffic.
71#[derive(Clone)]
72pub struct RangeCache {
73 origin: Arc<dyn ObjectStore>,
74 backend: Arc<dyn RangeCacheBackend>,
75 block_size: u64,
76 ns: String,
77 scheduler: Arc<FetchScheduler>,
78 max_coalesced_get_bytes: u64,
79 /// Configured `prefix` labels (the server's `allowed_prefixes`, leaked to
80 /// `'static` once at startup), in the same order as `prefix_tags`. Empty
81 /// unless `with_prefix_labels` was used, in which case every key
82 /// classifies as `metric_tags::PREFIX_OTHER`.
83 prefix_labels: Arc<[&'static str]>,
84 /// Precomputed `PrefixTags` parallel to `prefix_labels` (`prefix_tags[i]`
85 /// corresponds to `prefix_labels[i]`), so `classify_tags` is an array
86 /// lookup rather than an allocation + intern-lock per call.
87 prefix_tags: Arc<[PrefixTags]>,
88 /// Precomputed tags for a key matching none of `prefix_labels`.
89 other_tags: PrefixTags,
90}
91
92impl RangeCache {
93 #[allow(clippy::too_many_arguments)]
94 pub fn new(
95 origin: Arc<dyn ObjectStore>,
96 backend: Arc<dyn RangeCacheBackend>,
97 block_size: u64,
98 ns: String,
99 total_fetch_permits: usize,
100 demand_reserved_fetch_permits: usize,
101 max_coalesced_get_bytes: u64,
102 promote_whole_batch: bool,
103 ) -> Self {
104 Self {
105 origin,
106 backend,
107 block_size,
108 ns,
109 scheduler: Arc::new(FetchScheduler::new(
110 total_fetch_permits,
111 demand_reserved_fetch_permits,
112 promote_whole_batch,
113 )),
114 max_coalesced_get_bytes,
115 prefix_labels: Arc::from(Vec::new()),
116 prefix_tags: Arc::from(Vec::new()),
117 other_tags: PrefixTags::new(metric_tags::PREFIX_OTHER),
118 }
119 }
120
121 /// Attach a `prefix` classifier for the dimensioned hit-rate/request
122 /// metrics: `labels` are the server's configured `allowed_prefixes`,
123 /// leaked to `'static` once at startup by the caller (bounded,
124 /// low-cardinality, set once). Every request key is then
125 /// longest-prefix-matched against `labels` (see `classify`); a key
126 /// matching none classifies as `metric_tags::PREFIX_OTHER`.
127 ///
128 /// `RangeCache::new` itself leaves this empty (every key classifies as
129 /// `"other"`), so its existing callers/tests compile unmodified; only
130 /// `object_cache_srv.rs` opts in.
131 pub fn with_prefix_labels(mut self, labels: Arc<[&'static str]>) -> Self {
132 let tags: Vec<PrefixTags> = labels.iter().map(|&label| PrefixTags::new(label)).collect();
133 self.prefix_tags = Arc::from(tags);
134 self.prefix_labels = labels;
135 self
136 }
137
138 /// The precomputed tags for the `prefix` `key` falls under, resolved by
139 /// longest-prefix match against `prefix_labels` (`other_tags` on no
140 /// match). Private: callers needing just the label use `classify`;
141 /// hot-path callers inside this module use the tags directly.
142 fn classify_tags(&self, key: &str) -> &PrefixTags {
143 match metric_tags::longest_prefix_match(&self.prefix_labels, key) {
144 Some(i) => &self.prefix_tags[i],
145 None => &self.other_tags,
146 }
147 }
148
149 /// The `prefix` label `key` falls under (e.g. `"blobs"`, `"views"`, per
150 /// the server's configured `allowed_prefixes`), or
151 /// `metric_tags::PREFIX_OTHER` if it matches none. See
152 /// `with_prefix_labels`.
153 pub fn classify(&self, key: &str) -> &'static str {
154 self.classify_tags(key).label
155 }
156
157 /// `(shared_available, shared_total, prefetch_available, prefetch_total)`
158 /// -- the fetch-permit budget's current occupancy, for the saturation
159 /// sampler.
160 pub fn fetch_budget_stats(&self) -> (usize, usize, usize, usize) {
161 self.scheduler.fetch_budget_stats()
162 }
163
164 /// Number of keys (blocks or `size()` heads) currently in flight to
165 /// origin, for the saturation sampler.
166 pub fn inflight_len(&self) -> usize {
167 self.scheduler.inflight_len()
168 }
169
170 /// Backend disk write-path counters (`None` for a backend with no disk
171 /// tier), for the saturation sampler's per-second foyer disk gauges.
172 pub fn backend_disk_stats(&self) -> Option<BackendDiskStats> {
173 self.backend.disk_stats()
174 }
175
176 /// Size in bytes of one cache block. Every distinct block a request
177 /// touches is fetched and held whole, so callers gating memory (e.g. the
178 /// server's cross-request budget) need this to account for amplification
179 /// from small scattered ranges.
180 pub fn block_size(&self) -> u64 {
181 self.block_size
182 }
183
184 #[span_fn]
185 pub async fn size(&self, key: &str) -> Result<u64> {
186 // The cache key carries no etag/version: see the module docs — keys
187 // are assumed write-once and content-addressed, so a cached size is
188 // never invalidated.
189 let meta_key = format!("meta:{}:{key}", self.ns);
190 let prefix_tag = self.classify_tags(key).prefix;
191
192 if let Some(data) = self.backend.get(&meta_key).await
193 && data.len() == 8
194 {
195 imetric!("range_cache_size_backend_hit", "count", prefix_tag, 1_u64);
196 return decode_size(&data);
197 }
198
199 match self
200 .scheduler
201 .own_or_join(meta_key.clone(), Priority::Demand, None)
202 {
203 Ownership::Owner(entry) => {
204 let origin = self.origin.clone();
205 let backend = self.backend.clone();
206 let scheduler = self.scheduler.clone();
207 let key_owned = key.to_string();
208 let meta_key_owned = meta_key.clone();
209 let task_entry = entry.clone();
210 tokio::spawn(async move {
211 let guard = FulfillGuard::new(
212 scheduler.clone(),
213 vec![(meta_key_owned.clone(), task_entry.clone())],
214 );
215 imetric!("range_cache_origin_head", "count", prefix_tag, 1_u64);
216 let head_path = Path::from(key_owned.as_str());
217 let head_result = instrument_named!(
218 origin.head(&head_path),
219 "range_cache_origin_head_latency"
220 )
221 .await;
222 match head_result {
223 Ok(object_meta) => {
224 let size = object_meta.size;
225 debug!("range_cache origin head key={key_owned} size={size}");
226 let size_bytes = Bytes::from(size.to_le_bytes().to_vec());
227 backend
228 .put(meta_key_owned.clone(), size_bytes.clone(), FillHint::Demand)
229 .await;
230 task_entry.fulfill(Ok(size_bytes));
231 }
232 Err(e) => {
233 task_entry.fulfill(Err(Arc::new(anyhow::Error::from(e))));
234 }
235 }
236 scheduler.remove_entry(&meta_key_owned);
237 guard.disarm();
238 });
239 let data = entry
240 .join()
241 .await
242 .map_err(|e| reconstruct_shared_error(&e))?;
243 decode_size(&data)
244 }
245 Ownership::Joiner(entry) => {
246 let data = entry
247 .join()
248 .await
249 .map_err(|e| reconstruct_shared_error(&e))?;
250 decode_size(&data)
251 }
252 }
253 }
254
255 /// Stream the bytes for `ranges` (each half-open `[start, end)`) without
256 /// materializing more than a couple of `DEMAND_WINDOW_BLOCKS` windows of
257 /// blocks at a time, regardless of how large the ranges are in total.
258 ///
259 /// Upfront validation — the `size()` lookup and every range's
260 /// out-of-bounds check — runs before the stream is constructed and
261 /// returned, so 404/416-shaped errors surface synchronously to the
262 /// caller with proper status codes; only a failure *after* that point
263 /// (a mid-stream `fetch_blocks` error, e.g. the origin going down) is
264 /// yielded as the stream's terminal `Err` item. A degenerate
265 /// `start >= end` range is validated like any other (its `end` is still
266 /// checked against `file_size`) but yields no bytes.
267 ///
268 /// Yields a flat, ordered sequence of chunks: ranges are processed in
269 /// the order given and each range's bytes are emitted contiguously
270 /// (possibly split into several `Bytes` chunks at window boundaries)
271 /// before the next range's bytes begin. There is no cross-range block
272 /// dedup — a block shared by two ranges is fetched once per range it
273 /// appears in, though a repeat is always a backend hit or an in-flight
274 /// join, never a second origin GET (see `own_or_join`).
275 ///
276 /// `caller` selects which of the two distinct error counters
277 /// (`range_cache_get_range_error` / `range_cache_get_ranges_error`) this
278 /// call emits on validation failure or a mid-stream fetch error, so
279 /// `get_range`/`get_ranges` (and the two HTTP handlers, which call this
280 /// directly) keep emitting the metric they always have.
281 #[span_fn]
282 pub async fn stream_ranges(
283 &self,
284 key: &str,
285 ranges: Vec<Range<u64>>,
286 caller: StreamRangesCaller,
287 ) -> Result<impl Stream<Item = Result<Bytes>> + Send + 'static> {
288 let file_size = match self.size(key).await {
289 Ok(s) => s,
290 Err(e) => {
291 caller.emit_error_metric();
292 return Err(e);
293 }
294 };
295 self.stream_ranges_inner(key, ranges, file_size, caller)
296 .await
297 }
298
299 /// Like `stream_ranges`, but for a caller that already resolved
300 /// `file_size` itself (e.g. `get_range_handler`, which needs it up front
301 /// for range validation): skips the redundant `self.size()` call, so a
302 /// cache hit doesn't fire `range_cache_size_backend_hit` a second time
303 /// per call.
304 #[span_fn]
305 pub async fn stream_ranges_with_size(
306 &self,
307 key: &str,
308 ranges: Vec<Range<u64>>,
309 file_size: u64,
310 caller: StreamRangesCaller,
311 ) -> Result<impl Stream<Item = Result<Bytes>> + Send + 'static> {
312 self.stream_ranges_inner(key, ranges, file_size, caller)
313 .await
314 }
315
316 async fn stream_ranges_inner(
317 &self,
318 key: &str,
319 ranges: Vec<Range<u64>>,
320 file_size: u64,
321 caller: StreamRangesCaller,
322 ) -> Result<impl Stream<Item = Result<Bytes>> + Send + 'static> {
323 for r in &ranges {
324 if r.end > file_size {
325 caller.emit_error_metric();
326 return Err(RangeError::OutOfBounds {
327 requested_end: r.end,
328 file_size,
329 }
330 .into());
331 }
332 }
333
334 let cache = self.clone();
335 let key = key.to_string();
336 Ok(try_stream! {
337 for r in ranges {
338 if r.start >= r.end {
339 continue;
340 }
341 let blk_range = blocks_for_range(r.start, r.end, cache.block_size);
342 let mut windows = cache.stream_demand_windows(&key, file_size, blk_range);
343 while let Some((w, result)) = windows.next().await {
344 let block_map = result.inspect_err(|_| caller.emit_error_metric())?;
345 yield cache.assemble_window(&block_map, &w, r.start, r.end, file_size);
346 }
347 }
348 })
349 }
350
351 /// Build the ordered, bounded stream of demand-priority window fetches for
352 /// one requested range's block span `blk_range`: chunk it into
353 /// `DEMAND_WINDOW_BLOCKS`-sized windows and fetch each (at most two in
354 /// flight via `buffered(2)`), yielding `(window_indices, fetch_result)`.
355 /// Extracted from `stream_ranges_inner`'s `try_stream!` body to keep that
356 /// generator small; the returned stream owns its own `RangeCache`/key
357 /// clones so it is `'static`.
358 fn stream_demand_windows(
359 &self,
360 key: &str,
361 file_size: u64,
362 blk_range: Range<u64>,
363 ) -> impl Stream<Item = (Vec<u64>, Result<HashMap<u64, Bytes>>)> + Send + 'static {
364 let window_indices: Vec<Vec<u64>> = (blk_range.start..blk_range.end)
365 .collect::<Vec<u64>>()
366 .chunks(DEMAND_WINDOW_BLOCKS as usize)
367 .map(|w| w.to_vec())
368 .collect();
369 let cache = self.clone();
370 let key = key.to_string();
371 stream::iter(window_indices)
372 .map(move |w| {
373 let cache = cache.clone();
374 let key = key.clone();
375 async move {
376 let result = cache
377 .fetch_blocks(&key, file_size, &w, Priority::Demand)
378 .await;
379 (w, result)
380 }
381 })
382 .buffered(2)
383 }
384
385 /// Assemble the bytes for one window: gather its blocks from `block_map`,
386 /// then clamp to the window's own byte span intersected with the outer
387 /// requested range `[req_start, req_end)` before assembling. Clamping to
388 /// the window (not the whole range) matters because `block_map` holds only
389 /// this window's data and `assemble_range` pre-sizes its output buffer from
390 /// `req_end - req_start`, so passing the full range's bounds on every
391 /// window would over-allocate by up to the entire range's size each
392 /// iteration.
393 fn assemble_window(
394 &self,
395 block_map: &HashMap<u64, Bytes>,
396 window: &[u64],
397 req_start: u64,
398 req_end: u64,
399 file_size: u64,
400 ) -> Bytes {
401 let blocks: Vec<(u64, Bytes)> = window
402 .iter()
403 .map(|idx| {
404 let data = block_map
405 .get(idx)
406 .cloned()
407 .expect("fetch_blocks returns every requested index");
408 (*idx, data)
409 })
410 .collect();
411 let win_start = window[0] * self.block_size;
412 let win_end = block_byte_range(
413 *window.last().expect("window is non-empty"),
414 self.block_size,
415 file_size,
416 )
417 .end;
418 let local_start = req_start.max(win_start);
419 let local_end = req_end.min(win_end);
420 assemble_range(&blocks, self.block_size, local_start, local_end)
421 }
422
423 #[span_fn]
424 pub async fn get_range(&self, key: &str, range: Range<u64>) -> Result<Bytes> {
425 let mut stream = Box::pin(
426 self.stream_ranges(key, vec![range], StreamRangesCaller::Range)
427 .await?,
428 );
429 let mut buf = BytesMut::new();
430 while let Some(chunk) = stream.next().await {
431 buf.put_slice(&chunk?);
432 }
433 Ok(buf.freeze())
434 }
435
436 /// Like `get_range`, but for a caller that already resolved `file_size`
437 /// itself, skipping the redundant `self.size()` call inside
438 /// `stream_ranges` (see `stream_ranges_with_size`).
439 #[span_fn]
440 pub async fn get_range_with_size(
441 &self,
442 key: &str,
443 file_size: u64,
444 range: Range<u64>,
445 ) -> Result<Bytes> {
446 let mut stream = Box::pin(
447 self.stream_ranges_with_size(key, vec![range], file_size, StreamRangesCaller::Range)
448 .await?,
449 );
450 let mut buf = BytesMut::new();
451 while let Some(chunk) = stream.next().await {
452 buf.put_slice(&chunk?);
453 }
454 Ok(buf.freeze())
455 }
456
457 #[span_fn]
458 pub async fn get_ranges(&self, key: &str, ranges: &[Range<u64>]) -> Result<Vec<Bytes>> {
459 if ranges.is_empty() {
460 return Ok(vec![]);
461 }
462 let owned_ranges: Vec<Range<u64>> = ranges.to_vec();
463 let stream = Box::pin(
464 self.stream_ranges(key, owned_ranges, StreamRangesCaller::Ranges)
465 .await?,
466 );
467 collect_ranges_from_stream(ranges, stream).await
468 }
469
470 /// Like `get_ranges`, but for a caller that already resolved `file_size`
471 /// itself (see `stream_ranges_with_size`).
472 #[span_fn]
473 pub async fn get_ranges_with_size(
474 &self,
475 key: &str,
476 file_size: u64,
477 ranges: &[Range<u64>],
478 ) -> Result<Vec<Bytes>> {
479 if ranges.is_empty() {
480 return Ok(vec![]);
481 }
482 let owned_ranges: Vec<Range<u64>> = ranges.to_vec();
483 let stream = Box::pin(
484 self.stream_ranges_with_size(key, owned_ranges, file_size, StreamRangesCaller::Ranges)
485 .await?,
486 );
487 collect_ranges_from_stream(ranges, stream).await
488 }
489
490 /// Warm the cache for `ranges` at `Prefetch` priority without returning
491 /// any bytes. The HTTP surface for this (endpoint + client method) is
492 /// #1198; this is the priority-carrying core it builds on. Public (rather
493 /// than crate-private) so integration tests under `tests/` — which
494 /// compile as a separate crate — can exercise the promotion behavior
495 /// described in the fetch-rework plan.
496 pub async fn prefetch_ranges(&self, key: &str, ranges: &[Range<u64>]) -> Result<()> {
497 if ranges.is_empty() {
498 return Ok(());
499 }
500 let file_size = self.size(key).await?;
501 let mut all_block_indices = BTreeSet::new();
502 for r in ranges {
503 let start = r.start;
504 let end = r.end;
505 if end > file_size {
506 return Err(RangeError::OutOfBounds {
507 requested_end: end,
508 file_size,
509 }
510 .into());
511 }
512 if start < end {
513 let blk = blocks_for_range(start, end, self.block_size);
514 all_block_indices.extend(blk.start..blk.end);
515 }
516 }
517 self.prefetch_blocks(
518 key,
519 file_size,
520 &all_block_indices.into_iter().collect::<Vec<_>>(),
521 )
522 .await
523 }
524
525 /// Warm the cache for the given block indices at `Prefetch` priority.
526 pub async fn prefetch_blocks(&self, key: &str, file_size: u64, indices: &[u64]) -> Result<()> {
527 self.fetch_blocks(key, file_size, indices, Priority::Prefetch)
528 .await?;
529 Ok(())
530 }
531}
532
533/// Reassemble the flat, ordered chunk sequence a `stream_ranges*` stream
534/// yields back into one `Bytes` per requested range, using each range's
535/// known length rather than relying on a chunk boundary lining up with a
536/// range boundary. Shared by `get_ranges` and `get_ranges_with_size`.
537async fn collect_ranges_from_stream(
538 ranges: &[Range<u64>],
539 mut stream: impl Stream<Item = Result<Bytes>> + Unpin,
540) -> Result<Vec<Bytes>> {
541 let mut result = Vec::with_capacity(ranges.len());
542 let mut pending: Option<Bytes> = None;
543 for r in ranges {
544 let start = r.start;
545 let end = r.end;
546 if start >= end {
547 // `stream_ranges` yields nothing for a degenerate range, so
548 // reinsert the empty chunk at its position ourselves.
549 result.push(Bytes::new());
550 continue;
551 }
552 let need = (end - start) as usize;
553 let mut collected = BytesMut::with_capacity(need);
554 while collected.len() < need {
555 let chunk = match pending.take() {
556 Some(c) => c,
557 None => stream
558 .next()
559 .await
560 .expect("stream_ranges under-yielded for a non-degenerate range")?,
561 };
562 let remaining = need - collected.len();
563 if chunk.len() > remaining {
564 collected.put_slice(&chunk[..remaining]);
565 pending = Some(chunk.slice(remaining..));
566 } else {
567 collected.put_slice(&chunk);
568 }
569 }
570 result.push(collected.freeze());
571 }
572 Ok(result)
573}