Skip to main content

tylertoo_core/
input.rs

1//! Input-source abstraction: local files or remote objects (issue #210).
2//!
3//! The overview converter historically opened its input with
4//! [`std::fs::File`]. This module generalizes that to an [`InputSource`]
5//! that can also point at an object in remote storage (`s3://`, `https://`,
6//! `http://`, `gs://`), served to the existing *synchronous* parquet reader
7//! plumbing through the [`parquet::file::reader::ChunkReader`] trait:
8//!
9//! - the parquet footer is fetched with range requests,
10//! - each column chunk of each *selected* row group is fetched as ONE byte
11//!   range, the first time the sync reader touches it (the buffered
12//!   range-fetch adapter: the page reader's many small header/page reads
13//!   are served from the whole-chunk buffer).
14//!
15//! Fetches never extend past a column chunk **by design**: only chunks the
16//! parquet reader actually touches are requested. This is what makes the
17//! composition with `--bbox` row-group pruning (#102 / PR #207) the headline
18//! feature — pruned row groups are never requested at all, so a city-scale
19//! extract from a country-scale remote file moves only a fraction of the
20//! object's bytes. [`InputSource::fetch_stats`] exposes request/byte
21//! counters so callers (and tests) can verify that property.
22//!
23//! The streaming pipeline re-reads the input across several passes (assign,
24//! coarse levels, finest streamed last). Each [`InputSource::open`] of a
25//! remote source reuses a cached parsed footer, and fetched column chunks are
26//! served from the cheapest tier that holds them:
27//!
28//! - **L1**, a bounded in-memory cache (insertion-order eviction) sized to the
29//!   largest row group's working set (floored at
30//!   [`remote::CHUNK_CACHE_MAX_BYTES`]), so a row group larger than the floor
31//!   does not thrash — the fix for the per-page re-fetch of an oversized column
32//!   chunk (issue #261);
33//! - **L2**, a local on-disk spill of every chunk ever fetched, so a chunk
34//!   evicted from L1 between passes is drained from local disk instead of the
35//!   network — this bounds remote traffic to ≈1× the object regardless of pass
36//!   or level count (issue #219; without it a full-file remote convert moved
37//!   ~3× the object);
38//! - **L3**, one network range request, taken only on the first touch of a
39//!   chunk.
40//!
41//! See `docs/remote-reads.md` for the fetch-count implications.
42//!
43//! Remote support is compiled behind the `remote` cargo feature; the CLI and
44//! Python bindings enable it by default. Without the feature, URL inputs
45//! fail with a clear [`InputError::RemoteDisabled`] error.
46
47use std::fs::File;
48use std::io::Read;
49use std::path::{Path, PathBuf};
50
51use bytes::Bytes;
52use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
53use parquet::errors::ParquetError;
54use parquet::file::reader::{ChunkReader, Length};
55use serde::Serialize;
56
57/// Schemes recognized as remote inputs (when the `remote` feature is on).
58const REMOTE_SCHEMES: &[&str] = &["s3", "s3a", "http", "https", "gs"];
59
60/// Errors from opening or reading an [`InputSource`].
61#[derive(Debug, thiserror::Error)]
62pub enum InputError {
63    /// Local I/O error.
64    #[error("io error: {0}")]
65    Io(#[from] std::io::Error),
66    /// Parquet error (footer parse, reader construction).
67    #[error("parquet error: {0}")]
68    Parquet(#[from] ParquetError),
69    /// The input looks like a URL but uses a scheme we do not support.
70    #[error(
71        "unsupported input URL scheme `{scheme}://` in {url:?}: supported inputs are \
72         local paths and s3://, https://, http://, gs:// URLs"
73    )]
74    UnsupportedScheme {
75        /// The unrecognized scheme.
76        scheme: String,
77        /// The full input string.
78        url: String,
79    },
80    /// A remote URL was supplied but the crate was built without `remote`.
81    #[cfg(not(feature = "remote"))]
82    #[error(
83        "remote input {0:?} requires tylertoo-core's `remote` feature (the official \
84         CLI and Python builds enable it; rebuild with `--features remote`)"
85    )]
86    RemoteDisabled(String),
87    /// The remote object store rejected a request.
88    #[cfg(feature = "remote")]
89    #[error("remote input error for {url}: {source}")]
90    Remote {
91        /// The input URL.
92        url: String,
93        /// The underlying object-store error.
94        #[source]
95        source: object_store::Error,
96    },
97    /// Remote configuration problem (bad URL, missing region, ...).
98    #[cfg(feature = "remote")]
99    #[error("{0}")]
100    RemoteConfig(String),
101    /// Arrow error surfaced while decoding record batches from a stream.
102    #[error("arrow error: {0}")]
103    Arrow(#[from] arrow_schema::ArrowError),
104    /// A partition of a multi-file input does not match the first partition
105    /// (schema shape, geometry extension metadata, or CRS).
106    #[error("incompatible input partition {offender:?}: {detail} (first partition: {first:?})")]
107    IncompatiblePartition {
108        /// Display name of the reference (first) partition.
109        first: String,
110        /// Display name of the partition that failed validation.
111        offender: String,
112        /// What differs.
113        detail: String,
114    },
115    /// A directory or glob input matched no `.parquet` files.
116    #[error("no .parquet files found for input {input:?}")]
117    NoParquetInputs {
118        /// The original input string (directory path or glob pattern).
119        input: String,
120    },
121    /// An `http(s)://` URL naming a prefix ("directory"). Generic HTTP has
122    /// no listing API, so the objects must be named explicitly; `s3://` and
123    /// `gs://` prefixes are listed natively.
124    #[error(
125        "cannot list objects under {url:?}: http(s) prefixes have no generic \
126         listing API. Pass the object URLs explicitly with --files-from \
127         <manifest> (one per line); s3:// and gs:// prefixes are listed \
128         natively"
129    )]
130    RemotePrefixUnsupported {
131        /// The rejected URL.
132        url: String,
133    },
134    /// The `--files-from` manifest itself could not be read.
135    #[error("cannot read --files-from manifest {path:?}: {source}")]
136    ManifestRead {
137        /// The manifest path as supplied.
138        path: String,
139        /// The underlying I/O error.
140        #[source]
141        source: std::io::Error,
142    },
143    /// An explicitly listed input (a `--files-from` manifest line or a
144    /// Python input-list entry) does not exist as a local file. Listed
145    /// entries are single files/objects only — no directory, glob, or
146    /// prefix expansion.
147    #[error("{context}: input {input:?} does not exist or is not a file")]
148    MissingListedInput {
149        /// Where the entry came from (manifest line / list position).
150        context: String,
151        /// The offending entry.
152        input: String,
153    },
154    /// A glob input string is not a valid glob pattern.
155    #[error("invalid glob pattern {pattern:?}: {message}")]
156    GlobPattern {
157        /// The pattern as supplied.
158        pattern: String,
159        /// The glob crate's error message.
160        message: String,
161    },
162}
163
164/// Byte/request counters for a remote input. `Serialize` so conversion
165/// reports can carry it (benchmark tasks).
166#[derive(Debug, Clone, Copy, PartialEq, Serialize, Default)]
167pub struct FetchStats {
168    /// Number of range GET requests issued (HEAD not included).
169    pub requests: u64,
170    /// Total bytes fetched across all range requests.
171    pub bytes_fetched: u64,
172    /// Total size of the remote object in bytes.
173    pub object_size: u64,
174}
175
176/// A conversion input: a local parquet file or a remote parquet object.
177///
178/// `Clone` is cheap: the local variant clones a path, the remote variant
179/// clones `Arc` handles — clones share the fetch counters, chunk cache,
180/// disk spill, and cached footer of the original.
181#[derive(Debug, Clone)]
182pub enum InputSource {
183    /// A local filesystem path (the historical behavior).
184    Local(PathBuf),
185    /// A remote object read over range requests.
186    #[cfg(feature = "remote")]
187    Remote(remote::RemoteSource),
188}
189
190impl InputSource {
191    /// Classify a CLI-style input path. Anything shaped like `scheme://...`
192    /// is treated as a URL (`file://` maps back to a local path); everything
193    /// else is a local path.
194    pub fn from_path(path: &Path) -> Result<Self, InputError> {
195        let Some(s) = path.to_str() else {
196            // Non-UTF-8 paths cannot be URLs; treat as local.
197            return Ok(InputSource::Local(path.to_path_buf()));
198        };
199        Self::from_str_input(s)
200    }
201
202    /// [`InputSource::from_path`] for string inputs.
203    pub fn from_str_input(input: &str) -> Result<Self, InputError> {
204        let Some(scheme) = url_scheme(input) else {
205            return Ok(InputSource::Local(PathBuf::from(input)));
206        };
207        if scheme.eq_ignore_ascii_case("file") {
208            let rest = &input[scheme.len() + 3..];
209            return Ok(InputSource::Local(PathBuf::from(rest)));
210        }
211        if !REMOTE_SCHEMES
212            .iter()
213            .any(|s| scheme.eq_ignore_ascii_case(s))
214        {
215            return Err(InputError::UnsupportedScheme {
216                scheme: scheme.to_string(),
217                url: input.to_string(),
218            });
219        }
220        #[cfg(feature = "remote")]
221        {
222            Ok(InputSource::Remote(remote::RemoteSource::connect(input)?))
223        }
224        #[cfg(not(feature = "remote"))]
225        {
226            Err(InputError::RemoteDisabled(input.to_string()))
227        }
228    }
229
230    /// Whether this source is remote.
231    pub fn is_remote(&self) -> bool {
232        match self {
233            InputSource::Local(_) => false,
234            #[cfg(feature = "remote")]
235            InputSource::Remote(_) => true,
236        }
237    }
238
239    /// Human-readable name of the input (path or URL).
240    pub fn display_name(&self) -> String {
241        match self {
242            InputSource::Local(p) => p.display().to_string(),
243            #[cfg(feature = "remote")]
244            InputSource::Remote(r) => r.url().to_string(),
245        }
246    }
247
248    /// Open a parquet reader builder over this input.
249    ///
250    /// Local: opens the file and parses the footer (cheap, OS page cache).
251    /// Remote: reuses a cached parsed footer after the first open, so the
252    /// multi-pass streaming pipeline pays the footer fetch only once.
253    pub fn open(&self) -> Result<ParquetRecordBatchReaderBuilder<InputReader>, InputError> {
254        match self {
255            InputSource::Local(p) => {
256                let file = File::open(p)?;
257                Ok(ParquetRecordBatchReaderBuilder::try_new(
258                    InputReader::Local(file),
259                )?)
260            }
261            #[cfg(feature = "remote")]
262            InputSource::Remote(r) => r.open_builder(),
263        }
264    }
265
266    /// Fetch counters for a remote source (`None` for local inputs).
267    pub fn fetch_stats(&self) -> Option<FetchStats> {
268        match self {
269            InputSource::Local(_) => None,
270            #[cfg(feature = "remote")]
271            InputSource::Remote(r) => Some(r.fetch_stats()),
272        }
273    }
274
275    /// The byte ranges fetched so far from a remote source (`None` for
276    /// local inputs). Ordered by request time; used by tests to prove that
277    /// bbox-pruned row groups are never downloaded.
278    pub fn fetched_ranges(&self) -> Option<Vec<std::ops::Range<u64>>> {
279        match self {
280            InputSource::Local(_) => None,
281            #[cfg(feature = "remote")]
282            InputSource::Remote(r) => Some(r.fetched_ranges()),
283        }
284    }
285
286    /// Place the remote-input disk spill (#219) in `dir` instead of the
287    /// process temp dir (`$TMPDIR`) — issue #272. No-op for local inputs,
288    /// which never spill. Call before reading: chunks already spilled stay
289    /// in the previously created file; only the spill-file creation (lazy,
290    /// on the first spilled chunk) honors the directory.
291    #[cfg_attr(not(feature = "remote"), allow(unused_variables))]
292    pub fn set_spill_dir(&self, dir: Option<&Path>) {
293        #[cfg(feature = "remote")]
294        if let InputSource::Remote(r) = self {
295            r.set_spill_dir(dir);
296        }
297    }
298
299    /// Release the in-memory read cache (no-op for local files). For a
300    /// remote source this clears the L1 chunk cache but KEEPS the disk
301    /// spill and the cached footer, so a later touch of the same chunk is
302    /// served from local disk, not the network. Called by multi-partition
303    /// streams on part transitions to bound resident memory to one part's
304    /// working set.
305    pub fn release_read_cache(&self) {
306        match self {
307            InputSource::Local(_) => {}
308            #[cfg(feature = "remote")]
309            InputSource::Remote(r) => r.release_read_cache(),
310        }
311    }
312
313    /// Stage the selected row groups to the local disk spill up front (pass
314    /// 0, #286/#287), coalescing each row group into one parallel range
315    /// request so the later passes read entirely from disk. No-op for local
316    /// inputs (the OS page cache already serves re-reads). `selected` (`None`
317    /// = all) must match the row groups the passes will read, so pruned groups
318    /// are never fetched and total traffic stays ≈1× (#219).
319    #[cfg_attr(not(feature = "remote"), allow(unused_variables))]
320    pub fn stage_row_groups(&self, selected: Option<&[usize]>) -> Result<(), InputError> {
321        match self {
322            InputSource::Local(_) => Ok(()),
323            #[cfg(feature = "remote")]
324            InputSource::Remote(r) => r.stage_row_groups(selected),
325        }
326    }
327}
328
329/// Sum of the compressed byte sizes of the selected input row groups — the
330/// bytes a remote convert will touch, and therefore the projected size of
331/// the disk spill (#219): every touched chunk is staged locally exactly
332/// once, so the spill grows to ≈ this number (issue #272 free-space
333/// preflight). `None` selects every row group (a full-file read); indices
334/// out of range are ignored. Single-file shape on purpose (metadata +
335/// selection → bytes): a multi-partition source sums it across parts.
336pub fn selected_compressed_bytes(
337    metadata: &parquet::file::metadata::ParquetMetaData,
338    selected_row_groups: Option<&[usize]>,
339) -> u64 {
340    let group_bytes = |i: usize| -> u64 {
341        metadata
342            .row_groups()
343            .get(i)
344            .map_or(0, |rg| rg.compressed_size().max(0) as u64)
345    };
346    match selected_row_groups {
347        None => (0..metadata.num_row_groups()).map(group_bytes).sum(),
348        Some(selected) => selected.iter().map(|&i| group_bytes(i)).sum(),
349    }
350}
351
352/// Whether `scheme` is one of the recognized remote URL schemes.
353#[cfg(feature = "remote")]
354pub(crate) fn is_remote_scheme(scheme: &str) -> bool {
355    REMOTE_SCHEMES
356        .iter()
357        .any(|s| scheme.eq_ignore_ascii_case(s))
358}
359
360/// Return the URL scheme of `input` if it is shaped like `scheme://rest`.
361pub(crate) fn url_scheme(input: &str) -> Option<&str> {
362    let (scheme, _) = input.split_once("://")?;
363    if scheme.is_empty() {
364        return None;
365    }
366    let mut chars = scheme.chars();
367    let first = chars.next()?;
368    if !first.is_ascii_alphabetic() {
369        return None;
370    }
371    if chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) {
372        Some(scheme)
373    } else {
374        None
375    }
376}
377
378/// A [`ChunkReader`] over an [`InputSource`]: the type the parquet reader
379/// plumbing is instantiated with.
380#[derive(Debug)]
381pub enum InputReader {
382    /// Local file (delegates to parquet's own `File` impl).
383    Local(File),
384    /// Remote object; every `get_bytes` is one range request.
385    #[cfg(feature = "remote")]
386    Remote(remote::RemoteReader),
387}
388
389impl Length for InputReader {
390    fn len(&self) -> u64 {
391        match self {
392            InputReader::Local(f) => f.len(),
393            #[cfg(feature = "remote")]
394            InputReader::Remote(r) => r.object_size(),
395        }
396    }
397}
398
399impl ChunkReader for InputReader {
400    type T = Box<dyn Read + Send>;
401
402    fn get_read(&self, start: u64) -> Result<Self::T, ParquetError> {
403        match self {
404            InputReader::Local(f) => Ok(Box::new(f.get_read(start)?)),
405            #[cfg(feature = "remote")]
406            InputReader::Remote(r) => Ok(Box::new(r.sequential_reader(start))),
407        }
408    }
409
410    fn get_bytes(&self, start: u64, length: usize) -> Result<Bytes, ParquetError> {
411        match self {
412            InputReader::Local(f) => f.get_bytes(start, length),
413            #[cfg(feature = "remote")]
414            InputReader::Remote(r) => r.get_bytes_range(start, length),
415        }
416    }
417}
418
419#[cfg(feature = "remote")]
420pub(crate) mod remote {
421    //! Remote object-store backend (`remote` feature).
422
423    use std::fs::File;
424    use std::io::{Read, Seek, SeekFrom, Write};
425    use std::ops::Range;
426    use std::path::{Path, PathBuf};
427    use std::sync::atomic::{AtomicU64, Ordering};
428    use std::sync::{Arc, Mutex, OnceLock};
429
430    use bytes::Bytes;
431    use object_store::aws::{AmazonS3Builder, AwsCredential};
432    use object_store::gcp::GoogleCloudStorageBuilder;
433    use object_store::http::HttpBuilder;
434    use object_store::path::Path as ObjectPath;
435    // ObjectStoreExt: object_store 0.14 moved `get_range` and `head` off the
436    // base trait onto an extension trait.
437    use object_store::{
438        ClientOptions, CredentialProvider, ObjectStore, ObjectStoreExt, ObjectStoreScheme,
439    };
440    use parquet::arrow::arrow_reader::{
441        ArrowReaderMetadata, ArrowReaderOptions, ParquetRecordBatchReaderBuilder,
442    };
443    use parquet::errors::ParquetError;
444    use url::Url;
445
446    use super::{FetchStats, InputError, InputReader};
447
448    /// Readahead for the *sequential* read path
449    /// ([`parquet::file::reader::ChunkReader::get_read`]). The page reader
450    /// uses `get_read` only to thrift-decode page *headers* (tens of bytes;
451    /// page data goes through exact-range `get_bytes`), so keep this small —
452    /// and always clamp it to the surrounding column chunk (see
453    /// [`SharedState::clamp_to_chunk`]) so a header read near a chunk
454    /// boundary can never pull bytes from a bbox-pruned row group.
455    const SEQUENTIAL_CHUNK: u64 = 8 * 1024;
456
457    /// Shared tokio runtime driving object_store's async I/O from our
458    /// synchronous reader plumbing. Two worker threads: requests are issued
459    /// one at a time per reader, so this only needs to run the HTTP client.
460    fn runtime() -> &'static tokio::runtime::Runtime {
461        static RT: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
462        RT.get_or_init(|| {
463            tokio::runtime::Builder::new_multi_thread()
464                .worker_threads(2)
465                .thread_name("gpq-remote-io")
466                .enable_all()
467                .build()
468                .expect("failed to build tokio runtime for remote input")
469        })
470    }
471
472    /// State shared by all readers of one source: request/byte counters, the
473    /// fetched-range log, and (once the footer is parsed) the column-chunk
474    /// byte ranges used to clamp sequential readahead.
475    #[derive(Debug, Default)]
476    struct SharedState {
477        requests: AtomicU64,
478        bytes: AtomicU64,
479        ranges: Mutex<Vec<Range<u64>>>,
480        /// Byte ranges of every column chunk, sorted by start; populated
481        /// from the parsed footer on first open.
482        chunk_ranges: OnceLock<Vec<Range<u64>>>,
483    }
484
485    impl SharedState {
486        /// Clamp a readahead starting at `start` so it never crosses out of
487        /// the column chunk containing `start` (a page-header read must not
488        /// bleed into a neighboring — possibly bbox-pruned — row group).
489        fn clamp_to_chunk(&self, start: u64, want_end: u64) -> u64 {
490            let Some(chunks) = self.chunk_ranges.get() else {
491                return want_end;
492            };
493            // Last chunk starting at or before `start`.
494            let idx = chunks.partition_point(|r| r.start <= start);
495            if idx == 0 {
496                return want_end;
497            }
498            let chunk = &chunks[idx - 1];
499            if start < chunk.end {
500                want_end.min(chunk.end)
501            } else {
502                want_end
503            }
504        }
505    }
506
507    /// A remote parquet object: store handle, resolved location, object
508    /// size, fetch counters, and (after the first open) the parsed footer.
509    ///
510    /// `Clone` shares the counters, caches, spill, and cached footer via the
511    /// inner `Arc`s (a clone is a second handle to the same object).
512    #[derive(Debug, Clone)]
513    pub struct RemoteSource {
514        url: String,
515        store: Arc<dyn ObjectStore>,
516        location: ObjectPath,
517        size: u64,
518        shared: Arc<SharedState>,
519        metadata: OnceLock<ArrowReaderMetadata>,
520        cache: Arc<Mutex<ChunkCache>>,
521        /// On-disk overflow for fetched column chunks (issue #219), shared by
522        /// every reader clone across the pipeline's passes so a chunk fetched
523        /// in one pass is drained from local disk in the next.
524        spill: Arc<Mutex<DiskSpill>>,
525        /// Floor for the chunk-cache eviction budget. [`Self::open_builder`]
526        /// raises the live cap to the largest row group's working set but
527        /// never below this. Production uses [`CHUNK_CACHE_MAX_BYTES`]; tests
528        /// inject a tiny value to exercise the eviction path at small scale.
529        cap_base: u64,
530    }
531
532    /// Resolve the object store + object path for `url_str`.
533    ///
534    /// Stores are cached per `scheme://authority` for the life of the
535    /// process, so one convert resolves credentials ONCE per bucket: a
536    /// prefix listing and its N per-part sources, or N `--files-from`
537    /// manifest entries in the same bucket, all share one store instance
538    /// (the AWS credential chain probe in [`build_s3`] is otherwise paid
539    /// per part).
540    pub(crate) fn store_and_location(
541        url_str: &str,
542    ) -> Result<(Arc<dyn ObjectStore>, ObjectPath), InputError> {
543        static STORES: OnceLock<Mutex<std::collections::HashMap<String, Arc<dyn ObjectStore>>>> =
544            OnceLock::new();
545
546        let url = Url::parse(url_str)
547            .map_err(|e| InputError::RemoteConfig(format!("invalid URL {url_str:?}: {e}")))?;
548        let (scheme, location) = ObjectStoreScheme::parse(&url).map_err(|e| {
549            InputError::RemoteConfig(format!("cannot interpret URL {url_str:?}: {e}"))
550        })?;
551        let key = url[..url::Position::BeforePath].to_ascii_lowercase();
552        let stores = STORES.get_or_init(Default::default);
553        if let Some(store) = stores.lock().expect("store cache lock").get(&key) {
554            return Ok((Arc::clone(store), location));
555        }
556        let store: Arc<dyn ObjectStore> = match scheme {
557            ObjectStoreScheme::AmazonS3 => build_s3(&url)?,
558            ObjectStoreScheme::GoogleCloudStorage => Arc::new(
559                GoogleCloudStorageBuilder::from_env()
560                    .with_url(url_str)
561                    .build()
562                    .map_err(|e| store_error(url_str, e))?,
563            ),
564            ObjectStoreScheme::Http => {
565                let origin = &url[..url::Position::BeforePath];
566                Arc::new(
567                    HttpBuilder::new()
568                        .with_url(origin)
569                        .with_client_options(ClientOptions::new().with_allow_http(true))
570                        .build()
571                        .map_err(|e| store_error(url_str, e))?,
572                )
573            }
574            _ => {
575                return Err(InputError::UnsupportedScheme {
576                    scheme: url.scheme().to_string(),
577                    url: url_str.to_string(),
578                })
579            }
580        };
581        // A concurrent resolve may have raced us; either instance works,
582        // last insert wins.
583        stores
584            .lock()
585            .expect("store cache lock")
586            .insert(key, Arc::clone(&store));
587        Ok((store, location))
588    }
589
590    /// One `.parquet` object found under a remote prefix.
591    pub(crate) struct ListedPart {
592        /// Full object key (from the bucket root).
593        pub location: ObjectPath,
594        /// Object size in bytes, straight from the listing (no HEAD).
595        pub size: u64,
596    }
597
598    /// List the `.parquet` objects under remote prefix `prefix`, applying
599    /// the same hygiene as the local directory walk
600    /// ([`crate::input_set::list_parquet_files`]): keys must end
601    /// `.parquet`; zero-byte objects and any path component below the
602    /// prefix starting with `.` or `_` (`_SUCCESS`, `_delta_log/…`,
603    /// `.crc`) are skipped; results are sorted by key — the ordering the
604    /// converter's row-order invariant keys on.
605    pub(crate) fn list_parquet_under_prefix(
606        store: &Arc<dyn ObjectStore>,
607        prefix: &ObjectPath,
608        url_str: &str,
609    ) -> Result<Vec<ListedPart>, InputError> {
610        use futures_util::TryStreamExt;
611        let metas: Vec<object_store::ObjectMeta> = runtime()
612            .block_on(store.list(Some(prefix)).try_collect())
613            .map_err(|e| store_error(url_str, e))?;
614        let prefix_depth = prefix.parts().count();
615        let mut parts: Vec<ListedPart> = metas
616            .into_iter()
617            .filter(|m| {
618                m.size > 0
619                    && m.location.as_ref().ends_with(".parquet")
620                    && m.location
621                        .parts()
622                        .skip(prefix_depth)
623                        .all(|c| !c.as_ref().starts_with(['.', '_']))
624            })
625            .map(|m| ListedPart {
626                location: m.location,
627                size: m.size,
628            })
629            .collect();
630        parts.sort_by(|a, b| a.location.as_ref().cmp(b.location.as_ref()));
631        Ok(parts)
632    }
633
634    /// Connect every listed `.parquet` object under remote prefix
635    /// `url_str`, in key order. ONE store instance serves the listing and
636    /// all returned sources (one credential resolution), and the object
637    /// sizes come from the listing itself — no per-part HEAD requests.
638    pub(crate) fn sources_under_prefix(url_str: &str) -> Result<Vec<RemoteSource>, InputError> {
639        let (store, prefix) = store_and_location(url_str)?;
640        let parts = list_parquet_under_prefix(&store, &prefix, url_str)?;
641        Ok(parts
642            .into_iter()
643            .map(|p| {
644                let url = part_url(url_str, &p.location);
645                RemoteSource::from_store_sized(Arc::clone(&store), p.location, url, p.size)
646            })
647            .collect())
648    }
649
650    /// URL of one listed object: the prefix URL's `scheme://authority`
651    /// plus the object's full key.
652    fn part_url(prefix_url: &str, location: &ObjectPath) -> String {
653        match Url::parse(prefix_url) {
654            Ok(u) => format!("{}/{}", &u[..url::Position::BeforePath], location),
655            Err(_) => format!("{prefix_url}{location}"),
656        }
657    }
658
659    impl RemoteSource {
660        /// Connect to `url`, resolving the backing store from the scheme
661        /// (cached per bucket, see [`store_and_location`]) and HEAD-ing the
662        /// object for its size.
663        pub(crate) fn connect(url_str: &str) -> Result<Self, InputError> {
664            let (store, location) = store_and_location(url_str)?;
665            Self::from_store(store, location, url_str.to_string())
666        }
667
668        /// Build a source over an explicit store + location (also the test
669        /// seam: unit tests inject [`object_store::memory::InMemory`]).
670        pub fn from_store(
671            store: Arc<dyn ObjectStore>,
672            location: ObjectPath,
673            url: String,
674        ) -> Result<Self, InputError> {
675            Self::from_store_with_cap_base(store, location, url, CHUNK_CACHE_MAX_BYTES)
676        }
677
678        /// [`Self::from_store`] with an explicit chunk-cache floor. Tests pass
679        /// a tiny `cap_base` so a small fixture's row group exceeds it, which
680        /// exercises the eviction path (and the #261 refetch pathology) at
681        /// unit-test scale instead of needing a >256 MiB object.
682        pub(crate) fn from_store_with_cap_base(
683            store: Arc<dyn ObjectStore>,
684            location: ObjectPath,
685            url: String,
686            cap_base: u64,
687        ) -> Result<Self, InputError> {
688            let head = runtime()
689                .block_on(store.head(&location))
690                .map_err(|e| store_error(&url, e))?;
691            Ok(Self::from_store_sized_with_cap_base(
692                store, location, url, head.size, cap_base,
693            ))
694        }
695
696        /// [`Self::from_store`] with the object size already known (from a
697        /// prefix listing) — skips the HEAD request, so connecting N listed
698        /// parts costs no network round-trips at all.
699        pub(crate) fn from_store_sized(
700            store: Arc<dyn ObjectStore>,
701            location: ObjectPath,
702            url: String,
703            size: u64,
704        ) -> Self {
705            Self::from_store_sized_with_cap_base(store, location, url, size, CHUNK_CACHE_MAX_BYTES)
706        }
707
708        fn from_store_sized_with_cap_base(
709            store: Arc<dyn ObjectStore>,
710            location: ObjectPath,
711            url: String,
712            size: u64,
713            cap_base: u64,
714        ) -> Self {
715            Self {
716                url,
717                store,
718                location,
719                size,
720                shared: Arc::new(SharedState::default()),
721                metadata: OnceLock::new(),
722                cache: Arc::new(Mutex::new(ChunkCache::new(cap_base))),
723                spill: Arc::new(Mutex::new(DiskSpill::default())),
724                cap_base,
725            }
726        }
727
728        /// The input URL.
729        pub fn url(&self) -> &str {
730            &self.url
731        }
732
733        /// Open a parquet reader builder; the parsed footer is cached across
734        /// opens so multi-pass pipelines fetch it once.
735        pub(crate) fn open_builder(
736            &self,
737        ) -> Result<ParquetRecordBatchReaderBuilder<InputReader>, InputError> {
738            let reader = InputReader::Remote(self.reader());
739            let metadata = match self.metadata.get() {
740                Some(md) => md.clone(),
741                None => {
742                    let md = ArrowReaderMetadata::load(&reader, ArrowReaderOptions::new())?;
743                    // A concurrent open may have won the race; either copy
744                    // is equivalent.
745                    let _ = self.metadata.set(md.clone());
746                    md
747                }
748            };
749            // Column-chunk byte ranges (sorted) for readahead clamping.
750            self.shared.chunk_ranges.get_or_init(|| {
751                let mut ranges: Vec<Range<u64>> = metadata
752                    .metadata()
753                    .row_groups()
754                    .iter()
755                    .flat_map(|rg| {
756                        rg.columns().iter().map(|col| {
757                            let (start, len) = col.byte_range();
758                            start..start + len
759                        })
760                    })
761                    .collect();
762                ranges.sort_by_key(|r| r.start);
763                ranges
764            });
765            // Size the chunk-cache eviction budget to the largest row group's
766            // working set (#261). The arrow reader interleaves a row group's
767            // projected column chunks across batches, so if their combined size
768            // exceeds the cache the reader thrashes — a chunk is evicted and
769            // re-fetched on the next batch. A remote input whose geometry
770            // column chunk alone dwarfs the 256 MiB floor then re-fetches that
771            // chunk on every page read (measured 96× on fieldmaps-adm4, whose
772            // 3 row groups each carry a 1.3 GiB geometry chunk). Holding one
773            // row group's chunks resident bounds the fetch to ≈ 1× per pass;
774            // memory stays O(largest row group), which the whole-chunk fetch
775            // already materializes to serve a range.
776            let max_row_group: u64 = metadata
777                .metadata()
778                .row_groups()
779                .iter()
780                .map(|rg| {
781                    rg.columns()
782                        .iter()
783                        .map(|col| col.compressed_size().max(0) as u64)
784                        .sum::<u64>()
785                })
786                .max()
787                .unwrap_or(0);
788            {
789                let mut cache = self.cache.lock().expect("chunk cache lock");
790                cache.cap = self.cap_base.max(max_row_group);
791            }
792            Ok(ParquetRecordBatchReaderBuilder::new_with_metadata(
793                reader, metadata,
794            ))
795        }
796
797        /// A cheap handle for issuing counted range requests.
798        pub(crate) fn reader(&self) -> RemoteReader {
799            RemoteReader {
800                store: Arc::clone(&self.store),
801                location: self.location.clone(),
802                size: self.size,
803                shared: Arc::clone(&self.shared),
804                cache: Arc::clone(&self.cache),
805                spill: Arc::clone(&self.spill),
806            }
807        }
808
809        /// Place the disk spill's (anonymous) file in `dir` — issue #272,
810        /// see [`InputSource::set_spill_dir`]. Shared by every reader clone.
811        ///
812        /// [`InputSource::set_spill_dir`]: super::InputSource::set_spill_dir
813        pub(crate) fn set_spill_dir(&self, dir: Option<&Path>) {
814            self.spill.lock().expect("spill lock").dir = dir.map(Path::to_path_buf);
815        }
816
817        /// Parse (and cache) the footer, returning the shared metadata.
818        /// Mirrors the load in [`Self::open_builder`] so staging (pass 0) does
819        /// not depend on a builder open; the header phase has usually loaded
820        /// it already, in which case this is a cheap clone with no fetch.
821        fn load_footer(&self) -> Result<ArrowReaderMetadata, InputError> {
822            if let Some(md) = self.metadata.get() {
823                return Ok(md.clone());
824            }
825            let reader = InputReader::Remote(self.reader());
826            let md = ArrowReaderMetadata::load(&reader, ArrowReaderOptions::new())?;
827            let _ = self.metadata.set(md.clone());
828            Ok(md)
829        }
830
831        /// Pass 0 (#286/#287): stage the selected row groups to the disk spill
832        /// up front — ONE coalesced range request per row group, several in
833        /// flight at once — so both later passes read entirely from local
834        /// disk.
835        ///
836        /// A row group's column chunks form a contiguous byte span, so each
837        /// row group is fetched as a single large GET and sliced back into the
838        /// per-column-chunk spill entries the reader's L2 path already serves
839        /// from ([`RemoteReader::chunk_data`]). This removes the two
840        /// latency-bound patterns the demo hit: the per-column-chunk, per-pass
841        /// serial re-fetch (#287, reader kept ~1 request in flight), and in
842        /// particular pass 2's cold re-fetch of the property columns that pass
843        /// 1's geometry+ranking projection skipped (#286, ~10 small serial
844        /// range requests per row group).
845        ///
846        /// `selected` (`None` = every row group) is the SAME per-part bbox
847        /// row-group selection the passes read, so pruned row groups are still
848        /// never touched and total network traffic stays ≈1× the object
849        /// (#219); no speculative over-fetch. Each span counts as one request
850        /// so callers observe the coalesced pattern. Best-effort by
851        /// construction: a chunk that fails to spill (spill disabled / out of
852        /// space, #272) simply falls back to the reader's network path on
853        /// first touch, so a spill write is never fatal — only an actual fetch
854        /// error, which the passes would hit anyway, is surfaced.
855        pub(crate) fn stage_row_groups(
856            &self,
857            selected: Option<&[usize]>,
858        ) -> Result<(), InputError> {
859            let metadata = self.load_footer()?;
860            let row_groups = metadata.metadata().row_groups();
861
862            let indices: Vec<usize> = match selected {
863                Some(sel) => sel.to_vec(),
864                None => (0..row_groups.len()).collect(),
865            };
866            let plan: Vec<StagedRowGroup> = indices
867                .into_iter()
868                .filter_map(|i| row_groups.get(i))
869                .filter_map(|rg| {
870                    let mut start = u64::MAX;
871                    let mut end = 0u64;
872                    let mut chunks = Vec::with_capacity(rg.columns().len());
873                    for col in rg.columns() {
874                        let (s, len) = col.byte_range();
875                        start = start.min(s);
876                        end = end.max(s + len);
877                        chunks.push((s, len as usize));
878                    }
879                    (end > start).then_some(StagedRowGroup {
880                        span: start..end,
881                        chunks,
882                    })
883                })
884                .collect();
885            if plan.is_empty() {
886                return Ok(());
887            }
888
889            // Bound in-flight (resident) spans by the memory budget: holding
890            // one row group transiently matches the L1 cache ceiling, so never
891            // hold more than the budget's worth at once.
892            let max_span = plan
893                .iter()
894                .map(|r| r.span.end - r.span.start)
895                .max()
896                .unwrap_or(0);
897            let concurrency = match max_span {
898                0 => 1,
899                s => ((STAGE_MEM_BUDGET / s).max(1) as usize).min(STAGE_MAX_CONCURRENCY),
900            };
901
902            let store = Arc::clone(&self.store);
903            let location = self.location.clone();
904            let spill = Arc::clone(&self.spill);
905            let shared = Arc::clone(&self.shared);
906
907            runtime().block_on(async move {
908                use futures_util::stream::StreamExt;
909                let mut inflight = futures_util::stream::iter(plan.into_iter().map(|rg| {
910                    let store = Arc::clone(&store);
911                    let location = location.clone();
912                    async move {
913                        let bytes = store
914                            .get_range(&location, rg.span.clone())
915                            .await
916                            .map_err(|e| ParquetError::External(Box::new(e)))?;
917                        Ok::<(StagedRowGroup, Bytes), ParquetError>((rg, bytes))
918                    }
919                }))
920                .buffer_unordered(concurrency);
921
922                while let Some(result) = inflight.next().await {
923                    let (rg, bytes) = result?;
924                    // Count the coalesced request (mirrors `fetch`).
925                    shared.requests.fetch_add(1, Ordering::Relaxed);
926                    shared
927                        .bytes
928                        .fetch_add(bytes.len() as u64, Ordering::Relaxed);
929                    shared
930                        .ranges
931                        .lock()
932                        .expect("ranges lock")
933                        .push(rg.span.clone());
934                    // Slice into per-column-chunk spill entries. Guard the
935                    // slice bounds: a well-behaved store returns exactly the
936                    // requested span, but a short read must fall back to the
937                    // reader's network path (skip spilling), never panic.
938                    let base = rg.span.start;
939                    let mut spill = spill.lock().expect("spill lock");
940                    for (chunk_start, len) in rg.chunks {
941                        let off = (chunk_start - base) as usize;
942                        if off + len <= bytes.len() {
943                            spill.put(chunk_start, &bytes.slice(off..off + len));
944                        }
945                    }
946                }
947                Ok::<(), ParquetError>(())
948            })?;
949
950            Ok(())
951        }
952
953        /// Snapshot of the fetch counters.
954        pub fn fetch_stats(&self) -> FetchStats {
955            FetchStats {
956                requests: self.shared.requests.load(Ordering::Relaxed),
957                bytes_fetched: self.shared.bytes.load(Ordering::Relaxed),
958                object_size: self.size,
959            }
960        }
961
962        /// The byte ranges fetched so far, in request order.
963        pub fn fetched_ranges(&self) -> Vec<Range<u64>> {
964            self.shared.ranges.lock().expect("ranges lock").clone()
965        }
966
967        /// Drop the in-memory (L1) chunk cache. The disk spill (L2) and the
968        /// cached footer are KEPT: a later pass re-reads spilled chunks from
969        /// local disk, never the network. Multi-partition streams call this
970        /// on part transitions so resident memory stays O(one part's row
971        /// group) instead of O(parts × cap) (v0.7 multi-partition input).
972        pub fn release_read_cache(&self) {
973            let mut cache = self.cache.lock().expect("chunk cache lock");
974            cache.entries.clear();
975            cache.order.clear();
976            cache.total = 0;
977        }
978    }
979
980    /// Map an object-store error, attaching the input URL.
981    fn store_error(url: &str, source: object_store::Error) -> InputError {
982        store_error_with_hint(url, source, custom_endpoint_configured())
983    }
984
985    /// Whether a custom (non-AWS) S3 endpoint is configured via the env
986    /// vars `AmazonS3Builder::from_env` honors.
987    fn custom_endpoint_configured() -> bool {
988        std::env::var_os("AWS_ENDPOINT_URL").is_some() || std::env::var_os("AWS_ENDPOINT").is_some()
989    }
990
991    /// Whether `text` (an object-store error rendering) looks like an S3
992    /// signature/authorization failure — the shape an anonymous
993    /// S3-compatible endpoint produces when ambient AWS credentials sign
994    /// requests it cannot verify.
995    fn looks_like_signature_error(text: &str) -> bool {
996        const MARKERS: [&str; 5] = [
997            "403",
998            "forbidden",
999            "invalidaccesskeyid",
1000            "signaturedoesnotmatch",
1001            "accessdenied",
1002        ];
1003        let lower = text.to_ascii_lowercase();
1004        MARKERS.iter().any(|m| lower.contains(m))
1005    }
1006
1007    /// [`store_error`] with the endpoint check injected (test seam). With a
1008    /// custom endpoint configured, a signature/authorization-style failure
1009    /// appends the `AWS_SKIP_SIGNATURE=true` hint — string-level, in the
1010    /// Display path only (the error types are unchanged): resolved AWS
1011    /// credentials are signed into every request, and an anonymous
1012    /// S3-compatible endpoint rejects them with exactly this error shape.
1013    fn store_error_with_hint(
1014        url: &str,
1015        source: object_store::Error,
1016        custom_endpoint: bool,
1017    ) -> InputError {
1018        if custom_endpoint {
1019            let text = source.to_string();
1020            if looks_like_signature_error(&text) {
1021                return InputError::RemoteConfig(format!(
1022                    "remote input error for {url}: {text} — if this S3-compatible \
1023                     endpoint serves anonymous (unsigned) requests, retry with \
1024                     AWS_SKIP_SIGNATURE=true"
1025                ));
1026            }
1027        }
1028        InputError::Remote {
1029            url: url.to_string(),
1030            source,
1031        }
1032    }
1033
1034    /// Build an S3 store for `url` with the standard AWS credential chain
1035    /// (env, shared config/credentials incl. `AWS_PROFILE`, SSO, IMDS —
1036    /// what DuckDB's `credential_chain` provider and gpio users expect).
1037    /// If the chain resolves no credentials the store falls back to
1038    /// unsigned requests, so public buckets work anonymously.
1039    fn build_s3(url: &Url) -> Result<Arc<dyn ObjectStore>, InputError> {
1040        use aws_credential_types::provider::ProvideCredentials;
1041
1042        // `from_env` honors explicit AWS_* env overrides (region, endpoint,
1043        // static keys); `with_url` extracts the bucket (and, for
1044        // virtual-hosted https URLs, the region embedded in the host).
1045        let mut builder = AmazonS3Builder::from_env().with_url(url.to_string());
1046
1047        let sdk_config =
1048            runtime().block_on(aws_config::defaults(aws_config::BehaviorVersion::latest()).load());
1049
1050        // Region: explicit env (AWS_REGION / AWS_DEFAULT_REGION) wins, then
1051        // the profile/IMDS-resolved region from the SDK chain.
1052        let env_region = std::env::var("AWS_REGION")
1053            .or_else(|_| std::env::var("AWS_DEFAULT_REGION"))
1054            .ok();
1055        match (&env_region, sdk_config.region()) {
1056            (Some(r), _) => builder = builder.with_region(r.clone()),
1057            (None, Some(r)) => builder = builder.with_region(r.as_ref()),
1058            (None, None) => {
1059                return Err(InputError::RemoteConfig(format!(
1060                    "no AWS region configured for {url}: set AWS_REGION (e.g. \
1061                     AWS_REGION=us-east-2) or add a region to your AWS profile"
1062                )));
1063            }
1064        }
1065
1066        // Credentials: probe the chain once; if it resolves, install a
1067        // refreshing bridge provider (SSO/STS credentials expire), else go
1068        // unsigned for public buckets.
1069        let mut anonymous = true;
1070        if let Some(provider) = sdk_config.credentials_provider() {
1071            match runtime().block_on(provider.provide_credentials()) {
1072                Ok(_) => {
1073                    builder = builder.with_credentials(Arc::new(SdkCredentialBridge(provider)));
1074                    anonymous = false;
1075                }
1076                Err(e) => {
1077                    log::warn!(
1078                        "no AWS credentials resolved ({e}); \
1079                         falling back to unsigned (anonymous) S3 requests"
1080                    );
1081                }
1082            }
1083        }
1084        if anonymous {
1085            builder = builder.with_skip_signature(true);
1086        }
1087
1088        Ok(Arc::new(builder.build().map_err(|e| {
1089            InputError::RemoteConfig(format!("cannot configure S3 store for {url}: {e}"))
1090        })?))
1091    }
1092
1093    /// Bridges the AWS SDK credential chain (profiles, SSO, IMDS, ...) into
1094    /// object_store's credential provider, re-resolving on each request so
1095    /// expiring credentials refresh (the SDK chain caches internally).
1096    #[derive(Debug)]
1097    struct SdkCredentialBridge(aws_credential_types::provider::SharedCredentialsProvider);
1098
1099    #[async_trait::async_trait]
1100    impl CredentialProvider for SdkCredentialBridge {
1101        type Credential = AwsCredential;
1102
1103        async fn get_credential(&self) -> object_store::Result<Arc<AwsCredential>> {
1104            use aws_credential_types::provider::ProvideCredentials;
1105            let creds =
1106                self.0
1107                    .provide_credentials()
1108                    .await
1109                    .map_err(|e| object_store::Error::Generic {
1110                        store: "S3",
1111                        source: Box::new(e),
1112                    })?;
1113            Ok(Arc::new(AwsCredential {
1114                key_id: creds.access_key_id().to_string(),
1115                secret_key: creds.secret_access_key().to_string(),
1116                token: creds.session_token().map(str::to_string),
1117            }))
1118        }
1119    }
1120
1121    /// Floor for the per-reader column-chunk fetch cache. The true working set
1122    /// is one row group's compressed column chunks (the arrow reader
1123    /// interleaves the columns of the row group it is decoding), so
1124    /// [`RemoteSource::open_builder`] raises the live cap to the largest row
1125    /// group's working set when that exceeds this floor — otherwise a row group
1126    /// bigger than the cache thrashes, re-fetching an evicted chunk on the next
1127    /// batch (issue #261). This constant is just the small-input floor.
1128    const CHUNK_CACHE_MAX_BYTES: u64 = 256 * 1024 * 1024;
1129
1130    /// Staging (pass 0, #286/#287) concurrency ceiling: at most this many
1131    /// row-group spans are fetched — and thus resident — at once. The live
1132    /// concurrency is the lesser of this and [`STAGE_MEM_BUDGET`] divided by
1133    /// the largest span, so a file of huge row groups never holds more than
1134    /// the budget's worth of in-flight spans in memory.
1135    const STAGE_MAX_CONCURRENCY: usize = 8;
1136
1137    /// Transient-memory budget bounding staging concurrency. One row group's
1138    /// working set is already the in-memory chunk-cache ceiling (#261 sizes
1139    /// the cache to the largest row group), so reuse that figure: staging
1140    /// never holds more than a budget's worth of in-flight spans at once.
1141    const STAGE_MEM_BUDGET: u64 = CHUNK_CACHE_MAX_BYTES;
1142
1143    /// One selected row group's staging plan: its contiguous byte span and
1144    /// the `(chunk_start, len)` of every column chunk inside it — keyed
1145    /// exactly how the L2 spill and [`RemoteReader::chunk_data`] look chunks
1146    /// up (`chunk.start`), so a staged slice is a drop-in L2 hit.
1147    struct StagedRowGroup {
1148        span: Range<u64>,
1149        chunks: Vec<(u64, usize)>,
1150    }
1151
1152    /// On-disk overflow for fetched column chunks (issue #219).
1153    ///
1154    /// The in-memory [`ChunkCache`] holds only one row group's working set, so
1155    /// across the streaming pipeline's multiple passes (assign, coarse levels,
1156    /// finest streamed last) a chunk evicted from memory is otherwise
1157    /// re-fetched over the network — paying remote bandwidth 2–3× for the bulk
1158    /// of the file (measured 3.0× on fieldmaps-adm4). Spilling every fetched
1159    /// chunk to a local temp file and draining re-reads from disk bounds remote
1160    /// traffic to ≈1× the object, regardless of pass or level count.
1161    ///
1162    /// The temp file is anonymous ([`tempfile::tempfile`]): it is unlinked on
1163    /// creation, so it never appears in the filesystem and the OS reclaims its
1164    /// space when the last handle drops. Its directory follows `TMPDIR`; point
1165    /// that at real disk if the default temp dir is a small tmpfs.
1166    ///
1167    /// Best-effort: if the temp file cannot be created or an I/O op fails, the
1168    /// spill disables itself (logging once) and the reader falls back to
1169    /// network re-fetch — correctness is unaffected, only the re-fetch cost
1170    /// returns. Access is serialized by the enclosing `Mutex`; the pipeline's
1171    /// passes read the input sequentially, so lock contention is negligible.
1172    #[derive(Debug, Default)]
1173    struct DiskSpill {
1174        /// Lazily created on the first spilled chunk; `None` until then, or
1175        /// left `None` after a spill error disables the cache.
1176        file: Option<File>,
1177        /// `chunk.start` → (offset within the spill file, byte length).
1178        index: std::collections::HashMap<u64, (u64, usize)>,
1179        /// Append cursor: total bytes written to the spill file so far.
1180        write_offset: u64,
1181        /// Set after a create/read/write error so we stop touching disk.
1182        disabled: bool,
1183        /// Directory for the (anonymous) spill file — `--spill-dir`, issue
1184        /// #272. `None` follows the process temp dir (`$TMPDIR`). Honored
1185        /// at file creation, i.e. on the first spilled chunk.
1186        dir: Option<PathBuf>,
1187    }
1188
1189    /// #273: the warning to emit when `dir` (the resolved spill directory)
1190    /// lives on a RAM-backed filesystem, or `None` when it is real disk or
1191    /// detection is unavailable (best-effort). Spilling to tmpfs/ramfs trades
1192    /// network bytes for memory pressure, defeating the spill and risking OOM.
1193    /// Split out so the wording is unit-testable without capturing a logger.
1194    fn ram_backed_spill_warning(dir: &Path) -> Option<String> {
1195        (crate::fs_probe::is_ram_backed(dir) == Some(true)).then(|| {
1196            format!(
1197                "input spill directory {} is on a RAM-backed filesystem \
1198                 (tmpfs/ramfs); spilling there consumes memory instead of disk \
1199                 and can OOM on large inputs — point --spill-dir (or $TMPDIR) at \
1200                 a real-disk location",
1201                dir.display()
1202            )
1203        })
1204    }
1205
1206    impl DiskSpill {
1207        /// Serve a previously spilled chunk, if present. `None` means "not
1208        /// spilled — fetch it over the network"; a read error disables the
1209        /// spill and also returns `None` so the caller falls back to fetch.
1210        fn get(&mut self, chunk_start: u64) -> Option<Bytes> {
1211            if self.disabled {
1212                return None;
1213            }
1214            let (offset, len) = *self.index.get(&chunk_start)?;
1215            let file = self.file.as_mut()?;
1216            let mut buf = vec![0u8; len];
1217            match file
1218                .seek(SeekFrom::Start(offset))
1219                .and_then(|_| file.read_exact(&mut buf))
1220            {
1221                Ok(()) => Some(Bytes::from(buf)),
1222                Err(e) => {
1223                    log::warn!("input spill read failed ({e}); falling back to network re-fetch");
1224                    self.disabled = true;
1225                    None
1226                }
1227            }
1228        }
1229
1230        /// Record a freshly fetched chunk on disk for later passes. No-op if
1231        /// the spill is disabled or already holds this chunk; a create/write
1232        /// error disables the spill.
1233        fn put(&mut self, chunk_start: u64, data: &Bytes) {
1234            if self.disabled || self.index.contains_key(&chunk_start) {
1235                return;
1236            }
1237            if self.file.is_none() {
1238                // #273: warn once (the spill file is created exactly once) when
1239                // the resolved directory is RAM-backed. `tempfile()` follows
1240                // `std::env::temp_dir()` ($TMPDIR or /tmp) when no dir is set.
1241                let resolved = self.dir.clone().unwrap_or_else(std::env::temp_dir);
1242                if let Some(msg) = ram_backed_spill_warning(&resolved) {
1243                    log::warn!("{msg}");
1244                }
1245                let created = match self.dir.as_deref() {
1246                    Some(d) => tempfile::tempfile_in(d),
1247                    None => tempfile::tempfile(),
1248                };
1249                match created {
1250                    Ok(f) => self.file = Some(f),
1251                    Err(e) => {
1252                        let dir = self
1253                            .dir
1254                            .clone()
1255                            .unwrap_or_else(std::env::temp_dir)
1256                            .display()
1257                            .to_string();
1258                        log::warn!(
1259                            "could not create input spill file in {dir} ({e}); remote \
1260                             re-reads will re-fetch over the network"
1261                        );
1262                        self.disabled = true;
1263                        return;
1264                    }
1265                }
1266            }
1267            let offset = self.write_offset;
1268            let file = self.file.as_mut().expect("spill file present");
1269            if let Err(e) = file
1270                .seek(SeekFrom::Start(offset))
1271                .and_then(|_| file.write_all(data))
1272            {
1273                log::warn!("input spill write failed ({e}); falling back to network re-fetch");
1274                self.disabled = true;
1275                return;
1276            }
1277            self.write_offset += data.len() as u64;
1278            self.index.insert(chunk_start, (offset, data.len()));
1279        }
1280    }
1281
1282    /// Per-reader cache of whole column chunks, insertion-ordered for
1283    /// eviction. This is the "buffered range-fetch adapter": the page reader
1284    /// asks for a column chunk's bytes in many small pieces (a thrift page
1285    /// header via `get_read`, then each page via `get_bytes`); fetching the
1286    /// whole chunk on first touch turns that into ONE range request per
1287    /// selected column chunk. Chunks of bbox-pruned row groups are never
1288    /// touched, so they are still never fetched.
1289    ///
1290    /// `cap` is the eviction budget. It starts at the [`CHUNK_CACHE_MAX_BYTES`]
1291    /// floor and is raised at open time to the largest row group's working set
1292    /// ([`RemoteSource::open_builder`]) so that a row group whose column chunks
1293    /// exceed the floor is never evicted mid-read — the fix for issue #261,
1294    /// where a >256 MiB geometry chunk was evicted on insert and re-fetched
1295    /// on every page read (measured 96× re-fetch on a vertex-heavy input).
1296    #[derive(Debug)]
1297    struct ChunkCache {
1298        entries: std::collections::HashMap<u64, (Range<u64>, Bytes)>,
1299        order: std::collections::VecDeque<u64>,
1300        total: u64,
1301        cap: u64,
1302    }
1303
1304    impl ChunkCache {
1305        fn new(cap: u64) -> Self {
1306            ChunkCache {
1307                entries: std::collections::HashMap::new(),
1308                order: std::collections::VecDeque::new(),
1309                total: 0,
1310                cap,
1311            }
1312        }
1313    }
1314
1315    /// Range-request reader over one remote object. Cloneable; all clones
1316    /// share the source's counters (the chunk cache too — it lives per
1317    /// source, so multi-pass pipelines could reuse it, though in practice
1318    /// eviction keeps it near one row group).
1319    #[derive(Debug, Clone)]
1320    pub struct RemoteReader {
1321        store: Arc<dyn ObjectStore>,
1322        location: ObjectPath,
1323        size: u64,
1324        shared: Arc<SharedState>,
1325        cache: Arc<Mutex<ChunkCache>>,
1326        spill: Arc<Mutex<DiskSpill>>,
1327    }
1328
1329    impl RemoteReader {
1330        /// Total object size (the [`parquet::file::reader::Length`] answer).
1331        pub(crate) fn object_size(&self) -> u64 {
1332            self.size
1333        }
1334
1335        /// One counted range GET.
1336        fn fetch(&self, range: Range<u64>) -> Result<Bytes, ParquetError> {
1337            let bytes = runtime()
1338                .block_on(self.store.get_range(&self.location, range.clone()))
1339                .map_err(|e| ParquetError::External(Box::new(e)))?;
1340            self.shared.requests.fetch_add(1, Ordering::Relaxed);
1341            self.shared
1342                .bytes
1343                .fetch_add(bytes.len() as u64, Ordering::Relaxed);
1344            self.shared.ranges.lock().expect("ranges lock").push(range);
1345            Ok(bytes)
1346        }
1347
1348        /// The column chunk containing `start..end`, if the footer is parsed
1349        /// and the range falls entirely inside one chunk.
1350        fn chunk_containing(&self, start: u64, end: u64) -> Option<Range<u64>> {
1351            let chunks = self.shared.chunk_ranges.get()?;
1352            let idx = chunks.partition_point(|r| r.start <= start);
1353            let chunk = chunks.get(idx.checked_sub(1)?)?;
1354            (start >= chunk.start && end <= chunk.end).then(|| chunk.clone())
1355        }
1356
1357        /// Bytes of a whole column chunk, served from the cheapest tier that
1358        /// holds it: the in-memory cache (L1), the local disk spill (L2, #219),
1359        /// or a single network range request (L3). A byte therefore crosses the
1360        /// network at most once across the pipeline's passes.
1361        fn chunk_data(&self, chunk: &Range<u64>) -> Result<Bytes, ParquetError> {
1362            // L1: in-memory cache (hot, one row group's working set).
1363            {
1364                let cache = self.cache.lock().expect("chunk cache lock");
1365                if let Some((_, data)) = cache.entries.get(&chunk.start) {
1366                    return Ok(data.clone());
1367                }
1368            }
1369            // L2: local disk spill. A hit avoids re-fetching over the network on
1370            // a later pass; re-warm L1 so same-pass touches stay in memory.
1371            if let Some(data) = self.spill.lock().expect("spill lock").get(chunk.start) {
1372                self.cache_insert(chunk, &data);
1373                return Ok(data);
1374            }
1375            // L3: network. Fetch once, then spill the bytes for later passes.
1376            let data = self.fetch(chunk.clone())?;
1377            self.spill
1378                .lock()
1379                .expect("spill lock")
1380                .put(chunk.start, &data);
1381            self.cache_insert(chunk, &data);
1382            Ok(data)
1383        }
1384
1385        /// Insert a chunk into the in-memory L1 cache, evicting in insertion
1386        /// order until the working-set budget (`cap`, sized to the largest row
1387        /// group in [`RemoteSource::open_builder`]) is respected.
1388        fn cache_insert(&self, chunk: &Range<u64>, data: &Bytes) {
1389            let mut cache = self.cache.lock().expect("chunk cache lock");
1390            if !cache.entries.contains_key(&chunk.start) {
1391                cache.total += data.len() as u64;
1392                cache
1393                    .entries
1394                    .insert(chunk.start, (chunk.clone(), data.clone()));
1395                cache.order.push_back(chunk.start);
1396                while cache.total > cache.cap {
1397                    let Some(oldest) = cache.order.pop_front() else {
1398                        break;
1399                    };
1400                    if let Some((_, evicted)) = cache.entries.remove(&oldest) {
1401                        cache.total -= evicted.len() as u64;
1402                    }
1403                }
1404            }
1405        }
1406
1407        /// Exact-range read for [`parquet::file::reader::ChunkReader::get_bytes`].
1408        pub(crate) fn get_bytes_range(
1409            &self,
1410            start: u64,
1411            length: usize,
1412        ) -> Result<Bytes, ParquetError> {
1413            let end = start
1414                .checked_add(length as u64)
1415                .filter(|end| *end <= self.size)
1416                .ok_or_else(|| {
1417                    ParquetError::EOF(format!(
1418                        "range {start}..{} beyond object size {} for {}",
1419                        start as u128 + length as u128,
1420                        self.size,
1421                        self.location
1422                    ))
1423                })?;
1424            if length == 0 {
1425                return Ok(Bytes::new());
1426            }
1427            // Page reads land inside a column chunk: serve them from the
1428            // whole-chunk buffer (one request per chunk). Everything else
1429            // (footer tail, metadata) is fetched exactly.
1430            if let Some(chunk) = self.chunk_containing(start, end) {
1431                let data = self.chunk_data(&chunk)?;
1432                let offset = (start - chunk.start) as usize;
1433                return Ok(data.slice(offset..offset + length));
1434            }
1435            self.fetch(start..end)
1436        }
1437
1438        /// Chunked sequential reader for
1439        /// [`parquet::file::reader::ChunkReader::get_read`] (not used by the
1440        /// arrow reader path, provided for trait completeness).
1441        pub(crate) fn sequential_reader(&self, start: u64) -> SequentialRemoteRead {
1442            SequentialRemoteRead {
1443                reader: self.clone(),
1444                pos: start,
1445                buf: Bytes::new(),
1446                buf_offset: 0,
1447            }
1448        }
1449    }
1450
1451    /// `Read` adapter fetching forward in [`SEQUENTIAL_CHUNK`] steps, each
1452    /// step clamped to the column chunk containing the read position.
1453    pub struct SequentialRemoteRead {
1454        reader: RemoteReader,
1455        pos: u64,
1456        buf: Bytes,
1457        buf_offset: usize,
1458    }
1459
1460    impl std::io::Read for SequentialRemoteRead {
1461        fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
1462            if self.buf_offset >= self.buf.len() {
1463                let remaining = self.reader.size.saturating_sub(self.pos);
1464                if remaining == 0 {
1465                    return Ok(0);
1466                }
1467                // Inside a column chunk (the page-header read path): serve
1468                // the rest of the chunk from the whole-chunk buffer.
1469                if let Some(chunk) = self.reader.chunk_containing(self.pos, self.pos + 1) {
1470                    let data = self
1471                        .reader
1472                        .chunk_data(&chunk)
1473                        .map_err(std::io::Error::other)?;
1474                    let offset = (self.pos - chunk.start) as usize;
1475                    self.buf = data.slice(offset..);
1476                    self.buf_offset = 0;
1477                    self.pos = chunk.end;
1478                } else {
1479                    let want_end = self.pos + remaining.min(SEQUENTIAL_CHUNK);
1480                    // Never read across a column-chunk boundary: bytes past
1481                    // it may belong to a bbox-pruned row group that must not
1482                    // be downloaded.
1483                    let end = self.reader.shared.clamp_to_chunk(self.pos, want_end);
1484                    self.buf = self
1485                        .reader
1486                        .fetch(self.pos..end)
1487                        .map_err(std::io::Error::other)?;
1488                    self.buf_offset = 0;
1489                    self.pos = end;
1490                }
1491            }
1492            let n = out.len().min(self.buf.len() - self.buf_offset);
1493            out[..n].copy_from_slice(&self.buf[self.buf_offset..self.buf_offset + n]);
1494            self.buf_offset += n;
1495            Ok(n)
1496        }
1497    }
1498
1499    #[cfg(test)]
1500    mod spill_dir_tests {
1501        use super::*;
1502
1503        /// #272: a configured spill directory is where the spill file is
1504        /// created. The file is anonymous (unlinked on creation), so the
1505        /// dir is observed through behavior: with a valid dir the put/get
1506        /// roundtrip works end to end.
1507        #[test]
1508        fn disk_spill_writes_into_configured_dir() {
1509            let dir = tempfile::tempdir().unwrap();
1510            let mut spill = DiskSpill {
1511                dir: Some(dir.path().to_path_buf()),
1512                ..DiskSpill::default()
1513            };
1514            spill.put(0, &Bytes::from_static(b"hello spill"));
1515            assert_eq!(
1516                spill.get(0).as_deref(),
1517                Some(&b"hello spill"[..]),
1518                "spill roundtrip through the configured dir"
1519            );
1520        }
1521
1522        /// #272 counterpart: a nonexistent configured dir makes file
1523        /// creation fail, which disables the spill (best-effort, #219) —
1524        /// proof the configured dir, not $TMPDIR, is what `put` uses.
1525        #[test]
1526        fn disk_spill_nonexistent_dir_disables_spill() {
1527            let mut spill = DiskSpill {
1528                dir: Some(std::path::PathBuf::from(
1529                    "/nonexistent/tylertoo-spill-dir-272",
1530                )),
1531                ..DiskSpill::default()
1532            };
1533            spill.put(0, &Bytes::from_static(b"hello spill"));
1534            assert!(
1535                spill.get(0).is_none(),
1536                "create failure must disable the spill"
1537            );
1538        }
1539
1540        /// #273: a RAM-backed spill directory produces a warning naming the
1541        /// directory and pointing at `--spill-dir`. `/dev/shm` is tmpfs on
1542        /// essentially every Linux system.
1543        #[cfg(target_os = "linux")]
1544        #[test]
1545        fn ram_backed_dir_warns_naming_dir_and_flag() {
1546            let shm = std::path::Path::new("/dev/shm");
1547            if !shm.exists() {
1548                eprintln!("skipping ram_backed_dir_warns: /dev/shm absent");
1549                return;
1550            }
1551            let msg = ram_backed_spill_warning(shm)
1552                .expect("/dev/shm must produce a RAM-backed spill warning");
1553            assert!(msg.contains("/dev/shm"), "names the directory: {msg}");
1554            assert!(msg.contains("--spill-dir"), "suggests --spill-dir: {msg}");
1555            assert!(
1556                msg.contains("RAM-backed"),
1557                "explains the RAM-backed hazard: {msg}"
1558            );
1559        }
1560
1561        /// #273 counterpart: a real-disk directory is not flagged, so the
1562        /// spill path stays silent and functional.
1563        #[test]
1564        fn real_disk_dir_does_not_warn() {
1565            let dir = tempfile::tempdir().unwrap();
1566            // Best-effort: on a machine whose $TMPDIR is tmpfs this could be
1567            // Some(_); guard on the detection result to stay non-flaky.
1568            if crate::fs_probe::is_ram_backed(dir.path()) == Some(true) {
1569                eprintln!("skipping real_disk_dir_does_not_warn: tempdir is tmpfs");
1570                return;
1571            }
1572            assert!(
1573                ram_backed_spill_warning(dir.path()).is_none(),
1574                "a non-RAM-backed dir must not warn"
1575            );
1576        }
1577    }
1578
1579    #[cfg(test)]
1580    mod hint_tests {
1581        use super::*;
1582
1583        fn signature_shaped_error() -> object_store::Error {
1584            object_store::Error::Generic {
1585                store: "S3",
1586                source: "Client error with status 403 Forbidden: \
1587                         <Code>InvalidAccessKeyId</Code>"
1588                    .into(),
1589            }
1590        }
1591
1592        /// PR-C: a signature/authorization-style failure against a custom
1593        /// S3-compatible endpoint appends the AWS_SKIP_SIGNATURE hint in
1594        /// the error text (string-level; the error types are unchanged).
1595        #[test]
1596        fn signature_error_on_custom_endpoint_appends_skip_signature_hint() {
1597            let msg = store_error_with_hint("s3://b/k.parquet", signature_shaped_error(), true)
1598                .to_string();
1599            assert!(
1600                msg.contains("AWS_SKIP_SIGNATURE=true"),
1601                "hint appended: {msg}"
1602            );
1603            assert!(msg.contains("s3://b/k.parquet"), "keeps the URL: {msg}");
1604            assert!(msg.contains("403"), "keeps the original error: {msg}");
1605        }
1606
1607        /// Without a custom endpoint the error is untouched (the hint is
1608        /// specific to S3-compatible endpoints like anonymous data hosts).
1609        #[test]
1610        fn signature_error_without_custom_endpoint_is_unchanged() {
1611            let msg = store_error_with_hint("s3://b/k.parquet", signature_shaped_error(), false)
1612                .to_string();
1613            assert!(!msg.contains("AWS_SKIP_SIGNATURE"), "no hint: {msg}");
1614            assert!(msg.contains("403"), "original error preserved: {msg}");
1615        }
1616
1617        /// A non-signature failure (e.g. object not found) never grows the
1618        /// hint, custom endpoint or not.
1619        #[test]
1620        fn non_signature_error_never_hints() {
1621            let not_found = object_store::Error::NotFound {
1622                path: "k.parquet".to_string(),
1623                source: "no such key".into(),
1624            };
1625            let msg = store_error_with_hint("s3://b/k.parquet", not_found, true).to_string();
1626            assert!(!msg.contains("AWS_SKIP_SIGNATURE"), "no hint: {msg}");
1627        }
1628    }
1629}
1630
1631/// Test-only: an [`InputSource`] over an in-memory object store seeded with
1632/// `bytes` — the seam remote tests (here and in `overview::convert`) inject
1633/// data through without touching the network.
1634#[cfg(all(test, feature = "remote"))]
1635pub(crate) fn test_memory_source(bytes: Vec<u8>, name: &str) -> InputSource {
1636    use object_store::memory::InMemory;
1637    use object_store::path::Path as ObjectPath;
1638    use object_store::ObjectStoreExt;
1639    use std::sync::Arc;
1640
1641    let store = Arc::new(InMemory::new());
1642    let location = ObjectPath::from(name);
1643    let rt = tokio::runtime::Builder::new_current_thread()
1644        .enable_all()
1645        .build()
1646        .unwrap();
1647    rt.block_on(store.put(&location, bytes.into())).unwrap();
1648    InputSource::Remote(
1649        remote::RemoteSource::from_store(store, location, format!("memory://{name}")).unwrap(),
1650    )
1651}
1652
1653/// Test-only: a multi-partition [`crate::input_set::ConvertSource`] over ONE
1654/// in-memory object store — `objects` are `(basename, bytes)` pairs placed
1655/// under a `set/` prefix and resolved through the REAL prefix-listing path
1656/// (filtering, key sort, shared store instance, sizes from the listing).
1657/// Also returns the per-part [`InputSource`] handles: clones share the fetch
1658/// counters, so tests can assert per-part fetched ranges after a convert.
1659#[cfg(all(test, feature = "remote"))]
1660pub(crate) fn test_memory_multi_source(
1661    objects: Vec<(&str, Vec<u8>)>,
1662) -> (crate::input_set::ConvertSource, Vec<InputSource>) {
1663    use crate::input_set::{ConvertSource, MultiSource};
1664    use object_store::memory::InMemory;
1665    use object_store::path::Path as ObjectPath;
1666    use object_store::{ObjectStore, ObjectStoreExt};
1667    use std::sync::Arc;
1668
1669    let store = Arc::new(InMemory::new());
1670    let rt = tokio::runtime::Builder::new_current_thread()
1671        .enable_all()
1672        .build()
1673        .unwrap();
1674    for (name, bytes) in objects {
1675        rt.block_on(store.put(&ObjectPath::from(format!("set/{name}")), bytes.into()))
1676            .unwrap();
1677    }
1678    let store: Arc<dyn ObjectStore> = store;
1679    let listed =
1680        remote::list_parquet_under_prefix(&store, &ObjectPath::from("set"), "memory://set/")
1681            .unwrap();
1682    let parts: Vec<InputSource> = listed
1683        .into_iter()
1684        .map(|p| {
1685            InputSource::Remote(remote::RemoteSource::from_store_sized(
1686                Arc::clone(&store),
1687                p.location.clone(),
1688                format!("memory://{}", p.location),
1689                p.size,
1690            ))
1691        })
1692        .collect();
1693    let source = if parts.len() == 1 {
1694        ConvertSource::single(parts[0].clone())
1695    } else {
1696        ConvertSource::Multi(
1697            MultiSource::from_sources("memory://set/".to_string(), parts.clone()).unwrap(),
1698        )
1699    };
1700    (source, parts)
1701}
1702
1703/// Test-only: [`test_memory_source`] with an explicit chunk-cache floor, so a
1704/// small fixture whose row group exceeds `cap_base` exercises the eviction /
1705/// re-fetch path at unit-test scale (issue #261 regression coverage).
1706#[cfg(all(test, feature = "remote"))]
1707pub(crate) fn test_memory_source_with_cap(
1708    bytes: Vec<u8>,
1709    name: &str,
1710    cap_base: u64,
1711) -> InputSource {
1712    use object_store::memory::InMemory;
1713    use object_store::path::Path as ObjectPath;
1714    use object_store::ObjectStoreExt;
1715    use std::sync::Arc;
1716
1717    let store = Arc::new(InMemory::new());
1718    let location = ObjectPath::from(name);
1719    let rt = tokio::runtime::Builder::new_current_thread()
1720        .enable_all()
1721        .build()
1722        .unwrap();
1723    rt.block_on(store.put(&location, bytes.into())).unwrap();
1724    InputSource::Remote(
1725        remote::RemoteSource::from_store_with_cap_base(
1726            store,
1727            location,
1728            format!("memory://{name}"),
1729            cap_base,
1730        )
1731        .unwrap(),
1732    )
1733}
1734
1735#[cfg(test)]
1736mod tests {
1737    use super::*;
1738
1739    #[test]
1740    fn local_path_is_local() {
1741        let s = InputSource::from_path(Path::new("/tmp/foo.parquet")).unwrap();
1742        assert!(!s.is_remote());
1743        assert!(s.fetch_stats().is_none());
1744    }
1745
1746    #[test]
1747    fn relative_path_is_local() {
1748        let s = InputSource::from_str_input("data/foo.parquet").unwrap();
1749        assert!(!s.is_remote());
1750    }
1751
1752    #[test]
1753    fn file_url_maps_to_local() {
1754        let s = InputSource::from_str_input("file:///tmp/foo.parquet").unwrap();
1755        match s {
1756            InputSource::Local(p) => assert_eq!(p, PathBuf::from("/tmp/foo.parquet")),
1757            #[cfg(feature = "remote")]
1758            InputSource::Remote(_) => panic!("file:// must be local"),
1759        }
1760    }
1761
1762    #[test]
1763    fn unsupported_scheme_is_a_helpful_error() {
1764        let err = InputSource::from_str_input("ftp://example.com/foo.parquet").unwrap_err();
1765        let msg = err.to_string();
1766        assert!(msg.contains("ftp"), "message names the scheme: {msg}");
1767        assert!(msg.contains("s3://"), "message lists alternatives: {msg}");
1768    }
1769
1770    #[test]
1771    fn windows_style_drive_is_local() {
1772        // `C:\...` has a colon but no `://`; must not be parsed as a URL.
1773        let s = InputSource::from_str_input(r"C:\data\foo.parquet").unwrap();
1774        assert!(!s.is_remote());
1775    }
1776
1777    #[cfg(not(feature = "remote"))]
1778    #[test]
1779    fn remote_url_without_feature_is_a_clear_error() {
1780        let err = InputSource::from_str_input("s3://bucket/key.parquet").unwrap_err();
1781        assert!(err.to_string().contains("remote"), "err: {err}");
1782    }
1783
1784    #[cfg(feature = "remote")]
1785    mod remote_tests {
1786        use super::super::*;
1787
1788        use super::super::test_memory_source as memory_source;
1789
1790        /// Minimal single-column parquet bytes for reader plumbing tests.
1791        fn tiny_parquet() -> Vec<u8> {
1792            use arrow_array::{Int64Array, RecordBatch};
1793            use parquet::arrow::ArrowWriter;
1794            use std::sync::Arc as SArc;
1795
1796            let batch = RecordBatch::try_from_iter([(
1797                "v",
1798                SArc::new(Int64Array::from(vec![1i64, 2, 3])) as _,
1799            )])
1800            .unwrap();
1801            let mut buf = Vec::new();
1802            let mut w = ArrowWriter::try_new(&mut buf, batch.schema(), None).unwrap();
1803            w.write(&batch).unwrap();
1804            w.close().unwrap();
1805            buf
1806        }
1807
1808        #[test]
1809        fn remote_reader_roundtrips_parquet() {
1810            let bytes = tiny_parquet();
1811            let total = bytes.len() as u64;
1812            let source = memory_source(bytes, "tiny.parquet");
1813            assert!(source.is_remote());
1814
1815            let builder = source.open().unwrap();
1816            let reader = builder.build().unwrap();
1817            let rows: usize = reader.map(|b| b.unwrap().num_rows()).sum();
1818            assert_eq!(rows, 3);
1819
1820            let stats = source.fetch_stats().unwrap();
1821            assert_eq!(stats.object_size, total);
1822            assert!(stats.requests >= 2, "footer + data: {stats:?}");
1823            assert!(stats.bytes_fetched > 0);
1824            // Every individual range stays within the object (requests may
1825            // overlap each other: footer suffix then full footer).
1826            for r in source.fetched_ranges().unwrap() {
1827                assert!(r.end <= total, "range {r:?} beyond object size {total}");
1828            }
1829        }
1830
1831        #[test]
1832        fn footer_is_cached_across_opens() {
1833            let source = memory_source(tiny_parquet(), "tiny.parquet");
1834            let _ = source.open().unwrap();
1835            let after_first = source.fetch_stats().unwrap();
1836            let _ = source.open().unwrap();
1837            let after_second = source.fetch_stats().unwrap();
1838            assert_eq!(
1839                after_first.requests, after_second.requests,
1840                "second open must not re-fetch the footer"
1841            );
1842        }
1843
1844        #[test]
1845        fn sequential_read_matches_object_bytes() {
1846            use parquet::file::reader::ChunkReader;
1847            use std::io::Read;
1848
1849            let bytes = tiny_parquet();
1850            let source = memory_source(bytes.clone(), "tiny.parquet");
1851            let InputSource::Remote(ref r) = source else {
1852                unreachable!()
1853            };
1854            let reader = InputReader::Remote(r.reader());
1855            let mut out = Vec::new();
1856            reader.get_read(4).unwrap().read_to_end(&mut out).unwrap();
1857            assert_eq!(out, &bytes[4..]);
1858        }
1859
1860        /// Anonymous HTTPS against a public object (a GitHub release asset,
1861        /// which serves range requests): the footer must be readable with a
1862        /// partial fetch and no credentials. Skips (passing trivially,
1863        /// loudly) when the network is unavailable.
1864        #[test]
1865        fn https_public_object_integration() {
1866            const URL: &str = "https://github.com/geoparquet-io/tylertoo/releases/download/fixtures-v1/fieldmaps-boundaries.parquet";
1867            let source = match InputSource::from_str_input(URL) {
1868                Ok(s) => s,
1869                Err(e) => {
1870                    eprintln!("SKIP https_public_object_integration (no network?): {e}");
1871                    return;
1872                }
1873            };
1874            let builder = match source.open() {
1875                Ok(b) => b,
1876                Err(e) => {
1877                    eprintln!("SKIP https_public_object_integration (no network?): {e}");
1878                    return;
1879                }
1880            };
1881            assert!(
1882                builder
1883                    .schema()
1884                    .fields()
1885                    .iter()
1886                    .any(|f| f.name() == "geometry"),
1887                "public GeoParquet fixture has a geometry column"
1888            );
1889            let stats = source.fetch_stats().unwrap();
1890            assert!(stats.bytes_fetched > 0);
1891            assert!(
1892                stats.bytes_fetched < stats.object_size,
1893                "footer open must be a partial fetch: {stats:?}"
1894            );
1895        }
1896
1897        /// A two-column parquet with many small data pages in one row group,
1898        /// so the arrow reader interleaves the wide column with the narrow one
1899        /// across batches and touches the wide chunk's pages repeatedly — the
1900        /// access pattern that made issue #261's oversized geometry chunk
1901        /// re-fetch per page. Dictionary encoding is off and the strings are
1902        /// distinct so the wide column stays genuinely wide.
1903        fn two_column_multipage_parquet(rows: usize) -> Vec<u8> {
1904            use arrow_array::{Int64Array, RecordBatch, StringArray};
1905            use parquet::file::properties::WriterProperties;
1906            use std::sync::Arc as SArc;
1907
1908            let wide: Vec<String> = (0..rows)
1909                .map(|i| format!("feature-{i:08}-{:-<48}", i % 7))
1910                .collect();
1911            let narrow: Vec<i64> = (0..rows as i64).collect();
1912            let batch = RecordBatch::try_from_iter([
1913                ("geo", SArc::new(StringArray::from(wide)) as _),
1914                ("tag", SArc::new(Int64Array::from(narrow)) as _),
1915            ])
1916            .unwrap();
1917            let props = WriterProperties::builder()
1918                .set_dictionary_enabled(false)
1919                .set_data_page_row_count_limit(128)
1920                .build();
1921            let mut buf = Vec::new();
1922            let mut w = parquet::arrow::ArrowWriter::try_new(&mut buf, batch.schema(), Some(props))
1923                .unwrap();
1924            w.write(&batch).unwrap();
1925            w.close().unwrap();
1926            buf
1927        }
1928
1929        /// #261 regression: when a row group's working set exceeds the chunk
1930        /// cache floor, a single in-order read must still move ≈ the object's
1931        /// bytes, not re-fetch the wide column per page. With a tiny `cap_base`
1932        /// the pre-fix cache evicted the wide chunk on insert and re-fetched it
1933        /// on every page read (measured 96× on a vertex-heavy input); the
1934        /// footer-sized cap keeps the row group's chunks resident.
1935        #[test]
1936        fn oversized_row_group_is_not_refetched_per_page() {
1937            let bytes = two_column_multipage_parquet(4096);
1938            let object_size = bytes.len() as u64;
1939            // Floor far below one row group, forcing the eviction path.
1940            let source = super::super::test_memory_source_with_cap(bytes, "wide.parquet", 64);
1941
1942            let reader = source.open().unwrap().with_batch_size(64).build().unwrap();
1943            let rows: usize = reader.map(|b| b.unwrap().num_rows()).sum();
1944            assert_eq!(rows, 4096);
1945
1946            let stats = source.fetch_stats().unwrap();
1947            assert_eq!(stats.object_size, object_size);
1948            // One in-order pass moves the object once (plus footer overhead),
1949            // never a multiple of it. Pre-fix this ratio was many×.
1950            assert!(
1951                stats.bytes_fetched <= object_size + object_size / 2,
1952                "single read re-fetched the input: moved {} bytes for a {}-byte \
1953                 object ({:.1}×) — oversized chunk evicted mid-read (#261)",
1954                stats.bytes_fetched,
1955                object_size,
1956                stats.bytes_fetched as f64 / object_size as f64,
1957            );
1958        }
1959
1960        /// #219: across multiple passes over a remote input, each byte must
1961        /// move over the network at most once. The streaming converter reads
1962        /// the input several times (assign pass, coarse-level pass, finest
1963        /// streamed last); the in-memory chunk cache holds only one row group's
1964        /// working set, so without a local spill each pass re-fetches the bulk
1965        /// of the file (measured 3.0× on fieldmaps-adm4). The disk spill drains
1966        /// re-reads from local disk, bounding remote traffic to ≈1× the object
1967        /// regardless of pass count. With a `cap_base` far below one row group
1968        /// the in-memory cache cannot bridge passes, so only the spill keeps the
1969        /// ratio down.
1970        #[test]
1971        fn multi_pass_reads_move_object_once() {
1972            let bytes = multi_row_group_wide_parquet(4096, 3);
1973            let object_size = bytes.len() as u64;
1974            let source = super::super::test_memory_source_with_cap(bytes, "spill.parquet", 64);
1975
1976            // Three full in-order passes, mimicking the converter's assign +
1977            // coarse-level + finest-streamed-last reads.
1978            for _ in 0..3 {
1979                let reader = source.open().unwrap().with_batch_size(64).build().unwrap();
1980                let rows: usize = reader.map(|b| b.unwrap().num_rows()).sum();
1981                assert_eq!(rows, 4096 * 3);
1982            }
1983
1984            let stats = source.fetch_stats().unwrap();
1985            assert_eq!(stats.object_size, object_size);
1986            // Every column chunk is fetched over the network exactly once; the
1987            // second and third passes drain from the local spill. Pre-spill,
1988            // three passes moved ≈3× the object.
1989            assert!(
1990                stats.bytes_fetched <= object_size + object_size / 2,
1991                "three passes moved {} bytes for a {}-byte object ({:.1}×) — the \
1992                 disk spill must serve re-reads locally (#219)",
1993                stats.bytes_fetched,
1994                object_size,
1995                stats.bytes_fetched as f64 / object_size as f64,
1996            );
1997
1998            // No column-chunk range is fetched twice: re-touches hit the spill.
1999            let fetched = source.fetched_ranges().unwrap();
2000            let mut seen = std::collections::HashSet::new();
2001            for r in &fetched {
2002                assert!(
2003                    seen.insert((r.start, r.end)),
2004                    "range {r:?} fetched over the network more than once (#219)"
2005                );
2006            }
2007        }
2008
2009        /// #272: `set_spill_dir` threads through [`InputSource`] →
2010        /// `RemoteSource` → `DiskSpill`. Observed through behavior: a
2011        /// deliberately broken spill dir disables the spill (best-effort,
2012        /// #219), so multi-pass reads degrade to per-pass network re-fetch —
2013        /// proof the configured dir, not `$TMPDIR`, is what the spill uses
2014        /// (with the default dir the same reads stay ≈1×, see
2015        /// [`multi_pass_reads_move_object_once`]).
2016        #[test]
2017        fn spill_dir_reaches_disk_spill_via_source() {
2018            let bytes = multi_row_group_wide_parquet(4096, 3);
2019            let object_size = bytes.len() as u64;
2020            let source = super::super::test_memory_source_with_cap(bytes, "spill-dir.parquet", 64);
2021            source.set_spill_dir(Some(Path::new("/nonexistent/tylertoo-spill-dir-272")));
2022
2023            for _ in 0..3 {
2024                let reader = source.open().unwrap().with_batch_size(64).build().unwrap();
2025                let rows: usize = reader.map(|b| b.unwrap().num_rows()).sum();
2026                assert_eq!(rows, 4096 * 3);
2027            }
2028
2029            let stats = source.fetch_stats().unwrap();
2030            assert!(
2031                stats.bytes_fetched >= 2 * object_size,
2032                "spill disabled by the broken dir: three passes must re-fetch \
2033                 (moved {} bytes for a {}-byte object) — is the configured dir \
2034                 actually reaching DiskSpill?",
2035                stats.bytes_fetched,
2036                object_size,
2037            );
2038        }
2039
2040        /// #272 positive counterpart: with a valid caller-chosen spill dir,
2041        /// multi-pass reads keep the #219 ≈1× network bound.
2042        #[test]
2043        fn valid_spill_dir_keeps_one_pass_bound() {
2044            let bytes = multi_row_group_wide_parquet(4096, 3);
2045            let object_size = bytes.len() as u64;
2046            let source =
2047                super::super::test_memory_source_with_cap(bytes, "spill-dir-ok.parquet", 64);
2048            let dir = tempfile::tempdir().unwrap();
2049            source.set_spill_dir(Some(dir.path()));
2050
2051            for _ in 0..3 {
2052                let reader = source.open().unwrap().with_batch_size(64).build().unwrap();
2053                let rows: usize = reader.map(|b| b.unwrap().num_rows()).sum();
2054                assert_eq!(rows, 4096 * 3);
2055            }
2056
2057            let stats = source.fetch_stats().unwrap();
2058            assert!(
2059                stats.bytes_fetched <= object_size + object_size / 2,
2060                "three passes moved {} bytes for a {}-byte object ({:.1}×) — the \
2061                 spill in the configured dir must serve re-reads locally",
2062                stats.bytes_fetched,
2063                object_size,
2064                stats.bytes_fetched as f64 / object_size as f64,
2065            );
2066        }
2067
2068        /// #272: the projected spill size is the Σ compressed bytes of the
2069        /// selected row groups, straight from the parquet footer. Signature
2070        /// is file-count-agnostic (metadata + selection → u64) so a
2071        /// multi-partition source can sum it across parts.
2072        #[test]
2073        fn selected_compressed_bytes_sums_selection() {
2074            let bytes = multi_row_group_wide_parquet(1024, 3);
2075            let source = memory_source(bytes, "selected-bytes.parquet");
2076            let builder = source.open().unwrap();
2077            let md = builder.metadata();
2078            let per_group: Vec<u64> = md
2079                .row_groups()
2080                .iter()
2081                .map(|rg| rg.compressed_size() as u64)
2082                .collect();
2083            assert_eq!(per_group.len(), 3);
2084            assert!(per_group.iter().all(|b| *b > 0));
2085            assert_eq!(
2086                selected_compressed_bytes(md, None),
2087                per_group.iter().sum::<u64>(),
2088                "no selection = whole file"
2089            );
2090            assert_eq!(
2091                selected_compressed_bytes(md, Some(&[0, 2])),
2092                per_group[0] + per_group[2],
2093                "selection sums only the selected row groups"
2094            );
2095            assert_eq!(selected_compressed_bytes(md, Some(&[])), 0);
2096        }
2097
2098        /// Like [`two_column_multipage_parquet`] but split into `groups` row
2099        /// groups, so a full read walks several oversized row groups in
2100        /// sequence — exercising cross-row-group eviction (the cache must drop
2101        /// the previous group's chunks, not accumulate them).
2102        fn multi_row_group_wide_parquet(rows_per_group: usize, groups: usize) -> Vec<u8> {
2103            use arrow_array::{Int64Array, RecordBatch, StringArray};
2104            use parquet::file::properties::WriterProperties;
2105            use std::sync::Arc as SArc;
2106
2107            let props = WriterProperties::builder()
2108                .set_dictionary_enabled(false)
2109                .set_data_page_row_count_limit(128)
2110                .set_max_row_group_row_count(Some(rows_per_group))
2111                .build();
2112            let total = rows_per_group * groups;
2113            let wide: Vec<String> = (0..total)
2114                .map(|i| format!("feature-{i:08}-{:-<48}", i % 7))
2115                .collect();
2116            let narrow: Vec<i64> = (0..total as i64).collect();
2117            let batch = RecordBatch::try_from_iter([
2118                ("geo", SArc::new(StringArray::from(wide)) as _),
2119                ("tag", SArc::new(Int64Array::from(narrow)) as _),
2120            ])
2121            .unwrap();
2122            let mut buf = Vec::new();
2123            let mut w = parquet::arrow::ArrowWriter::try_new(&mut buf, batch.schema(), Some(props))
2124                .unwrap();
2125            w.write(&batch).unwrap();
2126            w.close().unwrap();
2127            buf
2128        }
2129
2130        /// #261 benchmark: report the remote-fetch amplification (bytes moved ÷
2131        /// object size) for a single in-order read of an input whose row groups
2132        /// each exceed the chunk-cache floor. Shrinking the cache via `cap_base`
2133        /// reproduces the "row group larger than cache" regime at MB scale —
2134        /// the same mechanism as fieldmaps-adm4's 1.3 GiB geometry chunks vs the
2135        /// 256 MiB floor. Run with:
2136        ///
2137        /// ```text
2138        /// cargo test -p tylertoo-core --features remote --lib \
2139        ///   remote_tests::bench_remote_refetch_ratio -- --ignored --nocapture
2140        /// ```
2141        #[test]
2142        #[ignore = "benchmark: prints the #261 fetch-amplification ratio"]
2143        fn bench_remote_refetch_ratio() {
2144            // 3 row groups, each ~1.4 MiB of wide-column pages; floor 256 KiB
2145            // sits below one row group, so the pre-fix cache thrashed.
2146            let bytes = multi_row_group_wide_parquet(8192, 3);
2147            let object_size = bytes.len() as u64;
2148            let cap_base = 256 * 1024;
2149            let source =
2150                super::super::test_memory_source_with_cap(bytes, "bench-wide.parquet", cap_base);
2151
2152            let reader = source.open().unwrap().with_batch_size(64).build().unwrap();
2153            let rows: usize = reader.map(|b| b.unwrap().num_rows()).sum();
2154            assert_eq!(rows, 8192 * 3);
2155
2156            let stats = source.fetch_stats().unwrap();
2157            let ratio = stats.bytes_fetched as f64 / object_size as f64;
2158            eprintln!(
2159                "[#261 bench] object={} B  moved={} B  ratio={:.2}×  requests={}  \
2160                 cap_base={} B  max_rg≈{} B",
2161                object_size,
2162                stats.bytes_fetched,
2163                ratio,
2164                stats.requests,
2165                cap_base,
2166                object_size / 3,
2167            );
2168            assert!(
2169                ratio <= 1.5,
2170                "single read amplified {ratio:.2}× (expected ≈1× with the \
2171                 footer-sized cap; a large ratio means #261 regressed)"
2172            );
2173        }
2174
2175        #[test]
2176        fn out_of_bounds_range_is_an_error() {
2177            let source = memory_source(tiny_parquet(), "tiny.parquet");
2178            let InputSource::Remote(ref r) = source else {
2179                unreachable!()
2180            };
2181            let reader = r.reader();
2182            let size = reader.object_size();
2183            assert!(reader.get_bytes_range(size - 1, 2).is_err());
2184        }
2185    }
2186}