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::path::Path;
9use object_store::{ObjectMeta, ObjectStore, ObjectStoreExt};
10use polars_buffer::Buffer;
11use polars_core::prelude::{InitHashMaps, PlHashMap};
12use polars_error::{PolarsError, PolarsResult};
13use polars_utils::pl_path::PlRefPath;
14use tokio::io::AsyncWriteExt;
15
16use super::concurrency::IoSample;
17use super::concurrency_config::{ConcurrencyStrategy, FetchConfig, get_download_chunk_size};
18use crate::pl_async::{
19    self, MAX_BUDGET_PER_REQUEST, get_concurrency_limit, tune_with_concurrency_budget,
20    with_concurrency_budget,
21};
22
23#[derive(Debug)]
24pub struct PolarsObjectStoreError {
25    pub base_url: PlRefPath,
26    pub source: object_store::Error,
27}
28
29impl PolarsObjectStoreError {
30    pub fn from_url(base_url: &PlRefPath) -> impl FnOnce(object_store::Error) -> Self {
31        |error| Self {
32            base_url: base_url.clone(),
33            source: error,
34        }
35    }
36}
37
38impl Display for PolarsObjectStoreError {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        write!(
41            f,
42            "object-store error: {} (path: {})",
43            self.source, &self.base_url
44        )
45    }
46}
47
48impl std::error::Error for PolarsObjectStoreError {
49    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
50        Some(&self.source)
51    }
52}
53
54impl From<PolarsObjectStoreError> for std::io::Error {
55    fn from(value: PolarsObjectStoreError) -> Self {
56        std::io::Error::other(value)
57    }
58}
59
60impl From<PolarsObjectStoreError> for PolarsError {
61    fn from(value: PolarsObjectStoreError) -> Self {
62        PolarsError::IO {
63            error: Arc::new(value.into()),
64            msg: None,
65        }
66    }
67}
68
69mod inner {
70
71    use std::borrow::Cow;
72    use std::future::Future;
73    use std::sync::Arc;
74
75    use object_store::ObjectStore;
76    use polars_core::config;
77    use polars_error::{PolarsError, PolarsResult};
78    use polars_utils::relaxed_cell::RelaxedCell;
79
80    use crate::cloud::concurrency::{ConcurrencyController, ControllerConfig};
81    use crate::cloud::{ObjectStoreErrorContext, PolarsObjectStoreBuilder};
82    use crate::metrics::{IOMetrics, OptIOMetrics};
83
84    #[derive(Debug)]
85    struct Inner {
86        store: tokio::sync::RwLock<Arc<dyn ObjectStore>>,
87        builder: PolarsObjectStoreBuilder,
88        rebuilt: RelaxedCell<bool>,
89    }
90
91    /// Polars wrapper around [`ObjectStore`] functionality. This struct is cheaply cloneable.
92    #[derive(Clone, Debug)]
93    pub struct PolarsObjectStore {
94        inner: Arc<Inner>,
95        /// Avoid contending the Mutex `lock()` until the first re-build.
96        initial_store: std::sync::Arc<dyn ObjectStore>,
97        io_metrics: OptIOMetrics,
98        /// In-flight concurrency control using the (new) BDP model.
99        concurrency: Arc<std::sync::OnceLock<Arc<ConcurrencyController>>>,
100    }
101
102    impl PolarsObjectStore {
103        pub(crate) fn new_from_inner(
104            store: Arc<dyn ObjectStore>,
105            builder: PolarsObjectStoreBuilder,
106        ) -> Self {
107            let initial_store = store.clone();
108            Self {
109                inner: Arc::new(Inner {
110                    store: tokio::sync::RwLock::new(store),
111                    builder,
112                    rebuilt: RelaxedCell::from(false),
113                }),
114                initial_store,
115                io_metrics: OptIOMetrics(None),
116                concurrency: Arc::new(std::sync::OnceLock::new()), // Arc::new(ConcurrencyController::new(ControllerConfig::default())),
117            }
118        }
119
120        pub fn set_io_metrics(&mut self, io_metrics: Option<Arc<IOMetrics>>) -> &mut Self {
121            self.io_metrics = OptIOMetrics(io_metrics);
122            self
123        }
124
125        pub fn io_metrics(&self) -> &OptIOMetrics {
126            &self.io_metrics
127        }
128
129        pub fn get_or_init_concurrency(&self) -> &Arc<ConcurrencyController> {
130            self.concurrency
131                .get_or_init(|| Arc::new(ConcurrencyController::new(ControllerConfig::default())))
132        }
133
134        /// Gets the underlying [`ObjectStore`] implementation.
135        pub async fn to_dyn_object_store(&self) -> Cow<'_, Arc<dyn ObjectStore>> {
136            if !self.inner.rebuilt.load() {
137                Cow::Borrowed(&self.initial_store)
138            } else {
139                Cow::Owned(self.inner.store.read().await.clone())
140            }
141        }
142
143        pub async fn rebuild_inner(
144            &self,
145            from_version: &Arc<dyn ObjectStore>,
146        ) -> PolarsResult<Arc<dyn ObjectStore>> {
147            let mut current_store = self.inner.store.write().await;
148
149            // If this does not eq, then `inner` was already re-built by another thread.
150            if Arc::ptr_eq(&*current_store, from_version) {
151                *current_store =
152                    self.inner
153                        .builder
154                        .clone()
155                        .build_impl(true)
156                        .await
157                        .map_err(|e| {
158                            e.wrap_msg(|e| format!("attempt to rebuild object store failed: {e}"))
159                        })?;
160            }
161
162            self.inner.rebuilt.store(true);
163
164            Ok((*current_store).clone())
165        }
166
167        pub async fn exec_with_rebuild_retry_on_err<'s, 'f, Fn, Fut, O>(
168            &'s self,
169            mut func: Fn,
170        ) -> PolarsResult<O>
171        where
172            Fn: FnMut(Cow<'s, Arc<dyn ObjectStore>>) -> Fut + 'f,
173            Fut: Future<Output = object_store::Result<O>>,
174        {
175            let store = self.to_dyn_object_store().await;
176
177            let out = func(store.clone()).await;
178
179            let orig_err = match out {
180                Ok(v) => return Ok(v),
181                Err(e) => e,
182            };
183
184            if config::verbose() {
185                eprintln!(
186                    "[PolarsObjectStore]: got error: {}, will rebuild store and retry",
187                    &orig_err
188                );
189            }
190
191            let store = self
192                .rebuild_inner(&store)
193                .await
194                .map_err(|e| e.wrap_msg(|e| format!("{e}; original error: {orig_err}")))?;
195
196            func(Cow::Owned(store)).await.map_err(|e| {
197                let e: PolarsError = self.error_context().attach_err_info(e).into();
198
199                if self.inner.builder.is_azure()
200                    && std::env::var("POLARS_AUTO_USE_AZURE_STORAGE_ACCOUNT_KEY").as_deref()
201                        != Ok("1")
202                {
203                    // Note: This error is intended for Python audiences. The logic for retrieving
204                    // these keys exist only on the Python side.
205                    e.wrap_msg(|e| {
206                        format!(
207                            "{e}; note: if you are using Python, consider setting \
208POLARS_AUTO_USE_AZURE_STORAGE_ACCOUNT_KEY=1 if you would like polars to try to retrieve \
209and use the storage account keys from Azure CLI to authenticate"
210                        )
211                    })
212                } else {
213                    e
214                }
215            })
216        }
217
218        pub fn error_context(&self) -> ObjectStoreErrorContext {
219            ObjectStoreErrorContext::new(self.inner.builder.path().clone())
220        }
221    }
222}
223
224#[derive(Clone)]
225pub struct ObjectStoreErrorContext {
226    path: PlRefPath,
227}
228
229impl ObjectStoreErrorContext {
230    pub fn new(path: PlRefPath) -> Self {
231        Self { path }
232    }
233
234    pub fn attach_err_info(self, err: object_store::Error) -> PolarsObjectStoreError {
235        let ObjectStoreErrorContext { path } = self;
236
237        PolarsObjectStoreError {
238            base_url: path,
239            source: err,
240        }
241    }
242}
243
244pub use inner::PolarsObjectStore;
245
246pub type ObjectStorePath = object_store::path::Path;
247
248impl PolarsObjectStore {
249    pub fn build_buffered_ranges_stream<'a, T: Iterator<Item = Range<usize>>>(
250        &'a self,
251        path: &'a Path,
252        ranges: T,
253        strategy: ConcurrencyStrategy,
254    ) -> impl Stream<Item = PolarsResult<Buffer<u8>>> + use<'a, T> {
255        let controller = match strategy {
256            ConcurrencyStrategy::BytesBased => Some(self.get_or_init_concurrency().clone()),
257            ConcurrencyStrategy::Unbounded | ConcurrencyStrategy::Legacy => None,
258        };
259
260        let n_buffered = match strategy {
261            // In case of bytes-based concurrency, the concurrency is controlled by the
262            // admission semaphore in the pipeline.
263            // The buffered size is set to a large constant as a backstop. Once all
264            // callsites are verified to pass finite, metadata-derived ranges, this can be
265            // set to usize::MAX.
266            ConcurrencyStrategy::BytesBased => 4096,
267            ConcurrencyStrategy::Unbounded | ConcurrencyStrategy::Legacy => {
268                get_concurrency_limit() as usize
269            },
270        };
271
272        futures::stream::iter(ranges.map(move |range| {
273            let controller = controller.clone();
274            async move {
275                if range.is_empty() {
276                    return Ok(Buffer::new());
277                }
278                let bytes_req = range.len() as u64;
279
280                // Held until end of block to bound in-flight bytes.
281                let _permit = match &controller {
282                    Some(controller) => Some(controller.acquire(bytes_req).await),
283                    None => None,
284                };
285
286                let (out, ttfb) = self
287                    .io_metrics()
288                    .record_io_read(
289                        bytes_req,
290                        self.exec_with_rebuild_retry_on_err(|s| async move {
291                            let t0 = Instant::now();
292                            let response = s
293                                .get_opts(
294                                    path,
295                                    object_store::GetOptions {
296                                        range: Some((range.start as u64..range.end as u64).into()),
297                                        ..Default::default()
298                                    },
299                                )
300                                .await?;
301                            let ttfb = t0.elapsed();
302                            let out = response.bytes().await?;
303
304                            Ok((out, ttfb))
305                        }),
306                    )
307                    .await?;
308
309                if let Some(controller) = &controller {
310                    controller.record_io(IoSample {
311                        n_bytes: out.len() as u64,
312                        ttfb,
313                        completion_time: Instant::now(),
314                    });
315                }
316
317                Ok(Buffer::from_owner(out))
318            }
319        }))
320        .buffered(n_buffered)
321    }
322
323    pub async fn get_range(
324        &self,
325        path: &Path,
326        range: Range<usize>,
327        config: FetchConfig,
328    ) -> PolarsResult<Buffer<u8>> {
329        if range.is_empty() {
330            return Ok(Buffer::new());
331        }
332
333        let parts = split_range(range.clone(), Some(config.chunk_size));
334
335        match config.strategy {
336            ConcurrencyStrategy::Legacy => self.get_range_legacy(path, range).await,
337            ConcurrencyStrategy::Unbounded | ConcurrencyStrategy::BytesBased => self
338                .build_buffered_ranges_stream(path, parts, config.strategy)
339                .try_collect::<Vec<_>>()
340                .await
341                .map(|parts| {
342                    if parts.len() == 1 {
343                        return parts.into_iter().next().unwrap();
344                    }
345                    let mut combined = Vec::with_capacity(range.len());
346                    for part in parts {
347                        combined.extend_from_slice(&part);
348                    }
349                    assert_eq!(combined.len(), range.len());
350                    Buffer::from_vec(combined)
351                }),
352        }
353    }
354
355    async fn get_range_legacy(&self, path: &Path, range: Range<usize>) -> PolarsResult<Buffer<u8>> {
356        if range.is_empty() {
357            return Ok(Buffer::new());
358        }
359
360        let parts = split_range(range.clone(), None);
361
362        if parts.len() == 1 {
363            let out = tune_with_concurrency_budget(1, move || async move {
364                let bytes = self
365                    .io_metrics()
366                    .record_io_read(
367                        range.len() as u64,
368                        self.exec_with_rebuild_retry_on_err(|s| async move {
369                            s.get_range(path, range.start as u64..range.end as u64)
370                                .await
371                        }),
372                    )
373                    .await?;
374
375                PolarsResult::Ok(Buffer::from_owner(bytes))
376            })
377            .await?;
378
379            Ok(out)
380        } else {
381            let parts = tune_with_concurrency_budget(
382                parts.len().clamp(0, MAX_BUDGET_PER_REQUEST) as u32,
383                || {
384                    self.build_buffered_ranges_stream(path, parts, ConcurrencyStrategy::Legacy)
385                        .try_collect::<Vec<Buffer<u8>>>()
386                },
387            )
388            .await?;
389
390            let mut combined = Vec::with_capacity(range.len());
391
392            for part in parts {
393                combined.extend_from_slice(&part)
394            }
395
396            assert_eq!(combined.len(), range.len());
397
398            PolarsResult::Ok(Buffer::from_vec(combined))
399        }
400    }
401
402    pub async fn get_ranges_sort(
403        &self,
404        path: &Path,
405        ranges: &mut [Range<usize>],
406        config: FetchConfig,
407    ) -> PolarsResult<PlHashMap<usize, Buffer<u8>>> {
408        if ranges.is_empty() {
409            return Ok(Default::default());
410        }
411
412        ranges.sort_unstable_by_key(|x| x.start);
413
414        let ranges_len = ranges.len();
415        let (merged_ranges, merged_ends): (Vec<_>, Vec<_>) =
416            merge_ranges(ranges, Some(config.chunk_size)).unzip();
417
418        let mut out = PlHashMap::with_capacity(ranges_len);
419
420        // Build an inflight admission-aware stream over the merged ranges.
421        let mut stream =
422            self.build_buffered_ranges_stream(path, merged_ranges.iter().cloned(), config.strategy);
423
424        let mut current_offset = 0;
425        let mut ends_iter = merged_ends.iter();
426        let mut splitted_parts: Vec<Buffer<u8>> = vec![];
427
428        while let Some(bytes) = stream.try_next().await? {
429            let end = *ends_iter.next().unwrap();
430
431            if end == 0 {
432                splitted_parts.push(bytes);
433                continue;
434            }
435
436            let full_range = ranges[current_offset..end]
437                .iter()
438                .cloned()
439                .reduce(|l, r| l.start.min(r.start)..l.end.max(r.end))
440                .unwrap();
441
442            let bytes = if splitted_parts.is_empty() {
443                bytes
444            } else {
445                let mut out = Vec::with_capacity(full_range.len());
446                for x in splitted_parts.drain(..) {
447                    out.extend_from_slice(&x);
448                }
449                out.extend_from_slice(&bytes);
450                Buffer::from(out)
451            };
452
453            assert_eq!(bytes.len(), full_range.len());
454
455            for range in &ranges[current_offset..end] {
456                let slice = bytes
457                    .clone()
458                    .sliced(range.start - full_range.start..range.end - full_range.start);
459
460                match out.raw_entry_mut().from_key(&range.start) {
461                    RawEntryMut::Vacant(slot) => {
462                        slot.insert(range.start, slice);
463                    },
464                    RawEntryMut::Occupied(mut slot) => {
465                        if slot.get_mut().len() < slice.len() {
466                            *slot.get_mut() = slice;
467                        }
468                    },
469                }
470            }
471
472            current_offset = end;
473        }
474
475        assert!(splitted_parts.is_empty());
476
477        Ok(out)
478    }
479
480    // TODO: Refactor for updated concurrency strategy.
481    pub async fn download(&self, path: &Path, file: &mut tokio::fs::File) -> PolarsResult<()> {
482        let size = self.head(path, ConcurrencyStrategy::Unbounded).await?.size;
483        let parts = split_range(0..size as usize, None);
484
485        // TODO: Replace the legacy concurrency_budget call and switch to BytesBased inflight
486        // admission control.
487        tune_with_concurrency_budget(
488            parts.len().clamp(0, MAX_BUDGET_PER_REQUEST) as u32,
489            || async {
490                let mut stream =
491                    self.build_buffered_ranges_stream(path, parts, ConcurrencyStrategy::Unbounded);
492                let mut len = 0;
493                while let Some(bytes) = stream.try_next().await? {
494                    len += bytes.len();
495                    file.write_all(&bytes).await?;
496                }
497
498                assert_eq!(len, size as usize);
499
500                PolarsResult::Ok(pl_async::Size::from(len as u64))
501            },
502        )
503        .await?;
504
505        // Dropping is delayed for tokio async files so we need to explicitly
506        // flush here (https://github.com/tokio-rs/tokio/issues/2307#issuecomment-596336451).
507        file.sync_all().await.map_err(PolarsError::from)?;
508
509        Ok(())
510    }
511
512    /// Fetch the metadata of the parquet file, do not memoize it.
513    pub async fn head(
514        &self,
515        path: &Path,
516        strategy: ConcurrencyStrategy,
517    ) -> PolarsResult<ObjectMeta> {
518        // TODO: Refactor for updated concurrency strategy.
519        // For now, we fall back to 'Legacy' which is fine for metadata.
520        // Since this carries an early signal, the IO Sample is of interest regardless of
521        // the strategy in use.
522        with_concurrency_budget(1, || {
523            self.exec_with_rebuild_retry_on_err(|s| {
524                async move {
525                    let t0 = Instant::now();
526                    let head_result = self.io_metrics().record_io_read(0, s.head(path)).await;
527                    if let ConcurrencyStrategy::BytesBased = strategy {
528                        // self.get_or_init_concurrency().record_ttfb(ttfb);
529                        self.get_or_init_concurrency().record_io(IoSample {
530                            n_bytes: 0,
531                            ttfb: t0.elapsed(),
532                            completion_time: Instant::now(),
533                        });
534                    }
535
536                    if head_result.is_err() {
537                        let t0 = Instant::now();
538                        // Pre-signed URLs forbid the HEAD method, but we can still retrieve the header
539                        // information with a range 0-1 request.
540                        let get_range_0_1_result = self
541                            .io_metrics()
542                            .record_io_read(
543                                0,
544                                s.get_opts(
545                                    path,
546                                    object_store::GetOptions {
547                                        range: Some((0..1).into()),
548                                        ..Default::default()
549                                    },
550                                ),
551                            )
552                            .await;
553
554                        if let ConcurrencyStrategy::BytesBased = strategy {
555                            self.get_or_init_concurrency().record_io(IoSample {
556                                n_bytes: 0,
557                                ttfb: t0.elapsed(),
558                                completion_time: Instant::now(),
559                            });
560                        }
561
562                        if let Ok(v) = get_range_0_1_result {
563                            return Ok(v.meta);
564                        }
565                    }
566
567                    let out = head_result?;
568
569                    Ok(out)
570                }
571            })
572        })
573        .await
574    }
575}
576
577/// Splits a single range into multiple smaller ranges, which can be downloaded concurrently for
578/// much higher throughput.
579fn split_range(
580    range: Range<usize>,
581    chunk_size: Option<usize>,
582) -> impl ExactSizeIterator<Item = Range<usize>> {
583    let chunk_size = chunk_size.unwrap_or_else(get_download_chunk_size);
584
585    // Calculate n_parts such that we are as close as possible to the `chunk_size`.
586    let n_parts = [
587        (range.len().div_ceil(chunk_size)).max(1),
588        (range.len() / chunk_size).max(1),
589    ]
590    .into_iter()
591    .min_by_key(|x| (range.len() / *x).abs_diff(chunk_size))
592    .unwrap();
593
594    let chunk_size = (range.len() / n_parts).max(1);
595
596    assert_eq!(n_parts, (range.len() / chunk_size).max(1));
597    let bytes_rem = range.len() % chunk_size;
598
599    (0..n_parts).map(move |part_no| {
600        let (start, end) = if part_no == 0 {
601            // Download remainder length in the first chunk since it starts downloading first.
602            let end = range.start + chunk_size + bytes_rem;
603            let end = if end > range.end { range.end } else { end };
604            (range.start, end)
605        } else {
606            let start = bytes_rem + range.start + part_no * chunk_size;
607            (start, start + chunk_size)
608        };
609
610        start..end
611    })
612}
613
614/// Note: For optimal performance, `ranges` should be sorted. More generally,
615/// ranges placed next to each other should also be close in range value.
616///
617/// # Returns
618/// `[(range1, end1), (range2, end2)]`, where:
619/// * `range1` contains bytes for the ranges from `ranges[0..end1]`
620/// * `range2` contains bytes for the ranges from `ranges[end1..end2]`
621/// * etc..
622///
623/// Note that if an end value is 0, it means the range is a splitted part and should be combined.
624fn merge_ranges(
625    ranges: &[Range<usize>],
626    chunk_size: Option<usize>,
627) -> impl Iterator<Item = (Range<usize>, usize)> + '_ {
628    let chunk_size = chunk_size.unwrap_or_else(get_download_chunk_size);
629
630    let mut current_merged_range = ranges.first().map_or(0..0, Clone::clone);
631    // Number of fetched bytes excluding excess.
632    let mut current_n_bytes = current_merged_range.len();
633
634    (0..ranges.len())
635        .filter_map(move |current_idx| {
636            let current_idx = 1 + current_idx;
637
638            if current_idx == ranges.len() {
639                // No more items - flush current state.
640                Some((current_merged_range.clone(), current_idx))
641            } else {
642                let range = ranges[current_idx].clone();
643
644                let new_merged = current_merged_range.start.min(range.start)
645                    ..current_merged_range.end.max(range.end);
646
647                // E.g.:
648                // |--------|
649                //  oo        // range1
650                //       oo   // range2
651                //    ^^^     // distance = 3, is_overlapping = false
652                // E.g.:
653                // |--------|
654                //  ooooo     // range1
655                //     ooooo  // range2
656                //     ^^     // distance = 2, is_overlapping = true
657                let (distance, is_overlapping) = {
658                    let l = current_merged_range.end.min(range.end);
659                    let r = current_merged_range.start.max(range.start);
660
661                    (r.abs_diff(l), r < l)
662                };
663
664                let should_merge = is_overlapping || {
665                    let leq_current_len_dist_to_chunk_size = new_merged.len().abs_diff(chunk_size)
666                        <= current_merged_range.len().abs_diff(chunk_size);
667                    let gap_tolerance =
668                        (current_n_bytes.max(range.len()) / 8).clamp(1024 * 1024, 8 * 1024 * 1024);
669
670                    leq_current_len_dist_to_chunk_size && distance <= gap_tolerance
671                };
672
673                if should_merge {
674                    // Merge to existing range
675                    current_merged_range = new_merged;
676                    current_n_bytes += if is_overlapping {
677                        range.len() - distance
678                    } else {
679                        range.len()
680                    };
681                    None
682                } else {
683                    let out = (current_merged_range.clone(), current_idx);
684                    current_merged_range = range;
685                    current_n_bytes = current_merged_range.len();
686                    Some(out)
687                }
688            }
689        })
690        .flat_map(move |x| {
691            // Split large individual ranges within the list of ranges.
692            let (range, end) = x;
693            let split = split_range(range, Some(chunk_size));
694            let len = split.len();
695
696            split
697                .enumerate()
698                .map(move |(i, range)| (range, if 1 + i == len { end } else { 0 }))
699        })
700}
701
702#[cfg(test)]
703mod tests {
704
705    #[test]
706    fn test_split_range() {
707        use super::{get_download_chunk_size, split_range};
708
709        let chunk_size = get_download_chunk_size();
710
711        assert_eq!(chunk_size, 64 * 1024 * 1024);
712
713        #[allow(clippy::single_range_in_vec_init)]
714        {
715            // Round-trip empty ranges.
716            assert_eq!(split_range(0..0, None).collect::<Vec<_>>(), [0..0]);
717            assert_eq!(split_range(3..3, None).collect::<Vec<_>>(), [3..3]);
718        }
719
720        // Threshold to start splitting to 2 ranges
721        //
722        // n - chunk_size == chunk_size - n / 2
723        // n + n / 2 == 2 * chunk_size
724        // 3 * n == 4 * chunk_size
725        // n = 4 * chunk_size / 3
726        let n = 4 * chunk_size / 3;
727
728        #[allow(clippy::single_range_in_vec_init)]
729        {
730            assert_eq!(split_range(0..n, None).collect::<Vec<_>>(), [0..89478485]);
731        }
732
733        assert_eq!(
734            split_range(0..n + 1, None).collect::<Vec<_>>(),
735            [0..44739243, 44739243..89478486]
736        );
737
738        // Threshold to start splitting to 3 ranges
739        //
740        // n / 2 - chunk_size == chunk_size - n / 3
741        // n / 2 + n / 3 == 2 * chunk_size
742        // 5 * n == 12 * chunk_size
743        // n == 12 * chunk_size / 5
744        let n = 12 * chunk_size / 5;
745
746        assert_eq!(
747            split_range(0..n, None).collect::<Vec<_>>(),
748            [0..80530637, 80530637..161061273]
749        );
750
751        assert_eq!(
752            split_range(0..n + 1, None).collect::<Vec<_>>(),
753            [0..53687092, 53687092..107374183, 107374183..161061274]
754        );
755    }
756
757    #[test]
758    fn test_merge_ranges() {
759        use super::{get_download_chunk_size, merge_ranges};
760
761        let chunk_size = get_download_chunk_size();
762
763        assert_eq!(chunk_size, 64 * 1024 * 1024);
764
765        // Round-trip empty slice
766        assert_eq!(merge_ranges(&[], None).collect::<Vec<_>>(), []);
767
768        // We have 1 tiny request followed by 1 huge request. They are combined as it reduces the
769        // `abs_diff()` to the `chunk_size`, but afterwards they are split to 2 evenly sized
770        // requests.
771        assert_eq!(
772            merge_ranges(&[0..1, 1..127 * 1024 * 1024], None).collect::<Vec<_>>(),
773            [(0..66584576, 0), (66584576..133169152, 2)]
774        );
775
776        // <= 1MiB gap, merge
777        assert_eq!(
778            merge_ranges(&[0..1, 1024 * 1024 + 1..1024 * 1024 + 2], None).collect::<Vec<_>>(),
779            [(0..1048578, 2)]
780        );
781
782        // > 1MiB gap, do not merge
783        assert_eq!(
784            merge_ranges(&[0..1, 1024 * 1024 + 2..1024 * 1024 + 3], None).collect::<Vec<_>>(),
785            [(0..1, 1), (1048578..1048579, 2)]
786        );
787
788        // <= 12.5% gap, merge
789        assert_eq!(
790            merge_ranges(&[0..8, 10..11], None).collect::<Vec<_>>(),
791            [(0..11, 2)]
792        );
793
794        // <= 12.5% gap relative to RHS, merge
795        assert_eq!(
796            merge_ranges(&[0..1, 3..11], None).collect::<Vec<_>>(),
797            [(0..11, 2)]
798        );
799
800        // Overlapping range, merge
801        assert_eq!(
802            merge_ranges(
803                &[0..80 * 1024 * 1024, 10 * 1024 * 1024..70 * 1024 * 1024],
804                None
805            )
806            .collect::<Vec<_>>(),
807            [(0..80 * 1024 * 1024, 2)]
808        );
809    }
810}