Skip to main content

polars_io/cloud/
polars_object_store.rs

1use std::fmt::Display;
2use std::ops::Range;
3use std::sync::Arc;
4use std::time::Instant;
5
6use futures::{Stream, StreamExt as _, TryStreamExt as _};
7use hashbrown::hash_map::RawEntryMut;
8use object_store::client::{HttpError, HttpErrorKind};
9use object_store::path::Path;
10use object_store::{ObjectMeta, ObjectStore, ObjectStoreExt};
11use polars_buffer::Buffer;
12use polars_core::config;
13use polars_core::prelude::{InitHashMaps, PlHashMap};
14use polars_error::{PolarsError, PolarsResult};
15use polars_utils::pl_path::PlRefPath;
16use tokio::io::AsyncWriteExt;
17
18use super::concurrency::{ConcurrencyController, IoSample};
19use super::concurrency_config::{ConcurrencyStrategy, FetchConfig, get_download_chunk_size};
20use crate::pl_async::{
21    self, MAX_BUDGET_PER_REQUEST, get_concurrency_limit, tune_with_concurrency_budget,
22    with_concurrency_budget,
23};
24
25#[derive(Debug)]
26pub struct PolarsObjectStoreError {
27    pub base_url: PlRefPath,
28    pub source: object_store::Error,
29}
30
31impl Display for PolarsObjectStoreError {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        write!(
34            f,
35            "object-store error: {} (path: {})",
36            self.source, self.base_url
37        )
38    }
39}
40
41impl std::error::Error for PolarsObjectStoreError {
42    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
43        Some(&self.source)
44    }
45}
46
47/// The `io::ErrorKind` for an `object_store::Error`, taken from the error
48/// variant or from the innermost `io::Error` in its source chain.
49pub fn object_store_error_kind(source: &object_store::Error) -> std::io::ErrorKind {
50    use std::io::ErrorKind::*;
51
52    match source {
53        object_store::Error::NotFound { .. } => NotFound,
54        object_store::Error::PermissionDenied { .. }
55        | object_store::Error::Unauthenticated { .. } => PermissionDenied,
56        _ => io_error_kind_from_source(source),
57    }
58}
59
60/// The kind of the innermost `io::Error` in the source chain of `error`,
61/// falling back to the kind of the innermost `HttpError`, or `Other`.
62pub fn io_error_kind_from_source(error: &(dyn std::error::Error + 'static)) -> std::io::ErrorKind {
63    use std::io::ErrorKind::*;
64
65    let mut kind = Other;
66    let mut http_kind = None;
67    let mut cur = Some(error);
68    while let Some(e) = cur {
69        if let Some(io) = e.downcast_ref::<std::io::Error>()
70            && io.kind() != Other
71        {
72            kind = io.kind();
73        } else if let Some(http) = e.downcast_ref::<HttpError>() {
74            http_kind = Some(http.kind());
75        }
76        cur = e.source();
77    }
78    if kind == Other {
79        // object_store maps this in its private `From<RetryError> for io::Error`,
80        // but the kind is lost when the error is boxed into `Error::Generic`.
81        kind = match http_kind {
82            Some(HttpErrorKind::Timeout) => TimedOut,
83            Some(HttpErrorKind::Connect) => NotConnected,
84            Some(HttpErrorKind::Interrupted) => ConnectionAborted,
85            _ => Other,
86        };
87    }
88    kind
89}
90
91impl From<PolarsObjectStoreError> for std::io::Error {
92    fn from(value: PolarsObjectStoreError) -> Self {
93        let kind = object_store_error_kind(&value.source);
94        std::io::Error::new(kind, value)
95    }
96}
97
98impl From<PolarsObjectStoreError> for PolarsError {
99    fn from(value: PolarsObjectStoreError) -> Self {
100        PolarsError::IO {
101            error: Arc::new(value.into()),
102            msg: None,
103        }
104    }
105}
106
107mod inner {
108
109    use std::borrow::Cow;
110    use std::future::Future;
111    use std::sync::Arc;
112
113    use object_store::ObjectStore;
114    use polars_core::config;
115    use polars_error::{PolarsError, PolarsResult};
116    use polars_utils::relaxed_cell::RelaxedCell;
117
118    use crate::cloud::concurrency::{ConcurrencyController, ControllerConfig};
119    use crate::cloud::{ObjectStoreErrorContext, PolarsObjectStoreBuilder};
120    use crate::metrics::{IOMetrics, OptIOMetrics};
121
122    #[derive(Debug)]
123    struct Inner {
124        store: tokio::sync::RwLock<Arc<dyn ObjectStore>>,
125        builder: PolarsObjectStoreBuilder,
126        rebuilt: RelaxedCell<bool>,
127        suffix_range_supported: RelaxedCell<bool>,
128    }
129
130    /// Polars wrapper around [`ObjectStore`] functionality. This struct is cheaply cloneable.
131    #[derive(Clone, Debug)]
132    pub struct PolarsObjectStore {
133        inner: Arc<Inner>,
134        /// Avoid contending the Mutex `lock()` until the first re-build.
135        initial_store: std::sync::Arc<dyn ObjectStore>,
136        io_metrics: OptIOMetrics,
137        /// In-flight concurrency control using the (new) BDP model.
138        concurrency: Arc<std::sync::OnceLock<Arc<ConcurrencyController>>>,
139    }
140
141    impl PolarsObjectStore {
142        pub(crate) fn new_from_inner(
143            store: Arc<dyn ObjectStore>,
144            builder: PolarsObjectStoreBuilder,
145        ) -> Self {
146            let initial_store = store.clone();
147            // Azure accepts only `bytes=start-` and `bytes=start-end`, so never attempt a
148            // suffix range there: https://learn.microsoft.com/en-us/rest/api/storageservices/specifying-the-range-header-for-blob-service-operations
149            let suffix_range_supported = RelaxedCell::from(!builder.is_azure());
150            Self {
151                inner: Arc::new(Inner {
152                    store: tokio::sync::RwLock::new(store),
153                    builder,
154                    rebuilt: RelaxedCell::from(false),
155                    suffix_range_supported,
156                }),
157                initial_store,
158                io_metrics: OptIOMetrics(None),
159                concurrency: Arc::new(std::sync::OnceLock::new()),
160            }
161        }
162
163        pub fn set_io_metrics(&mut self, io_metrics: Option<Arc<IOMetrics>>) -> &mut Self {
164            self.io_metrics = OptIOMetrics(io_metrics);
165            self
166        }
167
168        pub fn io_metrics(&self) -> &OptIOMetrics {
169            &self.io_metrics
170        }
171
172        pub fn get_or_init_concurrency(&self) -> &Arc<ConcurrencyController> {
173            self.concurrency.get_or_init(|| {
174                // Valid across rebuilds: the signal wraps the stable cell.
175                let signal = self.inner.builder.rate_limit_signal();
176                Arc::new(ConcurrencyController::new(
177                    ControllerConfig::default(),
178                    signal,
179                ))
180            })
181        }
182
183        /// Gets the underlying [`ObjectStore`] implementation.
184        pub async fn to_dyn_object_store(&self) -> Cow<'_, Arc<dyn ObjectStore>> {
185            if !self.inner.rebuilt.load() {
186                Cow::Borrowed(&self.initial_store)
187            } else {
188                Cow::Owned(self.inner.store.read().await.clone())
189            }
190        }
191
192        pub async fn rebuild_inner(
193            &self,
194            from_version: &Arc<dyn ObjectStore>,
195        ) -> PolarsResult<Arc<dyn ObjectStore>> {
196            let mut current_store = self.inner.store.write().await;
197
198            // If this does not eq, then `inner` was already re-built by another thread.
199            if Arc::ptr_eq(&*current_store, from_version) {
200                *current_store =
201                    self.inner
202                        .builder
203                        .clone()
204                        .build_impl(true)
205                        .await
206                        .map_err(|e| {
207                            e.wrap_msg(|e| format!("attempt to rebuild object store failed: {e}"))
208                        })?;
209            }
210
211            self.inner.rebuilt.store(true);
212
213            Ok((*current_store).clone())
214        }
215
216        pub async fn exec_with_rebuild_retry_on_err<'s, 'f, Fn, Fut, O>(
217            &'s self,
218            mut func: Fn,
219        ) -> PolarsResult<O>
220        where
221            Fn: FnMut(Cow<'s, Arc<dyn ObjectStore>>) -> Fut + 'f,
222            Fut: Future<Output = object_store::Result<O>>,
223        {
224            let store = self.to_dyn_object_store().await;
225
226            let out = func(store.clone()).await;
227
228            let orig_err = match out {
229                Ok(v) => return Ok(v),
230                Err(e) => e,
231            };
232
233            let rebuild = self.inner.builder.rebuild_on_error();
234
235            if config::verbose() {
236                eprintln!(
237                    "[PolarsObjectStore]: got error: {}, will {}retry",
238                    orig_err,
239                    if rebuild { "rebuild store and " } else { "" }
240                );
241            }
242
243            let store = if rebuild {
244                let store = self
245                    .rebuild_inner(&store)
246                    .await
247                    .map_err(|e| e.wrap_msg(|e| format!("{e}; original error: {orig_err}")))?;
248                Cow::Owned(store)
249            } else {
250                store
251            };
252
253            func(store).await.map_err(|e| {
254                let e: PolarsError = self.error_context().attach_err_info(e).into();
255
256                if self.inner.builder.is_azure()
257                    && std::env::var("POLARS_AUTO_USE_AZURE_STORAGE_ACCOUNT_KEY").as_deref()
258                        != Ok("1")
259                {
260                    // Note: This error is intended for Python audiences. The logic for retrieving
261                    // these keys exist only on the Python side.
262                    e.wrap_msg(|e| {
263                        format!(
264                            "{e}; note: if you are using Python, consider setting \
265POLARS_AUTO_USE_AZURE_STORAGE_ACCOUNT_KEY=1 if you would like polars to try to retrieve \
266and use the storage account keys from Azure CLI to authenticate"
267                        )
268                    })
269                } else {
270                    e
271                }
272            })
273        }
274
275        pub(crate) fn suffix_range_supported(&self) -> bool {
276            self.inner.suffix_range_supported.load()
277        }
278
279        pub(crate) fn disable_suffix_range(&self) {
280            self.inner.suffix_range_supported.store(false);
281        }
282
283        pub fn error_context(&self) -> ObjectStoreErrorContext {
284            ObjectStoreErrorContext::new(self.inner.builder.path().clone())
285        }
286    }
287}
288
289#[derive(Clone)]
290pub struct ObjectStoreErrorContext {
291    path: PlRefPath,
292}
293
294impl ObjectStoreErrorContext {
295    pub fn new(path: PlRefPath) -> Self {
296        Self { path }
297    }
298
299    pub fn attach_err_info(self, err: object_store::Error) -> PolarsObjectStoreError {
300        let ObjectStoreErrorContext { path } = self;
301
302        PolarsObjectStoreError {
303            base_url: path,
304            source: err,
305        }
306    }
307}
308
309pub use inner::PolarsObjectStore;
310
311/// `GetResult::bytes` for a streamed body of `len` bytes (the returned range) that reports arrived
312/// bytes to the controller as they come in, per `ARRIVAL_FLUSH_BYTES`.
313async fn collect_body(
314    stream: futures::stream::BoxStream<'static, object_store::Result<bytes::Bytes>>,
315    len: u64,
316    controller: &ConcurrencyController,
317) -> object_store::Result<bytes::Bytes> {
318    const ARRIVAL_FLUSH_BYTES: u64 = 64 * 1024;
319
320    let mut pending = 0;
321    let out = object_store::collect_bytes(
322        stream.inspect_ok(|chunk| {
323            pending += chunk.len() as u64;
324            if pending >= ARRIVAL_FLUSH_BYTES {
325                controller.record_arrived(std::mem::take(&mut pending));
326            }
327        }),
328        Some(len),
329    )
330    .await;
331    controller.record_arrived(pending);
332    out
333}
334
335pub type ObjectStorePath = object_store::path::Path;
336
337impl PolarsObjectStore {
338    pub fn build_buffered_ranges_stream<'a, T: Iterator<Item = Range<usize>>>(
339        &'a self,
340        path: &'a Path,
341        ranges: T,
342        strategy: ConcurrencyStrategy,
343    ) -> impl Stream<Item = PolarsResult<Buffer<u8>>> + use<'a, T> {
344        let controller = match strategy {
345            ConcurrencyStrategy::BytesBased => Some(self.get_or_init_concurrency().clone()),
346            ConcurrencyStrategy::Unbounded | ConcurrencyStrategy::Legacy => None,
347        };
348
349        let n_buffered = match strategy {
350            // In case of bytes-based concurrency, the concurrency is controlled by the
351            // admission semaphore in the pipeline.
352            // The buffered size is set to a large constant as a backstop. Once all
353            // callsites are verified to pass finite, metadata-derived ranges, this can be
354            // set to usize::MAX.
355            ConcurrencyStrategy::BytesBased => 4096,
356            ConcurrencyStrategy::Unbounded | ConcurrencyStrategy::Legacy => {
357                get_concurrency_limit() as usize
358            },
359        };
360
361        futures::stream::iter(ranges.map(move |range| {
362            let controller = controller.clone();
363            async move {
364                if range.is_empty() {
365                    return Ok(Buffer::new());
366                }
367                let bytes_req = range.len() as u64;
368
369                // Held until end of block to bound in-flight bytes.
370                let _permit = match &controller {
371                    Some(controller) => Some(controller.acquire(bytes_req).await),
372                    None => None,
373                };
374
375                let controller_ref = controller.as_deref();
376                let (out, ttfb) = self
377                    .io_metrics()
378                    .record_io_read(
379                        bytes_req,
380                        self.exec_with_rebuild_retry_on_err(|s| async move {
381                            let t0 = Instant::now();
382                            let response = s
383                                .get_opts(
384                                    path,
385                                    object_store::GetOptions {
386                                        range: Some((range.start as u64..range.end as u64).into()),
387                                        ..Default::default()
388                                    },
389                                )
390                                .await?;
391                            let ttfb = t0.elapsed();
392                            let out = match controller_ref {
393                                Some(controller)
394                                    if matches!(
395                                        response.payload,
396                                        object_store::GetResultPayload::Stream(_)
397                                    ) =>
398                                {
399                                    let len = response.range.end - response.range.start;
400                                    collect_body(response.into_stream(), len, controller).await?
401                                },
402                                _ => {
403                                    let out = response.bytes().await?;
404                                    // Not streamed (local files): the bytes arrive at completion.
405                                    if let Some(controller) = controller_ref {
406                                        controller.record_arrived(out.len() as u64);
407                                    }
408                                    out
409                                },
410                            };
411
412                            Ok((out, ttfb))
413                        }),
414                    )
415                    .await?;
416
417                if let Some(controller) = &controller {
418                    controller.record_io(IoSample {
419                        n_bytes: out.len() as u64,
420                        ttfb,
421                        completion_time: Instant::now(),
422                    });
423                }
424
425                Ok(Buffer::from_owner(out))
426            }
427        }))
428        .buffered(n_buffered)
429    }
430
431    pub async fn get_range(
432        &self,
433        path: &Path,
434        range: Range<usize>,
435        config: FetchConfig,
436    ) -> PolarsResult<Buffer<u8>> {
437        if range.is_empty() {
438            return Ok(Buffer::new());
439        }
440
441        let parts = split_range(range.clone(), Some(config.chunk_size));
442
443        match config.strategy {
444            ConcurrencyStrategy::Legacy => self.get_range_legacy(path, range).await,
445            ConcurrencyStrategy::Unbounded | ConcurrencyStrategy::BytesBased => self
446                .build_buffered_ranges_stream(path, parts, config.strategy)
447                .try_collect::<Vec<_>>()
448                .await
449                .map(|parts| {
450                    if parts.len() == 1 {
451                        return parts.into_iter().next().unwrap();
452                    }
453                    let mut combined = Vec::with_capacity(range.len());
454                    for part in parts {
455                        combined.extend_from_slice(&part);
456                    }
457                    assert_eq!(combined.len(), range.len());
458                    Buffer::from_vec(combined)
459                }),
460        }
461    }
462
463    async fn get_range_legacy(&self, path: &Path, range: Range<usize>) -> PolarsResult<Buffer<u8>> {
464        if range.is_empty() {
465            return Ok(Buffer::new());
466        }
467
468        let parts = split_range(range.clone(), None);
469
470        if parts.len() == 1 {
471            let out = tune_with_concurrency_budget(1, move || async move {
472                let bytes = self
473                    .io_metrics()
474                    .record_io_read(
475                        range.len() as u64,
476                        self.exec_with_rebuild_retry_on_err(|s| async move {
477                            s.get_range(path, range.start as u64..range.end as u64)
478                                .await
479                        }),
480                    )
481                    .await?;
482
483                PolarsResult::Ok(Buffer::from_owner(bytes))
484            })
485            .await?;
486
487            Ok(out)
488        } else {
489            let parts = tune_with_concurrency_budget(
490                parts.len().clamp(0, MAX_BUDGET_PER_REQUEST) as u32,
491                || {
492                    self.build_buffered_ranges_stream(path, parts, ConcurrencyStrategy::Legacy)
493                        .try_collect::<Vec<Buffer<u8>>>()
494                },
495            )
496            .await?;
497
498            let mut combined = Vec::with_capacity(range.len());
499
500            for part in parts {
501                combined.extend_from_slice(&part)
502            }
503
504            assert_eq!(combined.len(), range.len());
505
506            PolarsResult::Ok(Buffer::from_vec(combined))
507        }
508    }
509
510    pub async fn get_ranges_sort(
511        &self,
512        path: &Path,
513        ranges: &mut [Range<usize>],
514        config: FetchConfig,
515    ) -> PolarsResult<PlHashMap<usize, Buffer<u8>>> {
516        if ranges.is_empty() {
517            return Ok(Default::default());
518        }
519
520        ranges.sort_unstable_by_key(|x| x.start);
521
522        let ranges_len = ranges.len();
523        let (merged_ranges, merged_ends): (Vec<_>, Vec<_>) =
524            merge_ranges(ranges, Some(config.chunk_size)).unzip();
525
526        let mut out = PlHashMap::with_capacity(ranges_len);
527
528        // Build an inflight admission-aware stream over the merged ranges.
529        let mut stream =
530            self.build_buffered_ranges_stream(path, merged_ranges.iter().cloned(), config.strategy);
531
532        let mut current_offset = 0;
533        let mut ends_iter = merged_ends.iter();
534        let mut splitted_parts: Vec<Buffer<u8>> = vec![];
535
536        while let Some(bytes) = stream.try_next().await? {
537            let end = *ends_iter.next().unwrap();
538
539            if end == 0 {
540                splitted_parts.push(bytes);
541                continue;
542            }
543
544            let full_range = ranges[current_offset..end]
545                .iter()
546                .cloned()
547                .reduce(|l, r| l.start.min(r.start)..l.end.max(r.end))
548                .unwrap();
549
550            let bytes = if splitted_parts.is_empty() {
551                bytes
552            } else {
553                let mut out = Vec::with_capacity(full_range.len());
554                for x in splitted_parts.drain(..) {
555                    out.extend_from_slice(&x);
556                }
557                out.extend_from_slice(&bytes);
558                Buffer::from(out)
559            };
560
561            assert_eq!(bytes.len(), full_range.len());
562
563            for range in &ranges[current_offset..end] {
564                let slice = bytes
565                    .clone()
566                    .sliced(range.start - full_range.start..range.end - full_range.start);
567
568                match out.raw_entry_mut().from_key(&range.start) {
569                    RawEntryMut::Vacant(slot) => {
570                        slot.insert(range.start, slice);
571                    },
572                    RawEntryMut::Occupied(mut slot) => {
573                        if slot.get_mut().len() < slice.len() {
574                            *slot.get_mut() = slice;
575                        }
576                    },
577                }
578            }
579
580            current_offset = end;
581        }
582
583        assert!(splitted_parts.is_empty());
584
585        Ok(out)
586    }
587
588    // TODO: Refactor for updated concurrency strategy.
589    pub async fn download(&self, path: &Path, file: &mut tokio::fs::File) -> PolarsResult<()> {
590        let size = self.head(path, ConcurrencyStrategy::Unbounded).await?.size;
591        let parts = split_range(0..size as usize, None);
592
593        // TODO: Replace the legacy concurrency_budget call and switch to BytesBased inflight
594        // admission control.
595        tune_with_concurrency_budget(
596            parts.len().clamp(0, MAX_BUDGET_PER_REQUEST) as u32,
597            || async {
598                let mut stream =
599                    self.build_buffered_ranges_stream(path, parts, ConcurrencyStrategy::Unbounded);
600                let mut len = 0;
601                while let Some(bytes) = stream.try_next().await? {
602                    len += bytes.len();
603                    file.write_all(&bytes).await?;
604                }
605
606                assert_eq!(len, size as usize);
607
608                PolarsResult::Ok(pl_async::Size::from(len as u64))
609            },
610        )
611        .await?;
612
613        // Dropping is delayed for tokio async files so we need to explicitly
614        // flush here (https://github.com/tokio-rs/tokio/issues/2307#issuecomment-596336451).
615        file.sync_all().await.map_err(PolarsError::from)?;
616
617        Ok(())
618    }
619
620    /// Fetch the last `n` bytes of the object, and the total size of the object. Returns fewer
621    /// than `n` bytes if the object is smaller than `n`.
622    ///
623    /// A suffix range request (`Range: bytes=-n`) carries the object size in its `Content-Range`,
624    /// saving a HEAD. Backends rejecting them (e.g. Azure) fall back to HEAD + bounded range.
625    pub async fn get_suffix(
626        &self,
627        path: &Path,
628        n: usize,
629        config: FetchConfig,
630    ) -> PolarsResult<(Buffer<u8>, usize)> {
631        let mut suffix_rejected = false;
632
633        if n > 0 && self.suffix_range_supported() {
634            match self
635                .try_get_with_size(path, SizedRange::Suffix(n), config.strategy)
636                .await
637            {
638                Ok(v) => return Ok(v),
639                Err(e) if is_suffix_range_rejection(&e) => {
640                    suffix_rejected = true;
641
642                    if config::verbose() {
643                        eprintln!(
644                            "[PolarsObjectStore]: suffix range request rejected ({e}), \
645                            falling back to head + range request"
646                        );
647                    }
648                },
649                Err(e) => return Err(e),
650            }
651        }
652
653        let size = self.head(path, config.strategy).await?.size as usize;
654
655        // An empty object is rejected regardless of backend support, so it proves nothing.
656        if suffix_rejected && size > 0 {
657            self.disable_suffix_range();
658        }
659
660        let bytes = self
661            .get_range(path, size.saturating_sub(n)..size, config)
662            .await?;
663
664        Ok((bytes, size))
665    }
666
667    /// Fetch the first `n` bytes of the object, and the total size of the object. Returns fewer
668    /// than `n` bytes if the object is smaller than `n`.
669    ///
670    /// A bounded range request carries the object size in its `Content-Range`, saving a HEAD.
671    /// Empty objects and backends ignoring the range fall back to HEAD + bounded range.
672    pub async fn get_prefix(
673        &self,
674        path: &Path,
675        n: usize,
676        config: FetchConfig,
677    ) -> PolarsResult<(Buffer<u8>, usize)> {
678        if n > 0 {
679            match self
680                .try_get_with_size(path, SizedRange::Prefix(n), config.strategy)
681                .await
682            {
683                Err(e) if is_prefix_range_rejection(&e) => {},
684                out => return out,
685            }
686        }
687
688        let size = self.head(path, config.strategy).await?.size as usize;
689        let bytes = self.get_range(path, 0..n.min(size), config).await?;
690
691        Ok((bytes, size))
692    }
693
694    /// Range request that also returns the total object size.
695    async fn try_get_with_size(
696        &self,
697        path: &Path,
698        range: SizedRange,
699        strategy: ConcurrencyStrategy,
700    ) -> PolarsResult<(Buffer<u8>, usize)> {
701        match strategy {
702            ConcurrencyStrategy::BytesBased => {
703                let controller = self.get_or_init_concurrency();
704                let _permit = controller.acquire(range.len() as u64).await;
705                self.try_get_with_size_inner(path, range, Some(&**controller))
706                    .await
707            },
708            ConcurrencyStrategy::Legacy => {
709                with_concurrency_budget(1, || self.try_get_with_size_inner(path, range, None)).await
710            },
711            ConcurrencyStrategy::Unbounded => self.try_get_with_size_inner(path, range, None).await,
712        }
713    }
714
715    async fn try_get_with_size_inner(
716        &self,
717        path: &Path,
718        range: SizedRange,
719        controller: Option<&ConcurrencyController>,
720    ) -> PolarsResult<(Buffer<u8>, usize)> {
721        let metrics = self.io_metrics();
722
723        // A prefix range is clamped to the object size, so it is accounted for once the response
724        // carries the size, once across retries.
725        if let SizedRange::Suffix(n) = range {
726            metrics.add_bytes_requested(n as u64);
727        }
728        let prefix_requested_recorded = &std::sync::atomic::AtomicBool::new(false);
729
730        let io_session = metrics.start_rx_session();
731
732        let out = self
733            .exec_with_rebuild_retry_on_err(|s| async move {
734                let t0 = Instant::now();
735                let response = s
736                    .get_opts(
737                        path,
738                        object_store::GetOptions {
739                            range: Some(range.into()),
740                            ..Default::default()
741                        },
742                    )
743                    .await?;
744                let ttfb = t0.elapsed();
745                // `get_opts()` rewrites this from `Content-Range`: the full object size.
746                let size = response.meta.size as usize;
747
748                if let SizedRange::Prefix(n) = range
749                    && !prefix_requested_recorded.swap(true, std::sync::atomic::Ordering::Relaxed)
750                {
751                    metrics.add_bytes_requested(n.min(size) as u64);
752                }
753
754                let bytes = response.bytes().await?;
755
756                if let Some(controller) = controller {
757                    controller.record_arrived(bytes.len() as u64);
758                    controller.record_io(IoSample {
759                        n_bytes: bytes.len() as u64,
760                        ttfb,
761                        completion_time: Instant::now(),
762                    });
763                }
764
765                Ok((Buffer::from_owner(bytes), size))
766            })
767            .await;
768
769        drop(io_session);
770
771        let (bytes, size) = out?;
772
773        // The response is short when the object is smaller than the range.
774        metrics.add_bytes_received(bytes.len() as u64);
775
776        Ok((bytes, size))
777    }
778
779    /// Fetch the object metadata with a HEAD request, do not memoize it.
780    pub async fn head(
781        &self,
782        path: &Path,
783        strategy: ConcurrencyStrategy,
784    ) -> PolarsResult<ObjectMeta> {
785        match strategy {
786            ConcurrencyStrategy::BytesBased => {
787                let controller = self.get_or_init_concurrency();
788                let _permit = controller.acquire(0).await;
789                self.head_inner(path, Some(&**controller)).await
790            },
791            ConcurrencyStrategy::Legacy => {
792                with_concurrency_budget(1, || self.head_inner(path, None)).await
793            },
794            ConcurrencyStrategy::Unbounded => self.head_inner(path, None).await,
795        }
796    }
797
798    async fn head_inner(
799        &self,
800        path: &Path,
801        controller: Option<&ConcurrencyController>,
802    ) -> PolarsResult<ObjectMeta> {
803        self.exec_with_rebuild_retry_on_err(|s| {
804            async move {
805                let t0 = Instant::now();
806                let head_result = self.io_metrics().record_io_read(0, s.head(path)).await;
807
808                if head_result.is_err() {
809                    let t0 = Instant::now();
810
811                    // Pre-signed URLs forbid the HEAD method, but we can still retrieve the header
812                    // information with a range 0-1 request.
813                    let get_range_0_1_result = self
814                        .io_metrics()
815                        .record_io_read(
816                            0,
817                            s.get_opts(
818                                path,
819                                object_store::GetOptions {
820                                    range: Some((0..1).into()),
821                                    ..Default::default()
822                                },
823                            ),
824                        )
825                        .await;
826
827                    if let Ok(v) = get_range_0_1_result {
828                        if let Some(controller) = controller {
829                            controller.record_head_rtt(t0.elapsed());
830                        }
831                        return Ok(v.meta);
832                    }
833                }
834
835                let out = head_result?;
836
837                if let Some(controller) = controller {
838                    controller.record_head_rtt(t0.elapsed());
839                }
840
841                Ok(out)
842            }
843        })
844        .await
845    }
846}
847
848/// Whether the error is about the suffix range rather than the object. Azure rejects them
849/// client-side, backends ignoring the header answer 200 instead of 206, strict ones answer 416.
850fn is_suffix_range_rejection(err: &PolarsError) -> bool {
851    let msg = err.to_string();
852
853    msg.contains("does not support suffix range requests")
854        || msg.contains("Received non-partial response")
855        || msg.contains("416 Range Not Satisfiable")
856}
857
858/// Range request forms whose response carries the total object size in its `Content-Range`.
859#[derive(Clone, Copy, Debug)]
860enum SizedRange {
861    /// The first `n` bytes.
862    Prefix(usize),
863    /// The last `n` bytes.
864    Suffix(usize),
865}
866
867impl SizedRange {
868    /// Number of bytes requested.
869    fn len(self) -> usize {
870        match self {
871            Self::Prefix(n) | Self::Suffix(n) => n,
872        }
873    }
874}
875
876impl From<SizedRange> for object_store::GetRange {
877    fn from(value: SizedRange) -> Self {
878        match value {
879            SizedRange::Prefix(n) => Self::Bounded(0..n as u64),
880            SizedRange::Suffix(n) => Self::Suffix(n as u64),
881        }
882    }
883}
884
885/// Whether a prefix range request was rejected rather than the object. Empty objects have
886/// no satisfiable range: the backend answers 416, or `object_store` rejects the range against the
887/// size. Backends ignoring the range answer 200 instead of 206, the HEAD fallback then decides
888/// (e.g. an empty object needs no further request).
889///
890/// The rejection can be nested in the source chain, e.g. for the local file system store.
891fn is_prefix_range_rejection(err: &PolarsError) -> bool {
892    std::iter::successors(Some(err as &dyn std::error::Error), |e| e.source()).any(|e| {
893        let msg = e.to_string();
894
895        msg.contains("416 Range Not Satisfiable")
896            || msg.contains("Wanted range starting at")
897            || msg.contains("Received non-partial response")
898            // `object_store` HTTP store.
899            || msg.contains("Range request not supported")
900    })
901}
902
903/// Splits a single range into multiple smaller ranges, which can be downloaded concurrently for
904/// much higher throughput.
905fn split_range(
906    range: Range<usize>,
907    chunk_size: Option<usize>,
908) -> impl ExactSizeIterator<Item = Range<usize>> {
909    let chunk_size = chunk_size.unwrap_or_else(get_download_chunk_size);
910
911    // Calculate n_parts such that we are as close as possible to the `chunk_size`.
912    let n_parts = [
913        (range.len().div_ceil(chunk_size)).max(1),
914        (range.len() / chunk_size).max(1),
915    ]
916    .into_iter()
917    .min_by_key(|x| (range.len() / *x).abs_diff(chunk_size))
918    .unwrap();
919
920    let chunk_size = (range.len() / n_parts).max(1);
921
922    assert_eq!(n_parts, (range.len() / chunk_size).max(1));
923    let bytes_rem = range.len() % chunk_size;
924
925    (0..n_parts).map(move |part_no| {
926        let (start, end) = if part_no == 0 {
927            // Download remainder length in the first chunk since it starts downloading first.
928            let end = range.start + chunk_size + bytes_rem;
929            let end = if end > range.end { range.end } else { end };
930            (range.start, end)
931        } else {
932            let start = bytes_rem + range.start + part_no * chunk_size;
933            (start, start + chunk_size)
934        };
935
936        start..end
937    })
938}
939
940/// Note: For optimal performance, `ranges` should be sorted. More generally,
941/// ranges placed next to each other should also be close in range value.
942///
943/// # Returns
944/// `[(range1, end1), (range2, end2)]`, where:
945/// * `range1` contains bytes for the ranges from `ranges[0..end1]`
946/// * `range2` contains bytes for the ranges from `ranges[end1..end2]`
947/// * etc..
948///
949/// Note that if an end value is 0, it means the range is a splitted part and should be combined.
950fn merge_ranges(
951    ranges: &[Range<usize>],
952    chunk_size: Option<usize>,
953) -> impl Iterator<Item = (Range<usize>, usize)> + '_ {
954    let chunk_size = chunk_size.unwrap_or_else(get_download_chunk_size);
955
956    let mut current_merged_range = ranges.first().map_or(0..0, Clone::clone);
957    // Number of fetched bytes excluding excess.
958    let mut current_n_bytes = current_merged_range.len();
959
960    (0..ranges.len())
961        .filter_map(move |current_idx| {
962            let current_idx = 1 + current_idx;
963
964            if current_idx == ranges.len() {
965                // No more items - flush current state.
966                Some((current_merged_range.clone(), current_idx))
967            } else {
968                let range = ranges[current_idx].clone();
969
970                let new_merged = current_merged_range.start.min(range.start)
971                    ..current_merged_range.end.max(range.end);
972
973                // E.g.:
974                // |--------|
975                //  oo        // range1
976                //       oo   // range2
977                //    ^^^     // distance = 3, is_overlapping = false
978                // E.g.:
979                // |--------|
980                //  ooooo     // range1
981                //     ooooo  // range2
982                //     ^^     // distance = 2, is_overlapping = true
983                let (distance, is_overlapping) = {
984                    let l = current_merged_range.end.min(range.end);
985                    let r = current_merged_range.start.max(range.start);
986
987                    (r.abs_diff(l), r < l)
988                };
989
990                let should_merge = is_overlapping || {
991                    let leq_current_len_dist_to_chunk_size = new_merged.len().abs_diff(chunk_size)
992                        <= current_merged_range.len().abs_diff(chunk_size);
993                    let gap_tolerance =
994                        (current_n_bytes.max(range.len()) / 8).clamp(1024 * 1024, 8 * 1024 * 1024);
995
996                    leq_current_len_dist_to_chunk_size && distance <= gap_tolerance
997                };
998
999                if should_merge {
1000                    // Merge to existing range
1001                    current_merged_range = new_merged;
1002                    current_n_bytes += if is_overlapping {
1003                        range.len() - distance
1004                    } else {
1005                        range.len()
1006                    };
1007                    None
1008                } else {
1009                    let out = (current_merged_range.clone(), current_idx);
1010                    current_merged_range = range;
1011                    current_n_bytes = current_merged_range.len();
1012                    Some(out)
1013                }
1014            }
1015        })
1016        .flat_map(move |x| {
1017            // Split large individual ranges within the list of ranges.
1018            let (range, end) = x;
1019            let split = split_range(range, Some(chunk_size));
1020            let len = split.len();
1021
1022            split
1023                .enumerate()
1024                .map(move |(i, range)| (range, if 1 + i == len { end } else { 0 }))
1025        })
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030
1031    #[test]
1032    fn test_split_range() {
1033        use super::{get_download_chunk_size, split_range};
1034
1035        let chunk_size = get_download_chunk_size();
1036
1037        assert_eq!(chunk_size, 64 * 1024 * 1024);
1038
1039        #[allow(clippy::single_range_in_vec_init)]
1040        {
1041            // Round-trip empty ranges.
1042            assert_eq!(split_range(0..0, None).collect::<Vec<_>>(), [0..0]);
1043            assert_eq!(split_range(3..3, None).collect::<Vec<_>>(), [3..3]);
1044        }
1045
1046        // Threshold to start splitting to 2 ranges
1047        //
1048        // n - chunk_size == chunk_size - n / 2
1049        // n + n / 2 == 2 * chunk_size
1050        // 3 * n == 4 * chunk_size
1051        // n = 4 * chunk_size / 3
1052        let n = 4 * chunk_size / 3;
1053
1054        #[allow(clippy::single_range_in_vec_init)]
1055        {
1056            assert_eq!(split_range(0..n, None).collect::<Vec<_>>(), [0..89478485]);
1057        }
1058
1059        assert_eq!(
1060            split_range(0..n + 1, None).collect::<Vec<_>>(),
1061            [0..44739243, 44739243..89478486]
1062        );
1063
1064        // Threshold to start splitting to 3 ranges
1065        //
1066        // n / 2 - chunk_size == chunk_size - n / 3
1067        // n / 2 + n / 3 == 2 * chunk_size
1068        // 5 * n == 12 * chunk_size
1069        // n == 12 * chunk_size / 5
1070        let n = 12 * chunk_size / 5;
1071
1072        assert_eq!(
1073            split_range(0..n, None).collect::<Vec<_>>(),
1074            [0..80530637, 80530637..161061273]
1075        );
1076
1077        assert_eq!(
1078            split_range(0..n + 1, None).collect::<Vec<_>>(),
1079            [0..53687092, 53687092..107374183, 107374183..161061274]
1080        );
1081    }
1082
1083    #[test]
1084    fn test_merge_ranges() {
1085        use super::{get_download_chunk_size, merge_ranges};
1086
1087        let chunk_size = get_download_chunk_size();
1088
1089        assert_eq!(chunk_size, 64 * 1024 * 1024);
1090
1091        // Round-trip empty slice
1092        assert_eq!(merge_ranges(&[], None).collect::<Vec<_>>(), []);
1093
1094        // We have 1 tiny request followed by 1 huge request. They are combined as it reduces the
1095        // `abs_diff()` to the `chunk_size`, but afterwards they are split to 2 evenly sized
1096        // requests.
1097        assert_eq!(
1098            merge_ranges(&[0..1, 1..127 * 1024 * 1024], None).collect::<Vec<_>>(),
1099            [(0..66584576, 0), (66584576..133169152, 2)]
1100        );
1101
1102        // <= 1MiB gap, merge
1103        assert_eq!(
1104            merge_ranges(&[0..1, 1024 * 1024 + 1..1024 * 1024 + 2], None).collect::<Vec<_>>(),
1105            [(0..1048578, 2)]
1106        );
1107
1108        // > 1MiB gap, do not merge
1109        assert_eq!(
1110            merge_ranges(&[0..1, 1024 * 1024 + 2..1024 * 1024 + 3], None).collect::<Vec<_>>(),
1111            [(0..1, 1), (1048578..1048579, 2)]
1112        );
1113
1114        // <= 12.5% gap, merge
1115        assert_eq!(
1116            merge_ranges(&[0..8, 10..11], None).collect::<Vec<_>>(),
1117            [(0..11, 2)]
1118        );
1119
1120        // <= 12.5% gap relative to RHS, merge
1121        assert_eq!(
1122            merge_ranges(&[0..1, 3..11], None).collect::<Vec<_>>(),
1123            [(0..11, 2)]
1124        );
1125
1126        // Overlapping range, merge
1127        assert_eq!(
1128            merge_ranges(
1129                &[0..80 * 1024 * 1024, 10 * 1024 * 1024..70 * 1024 * 1024],
1130                None
1131            )
1132            .collect::<Vec<_>>(),
1133            [(0..80 * 1024 * 1024, 2)]
1134        );
1135    }
1136}