Skip to main content

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}