Skip to main content

polars_io/utils/
byte_source.rs

1use std::ops::Range;
2#[cfg(target_os = "linux")]
3use std::os::fd::AsRawFd;
4use std::path::Path;
5use std::sync::{Arc, LazyLock};
6
7use dio_align::DioAlign;
8use futures::{StreamExt, TryStreamExt};
9use polars_buffer::Buffer;
10use polars_config::FileAdvice;
11use polars_core::prelude::PlHashMap;
12use polars_core::runtime::ASYNC;
13use polars_error::{PolarsResult, feature_gated, polars_bail, polars_err};
14use polars_utils::aliases::InitHashMaps;
15use polars_utils::io::_limit_path_len_io_err;
16use polars_utils::mmap::MMapSemaphore;
17use polars_utils::pl_path::PlRefPath;
18use tokio::sync::Semaphore;
19
20use crate::cloud::concurrency_config::{ConcurrencyStrategy, FetchConfig};
21use crate::cloud::options::CloudOptions;
22#[cfg(feature = "cloud")]
23use crate::cloud::{
24    CloudLocation, ObjectStorePath, PolarsObjectStore, build_object_store, object_path_from_str,
25};
26use crate::metrics::{IOMetrics, OptIOMetrics};
27
28pub mod dio_align;
29#[cfg(target_os = "linux")]
30mod direct_io;
31#[allow(async_fn_in_trait)]
32pub trait ByteSource: Send + Sync {
33    async fn get_size(&self) -> PolarsResult<usize>;
34    /// Fetch the last `n` bytes and the total size of the source, in a single request. Returns
35    /// fewer than `n` bytes if the source is smaller than `n`.
36    async fn get_suffix(&self, n: usize) -> PolarsResult<(Buffer<u8>, usize)>;
37    /// Fetch the first `n` bytes and the total size of the source, in a single request where
38    /// supported. Returns fewer than `n` bytes if the source is smaller than `n`.
39    async fn get_prefix(&self, n: usize) -> PolarsResult<(Buffer<u8>, usize)>;
40    /// # Panics
41    /// Panics if `range` is not in bounds.
42    async fn get_range(&self, range: Range<usize>) -> PolarsResult<Buffer<u8>>;
43    /// Note: This will mutably sort ranges for coalescing.
44    async fn get_ranges(
45        &self,
46        ranges: &mut [Range<usize>],
47    ) -> PolarsResult<PlHashMap<usize, Buffer<u8>>>;
48}
49
50/// Byte source backed by a `Buffer`, which can potentially be memory-mapped.
51pub struct BufferByteSource(pub Buffer<u8>);
52
53impl BufferByteSource {
54    async fn try_new_mmap_from_path(
55        path: &Path,
56        _cloud_options: Option<&CloudOptions>,
57    ) -> PolarsResult<Self> {
58        let file = Arc::new(
59            tokio::fs::File::open(path)
60                .await
61                .map_err(|err| _limit_path_len_io_err(path, err))?
62                .into_std()
63                .await,
64        );
65
66        Ok(Self(Buffer::from_owner(MMapSemaphore::new_from_file(
67            &file,
68        )?)))
69    }
70}
71
72impl ByteSource for BufferByteSource {
73    async fn get_size(&self) -> PolarsResult<usize> {
74        Ok(self.0.as_ref().len())
75    }
76
77    async fn get_suffix(&self, n: usize) -> PolarsResult<(Buffer<u8>, usize)> {
78        let len = self.0.as_ref().len();
79        Ok((self.0.clone().sliced(len.saturating_sub(n)..len), len))
80    }
81
82    async fn get_prefix(&self, n: usize) -> PolarsResult<(Buffer<u8>, usize)> {
83        let len = self.0.as_ref().len();
84        Ok((self.0.clone().sliced(0..n.min(len)), len))
85    }
86
87    async fn get_range(&self, range: Range<usize>) -> PolarsResult<Buffer<u8>> {
88        let out = self.0.clone().sliced(range);
89        Ok(out)
90    }
91
92    async fn get_ranges(
93        &self,
94        ranges: &mut [Range<usize>],
95    ) -> PolarsResult<PlHashMap<usize, Buffer<u8>>> {
96        Ok(ranges
97            .iter()
98            .map(|x| (x.start, self.0.clone().sliced(x.clone())))
99            .collect())
100    }
101}
102
103/// Byte source backed by a `File`.
104pub struct FileByteSource {
105    file: Arc<std::fs::File>,
106    // Alignment for O_DIRECT, if supported.
107    o_direct_align: Option<DioAlign>,
108    // Manage concurrency.
109    concurrency: usize,
110    permits: Arc<Semaphore>,
111    // File size.
112    size: u64,
113    io_metrics: OptIOMetrics,
114}
115
116/// Each permit pins a tokio blocking thread for the duration of a `pread`.
117pub fn global_read_permits() -> Arc<Semaphore> {
118    static PERMITS: LazyLock<Arc<Semaphore>> = LazyLock::new(|| {
119        Arc::new(Semaphore::new(
120            polars_config::config().file_read_concurrency().max(1) as usize,
121        ))
122    });
123
124    PERMITS.clone()
125}
126
127#[derive(Clone)]
128pub struct FileReadContext {
129    pub enable_o_direct: bool,
130    pub concurrency: usize,
131    pub permits: Arc<Semaphore>,
132    /// Ignored when direct I/O is active: there is no page cache to advise.
133    pub advice: FileAdvice,
134}
135
136impl FileReadContext {
137    pub fn from_config(name: &str) -> Self {
138        let cfg = polars_config::config();
139        let enable_o_direct = cfg.direct_io();
140        let concurrency = cfg.file_read_concurrency().max(1) as usize;
141
142        // TODO: Posix_fadv should follow the access-pattern, which varies
143        // by file type and projection.
144        let fadv = cfg.file_posix_fadv();
145
146        if cfg.verbose() {
147            eprintln!(
148                "[{name}]: file read_context as configured: \
149                    read_concurrency: {concurrency}, \
150                    posix_fadv: {fadv}, \
151                    o_direct: {enable_o_direct}"
152            );
153        }
154
155        Self {
156            enable_o_direct,
157            concurrency,
158            permits: global_read_permits(),
159            advice: fadv,
160        }
161    }
162}
163
164impl std::fmt::Debug for FileReadContext {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        f.debug_struct("FileReadContext")
167            .field("available_permits", &self.permits.available_permits())
168            .field("enable_odirect", &self.enable_o_direct)
169            .field("concurrency", &self.concurrency)
170            .field("advice", &self.advice)
171            .finish()
172    }
173}
174
175#[cfg(target_os = "linux")]
176fn _fadvise(file: &std::fs::File, advice: FileAdvice) {
177    let advice = match advice {
178        // The OS default is already POSIX_FADV_NORMAL.
179        FileAdvice::Normal => return,
180        FileAdvice::Sequential => libc::POSIX_FADV_SEQUENTIAL,
181        FileAdvice::Random => libc::POSIX_FADV_RANDOM,
182        FileAdvice::WillNeed => libc::POSIX_FADV_WILLNEED,
183    };
184
185    unsafe {
186        libc::posix_fadvise(file.as_raw_fd(), 0, 0, advice);
187    }
188}
189
190#[cfg(all(unix, not(feature = "nightly")))]
191fn pread_exact(file: &std::fs::File, buf: &mut [u8], offset: u64) -> std::io::Result<()> {
192    use std::os::unix::fs::FileExt;
193    file.read_exact_at(buf, offset)
194}
195
196#[cfg(all(windows, not(feature = "nightly")))]
197fn pread_exact(file: &std::fs::File, buf: &mut [u8], offset: u64) -> std::io::Result<()> {
198    use std::os::windows::fs::FileExt;
199    let mut filled = 0;
200    while filled < buf.len() {
201        match file.seek_read(&mut buf[filled..], offset + filled as u64)? {
202            0 => return Err(std::io::ErrorKind::UnexpectedEof.into()),
203            n => filled += n,
204        }
205    }
206    Ok(())
207}
208
209/// `pread_exact` into uninitialized memory, so that `read_buffered` does not
210/// have to zero the buffer the read is about to overwrite anyway.
211#[cfg(all(feature = "nightly", unix))]
212fn pread_exact_uninit(
213    file: &std::fs::File,
214    buf: std::io::BorrowedCursor<'_, u8>,
215    offset: u64,
216) -> std::io::Result<()> {
217    use std::os::unix::fs::FileExt;
218    file.read_buf_exact_at(buf, offset)
219}
220
221#[cfg(all(feature = "nightly", windows))]
222fn pread_exact_uninit(
223    file: &std::fs::File,
224    mut buf: std::io::BorrowedCursor<'_, u8>,
225    mut offset: u64,
226) -> std::io::Result<()> {
227    use std::os::windows::fs::FileExt;
228    // `seek_read_buf` is the short-read variant; loop it to fill the cursor.
229    while buf.capacity() > 0 {
230        let written = buf.written();
231        file.seek_read_buf(buf.reborrow(), offset)?;
232        match buf.written() - written {
233            0 => return Err(std::io::ErrorKind::UnexpectedEof.into()),
234            n => offset += n as u64,
235        }
236    }
237    Ok(())
238}
239
240/// Read `len` bytes at `offset` through the page cache.
241///
242/// Under `nightly` the destination is left uninitialized and filled by the read
243/// itself; on stable it must be zeroed first, which memsets the whole range
244/// before the kernel overwrites it.
245#[cfg(feature = "nightly")]
246fn read_buffered(file: &std::fs::File, offset: u64, len: usize) -> PolarsResult<Buffer<u8>> {
247    let mut v: Vec<u8> = Vec::with_capacity(len);
248
249    let filled = {
250        let mut buf = std::io::BorrowedBuf::from(&mut v.spare_capacity_mut()[..len]);
251        pread_exact_uninit(file, buf.unfilled(), offset)?;
252        buf.len()
253    };
254
255    debug_assert_eq!(filled, len);
256    // Safety: `pread_exact_uninit` filled the cursor, so the first `filled`
257    // bytes of the allocation are initialized.
258    unsafe { v.set_len(filled) };
259
260    Ok(Buffer::from(v))
261}
262
263#[cfg(not(feature = "nightly"))]
264fn read_buffered(file: &std::fs::File, offset: u64, len: usize) -> PolarsResult<Buffer<u8>> {
265    let mut buf = vec![0u8; len];
266    pread_exact(file, &mut buf, offset)?;
267    Ok(Buffer::from(buf))
268}
269
270/// Read `len` bytes at `offset`, through direct I/O when `align` says the file
271/// supports it and through the page cache otherwise.
272#[cfg(target_os = "linux")]
273fn read_at(
274    file: &std::fs::File,
275    align: Option<DioAlign>,
276    offset: u64,
277    len: usize,
278    size: u64,
279) -> PolarsResult<Buffer<u8>> {
280    match align {
281        Some(align) => direct_io::read_aligned(file, align, offset, len, size),
282        None => read_buffered(file, offset, len),
283    }
284}
285
286#[cfg(not(target_os = "linux"))]
287fn read_at(
288    file: &std::fs::File,
289    _align: Option<DioAlign>,
290    offset: u64,
291    len: usize,
292    _size: u64,
293) -> PolarsResult<Buffer<u8>> {
294    read_buffered(file, offset, len)
295}
296
297impl FileByteSource {
298    async fn try_new_from_path(
299        path: PlRefPath,
300        read_context: FileReadContext,
301        io_metrics: Option<Arc<IOMetrics>>,
302    ) -> PolarsResult<Self> {
303        // The open path is `open`, `fcntl`, `statx` and `fstat` - all blocking.
304        ASYNC
305            .spawn_blocking(move || Self::open_blocking(path, read_context, io_metrics))
306            .await
307            .expect("blocking task panicked")
308    }
309
310    fn open_blocking(
311        path: PlRefPath,
312        read_context: FileReadContext,
313        io_metrics: Option<Arc<IOMetrics>>,
314    ) -> PolarsResult<Self> {
315        let path = path.as_std_path();
316        let enable_o_direct = read_context.enable_o_direct;
317
318        let file = {
319            #[cfg(target_os = "linux")]
320            let f = if enable_o_direct {
321                direct_io::open_o_direct(path).or_else(|e| match e.raw_os_error() {
322                    // The filesystem does not support O_DIRECT.
323                    Some(libc::EINVAL) => std::fs::File::open(path),
324                    _ => Err(e),
325                })
326            } else {
327                std::fs::File::open(path)
328            };
329            #[cfg(not(target_os = "linux"))]
330            let f = std::fs::File::open(path);
331
332            f.map_err(|e| _limit_path_len_io_err(path, e))?
333        };
334
335        let file = Arc::new(file);
336
337        #[cfg(target_os = "linux")]
338        let o_direct_align =
339            direct_io::probe_or_disable(&file).map_err(|e| _limit_path_len_io_err(path, e))?;
340
341        #[cfg(not(target_os = "linux"))]
342        let o_direct_align: Option<DioAlign> = None;
343
344        #[cfg(target_os = "linux")]
345        if o_direct_align.is_none() {
346            // Inert under O_DIRECT: there is no page cache to advise.
347            _fadvise(&file, read_context.advice);
348        }
349
350        let concurrency = read_context.concurrency;
351        let permits = read_context.permits;
352
353        let size = file
354            .metadata()
355            .map_err(|e| _limit_path_len_io_err(path, e))?
356            .len();
357
358        if polars_config::config().verbose() {
359            let name = if polars_config::config().verbose_sensitive() {
360                path.display().to_string()
361            } else {
362                "<file>".to_string()
363            };
364            match (enable_o_direct, o_direct_align) {
365                (true, Some(a)) => eprintln!(
366                    "[FileByteSource]: {name}: direct IO active, \
367                        alignment offset: {}, memory: {}",
368                    a.offset, a.memory
369                ),
370                (true, None) => eprintln!(
371                    "[FileByteSource]: {name}: direct IO requested but not active, \
372                        using buffered reads"
373                ),
374                (false, _) => {},
375            }
376        }
377
378        Ok(FileByteSource {
379            file,
380            o_direct_align,
381            concurrency,
382            permits,
383            size,
384            io_metrics: OptIOMetrics(io_metrics),
385        })
386    }
387
388    pub fn try_new_from_std(
389        file: std::fs::File,
390        read_context: FileReadContext,
391        io_metrics: Option<Arc<IOMetrics>>,
392    ) -> PolarsResult<Self> {
393        let size = file.metadata()?.len();
394
395        #[cfg(target_os = "linux")]
396        let o_direct_align = direct_io::probe_or_disable(&file)?;
397
398        #[cfg(not(target_os = "linux"))]
399        let o_direct_align: Option<DioAlign> = None;
400
401        #[cfg(target_os = "linux")]
402        if o_direct_align.is_none() {
403            _fadvise(&file, read_context.advice);
404        }
405
406        let concurrency = read_context.concurrency;
407        let permits = read_context.permits;
408
409        // For verbose logging only. We cannot enable since the file_handle was handed to us.
410        let enable_o_direct = read_context.enable_o_direct;
411
412        if polars_config::config().verbose() {
413            match (enable_o_direct, o_direct_align) {
414                (true, Some(a)) => eprintln!(
415                    "[FileByteSource]: direct IO active, alignment offset: {}, memory: {}",
416                    a.offset, a.memory
417                ),
418                (true, None) => eprintln!(
419                    "[FileByteSource]: direct IO requested but not active, using buffered reads",
420                ),
421                (false, _) => {},
422            }
423        }
424
425        Ok(Self {
426            file: Arc::new(file),
427            o_direct_align,
428            concurrency,
429            permits,
430            size,
431            io_metrics: OptIOMetrics(io_metrics),
432        })
433    }
434
435    pub fn set_io_metrics(&mut self, io_metrics: Option<Arc<IOMetrics>>) -> &mut Self {
436        self.io_metrics = OptIOMetrics(io_metrics);
437        self
438    }
439
440    pub fn io_metrics(&self) -> &OptIOMetrics {
441        &self.io_metrics
442    }
443
444    /// Whether every page of `ranges` is in the page cache. `false` when the OS cannot tell
445    /// (non-Linux, kernels before 6.5) and under direct I/O.
446    pub fn is_cached(&self, ranges: &[Range<usize>]) -> bool {
447        #[cfg(all(
448            target_os = "linux",
449            any(target_arch = "x86_64", target_arch = "aarch64")
450        ))]
451        if self.o_direct_align.is_none() {
452            return cachestat::all_cached(&self.file, ranges);
453        }
454
455        let _ = ranges;
456        false
457    }
458
459    /// Reads `range` on the calling thread.
460    pub fn read_blocking(&self, range: Range<usize>) -> PolarsResult<Buffer<u8>> {
461        assert!(range.end as u64 <= self.size);
462
463        self.io_metrics()
464            .record_io_read_blocking(range.len() as u64, || {
465                read_at(
466                    &self.file,
467                    self.o_direct_align,
468                    range.start as u64,
469                    range.len(),
470                    self.size,
471                )
472            })
473    }
474}
475
476/// `cachestat(2)`, Linux 6.5+.
477#[cfg(all(
478    target_os = "linux",
479    any(target_arch = "x86_64", target_arch = "aarch64")
480))]
481mod cachestat {
482    use std::ops::Range;
483    use std::os::fd::AsRawFd;
484    use std::sync::atomic::{AtomicBool, Ordering};
485
486    use polars_utils::mem::PAGE_SIZE;
487
488    #[repr(C)]
489    struct CachestatRange {
490        off: u64,
491        len: u64,
492    }
493
494    #[repr(C)]
495    #[derive(Default)]
496    struct Cachestat {
497        nr_cache: u64,
498        nr_dirty: u64,
499        nr_writeback: u64,
500        nr_evicted: u64,
501        nr_recently_evicted: u64,
502    }
503
504    /// Not in `libc` for these targets yet; the same number on both.
505    const SYS_CACHESTAT: libc::c_long = 451;
506
507    /// Set once the kernel has refused the call, so later checks skip the syscall.
508    static UNSUPPORTED: AtomicBool = AtomicBool::new(false);
509
510    /// Whether every page overlapping `ranges` is in the page cache.
511    pub(super) fn all_cached(file: &std::fs::File, ranges: &[Range<usize>]) -> bool {
512        if UNSUPPORTED.load(Ordering::Relaxed) {
513            return false;
514        }
515
516        // One call per run of adjacent or overlapping ranges, e.g. the column chunks of a row
517        // group. Gaps are not bridged: their pages need not be cached.
518        let mut spans: Vec<Range<usize>> =
519            ranges.iter().filter(|r| !r.is_empty()).cloned().collect();
520        spans.sort_unstable_by_key(|r| r.start);
521        spans.dedup_by(|next, cur| {
522            let merge = next.start <= cur.end;
523            if merge {
524                cur.end = cur.end.max(next.end);
525            }
526            merge
527        });
528
529        let page = *PAGE_SIZE;
530        for r in &spans {
531            let mut range = CachestatRange {
532                off: r.start as u64,
533                len: r.len() as u64,
534            };
535            let mut cs = Cachestat::default();
536            // Safety: both structs match the kernel's layout and outlive the call.
537            let ret = unsafe {
538                libc::syscall(
539                    SYS_CACHESTAT,
540                    file.as_raw_fd() as libc::c_long,
541                    &mut range as *mut CachestatRange,
542                    &mut cs as *mut Cachestat,
543                    0 as libc::c_long,
544                )
545            };
546            if ret != 0 {
547                // ENOSYS before 6.5 and EPERM under some seccomp filters hold for every file.
548                // Others, like EOPNOTSUPP on hugetlbfs, are about this file only.
549                if let Some(libc::ENOSYS | libc::EPERM) =
550                    std::io::Error::last_os_error().raw_os_error()
551                {
552                    UNSUPPORTED.store(true, Ordering::Relaxed);
553                }
554                return false;
555            }
556            // The kernel counts the pages that overlap the byte range.
557            let pages = (r.end - 1) / page - r.start / page + 1;
558            if cs.nr_cache < pages as u64 {
559                return false;
560            }
561        }
562        true
563    }
564}
565
566impl ByteSource for FileByteSource {
567    async fn get_size(&self) -> PolarsResult<usize> {
568        usize::try_from(self.size)
569            .map_err(|_| polars_err!(ComputeError: "file size {} does not fit in usize", self.size))
570    }
571
572    async fn get_suffix(&self, n: usize) -> PolarsResult<(Buffer<u8>, usize)> {
573        let size = self.get_size().await?;
574        let bytes = self.get_range(size.saturating_sub(n)..size).await?;
575        Ok((bytes, size))
576    }
577
578    async fn get_prefix(&self, n: usize) -> PolarsResult<(Buffer<u8>, usize)> {
579        let size = self.get_size().await?;
580        let bytes = self.get_range(0..n.min(size)).await?;
581        Ok((bytes, size))
582    }
583
584    async fn get_range(&self, range: Range<usize>) -> PolarsResult<Buffer<u8>> {
585        assert!(range.end as u64 <= self.size);
586
587        let file = self.file.clone();
588        let offset = range.start as u64;
589        let len = range.len();
590        let size = self.size;
591        let o_direct = self.o_direct_align;
592
593        let permit = self.permits.clone().acquire_owned().await.unwrap();
594
595        self.io_metrics()
596            .record_io_read(len as u64, async move {
597                ASYNC
598                    .spawn_blocking(move || {
599                        let _permit = permit;
600                        read_at(&file, o_direct, offset, len, size)
601                    })
602                    .await
603            })
604            .await
605            .expect("blocking task panicked")
606    }
607
608    async fn get_ranges(
609        &self,
610        ranges: &mut [Range<usize>],
611    ) -> PolarsResult<PlHashMap<usize, Buffer<u8>>> {
612        if let [range] = ranges {
613            let mut out = PlHashMap::with_capacity(1);
614            out.insert(range.start, self.get_range(range.clone()).await?);
615            return Ok(out);
616        }
617
618        ranges.sort_unstable_by_key(|r| r.start);
619
620        let mut spans: Vec<Range<usize>> = Vec::with_capacity(ranges.len());
621
622        // Threshold for coalescing. Note, individual ranges may exceed MAX_SPAN.
623        const MAX_SPAN: usize = 8 << 20;
624        // Tolerate small gaps. We match typical page size for now; this could be tuned.
625        const MAX_GAP: usize = 4096;
626
627        for r in ranges.iter() {
628            match spans.last_mut() {
629                Some(last)
630                    if r.start.saturating_sub(last.end) <= MAX_GAP
631                        && r.end.saturating_sub(last.start) <= MAX_SPAN =>
632                {
633                    last.end = last.end.max(r.end)
634                },
635                _ => spans.push(r.clone()),
636            }
637        }
638
639        let mut fetched: Vec<(Range<usize>, Buffer<u8>)> = futures::stream::iter(spans)
640            .map(|span| async move {
641                let buf = self.get_range(span.clone()).await?;
642                PolarsResult::Ok((span, buf))
643            })
644            .buffer_unordered(self.concurrency)
645            .try_collect()
646            .await?;
647
648        // Slice out of the containing span into the original ranges.
649        fetched.sort_unstable_by_key(|(s, _)| s.start);
650
651        if let Some(w) = ranges.windows(2).find(|w| w[0].start == w[1].start) {
652            polars_bail!(
653                ComputeError:
654                "duplicate range start {} in read request ({:?} and {:?})",
655                w[0].start, w[0], w[1]
656            );
657        }
658
659        let mut out = PlHashMap::with_capacity(ranges.len());
660        for r in ranges.iter() {
661            // No span available.
662            if r.is_empty() {
663                out.insert(r.start, Buffer::new());
664                continue;
665            }
666
667            // Spans are sorted by start and each range lies within one, so the
668            // containing span is the last one starting at or before `r.start`.
669            let idx = fetched.partition_point(|(s, _)| s.start <= r.start);
670            let (span, buf) = &fetched[idx - 1];
671            debug_assert!(span.start <= r.start && r.end <= span.end);
672
673            let off = r.start - span.start;
674            out.insert(r.start, buf.clone().sliced(off..off + r.len()));
675        }
676
677        debug_assert_eq!(out.len(), ranges.len());
678        Ok(out)
679    }
680}
681
682#[cfg(feature = "cloud")]
683#[derive(Clone)]
684pub struct ObjectStoreByteSource {
685    store: PolarsObjectStore,
686    path: ObjectStorePath,
687    config: FetchConfig,
688}
689
690#[cfg(feature = "cloud")]
691impl ObjectStoreByteSource {
692    async fn try_new_from_path(
693        path: PlRefPath,
694        cloud_options: Option<&CloudOptions>,
695        io_metrics: Option<Arc<IOMetrics>>,
696        config: FetchConfig,
697    ) -> PolarsResult<Self> {
698        let (CloudLocation { prefix, .. }, mut store) =
699            build_object_store(path, cloud_options, false).await?;
700        let path = object_path_from_str(&prefix)?;
701
702        store.set_io_metrics(io_metrics);
703
704        Ok(Self {
705            store,
706            path,
707            config,
708        })
709    }
710
711    #[allow(unused)]
712    fn chunk_size(&self) -> usize {
713        self.config.chunk_size
714    }
715
716    fn concurrency_strategy(&self) -> ConcurrencyStrategy {
717        self.config.strategy
718    }
719}
720
721#[cfg(feature = "cloud")]
722impl ByteSource for ObjectStoreByteSource {
723    async fn get_size(&self) -> PolarsResult<usize> {
724        Ok(self
725            .store
726            .head(&self.path, self.concurrency_strategy())
727            .await?
728            .size as usize)
729    }
730
731    async fn get_suffix(&self, n: usize) -> PolarsResult<(Buffer<u8>, usize)> {
732        self.store.get_suffix(&self.path, n, self.config).await
733    }
734
735    async fn get_prefix(&self, n: usize) -> PolarsResult<(Buffer<u8>, usize)> {
736        self.store.get_prefix(&self.path, n, self.config).await
737    }
738
739    async fn get_range(&self, range: Range<usize>) -> PolarsResult<Buffer<u8>> {
740        self.store.get_range(&self.path, range, self.config).await
741    }
742
743    async fn get_ranges(
744        &self,
745        ranges: &mut [Range<usize>],
746    ) -> PolarsResult<PlHashMap<usize, Buffer<u8>>> {
747        self.store
748            .get_ranges_sort(&self.path, ranges, self.config)
749            .await
750    }
751}
752
753/// Dynamic dispatch to async functions.
754pub enum DynByteSource {
755    Buffer(BufferByteSource),
756    File(FileByteSource),
757    #[cfg(feature = "cloud")]
758    Cloud(ObjectStoreByteSource),
759}
760
761impl DynByteSource {
762    pub fn variant_name(&self) -> &str {
763        match self {
764            Self::Buffer(_) => "Buffer",
765            Self::File(_) => "File",
766            #[cfg(feature = "cloud")]
767            Self::Cloud(_) => "Cloud",
768        }
769    }
770
771    /// A clone of this object store source with a different fetch configuration. The clone
772    /// shares the underlying store, and this source keeps its configuration. Returns `None` for
773    /// non-object store sources.
774    #[cfg_attr(not(feature = "cloud"), allow(unused_variables))]
775    pub fn with_fetch_config(&self, config: FetchConfig) -> Option<Self> {
776        match self {
777            Self::Buffer(_) | Self::File(_) => None,
778            #[cfg(feature = "cloud")]
779            Self::Cloud(v) => Some(Self::Cloud(ObjectStoreByteSource {
780                config,
781                ..v.clone()
782            })),
783        }
784    }
785
786    pub fn is_cloud(&self) -> bool {
787        match self {
788            Self::Buffer(_) => false,
789            Self::File(_) => false,
790            #[cfg(feature = "cloud")]
791            Self::Cloud(_) => true,
792        }
793    }
794
795    pub fn chunk_size(&self) -> Option<usize> {
796        match self {
797            Self::Buffer(_) => None,
798            Self::File(_) => None,
799            #[cfg(feature = "cloud")]
800            Self::Cloud(source) => Some(source.config.chunk_size),
801        }
802    }
803
804    pub fn concurrency_strategy(&self) -> Option<ConcurrencyStrategy> {
805        match self {
806            Self::Buffer(_) => None,
807            // Concurrency is handled directly inside FileByteSource.
808            Self::File(_) => None,
809            #[cfg(feature = "cloud")]
810            Self::Cloud(source) => Some(source.concurrency_strategy()),
811        }
812    }
813}
814
815impl Default for DynByteSource {
816    fn default() -> Self {
817        Self::Buffer(BufferByteSource(Buffer::new()))
818    }
819}
820
821impl ByteSource for DynByteSource {
822    async fn get_size(&self) -> PolarsResult<usize> {
823        match self {
824            Self::Buffer(v) => v.get_size().await,
825            Self::File(v) => v.get_size().await,
826            #[cfg(feature = "cloud")]
827            Self::Cloud(v) => v.get_size().await,
828        }
829    }
830
831    async fn get_suffix(&self, n: usize) -> PolarsResult<(Buffer<u8>, usize)> {
832        match self {
833            Self::Buffer(v) => v.get_suffix(n).await,
834            Self::File(v) => v.get_suffix(n).await,
835            #[cfg(feature = "cloud")]
836            Self::Cloud(v) => v.get_suffix(n).await,
837        }
838    }
839
840    async fn get_prefix(&self, n: usize) -> PolarsResult<(Buffer<u8>, usize)> {
841        match self {
842            Self::Buffer(v) => v.get_prefix(n).await,
843            Self::File(v) => v.get_prefix(n).await,
844            #[cfg(feature = "cloud")]
845            Self::Cloud(v) => v.get_prefix(n).await,
846        }
847    }
848
849    async fn get_range(&self, range: Range<usize>) -> PolarsResult<Buffer<u8>> {
850        match self {
851            Self::Buffer(v) => v.get_range(range).await,
852            Self::File(v) => v.get_range(range).await,
853            #[cfg(feature = "cloud")]
854            Self::Cloud(v) => v.get_range(range).await,
855        }
856    }
857
858    async fn get_ranges(
859        &self,
860        ranges: &mut [Range<usize>],
861    ) -> PolarsResult<PlHashMap<usize, Buffer<u8>>> {
862        match self {
863            Self::Buffer(v) => v.get_ranges(ranges).await,
864            Self::File(v) => v.get_ranges(ranges).await,
865            #[cfg(feature = "cloud")]
866            Self::Cloud(v) => v.get_ranges(ranges).await,
867        }
868    }
869}
870
871impl From<BufferByteSource> for DynByteSource {
872    fn from(value: BufferByteSource) -> Self {
873        Self::Buffer(value)
874    }
875}
876
877impl From<FileByteSource> for DynByteSource {
878    fn from(value: FileByteSource) -> Self {
879        Self::File(value)
880    }
881}
882
883#[cfg(feature = "cloud")]
884impl From<ObjectStoreByteSource> for DynByteSource {
885    fn from(value: ObjectStoreByteSource) -> Self {
886        Self::Cloud(value)
887    }
888}
889
890impl From<Buffer<u8>> for DynByteSource {
891    fn from(value: Buffer<u8>) -> Self {
892        Self::Buffer(BufferByteSource(value))
893    }
894}
895
896#[derive(Clone, Debug)]
897pub enum DynByteSourceBuilder {
898    Mmap,
899    /// Use std::fs::File positional read (pread).
900    FilePread(FileReadContext),
901    /// Supports both cloud and local files, requires cloud feature.
902    ObjectStore(FetchConfig),
903}
904
905impl DynByteSourceBuilder {
906    pub async fn try_build_from_path(
907        &self,
908        path: PlRefPath,
909        cloud_options: Option<&CloudOptions>,
910        io_metrics: Option<Arc<IOMetrics>>,
911    ) -> PolarsResult<DynByteSource> {
912        Ok(match self {
913            Self::Mmap => {
914                BufferByteSource::try_new_mmap_from_path(path.as_std_path(), cloud_options)
915                    .await?
916                    .into()
917            },
918            Self::FilePread(read_context) => {
919                FileByteSource::try_new_from_path(path, read_context.clone(), io_metrics)
920                    .await?
921                    .into()
922            },
923            Self::ObjectStore(fetch_config) => feature_gated!("cloud", {
924                ObjectStoreByteSource::try_new_from_path(
925                    path,
926                    cloud_options,
927                    io_metrics,
928                    *fetch_config,
929                )
930                .await?
931                .into()
932            }),
933        })
934    }
935
936    pub fn chunk_size(&self) -> Option<usize> {
937        match self {
938            Self::Mmap => None,
939            Self::FilePread(_) => None,
940            Self::ObjectStore(fetch_config) => Some(fetch_config.chunk_size),
941        }
942    }
943
944    pub fn concurrency_strategy(&self) -> Option<&ConcurrencyStrategy> {
945        match self {
946            Self::Mmap => None,
947            Self::FilePread(_) => None,
948            Self::ObjectStore(fetch_config) => Some(&fetch_config.strategy),
949        }
950    }
951}
952
953#[cfg(test)]
954mod tests {
955    use super::*;
956
957    const MIB: usize = 1 << 20;
958
959    /// A file source over `len` bytes of `(i % 251)`, plus those bytes.
960    fn source(dir: &std::path::Path, len: usize) -> (FileByteSource, Vec<u8>) {
961        let path = dir.join("f.bin");
962        let contents: Vec<u8> = (0..len).map(|i| (i % 251) as u8).collect();
963        std::fs::write(&path, &contents).unwrap();
964
965        let read_context = FileReadContext {
966            enable_o_direct: false,
967            concurrency: 4,
968            permits: Arc::new(Semaphore::new(4)),
969            advice: FileAdvice::Normal,
970        };
971        let source = FileByteSource::try_new_from_std(
972            std::fs::File::open(&path).unwrap(),
973            read_context,
974            None,
975        )
976        .unwrap();
977        (source, contents)
978    }
979
980    fn runtime() -> tokio::runtime::Runtime {
981        tokio::runtime::Builder::new_multi_thread()
982            .worker_threads(2)
983            .enable_all()
984            .build()
985            .unwrap()
986    }
987
988    #[test]
989    fn get_ranges_resolves_every_range() {
990        let dir = tempfile::tempdir().unwrap();
991        let (source, contents) = source(dir.path(), 17 * MIB);
992        let rt = runtime();
993
994        let cases: [(&str, &[Range<usize>]); 6] = [
995            ("disjoint", &[0..100, 500..600, 900..1000]),
996            ("unsorted", &[900..1000, 0..100, 500..600]),
997            ("partial overlap", &[0..100, 50..150]),
998            ("contained", &[0..200, 50..100]),
999            // A zero-length column chunk is declarable in file metadata.
1000            ("empty range at a span end", &[0..100, 100..100]),
1001            // Spans become [0..4096, 5M..14M, 13M..16M]: the last two overlap,
1002            // and both contain 13.5M, but only the third covers 16M.
1003            (
1004                "overlapping spans",
1005                &[
1006                    0..4096,
1007                    5_000_000..14_000_000,
1008                    13_000_000..13_500_000,
1009                    13_500_000..16_000_000,
1010                ],
1011            ),
1012        ];
1013
1014        for (name, ranges) in cases {
1015            let mut ranges = ranges.to_vec();
1016            let expect = ranges.clone();
1017            let out = rt.block_on(source.get_ranges(&mut ranges)).unwrap();
1018
1019            assert_eq!(out.len(), expect.len(), "{name}");
1020            for r in &expect {
1021                let got = out
1022                    .get(&r.start)
1023                    .unwrap_or_else(|| panic!("{name}: {r:?} missing"));
1024                assert_eq!(got.as_ref(), &contents[r.clone()], "{name}: {r:?}");
1025            }
1026        }
1027    }
1028
1029    #[test]
1030    fn read_blocking_reads_the_range() {
1031        let dir = tempfile::tempdir().unwrap();
1032        let (source, contents) = source(dir.path(), 3 * MIB);
1033
1034        for r in [0..0, 0..1, 100..4096, MIB - 7..MIB + 9, 0..3 * MIB] {
1035            let got = source.read_blocking(r.clone()).unwrap();
1036            assert_eq!(got.as_ref(), &contents[r.clone()], "{r:?}");
1037        }
1038    }
1039
1040    #[cfg(all(
1041        target_os = "linux",
1042        any(target_arch = "x86_64", target_arch = "aarch64")
1043    ))]
1044    #[test]
1045    fn is_cached_follows_the_page_cache() {
1046        let dir = tempfile::tempdir().unwrap();
1047        let (source, _) = source(dir.path(), 4 * MIB);
1048        let ranges = [0..100, MIB + 1..2 * MIB + 1, 4 * MIB - 1..4 * MIB, 5..5];
1049
1050        // Just written, so cached; `false` means the kernel lacks cachestat(2).
1051        if !source.is_cached(&ranges) {
1052            return;
1053        }
1054        assert!(source.is_cached(&[]));
1055
1056        // Drop the pages. tmpfs keeps them, so only check where they can go.
1057        source.file.sync_all().unwrap();
1058        unsafe { libc::posix_fadvise(source.file.as_raw_fd(), 0, 0, libc::POSIX_FADV_DONTNEED) };
1059        let mut st: libc::statfs = unsafe { std::mem::zeroed() };
1060        let path = std::ffi::CString::new(dir.path().as_os_str().as_encoded_bytes()).unwrap();
1061        assert_eq!(unsafe { libc::statfs(path.as_ptr(), &mut st) }, 0);
1062        if st.f_type == libc::TMPFS_MAGIC {
1063            return;
1064        }
1065        assert!(!source.is_cached(&ranges));
1066
1067        let read = MIB + 1..2 * MIB + 1;
1068        source.read_blocking(read.clone()).unwrap();
1069        assert!(source.is_cached(std::slice::from_ref(&read)));
1070        assert!(!source.is_cached(&ranges));
1071    }
1072
1073    #[cfg(all(
1074        target_os = "linux",
1075        any(target_arch = "x86_64", target_arch = "aarch64")
1076    ))]
1077    #[test]
1078    fn is_cached_does_not_bridge_gaps() {
1079        let page = *polars_utils::mem::PAGE_SIZE;
1080        let dir = tempfile::tempdir().unwrap();
1081        let (source, _) = source(dir.path(), 3 * page);
1082        let (all, middle) = (0..3 * page, page..2 * page);
1083        // Just written, so cached; `false` means the kernel lacks cachestat(2).
1084        if !source.is_cached(std::slice::from_ref(&all)) {
1085            return;
1086        }
1087
1088        // Drop only the middle page. tmpfs keeps it, so only check where it can go.
1089        source.file.sync_all().unwrap();
1090        unsafe {
1091            libc::posix_fadvise(
1092                source.file.as_raw_fd(),
1093                page as libc::off_t,
1094                page as libc::off_t,
1095                libc::POSIX_FADV_DONTNEED,
1096            )
1097        };
1098        if source.is_cached(std::slice::from_ref(&middle)) {
1099            return;
1100        }
1101        assert!(source.is_cached(&[2 * page..3 * page, 0..page]));
1102        assert!(!source.is_cached(&[0..page, page..2 * page]));
1103        assert!(!source.is_cached(std::slice::from_ref(&all)));
1104    }
1105}