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}