This is an automated email from the ASF dual-hosted git repository.
sunchao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git
The following commit(s) were added to refs/heads/main by this push:
new 4e9ab670ba feat: expose native Parquet scan I/O and read-amplification
metrics (#5453)
4e9ab670ba is described below
commit 4e9ab670ba75ce831d09cd2d74478bcc731b135c
Author: Chao Sun <[email protected]>
AuthorDate: Thu Sep 10 18:46:53 2026 -0700
feat: expose native Parquet scan I/O and read-amplification metrics (#5453)
* feat: expose native Parquet scan I/O and read-amplification metrics
* fix: update scan metrics test to current Parquet Bloom filter API
---
docs/source/user-guide/latest/metrics.md | 49 +-
.../execution/operators/dynamic_filter/tests.rs | 3 +
.../core/src/execution/operators/parquet_writer.rs | 2 +-
native/core/src/execution/planner.rs | 14 +-
.../src/parquet/eager_page_index_reader_factory.rs | 673 +++++++++++++++-
native/core/src/parquet/parquet_exec.rs | 853 ++++++++++++++++++++-
native/core/src/parquet/parquet_support.rs | 200 ++++-
.../apache/spark/sql/comet/CometMetricNode.scala | 24 +
.../org/apache/comet/exec/CometExecSuite.scala | 21 +
.../spark/sql/benchmark/CometReadBenchmark.scala | 86 +++
10 files changed, 1871 insertions(+), 54 deletions(-)
diff --git a/docs/source/user-guide/latest/metrics.md
b/docs/source/user-guide/latest/metrics.md
index 9d35536fc2..a400a9fb1c 100644
--- a/docs/source/user-guide/latest/metrics.md
+++ b/docs/source/user-guide/latest/metrics.md
@@ -107,11 +107,51 @@ Here is a guide to some of the native metrics.
| `spilled_bytes` | Actual bytes written to native shuffle spill files
on disk. |
| `memory_spilled_bytes` | Uncompressed Arrow backing-buffer and
partition-index memory spilled. |
+### Native Parquet scans
+
+Native Parquet scans expose these counters in the Spark SQL metric map as well
as native
+execution metrics. Counters accumulate per scan operator; they do not
instrument individual rows.
+
+| Metric | Description
|
+| ------------------------------------------ |
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
+| `scan_io_data_bytes` | Bytes returned to the Parquet
reader for projected data-page ranges.
|
+| `scan_io_metadata_bytes` | Bytes returned for footer
prefetches, page indexes, and Bloom filters. A footer prefetch can also contain
unused data bytes.
|
+| `scan_io_footer_reads` | Storage reads of complete
serialized footer payloads, counted once per metadata open. Plaintext payloads
must decode successfully; encrypted payloads are counted before key
retrieval/decryption, even if those later fail. |
+| `scan_io_footer_bytes` | Serialized footer payload
bytes, excluding the final eight-byte trailer. These bytes are already included
in `scan_io_metadata_bytes`.
|
+| `scan_io_object_store_get_calls` | Nonempty GET operations at the
native remote `ObjectStore` API, after range coalescing. Does not count
transport retries or HEAD requests.
|
+| `scan_io_object_store_get_requested_bytes` | Requested coalesced range bytes
at that API. A failed or partly consumed request can contribute requested bytes
without the same number of response bytes.
|
+| `scan_io_object_store_response_bytes_read` | Response bytes actually
consumed at that API, including bytes fetched between coalesced ranges. Not
HTTP wire bytes.
|
+| `scan_io_metadata_cache_hits` | Successful, cache-eligible
metadata opens requiring no storage reads.
|
+| `scan_io_metadata_cache_misses` | Successful, cache-eligible
metadata opens requiring storage reads. Failed opens and encrypted opens, which
bypass this shared cache, increment neither cache counter.
|
+
+Reader-level and object-store bytes are two views of the same reads; do not
add them together.
+Likewise, footer bytes are a subset of metadata bytes, not a third
reader-level category. A warm
+metadata-cache hit contributes no new metadata or footer I/O. A valid
plaintext footer followed by
+a page-index failure still contributes footer bytes; an invalid plaintext
footer does not.
+
+Remote counters follow the backend selected during object-store construction,
including native
+S3 (`s3`/`s3a`), GCS (`gs`), Azure (`az`, `adl`, `azure`, `abfs`, `abfss`),
and HTTP(S) stores.
+Configured S3-compatible aliases are normalized before this classification.
+Local files, in-memory stores, and HDFS/custom backends (including
cloud-looking schemes selected
+through `fs.comet.libhdfs.schemes`) retain reader-level counters but have zero
remote counters.
+The native cloud wrapper observes default range coalescing; custom
`get_ranges` implementations
+require an explicit accounting contract before being composed with that
wrapper.
+
+For data-bearing scans, comparing remote response bytes with reader data bytes
can reveal
+coalescing and metadata overhead. For a metadata-only scan, data bytes are
zero: report the
+metadata and remote totals instead of dividing by zero. Cancellation can leave
late asynchronous
+work outside the final metric snapshot; these counters are not a guarantee of
complete network
+traffic accounting after cancellation.
+
## Task-Level Input Metrics on Spark 4.1+
-Comet's native scans set `inputMetrics.bytesRead` to the actual file IO
performed by the
-DataFusion parquet reader (`bytes_scanned`). This is the truthful number you
would see at the
-filesystem layer.
+Comet's native scans populate `inputMetrics.bytesRead` from the existing
`bytes_scanned`
+counter. It counts requested data/Bloom-filter ranges through the Parquet
reader's byte-read
+methods, not all filesystem I/O. Footer and page-index reads through metadata
loading bypass
+this counter, and range coalescing can fetch more bytes than the logical
ranges request. The
+additional scan I/O metrics above expose those differences without changing
`bytes_scanned`.
+The native `scan_efficiency_ratio` still uses `bytes_scanned` as its numerator
and has the same
+blind spots; it is not the remote read-amplification ratio described above.
Spark 4.1 changed its own parquet reader to pre-open the `SeekableInputStream`
and read the file
footer outside the `FileScanRDD.compute()` thread. Spark's
`inputMetrics.bytesRead` is updated
@@ -128,4 +168,5 @@ unaffected and remains exactly equal between Comet and
Spark.
If you compare Comet's `bytesRead` against vanilla Spark's on Spark 4.1+ (via
the Spark UI or
the REST API), expect Comet's number to be substantially larger for small
files, and closer to
-Spark's for large files. Comet's value reflects what the storage layer
actually delivered.
+Spark's for large files in that workload. Neither metric should be interpreted
as complete
+filesystem or network traffic accounting.
diff --git a/native/core/src/execution/operators/dynamic_filter/tests.rs
b/native/core/src/execution/operators/dynamic_filter/tests.rs
index b8742ae3c8..fe6b0d0653 100644
--- a/native/core/src/execution/operators/dynamic_filter/tests.rs
+++ b/native/core/src/execution/operators/dynamic_filter/tests.rs
@@ -21,6 +21,7 @@ use std::hash::{Hash, Hasher};
use crate::execution::planner::PhysicalPlanner;
use crate::parquet::parquet_exec::init_datasource_exec;
+use crate::parquet::parquet_support::ObjectStoreBackend;
use arrow::array::{ArrayRef, BooleanArray, Int32Array, Int64Array, Int8Array,
RecordBatch};
use arrow::compute::cast;
use arrow::datatypes::{Field, Schema};
@@ -683,6 +684,7 @@ fn parquet_probe(
Some(file_schema),
None,
ObjectStoreUrl::local_filesystem(),
+ ObjectStoreBackend::Local,
vec![vec![PartitionedFile::from_path(
file.path().to_string_lossy().into_owned(),
)
@@ -839,6 +841,7 @@ async fn
reader_filter_crosses_null_check_conjunction_and_retains_residual() {
Some(Arc::clone(&file_schema)),
None,
ObjectStoreUrl::local_filesystem(),
+ ObjectStoreBackend::Local,
vec![vec![PartitionedFile::from_path(
file.path().to_string_lossy().into_owned(),
)
diff --git a/native/core/src/execution/operators/parquet_writer.rs
b/native/core/src/execution/operators/parquet_writer.rs
index 85b7648921..23ffddef0a 100644
--- a/native/core/src/execution/operators/parquet_writer.rs
+++ b/native/core/src/execution/operators/parquet_writer.rs
@@ -313,7 +313,7 @@ impl ParquetWriterExec {
#[cfg(feature = "hdfs-opendal")]
{
// Use prepare_object_store_with_configs to create and
register the object store
- let (_object_store_url, object_store_path) =
prepare_object_store_with_configs(
+ let (_object_store_url, object_store_path, _) =
prepare_object_store_with_configs(
_runtime_env,
output_file_path.to_string(),
object_store_options,
diff --git a/native/core/src/execution/planner.rs
b/native/core/src/execution/planner.rs
index 37d5e74441..1cd84d314f 100644
--- a/native/core/src/execution/planner.rs
+++ b/native/core/src/execution/planner.rs
@@ -1710,11 +1710,12 @@ impl PhysicalPlanner {
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
- let (object_store_url, _) = prepare_object_store_with_configs(
- self.session_ctx.runtime_env(),
- one_file,
- &object_store_options,
- )?;
+ let (object_store_url, _, object_store_backend) =
+ prepare_object_store_with_configs(
+ self.session_ctx.runtime_env(),
+ one_file,
+ &object_store_options,
+ )?;
// Get files for this partition
let files = self.get_partitioned_files(partition_files,
&object_store_options)?;
@@ -1725,6 +1726,7 @@ impl PhysicalPlanner {
Some(data_schema),
Some(partition_schema),
object_store_url,
+ object_store_backend,
file_groups,
Some(projection_vector),
if common.has_data_filters ||
!common.data_filters.is_empty() {
@@ -1766,7 +1768,7 @@ impl PhysicalPlanner {
.and_then(|f| f.partitioned_file.first())
.map(|f| f.file_path.clone())
.ok_or(GeneralError("Failed to locate file".to_string()))?;
- let (object_store_url, _) = prepare_object_store_with_configs(
+ let (object_store_url, _, _) =
prepare_object_store_with_configs(
self.session_ctx.runtime_env(),
one_file,
&object_store_options,
diff --git a/native/core/src/parquet/eager_page_index_reader_factory.rs
b/native/core/src/parquet/eager_page_index_reader_factory.rs
index 22a94d04a1..59c68a3f86 100644
--- a/native/core/src/parquet/eager_page_index_reader_factory.rs
+++ b/native/core/src/parquet/eager_page_index_reader_factory.rs
@@ -46,6 +46,7 @@
//! Filed upstream as apache/datafusion#23978. Revert this once the opener
merges its deferred
//! page-index load back into `FileMetadataCache` instead of bypassing it.
+use async_trait::async_trait;
use bytes::Bytes;
use datafusion::common::Result as DFResult;
use
datafusion::datasource::physical_plan::parquet::metadata::DFParquetMetadata;
@@ -53,35 +54,141 @@ use datafusion::datasource::physical_plan::parquet::{
ParquetFileMetrics, ParquetFileReaderFactory,
};
use datafusion::execution::cache::cache_manager::FileMetadataCache;
-use datafusion::physical_plan::metrics::ExecutionPlanMetricsSet;
+use datafusion::physical_plan::metrics::{
+ Count, ExecutionPlanMetricsSet, MetricBuilder, MetricCategory, MetricType,
+};
use datafusion_datasource::PartitionedFile;
use futures::future::BoxFuture;
-use futures::{FutureExt, TryFutureExt};
-use object_store::{ObjectStore, ObjectStoreExt};
+use futures::{FutureExt, StreamExt, TryStreamExt};
+use object_store::path::Path;
+use object_store::{
+ coalesce_ranges, CopyOptions, GetOptions, GetRange, GetResult,
GetResultPayload, ListResult,
+ MultipartUpload, ObjectMeta, ObjectStore, ObjectStoreExt,
PutMultipartOptions, PutOptions,
+ PutPayload, PutResult, RenameOptions, Result as ObjectStoreResult,
+ OBJECT_STORE_COALESCE_DEFAULT,
+};
use parquet::arrow::arrow_reader::ArrowReaderOptions;
use parquet::arrow::async_reader::AsyncFileReader;
use parquet::errors::ParquetError;
-use parquet::file::metadata::{PageIndexPolicy, ParquetMetaData};
-use std::fmt::Debug;
+use parquet::file::metadata::{
+ FooterTail, PageIndexPolicy, ParquetMetaData, ParquetMetaDataReader,
+};
+use std::fmt::{Debug, Display, Formatter};
use std::ops::Range;
-use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
+use std::sync::{Arc, OnceLock};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum ScanIoSource {
+ ObjectStore,
+ Local,
+ OtherObjectStore,
+}
+
+#[derive(Debug)]
+struct ScanIoMetrics {
+ data_bytes: Count,
+ metadata_bytes: Count,
+ footer_reads: Count,
+ footer_bytes: Count,
+ object_store_get_calls: Count,
+ object_store_get_requested_bytes: Count,
+ object_store_response_bytes_read: Count,
+ metadata_cache_hits: Count,
+ metadata_cache_misses: Count,
+}
+
+impl ScanIoMetrics {
+ fn new(metrics: &ExecutionPlanMetricsSet) -> Self {
+ Self {
+ data_bytes: byte_counter(metrics, "scan_io_data_bytes"),
+ metadata_bytes: byte_counter(metrics, "scan_io_metadata_bytes"),
+ footer_reads: count_counter(metrics, "scan_io_footer_reads"),
+ footer_bytes: byte_counter(metrics, "scan_io_footer_bytes"),
+ object_store_get_calls: count_counter(metrics,
"scan_io_object_store_get_calls"),
+ object_store_get_requested_bytes: byte_counter(
+ metrics,
+ "scan_io_object_store_get_requested_bytes",
+ ),
+ object_store_response_bytes_read: byte_counter(
+ metrics,
+ "scan_io_object_store_response_bytes_read",
+ ),
+ metadata_cache_hits: count_counter(metrics,
"scan_io_metadata_cache_hits"),
+ metadata_cache_misses: count_counter(metrics,
"scan_io_metadata_cache_misses"),
+ }
+ }
+
+ fn record_metadata_cache_result(&self, storage_reads: usize) {
+ if storage_reads == 0 {
+ self.metadata_cache_hits.add(1);
+ } else {
+ self.metadata_cache_misses.add(1);
+ }
+ }
+}
+
+fn byte_counter(metrics: &ExecutionPlanMetricsSet, name: &'static str) ->
Count {
+ MetricBuilder::new(metrics)
+ .with_type(MetricType::Summary)
+ .with_category(MetricCategory::Bytes)
+ .global_counter(name)
+}
+
+fn count_counter(metrics: &ExecutionPlanMetricsSet, name: &'static str) ->
Count {
+ MetricBuilder::new(metrics)
+ .with_type(MetricType::Summary)
+ .global_counter(name)
+}
+
+fn range_bytes(range: &Range<u64>) -> usize {
+ (range.end - range.start) as usize
+}
+
+fn ranges_bytes(ranges: &[Range<u64>]) -> usize {
+ ranges.iter().map(range_bytes).sum()
+}
#[derive(Debug)]
pub struct EagerPageIndexReaderFactory {
store: Arc<dyn ObjectStore>,
metadata_cache: Arc<FileMetadataCache>,
+ scan_io_metrics: Arc<ScanIoMetrics>,
}
impl EagerPageIndexReaderFactory {
- pub fn new(store: Arc<dyn ObjectStore>, metadata_cache:
Arc<FileMetadataCache>) -> Self {
+ /// Shares the scan's counters and metadata cache across all readers made
by this factory.
+ /// Native cloud stores are wrapped so coalesced requests and consumed
response bytes are
+ /// visible to the counters. Local and custom HDFS stores retain their
range-read behavior.
+ /// Construction performs no I/O; the returned factory owns the store and
cache references.
+ pub(crate) fn new(
+ store: Arc<dyn ObjectStore>,
+ metadata_cache: Arc<FileMetadataCache>,
+ source: ScanIoSource,
+ metrics: &ExecutionPlanMetricsSet,
+ ) -> Self {
+ let scan_io_metrics = Arc::new(ScanIoMetrics::new(metrics));
+ let store: Arc<dyn ObjectStore> = if source ==
ScanIoSource::ObjectStore {
+ Arc::new(ScanIoObjectStore {
+ inner: store,
+ scan_io_metrics: Arc::clone(&scan_io_metrics),
+ role: ScanIoStoreRole::ObjectStore,
+ })
+ } else {
+ store
+ };
Self {
store,
metadata_cache,
+ scan_io_metrics,
}
}
}
impl ParquetFileReaderFactory for EagerPageIndexReaderFactory {
+ /// Creates a reader without issuing I/O. It owns the file description and
shares the
+ /// factory's store, metadata cache, and scan counters. Per-file
DataFusion metrics remain
+ /// associated with `partition_index`; dropping the reader updates its
scan-efficiency metric.
fn create_reader(
&self,
partition_index: usize,
@@ -97,6 +204,7 @@ impl ParquetFileReaderFactory for
EagerPageIndexReaderFactory {
Ok(Box::new(EagerPageIndexReader {
file_metrics,
+ scan_io_metrics: Arc::clone(&self.scan_io_metrics),
store: Arc::clone(&self.store),
partitioned_file,
metadata_cache: Arc::clone(&self.metadata_cache),
@@ -106,10 +214,11 @@ impl ParquetFileReaderFactory for
EagerPageIndexReaderFactory {
}
/// Reads bytes straight off the `ObjectStore`, the same way DataFusion's own
`ParquetFileReader`
-/// does, and overrides only the metadata fetch. `ParquetFileReader::new` is
crate-private, so the
-/// byte-range plumbing is duplicated here rather than delegated.
+/// does, while instrumenting ranges and overriding metadata fetching.
`ParquetFileReader::new`
+/// is crate-private, so the byte-range plumbing is duplicated here rather
than delegated.
struct EagerPageIndexReader {
file_metrics: ParquetFileMetrics,
+ scan_io_metrics: Arc<ScanIoMetrics>,
store: Arc<dyn ObjectStore>,
partitioned_file: PartitionedFile,
metadata_cache: Arc<FileMetadataCache>,
@@ -117,15 +226,36 @@ struct EagerPageIndexReader {
}
impl AsyncFileReader for EagerPageIndexReader {
+ /// Reads a metadata range, counting its requested size before I/O and its
returned
+ /// bytes only on success. The returned future borrows this reader; store
errors retain
+ /// their cause as an external Parquet error.
+ ///
+ /// Parquet 59.2 uses this entry point for Bloom filters and
`get_byte_ranges` for data
+ /// pages. Footer/page-index loading is instrumented separately in
`get_metadata`.
+ /// Revisit these call sites on upgrades: offsets alone do not identify
metadata.
fn get_bytes(&mut self, range: Range<u64>) -> BoxFuture<'_,
parquet::errors::Result<Bytes>> {
- let bytes_scanned = range.end - range.start;
- self.file_metrics.bytes_scanned.add(bytes_scanned as usize);
- self.store
- .get_range(&self.partitioned_file.object_meta.location, range)
- .map_err(|e| ParquetError::External(Box::new(e)))
- .boxed()
+ let requested = range_bytes(&range);
+ // Preserve the existing requested-range metric. Metadata fetched
through get_metadata
+ // bypasses it, and object-store coalescing can fetch more bytes than
these ranges.
+ self.file_metrics.bytes_scanned.add(requested);
+ let scan_io_metrics = Arc::clone(&self.scan_io_metrics);
+ async move {
+ let bytes = self
+ .store
+ .get_range(&self.partitioned_file.object_meta.location, range)
+ .await
+ .map_err(|e| ParquetError::External(Box::new(e)))?;
+ scan_io_metrics.metadata_bytes.add(bytes.len());
+ Ok(bytes)
+ }
+ .boxed()
}
+ /// Reads data-page ranges through the selected store's multi-range API.
Local/custom stores
+ /// retain delegation; native cloud wrappers observe coalescing. Requested
bytes update
+ /// `bytes_scanned` before I/O; successful
+ /// logical ranges update `data_bytes`, excluding gaps fetched by
cloud-store coalescing.
+ /// The future borrows this reader and propagates store errors as external
Parquet errors.
fn get_byte_ranges(
&mut self,
ranges: Vec<Range<u64>>,
@@ -133,13 +263,19 @@ impl AsyncFileReader for EagerPageIndexReader {
where
Self: Send,
{
- let total: u64 = ranges.iter().map(|r| r.end - r.start).sum();
- self.file_metrics.bytes_scanned.add(total as usize);
+ let requested = ranges_bytes(&ranges);
+ self.file_metrics.bytes_scanned.add(requested);
+ let scan_io_metrics = Arc::clone(&self.scan_io_metrics);
async move {
- self.store
+ let bytes = self
+ .store
.get_ranges(&self.partitioned_file.object_meta.location,
&ranges)
.await
- .map_err(|e| ParquetError::External(Box::new(e)))
+ .map_err(|e| ParquetError::External(Box::new(e)))?;
+ scan_io_metrics
+ .data_bytes
+ .add(bytes.iter().map(Bytes::len).sum());
+ Ok(bytes)
}
.boxed()
}
@@ -154,17 +290,33 @@ impl AsyncFileReader for EagerPageIndexReader {
let metadata_cache = Arc::clone(&self.metadata_cache);
let store = Arc::clone(&self.store);
let metadata_size_hint = self.metadata_size_hint;
+ let scan_io_metrics = Arc::clone(&self.scan_io_metrics);
async move {
let file_decryption_properties = options
.and_then(|o| o.file_decryption_properties())
.map(Arc::clone);
+ let cache_enabled = file_decryption_properties.is_none();
let page_index_policy = if file_decryption_properties.is_none() {
Some(PageIndexPolicy::Optional)
} else {
options.map(|o| o.column_index_policy())
};
+ let metadata_storage_reads = Arc::new(AtomicUsize::new(0));
+ let footer_payload_bytes = Arc::new(AtomicUsize::new(0));
+ let metadata_store = ScanIoObjectStore {
+ inner: store,
+ scan_io_metrics: Arc::clone(&scan_io_metrics),
+ role: ScanIoStoreRole::Metadata {
+ storage_reads: Arc::clone(&metadata_storage_reads),
+ footer_payload_bytes: Arc::clone(&footer_payload_bytes),
+ footer_payload: OnceLock::new(),
+ footer_recorded: AtomicBool::new(false),
+ file_size: object_meta.size,
+ record_footer_immediately: !cache_enabled,
+ },
+ };
- DFParquetMetadata::new(store.as_ref(), &object_meta)
+ let metadata = DFParquetMetadata::new(&metadata_store,
&object_meta)
.with_decryption_properties(file_decryption_properties)
.with_file_metadata_cache(Some(metadata_cache))
.with_metadata_size_hint(metadata_size_hint)
@@ -176,12 +328,334 @@ impl AsyncFileReader for EagerPageIndexReader {
"Failed to fetch metadata for file {}: {e}",
object_meta.location,
))
- })
+ });
+
+ if metadata.is_ok() {
+ let footer_bytes =
footer_payload_bytes.load(Ordering::Relaxed);
+ if footer_bytes > 0 && cache_enabled {
+ metadata_store.record_footer(footer_bytes);
+ }
+ if cache_enabled {
+ scan_io_metrics.record_metadata_cache_result(
+ metadata_storage_reads.load(Ordering::Relaxed),
+ );
+ }
+ } else if cache_enabled {
+ metadata_store.record_valid_footer();
+ }
+
+ metadata
}
.boxed()
}
}
+#[derive(Debug)]
+enum ScanIoStoreRole {
+ ObjectStore,
+ Metadata {
+ storage_reads: Arc<AtomicUsize>,
+ footer_payload_bytes: Arc<AtomicUsize>,
+ footer_payload: OnceLock<Bytes>,
+ footer_recorded: AtomicBool,
+ file_size: u64,
+ record_footer_immediately: bool,
+ },
+}
+
+#[derive(Debug)]
+struct ScanIoObjectStore {
+ inner: Arc<dyn ObjectStore>,
+ scan_io_metrics: Arc<ScanIoMetrics>,
+ role: ScanIoStoreRole,
+}
+
+impl ScanIoObjectStore {
+ fn record_valid_footer(&self) {
+ if let ScanIoStoreRole::Metadata {
+ footer_payload_bytes,
+ footer_payload,
+ footer_recorded,
+ ..
+ } = &self.role
+ {
+ if !footer_recorded.load(Ordering::Relaxed)
+ && footer_payload
+ .get()
+ .is_some_and(|payload|
ParquetMetaDataReader::decode_metadata(payload).is_ok())
+ {
+
self.record_footer(footer_payload_bytes.load(Ordering::Relaxed));
+ }
+ }
+ }
+
+ fn record_footer(&self, bytes: usize) {
+ if let ScanIoStoreRole::Metadata {
+ footer_recorded, ..
+ } = &self.role
+ {
+ if bytes > 0 && !footer_recorded.swap(true, Ordering::Relaxed) {
+ self.scan_io_metrics.footer_reads.add(1);
+ self.scan_io_metrics.footer_bytes.add(bytes);
+ }
+ }
+ }
+
+ fn record_request(&self, bytes: usize) {
+ if bytes == 0 {
+ return;
+ }
+ match &self.role {
+ ScanIoStoreRole::ObjectStore => {
+ self.scan_io_metrics.object_store_get_calls.add(1);
+ self.scan_io_metrics
+ .object_store_get_requested_bytes
+ .add(bytes);
+ }
+ ScanIoStoreRole::Metadata { storage_reads, .. } => {
+ storage_reads.fetch_add(1, Ordering::Relaxed);
+ }
+ }
+ }
+
+ // Footer accounting follows the DataFusion 55.0/parquet 59.2 metadata
push decoder: a tail
+ // read supplies the payload length, then one or more reads supply the
complete payload.
+ // Plaintext payloads are retained here and counted only after metadata
succeeds, or when
+ // get_ranges observes the subsequent page-index request wholly below the
footer. Thus a
+ // later index failure does not erase a decoded footer. Encrypted payloads
are counted as
+ // soon as complete, before key retrieval/decryption; their counter
measures payload I/O,
+ // not successful authentication. Recheck this protocol when upgrading the
decoder.
+ fn record_returned(&self, range: Option<&Range<u64>>, bytes: &Bytes) {
+ match &self.role {
+ ScanIoStoreRole::ObjectStore => self
+ .scan_io_metrics
+ .object_store_response_bytes_read
+ .add(bytes.len()),
+ ScanIoStoreRole::Metadata {
+ footer_payload_bytes,
+ footer_payload,
+ file_size,
+ record_footer_immediately,
+ ..
+ } => {
+ self.scan_io_metrics.metadata_bytes.add(bytes.len());
+ if range.is_some_and(|range| range.end == *file_size) &&
bytes.len() >= 8 {
+ if let Ok(footer) =
FooterTail::try_from(&bytes[bytes.len() - 8..]) {
+ let _ = footer_payload_bytes.compare_exchange(
+ 0,
+ footer.metadata_length(),
+ Ordering::Relaxed,
+ Ordering::Relaxed,
+ );
+ }
+ }
+
+ let footer_bytes =
footer_payload_bytes.load(Ordering::Relaxed);
+ let footer_end = file_size.saturating_sub(8);
+ if footer_bytes > 0
+ && footer_end
+ .checked_sub(footer_bytes as u64)
+ .is_some_and(|footer_start| {
+ range.is_some_and(|range| {
+ range.start <= footer_start
+ && range.start.saturating_add(bytes.len()
as u64) >= footer_end
+ })
+ })
+ {
+ if *record_footer_immediately {
+ // Encrypted opens bypass the shared cache and
retrieve the key only after
+ // the complete encrypted payload is read. Count
completed payload I/O,
+ // even if key retrieval, authentication, or metadata
decoding later fails.
+ self.record_footer(footer_bytes);
+ } else if let Some(range) = range {
+ let payload_start =
+ (footer_end - footer_bytes as u64 - range.start)
as usize;
+ let _ = footer_payload
+ .set(bytes.slice(payload_start..payload_start +
footer_bytes));
+ }
+ }
+ }
+ }
+ }
+}
+
+impl Display for ScanIoObjectStore {
+ fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
+ write!(formatter, "scan-io({})", self.inner)
+ }
+}
+
+#[async_trait]
+#[deny(clippy::missing_trait_methods)]
+impl ObjectStore for ScanIoObjectStore {
+ async fn put_opts(
+ &self,
+ location: &Path,
+ payload: PutPayload,
+ options: PutOptions,
+ ) -> ObjectStoreResult<PutResult> {
+ self.inner.put_opts(location, payload, options).await
+ }
+
+ async fn put_multipart_opts(
+ &self,
+ location: &Path,
+ options: PutMultipartOptions,
+ ) -> ObjectStoreResult<Box<dyn MultipartUpload>> {
+ self.inner.put_multipart_opts(location, options).await
+ }
+
+ async fn get_opts(&self, location: &Path, options: GetOptions) ->
ObjectStoreResult<GetResult> {
+ if options.head {
+ return self.inner.get_opts(location, options).await;
+ }
+
+ let requested = match options.range.as_ref() {
+ Some(GetRange::Bounded(range)) => Some(range_bytes(range)),
+ Some(GetRange::Suffix(bytes)) => Some(*bytes as usize),
+ Some(GetRange::Offset(_)) | None => None,
+ };
+ if let Some(requested) = requested {
+ self.record_request(requested);
+ }
+
+ let result = self.inner.get_opts(location, options).await?;
+ if requested.is_none() {
+ self.record_request(range_bytes(&result.range));
+ }
+
+ let meta = result.meta.clone();
+ let range = result.range.clone();
+ let attributes = result.attributes.clone();
+ let payload = if matches!(&result.payload, GetResultPayload::File(..))
{
+ let bytes = result.bytes().await?;
+ self.record_returned(Some(&range), &bytes);
+ GetResultPayload::Stream(futures::stream::once(async move {
Ok(bytes) }).boxed())
+ } else {
+ let scan_io_metrics = Arc::clone(&self.scan_io_metrics);
+ let metadata_read = matches!(self.role, ScanIoStoreRole::Metadata
{ .. });
+ GetResultPayload::Stream(
+ result
+ .into_stream()
+ .inspect_ok(move |bytes| {
+ if metadata_read {
+ scan_io_metrics.metadata_bytes.add(bytes.len());
+ } else {
+ scan_io_metrics
+ .object_store_response_bytes_read
+ .add(bytes.len());
+ }
+ })
+ .boxed(),
+ )
+ };
+
+ Ok(GetResult {
+ payload,
+ meta,
+ range,
+ attributes,
+ })
+ }
+
+ async fn get_ranges(
+ &self,
+ location: &Path,
+ ranges: &[Range<u64>],
+ ) -> ObjectStoreResult<Vec<Bytes>> {
+ match &self.role {
+ ScanIoStoreRole::ObjectStore => {
+ // Supported native cloud stores use object_store's default
coalescing. Apply it
+ // here so get_opts observes the coalesced requests, not just
logical ranges.
+ // This deliberately bypasses an inner get_ranges override: a
cache or custom
+ // backend must define that observation boundary before being
composed here.
+ // Local/custom HDFS backends are not wrapped in this role and
keep delegation.
+ coalesce_ranges(
+ ranges,
+ |range| self.get_range(location, range),
+ OBJECT_STORE_COALESCE_DEFAULT,
+ )
+ .await
+ }
+ ScanIoStoreRole::Metadata {
+ footer_payload_bytes,
+ file_size,
+ record_footer_immediately,
+ ..
+ } => {
+ let footer_bytes =
footer_payload_bytes.load(Ordering::Relaxed);
+ if !record_footer_immediately
+ && footer_bytes > 0
+ && !ranges.is_empty()
+ && file_size
+ .saturating_sub(8)
+ .checked_sub(footer_bytes as u64)
+ .is_some_and(|footer_start| {
+ ranges.iter().all(|range| range.end <=
footer_start)
+ })
+ {
+ // In parquet 59.2, plaintext page-index requests below
the footer begin only
+ // after its metadata payload has decoded successfully.
Record that footer
+ // before awaiting the indexes, so a later index-read
failure does not erase
+ // a successful footer read. Encrypted opens use the
complete-payload path
+ // above; cached plaintext opens are recorded only after
metadata succeeds.
+ self.record_footer(footer_bytes);
+ }
+ self.record_request(ranges_bytes(ranges));
+ let bytes = self.inner.get_ranges(location, ranges).await?;
+ for (range, bytes) in ranges.iter().zip(bytes.iter()) {
+ self.record_returned(Some(range), bytes);
+ }
+ Ok(bytes)
+ }
+ }
+ }
+
+ fn delete_stream(
+ &self,
+ locations: futures::stream::BoxStream<'static,
ObjectStoreResult<Path>>,
+ ) -> futures::stream::BoxStream<'static, ObjectStoreResult<Path>> {
+ self.inner.delete_stream(locations)
+ }
+
+ fn list(
+ &self,
+ prefix: Option<&Path>,
+ ) -> futures::stream::BoxStream<'static, ObjectStoreResult<ObjectMeta>> {
+ self.inner.list(prefix)
+ }
+
+ fn list_with_offset(
+ &self,
+ prefix: Option<&Path>,
+ offset: &Path,
+ ) -> futures::stream::BoxStream<'static, ObjectStoreResult<ObjectMeta>> {
+ self.inner.list_with_offset(prefix, offset)
+ }
+
+ async fn list_with_delimiter(&self, prefix: Option<&Path>) ->
ObjectStoreResult<ListResult> {
+ self.inner.list_with_delimiter(prefix).await
+ }
+
+ async fn copy_opts(
+ &self,
+ from: &Path,
+ to: &Path,
+ options: CopyOptions,
+ ) -> ObjectStoreResult<()> {
+ self.inner.copy_opts(from, to, options).await
+ }
+
+ async fn rename_opts(
+ &self,
+ from: &Path,
+ to: &Path,
+ options: RenameOptions,
+ ) -> ObjectStoreResult<()> {
+ self.inner.rename_opts(from, to, options).await
+ }
+}
+
impl Drop for EagerPageIndexReader {
fn drop(&mut self) {
self.file_metrics
@@ -194,3 +668,160 @@ impl Drop for EagerPageIndexReader {
.set_total(self.partitioned_file.object_meta.size as usize);
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use object_store::memory::InMemory;
+
+ #[derive(Debug)]
+ struct RecordingRangeStore {
+ inner: InMemory,
+ range_calls: AtomicUsize,
+ get_calls: AtomicUsize,
+ }
+
+ impl Display for RecordingRangeStore {
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+ write!(f, "recording-range-store")
+ }
+ }
+
+ #[async_trait]
+ impl ObjectStore for RecordingRangeStore {
+ async fn put_opts(
+ &self,
+ p: &Path,
+ v: PutPayload,
+ o: PutOptions,
+ ) -> ObjectStoreResult<PutResult> {
+ self.inner.put_opts(p, v, o).await
+ }
+
+ async fn put_multipart_opts(
+ &self,
+ p: &Path,
+ o: PutMultipartOptions,
+ ) -> ObjectStoreResult<Box<dyn MultipartUpload>> {
+ self.inner.put_multipart_opts(p, o).await
+ }
+
+ async fn get_opts(&self, p: &Path, o: GetOptions) ->
ObjectStoreResult<GetResult> {
+ self.get_calls.fetch_add(1, Ordering::Relaxed);
+ self.inner.get_opts(p, o).await
+ }
+
+ async fn get_ranges(
+ &self,
+ p: &Path,
+ ranges: &[Range<u64>],
+ ) -> ObjectStoreResult<Vec<Bytes>> {
+ self.range_calls.fetch_add(1, Ordering::Relaxed);
+ self.inner.get_ranges(p, ranges).await
+ }
+
+ fn delete_stream(
+ &self,
+ paths: futures::stream::BoxStream<'static,
ObjectStoreResult<Path>>,
+ ) -> futures::stream::BoxStream<'static, ObjectStoreResult<Path>> {
+ self.inner.delete_stream(paths)
+ }
+
+ fn list(
+ &self,
+ p: Option<&Path>,
+ ) -> futures::stream::BoxStream<'static,
ObjectStoreResult<ObjectMeta>> {
+ self.inner.list(p)
+ }
+
+ async fn list_with_delimiter(&self, p: Option<&Path>) ->
ObjectStoreResult<ListResult> {
+ self.inner.list_with_delimiter(p).await
+ }
+
+ async fn copy_opts(
+ &self,
+ from: &Path,
+ to: &Path,
+ options: CopyOptions,
+ ) -> ObjectStoreResult<()> {
+ self.inner.copy_opts(from, to, options).await
+ }
+
+ async fn rename_opts(
+ &self,
+ from: &Path,
+ to: &Path,
+ options: RenameOptions,
+ ) -> ObjectStoreResult<()> {
+ self.inner.rename_opts(from, to, options).await
+ }
+ }
+
+ #[tokio::test]
+ async fn preserves_custom_range_delegation_for_local_and_other_backends() {
+ assert_range_read_contract(ScanIoSource::Local).await;
+ // This is also the classification returned for custom libhdfs
schemes, including s3.
+ assert_range_read_contract(ScanIoSource::OtherObjectStore).await;
+ }
+
+ #[tokio::test]
+ async fn
remote_metrics_observe_default_coalescing_instead_of_inner_override() {
+ assert_range_read_contract(ScanIoSource::ObjectStore).await;
+ }
+
+ async fn assert_range_read_contract(source: ScanIoSource) {
+ let store = Arc::new(RecordingRangeStore {
+ inner: InMemory::new(),
+ range_calls: AtomicUsize::new(0),
+ get_calls: AtomicUsize::new(0),
+ });
+ let location = Path::from("ranges.parquet");
+ store
+ .put(&location, Bytes::from_static(b"0123456789").into())
+ .await
+ .unwrap();
+ let runtime =
datafusion::execution::runtime_env::RuntimeEnv::default();
+ let metrics = ExecutionPlanMetricsSet::new();
+ let factory = EagerPageIndexReaderFactory::new(
+ Arc::clone(&store) as Arc<dyn ObjectStore>,
+ runtime.cache_manager.get_file_metadata_cache(),
+ source,
+ &metrics,
+ );
+ let mut reader = factory
+ .create_reader(
+ 0,
+ PartitionedFile::new(location.to_string(), 10),
+ None,
+ &metrics,
+ )
+ .unwrap();
+ let result = reader.get_byte_ranges(vec![0..2, 4..6]).await.unwrap();
+ assert_eq!(
+ result,
+ vec![Bytes::from_static(b"01"), Bytes::from_static(b"45")]
+ );
+ let remote = source == ScanIoSource::ObjectStore;
+ assert_eq!(
+ store.range_calls.load(Ordering::Relaxed),
+ usize::from(!remote)
+ );
+ assert_eq!(store.get_calls.load(Ordering::Relaxed),
usize::from(remote));
+ assert_eq!(
+ metrics
+ .clone_inner()
+ .sum_by_name("scan_io_data_bytes")
+ .unwrap()
+ .as_usize(),
+ 4
+ );
+ assert_eq!(
+ metrics
+ .clone_inner()
+ .sum_by_name("scan_io_object_store_response_bytes_read")
+ .unwrap()
+ .as_usize(),
+ if remote { 6 } else { 0 }
+ );
+ }
+}
diff --git a/native/core/src/parquet/parquet_exec.rs
b/native/core/src/parquet/parquet_exec.rs
index 96c762f8dd..ff611dd3d1 100644
--- a/native/core/src/parquet/parquet_exec.rs
+++ b/native/core/src/parquet/parquet_exec.rs
@@ -16,9 +16,10 @@
// under the License.
use crate::execution::operators::ExecutionError;
-use
crate::parquet::eager_page_index_reader_factory::EagerPageIndexReaderFactory;
+use
crate::parquet::eager_page_index_reader_factory::{EagerPageIndexReaderFactory,
ScanIoSource};
use crate::parquet::encryption_support::{CometEncryptionConfig,
ENCRYPTION_FACTORY_ID};
use crate::parquet::name_fold::fold_schema_names;
+use crate::parquet::parquet_support::ObjectStoreBackend;
use crate::parquet::parquet_support::SparkParquetOptions;
use crate::parquet::schema_adapter::SparkPhysicalExprAdapterFactory;
use arrow::datatypes::{Field, FieldRef, SchemaRef};
@@ -64,6 +65,7 @@ pub(crate) fn init_datasource_exec(
data_schema: Option<SchemaRef>,
partition_schema: Option<SchemaRef>,
object_store_url: ObjectStoreUrl,
+ object_store_backend: ObjectStoreBackend,
file_groups: Vec<Vec<PartitionedFile>>,
projection_vector: Option<Vec<usize>>,
data_filters: Option<Vec<Arc<dyn PhysicalExpr>>>,
@@ -163,15 +165,21 @@ pub(crate) fn init_datasource_exec(
// cached with the footer, at the cost of losing the skip's benefit when
it would have
// applied. Filed upstream as apache/datafusion#23978; revert this once
that's fixed.
//
- // TODO: metadata I/O is invisible in metrics. `fetch_metadata` reads via
`ObjectStore::get_ranges`,
- // bypassing the `get_bytes` path where `bytes_scanned` is counted. A
byte-counting ObjectStore
- // wrapper would surface it.
+ // Preserve bytes_scanned's existing requested data/Bloom-filter range
accounting. Footer
+ // and page-index reads through get_metadata bypass it, and coalescing may
fetch extra bytes.
+ // The scan I/O counters expose those gaps without changing task
inputMetrics.bytesRead or
+ // scan_efficiency_ratio, which continue to derive from bytes_scanned.
let runtime_env = session_ctx.runtime_env();
let store = runtime_env.object_store(&object_store_url)?;
let metadata_cache = runtime_env.cache_manager.get_file_metadata_cache();
- parquet_source = parquet_source.with_parquet_file_reader_factory(Arc::new(
- EagerPageIndexReaderFactory::new(store, metadata_cache),
+ let scan_io_source = scan_io_source(object_store_backend);
+ let reader_factory = Arc::new(EagerPageIndexReaderFactory::new(
+ store,
+ metadata_cache,
+ scan_io_source,
+ parquet_source.metrics(),
));
+ parquet_source =
parquet_source.with_parquet_file_reader_factory(reader_factory);
// Route data filters through `try_pushdown_filters` rather than calling
// `with_predicate` directly. This is the contract DataFusion's optimizer
@@ -241,6 +249,14 @@ fn physical_object_store_scheme(object_store_url:
&ObjectStoreUrl) -> &str {
scheme
}
+fn scan_io_source(backend: ObjectStoreBackend) -> ScanIoSource {
+ match backend {
+ ObjectStoreBackend::Local => ScanIoSource::Local,
+ ObjectStoreBackend::Remote => ScanIoSource::ObjectStore,
+ ObjectStoreBackend::Other => ScanIoSource::OtherObjectStore,
+ }
+}
+
#[allow(clippy::too_many_arguments)]
fn get_options(
session_timezone: &str,
@@ -323,13 +339,125 @@ mod tests {
use arrow::array::Int32Array;
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
- use
datafusion::datasource::physical_plan::parquet::metadata::CachedParquetMetaData;
+ use bytes::Bytes;
+ use datafusion::datasource::physical_plan::parquet::metadata::{
+ CachedParquetMetaData, DFParquetMetadata,
+ };
+ use
datafusion::datasource::physical_plan::parquet::ParquetFileReaderFactory;
+ use datafusion::execution::cache::cache_manager::CachedFileMetadataEntry;
+ use datafusion::logical_expr::Operator;
+ use datafusion::physical_expr::expressions::{BinaryExpr, Literal};
+ use datafusion::physical_plan::metrics::ExecutionPlanMetricsSet;
use datafusion::physical_plan::ExecutionPlan;
use datafusion_comet_spark_expr::test_common::file_util::get_temp_filename;
use futures::StreamExt;
+ use object_store::memory::InMemory;
+ use object_store::throttle::{ThrottleConfig, ThrottledStore};
+ use object_store::{ObjectStore, ObjectStoreExt};
+ use parquet::arrow::arrow_reader::ArrowReaderOptions;
use parquet::arrow::ArrowWriter;
+ use parquet::encryption::decrypt::{FileDecryptionProperties, KeyRetriever};
+ use parquet::encryption::encrypt::FileEncryptionProperties;
+ use parquet::file::metadata::{KeyValue, PageIndexPolicy,
ParquetMetaDataReader};
use parquet::file::properties::{EnabledStatistics, WriterProperties};
use std::fs::File;
+ use std::time::Duration;
+
+ fn write_scan_io_fixture() -> (String, SchemaRef) {
+ let schema = Arc::new(Schema::new(vec![
+ Field::new("a", DataType::Int32, false),
+ Field::new("b", DataType::Int32, false),
+ ]));
+ let first = RecordBatch::try_new(
+ Arc::clone(&schema),
+ vec![
+ Arc::new(Int32Array::from((0..500).collect::<Vec<i32>>())),
+
Arc::new(Int32Array::from((10_000..10_500).collect::<Vec<i32>>())),
+ ],
+ )
+ .unwrap();
+ let second = RecordBatch::try_new(
+ Arc::clone(&schema),
+ vec![
+
Arc::new(Int32Array::from((1_000..1_500).collect::<Vec<i32>>())),
+
Arc::new(Int32Array::from((11_000..11_500).collect::<Vec<i32>>())),
+ ],
+ )
+ .unwrap();
+
+ let filename = get_temp_filename()
+ .as_path()
+ .as_os_str()
+ .to_str()
+ .unwrap()
+ .to_string();
+ let props = WriterProperties::builder()
+ .set_statistics_enabled(EnabledStatistics::Page)
+ .set_data_page_row_count_limit(100)
+ .set_max_row_group_row_count(Some(500))
+ .build();
+ let file = File::create(&filename).unwrap();
+ let mut writer = ArrowWriter::try_new(file, Arc::clone(&schema),
Some(props)).unwrap();
+ writer.write(&first).unwrap();
+ writer.write(&second).unwrap();
+ writer.close().unwrap();
+
+ (filename, schema)
+ }
+
+ fn init_test_scan(
+ required_schema: SchemaRef,
+ data_schema: SchemaRef,
+ partitioned_file: PartitionedFile,
+ projection: Option<Vec<usize>>,
+ filters: Option<Vec<Arc<dyn PhysicalExpr>>>,
+ session_ctx: &Arc<SessionContext>,
+ ) -> Arc<DataSourceExec> {
+ init_datasource_exec(
+ required_schema,
+ Some(data_schema),
+ None,
+ ObjectStoreUrl::local_filesystem(),
+ ObjectStoreBackend::Local,
+ vec![vec![partitioned_file]],
+ projection,
+ filters,
+ None,
+ "UTC",
+ true,
+ false,
+ false,
+ false,
+ session_ctx,
+ false,
+ false,
+ false,
+ )
+ .unwrap()
+ }
+
+ async fn drain_scan(scan: &Arc<DataSourceExec>, session_ctx:
&Arc<SessionContext>) {
+ let mut stream = scan.execute(0, session_ctx.task_ctx()).unwrap();
+ while let Some(batch) = stream.next().await {
+ batch.unwrap();
+ }
+ }
+
+ fn scan_metric(scan: &Arc<DataSourceExec>, name: &str) -> usize {
+ scan.metrics()
+ .unwrap()
+ .sum_by_name(name)
+ .unwrap_or_else(|| panic!("missing metric {name}"))
+ .as_usize()
+ }
+
+ fn reader_metric(metrics: &ExecutionPlanMetricsSet, name: &str) -> usize {
+ metrics
+ .clone_inner()
+ .sum_by_name(name)
+ .unwrap_or_else(|| panic!("missing metric {name}"))
+ .as_usize()
+ }
#[test]
fn preserves_physical_uri_for_isolated_encrypted_object_stores() {
@@ -464,6 +592,7 @@ mod tests {
None,
None,
ObjectStoreUrl::local_filesystem(),
+ ObjectStoreBackend::Local,
vec![vec![partitioned_file]],
None,
None,
@@ -506,4 +635,714 @@ mod tests {
"cached metadata must include the page index"
);
}
+
+ #[tokio::test]
+ async fn reports_cold_and_warm_metadata_io_without_data_reads() {
+ let (filename, _schema) = write_scan_io_fixture();
+ let file_bytes = std::fs::read(&filename).unwrap();
+ let footer_bytes = usize::try_from(u32::from_le_bytes(
+ file_bytes[file_bytes.len() - 8..file_bytes.len() - 4]
+ .try_into()
+ .unwrap(),
+ ))
+ .unwrap();
+ let partitioned_file = PartitionedFile::from_path(filename).unwrap();
+ let session_ctx = Arc::new(SessionContext::new());
+ let runtime_env = session_ctx.runtime_env();
+ let store = runtime_env
+ .object_store(ObjectStoreUrl::local_filesystem())
+ .unwrap();
+ let cold_metrics = ExecutionPlanMetricsSet::new();
+ let metadata_cache =
runtime_env.cache_manager.get_file_metadata_cache();
+ let cold_factory = EagerPageIndexReaderFactory::new(
+ Arc::clone(&store),
+ Arc::clone(&metadata_cache),
+ ScanIoSource::Local,
+ &cold_metrics,
+ );
+ let mut cold_reader = cold_factory
+ .create_reader(0, partitioned_file.clone(), Some(512 * 1024),
&cold_metrics)
+ .unwrap();
+ cold_reader.get_metadata(None).await.unwrap();
+
+ let cold_metadata_bytes = reader_metric(&cold_metrics,
"scan_io_metadata_bytes");
+ assert!(cold_metadata_bytes > footer_bytes);
+ assert_eq!(reader_metric(&cold_metrics, "scan_io_data_bytes"), 0);
+ assert_eq!(reader_metric(&cold_metrics, "scan_io_footer_reads"), 1);
+ assert_eq!(
+ reader_metric(&cold_metrics, "scan_io_footer_bytes"),
+ footer_bytes
+ );
+ assert_eq!(
+ reader_metric(&cold_metrics, "scan_io_object_store_get_calls"),
+ 0
+ );
+ assert_eq!(
+ reader_metric(&cold_metrics,
"scan_io_object_store_get_requested_bytes"),
+ 0
+ );
+ assert_eq!(
+ reader_metric(&cold_metrics,
"scan_io_object_store_response_bytes_read"),
+ 0
+ );
+ assert_eq!(
+ reader_metric(&cold_metrics, "scan_io_metadata_cache_hits"),
+ 0
+ );
+ assert_eq!(
+ reader_metric(&cold_metrics, "scan_io_metadata_cache_misses"),
+ 1
+ );
+
+ let warm_metrics = ExecutionPlanMetricsSet::new();
+ let warm_factory = EagerPageIndexReaderFactory::new(
+ store,
+ metadata_cache,
+ ScanIoSource::Local,
+ &warm_metrics,
+ );
+ let mut warm_reader = warm_factory
+ .create_reader(0, partitioned_file, Some(512 * 1024),
&warm_metrics)
+ .unwrap();
+ warm_reader.get_metadata(None).await.unwrap();
+
+ assert_eq!(reader_metric(&warm_metrics, "scan_io_metadata_bytes"), 0);
+ assert_eq!(reader_metric(&warm_metrics, "scan_io_footer_reads"), 0);
+ assert_eq!(reader_metric(&warm_metrics, "scan_io_footer_bytes"), 0);
+ assert_eq!(
+ reader_metric(&warm_metrics, "scan_io_metadata_cache_hits"),
+ 1
+ );
+ assert_eq!(
+ reader_metric(&warm_metrics, "scan_io_metadata_cache_misses"),
+ 0
+ );
+ }
+
+ #[tokio::test]
+ async fn upgrades_partial_cached_metadata_with_local_direct_reads() {
+ let (filename, _schema) = write_scan_io_fixture();
+ let partitioned_file = PartitionedFile::from_path(filename).unwrap();
+ let session_ctx = Arc::new(SessionContext::new());
+ let runtime_env = session_ctx.runtime_env();
+ let store = runtime_env
+ .object_store(ObjectStoreUrl::local_filesystem())
+ .unwrap();
+ let metadata_cache =
runtime_env.cache_manager.get_file_metadata_cache();
+ let footer_metadata = DFParquetMetadata::new(store.as_ref(),
&partitioned_file.object_meta)
+ .with_page_index_policy(Some(PageIndexPolicy::Skip))
+ .fetch_metadata()
+ .await
+ .unwrap();
+ assert!(footer_metadata.column_index().is_none());
+ assert!(footer_metadata.offset_index().is_none());
+ metadata_cache.put(
+ &partitioned_file.object_meta.location,
+ CachedFileMetadataEntry::new(
+ partitioned_file.object_meta.clone(),
+ Arc::new(CachedParquetMetaData::new(footer_metadata)),
+ ),
+ );
+
+ let metrics = ExecutionPlanMetricsSet::new();
+ let factory =
+ EagerPageIndexReaderFactory::new(store, metadata_cache,
ScanIoSource::Local, &metrics);
+ let mut reader = factory
+ .create_reader(0, partitioned_file, Some(512 * 1024), &metrics)
+ .unwrap();
+ let metadata = reader.get_metadata(None).await.unwrap();
+
+ assert!(metadata.column_index().is_some());
+ assert!(metadata.offset_index().is_some());
+ let metadata_bytes = reader_metric(&metrics, "scan_io_metadata_bytes");
+ assert!(metadata_bytes > 0);
+ assert_eq!(reader_metric(&metrics, "scan_io_data_bytes"), 0);
+ assert_eq!(reader_metric(&metrics, "scan_io_footer_reads"), 0);
+ assert_eq!(reader_metric(&metrics, "scan_io_footer_bytes"), 0);
+ assert_eq!(reader_metric(&metrics, "scan_io_metadata_cache_hits"), 0);
+ assert_eq!(reader_metric(&metrics, "scan_io_metadata_cache_misses"),
1);
+
+ let column = metadata.row_group(0).column(0);
+ let page_index_offset =
u64::try_from(column.column_index_offset().unwrap()).unwrap();
+ let page_index_length =
u64::try_from(column.column_index_length().unwrap()).unwrap();
+ reader
+ .get_bytes(page_index_offset..page_index_offset +
page_index_length)
+ .await
+ .unwrap();
+ assert_eq!(
+ reader_metric(&metrics, "scan_io_metadata_bytes"),
+ metadata_bytes + page_index_length as usize
+ );
+ assert_eq!(reader_metric(&metrics, "scan_io_data_bytes"), 0);
+ }
+
+ #[test]
+ fn registers_scan_io_metrics_once_per_execution_plan() {
+ let (filename, _schema) = write_scan_io_fixture();
+ let partitioned_file = PartitionedFile::from_path(filename).unwrap();
+ let session_ctx = Arc::new(SessionContext::new());
+ let runtime_env = session_ctx.runtime_env();
+ let store = runtime_env
+ .object_store(ObjectStoreUrl::local_filesystem())
+ .unwrap();
+ let metadata_cache =
runtime_env.cache_manager.get_file_metadata_cache();
+ let metrics = ExecutionPlanMetricsSet::new();
+ let factory =
+ EagerPageIndexReaderFactory::new(store, metadata_cache,
ScanIoSource::Local, &metrics);
+
+ for partition in 0..8 {
+ factory
+ .create_reader(partition, partitioned_file.clone(), None,
&metrics)
+ .unwrap();
+ }
+
+ let registered_metrics = metrics.clone_inner();
+ let scan_io_metrics = registered_metrics
+ .iter()
+ .filter(|metric| metric.value().name().starts_with("scan_io_"))
+ .collect::<Vec<_>>();
+ assert_eq!(scan_io_metrics.len(), 9);
+ for metric in scan_io_metrics {
+ assert!(metric.labels().is_empty());
+ assert_eq!(metric.partition(), None);
+ }
+ }
+
+ #[tokio::test]
+ async fn reports_object_store_read_amplification_after_range_coalescing() {
+ let file_size = 524_416;
+ let location = object_store::path::Path::from("coalesced.parquet");
+ let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
+ store
+ .put(&location, Bytes::from(vec![0; file_size]).into())
+ .await
+ .unwrap();
+
+ let session_ctx = Arc::new(SessionContext::new());
+ let metadata_cache = session_ctx
+ .runtime_env()
+ .cache_manager
+ .get_file_metadata_cache();
+ let metrics = ExecutionPlanMetricsSet::new();
+ let factory = EagerPageIndexReaderFactory::new(
+ store,
+ metadata_cache,
+ ScanIoSource::ObjectStore,
+ &metrics,
+ );
+ let mut reader = factory
+ .create_reader(
+ 0,
+ PartitionedFile::new(location.to_string(), file_size as u64),
+ None,
+ &metrics,
+ )
+ .unwrap();
+ let buffers = reader
+ .get_byte_ranges(vec![0..64, 524_352..524_416])
+ .await
+ .unwrap();
+
+ assert_eq!(buffers.iter().map(Bytes::len).sum::<usize>(), 128);
+ assert_eq!(reader_metric(&metrics, "bytes_scanned"), 128);
+ assert_eq!(reader_metric(&metrics, "scan_io_data_bytes"), 128);
+ assert_eq!(reader_metric(&metrics, "scan_io_metadata_bytes"), 0);
+ assert_eq!(reader_metric(&metrics, "scan_io_object_store_get_calls"),
1);
+ assert_eq!(
+ reader_metric(&metrics,
"scan_io_object_store_get_requested_bytes"),
+ file_size
+ );
+ assert_eq!(
+ reader_metric(&metrics,
"scan_io_object_store_response_bytes_read"),
+ file_size
+ );
+ }
+
+ #[tokio::test]
+ async fn reports_object_store_footer_and_metadata_reads() {
+ let (filename, _schema) = write_scan_io_fixture();
+ let file_bytes = std::fs::read(filename).unwrap();
+ let file_size = file_bytes.len();
+ let footer_bytes = usize::try_from(u32::from_le_bytes(
+ file_bytes[file_size - 8..file_size - 4].try_into().unwrap(),
+ ))
+ .unwrap();
+ let location = object_store::path::Path::from("footer.parquet");
+ let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
+ store
+ .put(&location, Bytes::from(file_bytes).into())
+ .await
+ .unwrap();
+
+ let session_ctx = Arc::new(SessionContext::new());
+ let metadata_cache = session_ctx
+ .runtime_env()
+ .cache_manager
+ .get_file_metadata_cache();
+ let metrics = ExecutionPlanMetricsSet::new();
+ let factory = EagerPageIndexReaderFactory::new(
+ store,
+ metadata_cache,
+ ScanIoSource::ObjectStore,
+ &metrics,
+ );
+ let mut reader = factory
+ .create_reader(
+ 0,
+ PartitionedFile::new(location.to_string(), file_size as u64),
+ Some(512 * 1024),
+ &metrics,
+ )
+ .unwrap();
+ reader.get_metadata(None).await.unwrap();
+
+ assert_eq!(reader_metric(&metrics, "scan_io_data_bytes"), 0);
+ assert_eq!(reader_metric(&metrics, "scan_io_metadata_bytes"),
file_size);
+ assert_eq!(reader_metric(&metrics, "scan_io_footer_reads"), 1);
+ assert_eq!(
+ reader_metric(&metrics, "scan_io_footer_bytes"),
+ footer_bytes
+ );
+ assert_eq!(reader_metric(&metrics, "scan_io_object_store_get_calls"),
1);
+ assert_eq!(
+ reader_metric(&metrics,
"scan_io_object_store_get_requested_bytes"),
+ file_size
+ );
+ assert_eq!(
+ reader_metric(&metrics,
"scan_io_object_store_response_bytes_read"),
+ file_size
+ );
+ assert_eq!(reader_metric(&metrics, "scan_io_metadata_cache_misses"),
1);
+ }
+
+ #[tokio::test]
+ async fn reports_plaintext_footer_before_page_index_read() {
+ let (filename, _schema) = write_scan_io_fixture();
+ let file_bytes = std::fs::read(filename).unwrap();
+ let file_size = file_bytes.len();
+ let footer_bytes = usize::try_from(u32::from_le_bytes(
+ file_bytes[file_size - 8..file_size - 4].try_into().unwrap(),
+ ))
+ .unwrap();
+ let location =
object_store::path::Path::from("plaintext-footer.parquet");
+ let store: Arc<dyn ObjectStore> = Arc::new(ThrottledStore::new(
+ InMemory::new(),
+ ThrottleConfig {
+ wait_get_per_call: Duration::from_millis(10),
+ ..Default::default()
+ },
+ ));
+ store
+ .put(&location, Bytes::from(file_bytes).into())
+ .await
+ .unwrap();
+
+ let session_ctx = Arc::new(SessionContext::new());
+ let metadata_cache = session_ctx
+ .runtime_env()
+ .cache_manager
+ .get_file_metadata_cache();
+ let metrics = ExecutionPlanMetricsSet::new();
+ let factory = EagerPageIndexReaderFactory::new(
+ store,
+ metadata_cache,
+ ScanIoSource::ObjectStore,
+ &metrics,
+ );
+ let mut reader = factory
+ .create_reader(
+ 0,
+ PartitionedFile::new(location.to_string(), file_size as u64),
+ Some(footer_bytes + 8),
+ &metrics,
+ )
+ .unwrap();
+ let metadata = reader.get_metadata(None);
+ tokio::pin!(metadata);
+
+ tokio::select! {
+ result = &mut metadata => {
+ panic!("metadata load completed before the page-index read:
{result:?}");
+ }
+ () = async {
+ while reader_metric(&metrics,
"scan_io_object_store_get_calls") < 2 {
+ tokio::task::yield_now().await;
+ }
+ } => {
+ assert_eq!(reader_metric(&metrics, "scan_io_footer_reads"), 1);
+ assert_eq!(reader_metric(&metrics, "scan_io_footer_bytes"),
footer_bytes);
+ }
+ }
+
+ metadata.await.unwrap();
+ assert_eq!(reader_metric(&metrics, "scan_io_footer_reads"), 1);
+ assert_eq!(
+ reader_metric(&metrics, "scan_io_footer_bytes"),
+ footer_bytes
+ );
+ }
+
+ #[tokio::test]
+ async fn reports_plaintext_footer_when_prefetched_page_index_is_invalid() {
+ let (filename, _schema) = write_scan_io_fixture();
+ let mut file_bytes = std::fs::read(filename).unwrap();
+ let file_size = file_bytes.len();
+ let footer_bytes = usize::try_from(u32::from_le_bytes(
+ file_bytes[file_size - 8..file_size - 4].try_into().unwrap(),
+ ))
+ .unwrap();
+ let footer_start = file_size - 8 - footer_bytes;
+ let footer =
+
ParquetMetaDataReader::decode_metadata(&file_bytes[footer_start..file_size - 8])
+ .unwrap();
+ let column = footer.row_group(0).column(0);
+ let index_start =
usize::try_from(column.column_index_offset().unwrap()).unwrap();
+ let index_bytes =
usize::try_from(column.column_index_length().unwrap()).unwrap();
+ file_bytes[index_start..index_start + index_bytes].fill(0xff);
+
+ let location =
object_store::path::Path::from("invalid-prefetched-page-index.parquet");
+ let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
+ store
+ .put(&location, Bytes::from(file_bytes).into())
+ .await
+ .unwrap();
+
+ let session_ctx = Arc::new(SessionContext::new());
+ let metadata_cache = session_ctx
+ .runtime_env()
+ .cache_manager
+ .get_file_metadata_cache();
+ let metrics = ExecutionPlanMetricsSet::new();
+ let factory = EagerPageIndexReaderFactory::new(
+ store,
+ metadata_cache,
+ ScanIoSource::ObjectStore,
+ &metrics,
+ );
+ let mut reader = factory
+ .create_reader(
+ 0,
+ PartitionedFile::new(location.to_string(), file_size as u64),
+ Some(512 * 1024),
+ &metrics,
+ )
+ .unwrap();
+
+ assert!(reader.get_metadata(None).await.is_err());
+ assert_eq!(reader_metric(&metrics, "scan_io_metadata_bytes"),
file_size);
+ assert_eq!(reader_metric(&metrics, "scan_io_object_store_get_calls"),
1);
+ assert_eq!(reader_metric(&metrics, "scan_io_footer_reads"), 1);
+ assert_eq!(
+ reader_metric(&metrics, "scan_io_footer_bytes"),
+ footer_bytes
+ );
+ }
+
+ #[tokio::test]
+ async fn reports_encrypted_footer_before_key_retrieval() {
+ assert_encrypted_footer_before_key_retrieval(0, false).await;
+ }
+
+ #[tokio::test]
+ async fn does_not_report_encrypted_footer_before_payload_is_read() {
+ assert_encrypted_footer_before_key_retrieval(512 * 1024, false).await;
+ }
+
+ #[tokio::test]
+ async fn counts_complete_encrypted_footer_even_when_authentication_fails()
{
+ assert_encrypted_footer_before_key_retrieval(0, true).await;
+ }
+
+ async fn assert_encrypted_footer_before_key_retrieval(footer_padding:
usize, corrupt: bool) {
+ struct ObservingKeyRetriever {
+ key: Vec<u8>,
+ metrics: Arc<ExecutionPlanMetricsSet>,
+ footer_bytes: usize,
+ }
+
+ impl KeyRetriever for ObservingKeyRetriever {
+ fn retrieve_key(&self, _key_metadata: &[u8]) ->
parquet::errors::Result<Vec<u8>> {
+ assert_eq!(reader_metric(&self.metrics,
"scan_io_footer_reads"), 1);
+ assert_eq!(
+ reader_metric(&self.metrics, "scan_io_footer_bytes"),
+ self.footer_bytes
+ );
+ Ok(self.key.clone())
+ }
+ }
+
+ let key = b"0123456789012345".to_vec();
+ let encryption = FileEncryptionProperties::builder(key.clone())
+ .with_footer_key_metadata(b"footer".to_vec())
+ .build()
+ .unwrap();
+ let properties = WriterProperties::builder()
+ .with_file_encryption_properties(encryption)
+ .set_key_value_metadata((footer_padding > 0).then(|| {
+ vec![KeyValue::new(
+ "padding".to_string(),
+ "x".repeat(footer_padding),
+ )]
+ }))
+ .build();
+ let schema = Arc::new(Schema::new(vec![Field::new("a",
DataType::Int32, false)]));
+ let batch = RecordBatch::try_new(
+ Arc::clone(&schema),
+ vec![Arc::new(Int32Array::from(vec![1, 2, 3]))],
+ )
+ .unwrap();
+ let filename = get_temp_filename();
+ let mut writer =
+ ArrowWriter::try_new(File::create(&filename).unwrap(), schema,
Some(properties))
+ .unwrap();
+ writer.write(&batch).unwrap();
+ writer.close().unwrap();
+
+ let mut file_bytes = std::fs::read(&filename).unwrap();
+ let file_size = file_bytes.len();
+ let footer_bytes = usize::try_from(u32::from_le_bytes(
+ file_bytes[file_size - 8..file_size - 4].try_into().unwrap(),
+ ))
+ .unwrap();
+ let location =
object_store::path::Path::from("encrypted-footer.parquet");
+ if corrupt {
+ // Corrupt the encrypted payload's authentication tag, preserving
its length/trailer
+ // and key metadata. Complete I/O is counted before decryption is
attempted.
+ file_bytes[file_size - 9] ^= 1;
+ }
+ let store: Arc<dyn ObjectStore> = if footer_padding > 0 {
+ assert!(footer_bytes > 512 * 1024);
+ Arc::new(ThrottledStore::new(
+ InMemory::new(),
+ ThrottleConfig {
+ wait_get_per_call: Duration::from_millis(10),
+ ..Default::default()
+ },
+ ))
+ } else {
+ Arc::new(InMemory::new())
+ };
+ store
+ .put(&location, Bytes::from(file_bytes).into())
+ .await
+ .unwrap();
+
+ let session_ctx = Arc::new(SessionContext::new());
+ let metadata_cache = session_ctx
+ .runtime_env()
+ .cache_manager
+ .get_file_metadata_cache();
+ let metrics = Arc::new(ExecutionPlanMetricsSet::new());
+ let factory = EagerPageIndexReaderFactory::new(
+ store,
+ metadata_cache,
+ ScanIoSource::ObjectStore,
+ &metrics,
+ );
+ let mut reader = factory
+ .create_reader(
+ 0,
+ PartitionedFile::new(location.to_string(), file_size as u64),
+ Some(512 * 1024),
+ &metrics,
+ )
+ .unwrap();
+ let decryption =
+
FileDecryptionProperties::with_key_retriever(Arc::new(ObservingKeyRetriever {
+ key,
+ metrics: Arc::clone(&metrics),
+ footer_bytes,
+ }))
+ .build()
+ .unwrap();
+ let options =
ArrowReaderOptions::new().with_file_decryption_properties(decryption);
+
+ if footer_padding > 0 {
+ let metadata = reader.get_metadata(Some(&options));
+ tokio::pin!(metadata);
+
+ tokio::select! {
+ result = &mut metadata => {
+ panic!("metadata load completed before the second footer
read: {result:?}");
+ }
+ () = async {
+ while reader_metric(&metrics,
"scan_io_object_store_get_calls") < 2 {
+ tokio::task::yield_now().await;
+ }
+ } => {
+ assert_eq!(reader_metric(&metrics,
"scan_io_footer_reads"), 0);
+ assert_eq!(reader_metric(&metrics,
"scan_io_footer_bytes"), 0);
+ }
+ }
+
+ metadata.await.unwrap();
+ } else if corrupt {
+ assert!(reader.get_metadata(Some(&options)).await.is_err());
+ } else {
+ reader.get_metadata(Some(&options)).await.unwrap();
+ }
+
+ assert_eq!(reader_metric(&metrics, "scan_io_footer_reads"), 1);
+ assert_eq!(
+ reader_metric(&metrics, "scan_io_footer_bytes"),
+ footer_bytes
+ );
+ }
+
+ #[tokio::test]
+ async fn does_not_report_footer_metrics_when_metadata_decoding_fails() {
+ let (filename, _schema) = write_scan_io_fixture();
+ let mut file_bytes = std::fs::read(&filename).unwrap();
+ let file_size = file_bytes.len();
+ let footer_bytes = usize::try_from(u32::from_le_bytes(
+ file_bytes[file_size - 8..file_size - 4].try_into().unwrap(),
+ ))
+ .unwrap();
+ file_bytes[file_size - 8 - footer_bytes..file_size - 8].fill(0xff);
+ std::fs::write(&filename, file_bytes).unwrap();
+
+ let session_ctx = Arc::new(SessionContext::new());
+ let runtime_env = session_ctx.runtime_env();
+ let store = runtime_env
+ .object_store(ObjectStoreUrl::local_filesystem())
+ .unwrap();
+ let metadata_cache =
runtime_env.cache_manager.get_file_metadata_cache();
+ let metrics = ExecutionPlanMetricsSet::new();
+ let factory =
+ EagerPageIndexReaderFactory::new(store, metadata_cache,
ScanIoSource::Local, &metrics);
+ let mut reader = factory
+ .create_reader(
+ 0,
+ PartitionedFile::from_path(filename).unwrap(),
+ Some(512 * 1024),
+ &metrics,
+ )
+ .unwrap();
+
+ assert!(reader.get_metadata(None).await.is_err());
+ assert!(reader_metric(&metrics, "scan_io_metadata_bytes") > 0);
+ assert_eq!(reader_metric(&metrics, "scan_io_footer_reads"), 0);
+ assert_eq!(reader_metric(&metrics, "scan_io_footer_bytes"), 0);
+ assert_eq!(reader_metric(&metrics, "scan_io_metadata_cache_hits"), 0);
+ assert_eq!(reader_metric(&metrics, "scan_io_metadata_cache_misses"),
0);
+ }
+
+ #[tokio::test]
+ async fn classifies_bloom_filter_reads_as_metadata() {
+ let schema = Arc::new(Schema::new(vec![Field::new("a",
DataType::Int32, false)]));
+ let batch = RecordBatch::try_new(
+ Arc::clone(&schema),
+ vec![Arc::new(Int32Array::from(
+ (0..500).map(|value| value * 2).collect::<Vec<i32>>(),
+ ))],
+ )
+ .unwrap();
+ let filename = get_temp_filename()
+ .as_path()
+ .as_os_str()
+ .to_str()
+ .unwrap()
+ .to_string();
+ let props = WriterProperties::builder()
+ .set_statistics_enabled(EnabledStatistics::Page)
+ .set_bloom_filter_max_ndv(500)
+ .set_bloom_filter_fpp(0.0001)
+ .build();
+ let file = File::create(&filename).unwrap();
+ let mut writer = ArrowWriter::try_new(file, Arc::clone(&schema),
Some(props)).unwrap();
+ writer.write(&batch).unwrap();
+ writer.close().unwrap();
+
+ let filter: Arc<dyn PhysicalExpr> = Arc::new(BinaryExpr::new(
+ Arc::new(Column::new("a", 0)),
+ Operator::Eq,
+ Arc::new(Literal::new(ScalarValue::Int32(Some(501)))),
+ ));
+ let session_ctx = Arc::new(SessionContext::new());
+ let scan = init_test_scan(
+ Arc::clone(&schema),
+ schema,
+ PartitionedFile::from_path(filename).unwrap(),
+ None,
+ Some(vec![filter]),
+ &session_ctx,
+ );
+ drain_scan(&scan, &session_ctx).await;
+
+ let bloom_bytes = scan_metric(&scan, "bytes_scanned");
+ assert!(bloom_bytes > 0);
+ assert_eq!(scan_metric(&scan, "scan_io_data_bytes"), 0);
+ assert!(scan_metric(&scan, "scan_io_metadata_bytes") >= bloom_bytes);
+ }
+
+ #[tokio::test]
+ async fn reports_projected_and_predicate_pruned_data_io() {
+ let (filename, schema) = write_scan_io_fixture();
+ let partitioned_file = PartitionedFile::from_path(filename).unwrap();
+ let session_ctx = Arc::new(SessionContext::new());
+
+ let full_scan = init_test_scan(
+ Arc::clone(&schema),
+ Arc::clone(&schema),
+ partitioned_file.clone(),
+ None,
+ None,
+ &session_ctx,
+ );
+ drain_scan(&full_scan, &session_ctx).await;
+
+ let projected_schema = Arc::new(Schema::new(vec![Field::new("a",
DataType::Int32, false)]));
+ let projected_scan = init_test_scan(
+ Arc::clone(&projected_schema),
+ Arc::clone(&schema),
+ partitioned_file.clone(),
+ Some(vec![0]),
+ None,
+ &session_ctx,
+ );
+ drain_scan(&projected_scan, &session_ctx).await;
+
+ let filter: Arc<dyn PhysicalExpr> = Arc::new(BinaryExpr::new(
+ Arc::new(Column::new("a", 0)),
+ Operator::Lt,
+ Arc::new(Literal::new(ScalarValue::Int32(Some(500)))),
+ ));
+ let filtered_scan = init_test_scan(
+ projected_schema,
+ schema,
+ partitioned_file,
+ Some(vec![0]),
+ Some(vec![filter]),
+ &session_ctx,
+ );
+ drain_scan(&filtered_scan, &session_ctx).await;
+
+ let full_data = scan_metric(&full_scan, "scan_io_data_bytes");
+ let projected_data = scan_metric(&projected_scan,
"scan_io_data_bytes");
+ let filtered_data = scan_metric(&filtered_scan, "scan_io_data_bytes");
+ assert!(
+ projected_data < full_data,
+ "projection should request fewer data bytes:
projected={projected_data}, full={full_data}"
+ );
+ assert!(
+ filtered_data < projected_data,
+ "row-group pruning should request fewer data bytes:
filtered={filtered_data}, projected={projected_data}"
+ );
+
+ for scan in [&full_scan, &projected_scan, &filtered_scan] {
+ assert_eq!(
+ scan_metric(scan, "bytes_scanned"),
+ scan_metric(scan, "scan_io_data_bytes")
+ );
+ assert_eq!(scan_metric(scan, "scan_io_object_store_get_calls"), 0);
+ assert_eq!(
+ scan_metric(scan, "scan_io_object_store_get_requested_bytes"),
+ 0
+ );
+ assert_eq!(
+ scan_metric(scan, "scan_io_object_store_response_bytes_read"),
+ 0
+ );
+ }
+ }
}
diff --git a/native/core/src/parquet/parquet_support.rs
b/native/core/src/parquet/parquet_support.rs
index fb92965def..25abaa8a4c 100644
--- a/native/core/src/parquet/parquet_support.rs
+++ b/native/core/src/parquet/parquet_support.rs
@@ -39,7 +39,7 @@ use datafusion_comet_common::SparkError;
use datafusion_comet_spark_expr::EvalMode;
use log::debug;
use object_store::path::Path;
-use object_store::{parse_url, ObjectStore};
+use object_store::{parse_url, ObjectStore, ObjectStoreScheme};
use parquet::arrow::PARQUET_FIELD_ID_META_KEY;
use std::collections::HashMap;
use std::sync::OnceLock;
@@ -667,13 +667,46 @@ fn hash_object_store_configs(configs: &HashMap<String,
String>) -> u64 {
hasher.finish()
}
-/// Parses the url, registers the object store with configurations, and
returns a tuple of the object store url
-/// and object store path
+/// The selected backend, independent of the URL used to register it in
DataFusion.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum ObjectStoreBackend {
+ Local,
+ Remote,
+ Other,
+}
+
+/// Classifies a normalized URL using the same parser as backend construction.
`is_hdfs` records
+/// the configured libhdfs routing decision, which takes precedence over
cloud-like schemes.
+/// This does not create a store or perform I/O; unsupported native URLs
return a parsing error.
+fn object_store_backend(url: &Url, is_hdfs: bool) ->
Result<ObjectStoreBackend, ExecutionError> {
+ if is_hdfs {
+ // Custom libhdfs schemes may look like cloud URLs but retain their
own range-read API.
+ return Ok(ObjectStoreBackend::Other);
+ }
+ let (scheme, _) =
+ ObjectStoreScheme::parse(url).map_err(|e|
ExecutionError::GeneralError(e.to_string()))?;
+ Ok(match scheme {
+ ObjectStoreScheme::Local => ObjectStoreBackend::Local,
+ ObjectStoreScheme::AmazonS3
+ | ObjectStoreScheme::GoogleCloudStorage
+ | ObjectStoreScheme::MicrosoftAzure
+ | ObjectStoreScheme::Http => ObjectStoreBackend::Remote,
+ // Memory and future backends are not implicitly classified as remote
network traffic.
+ _ => ObjectStoreBackend::Other,
+ })
+}
+
+/// Normalizes the owned URL using the borrowed configuration, selects and
registers the backend
+/// in `runtime_env`, and returns its registry URL, object path, and I/O
classification. Stores are
+/// reused from the process-wide cache when their normalized physical URL,
configuration, and
+/// selected backend match. URL, configuration, and store-construction
failures propagate to the
+/// caller. Callers must use the returned backend classification rather than
infer it from an
+/// original alias or the synthetic registration scheme.
pub(crate) fn prepare_object_store_with_configs(
runtime_env: Arc<RuntimeEnv>,
url: String,
object_store_configs: &HashMap<String, String>,
-) -> Result<(ObjectStoreUrl, Path), ExecutionError> {
+) -> Result<(ObjectStoreUrl, Path, ObjectStoreBackend), ExecutionError> {
// `is_hdfs` comes back from normalization because it must be decided on
the URL as written.
// Re-deriving it from the normalized URL would let an `s3a`/alias rewrite
land on an `s3`
// entry in `fs.comet.libhdfs.schemes` and route an S3 read through
libhdfs.
@@ -681,6 +714,9 @@ pub(crate) fn prepare_object_store_with_configs(
url,
is_hdfs: is_hdfs_scheme,
} = normalize_object_store_url(url.as_str(), object_store_configs)?;
+ // Configured S3 aliases must be normalized before the object-store parser
classifies them.
+ // HDFS routing still wins, including when its configured schemes resemble
remote stores.
+ let backend = object_store_backend(&url, is_hdfs_scheme)?;
let scheme = url.scheme();
let url_key = format!(
"{}://{}",
@@ -749,12 +785,120 @@ pub(crate) fn prepare_object_store_with_configs(
))?
};
runtime_env.register_object_store(object_store_url.as_ref(), object_store);
- Ok((object_store_url, object_store_path))
+ Ok((object_store_url, object_store_path, backend))
}
#[cfg(test)]
mod tests {
- use super::{hash_object_store_configs, object_store_cache,
prepare_object_store_with_configs};
+ /// Checks parser-backed I/O labels without constructing stores, including
libhdfs overrides
+ /// and rejection of unknown native schemes. Configured S3 aliases follow
URL normalization.
+ #[test]
+ fn classifies_the_selected_backend_using_object_store_parser() {
+ use super::{
+ is_hdfs_scheme, normalize_object_store_url, object_store_backend,
ObjectStoreBackend,
+ };
+ let configs = std::collections::HashMap::from([(
+ "fs.comet.libhdfs.schemes".to_string(),
+ "s3,abfs".to_string(),
+ )]);
+ for address in [
+ "s3://bucket/path",
+ "s3a://bucket/path",
+ "gs://bucket/path",
+ "az://container/path",
+ "adl://container/path",
+ "azure://container/path",
+ "abfs://container/path",
+ "abfss://container/path",
+ "http://example.com/path",
+ "https://example.com/path",
+ "https://account.blob.core.windows.net/container/path",
+ ] {
+ let url = url::Url::parse(address).unwrap();
+ assert_eq!(
+ object_store_backend(&url, false).unwrap(),
+ ObjectStoreBackend::Remote,
+ "{address}"
+ );
+ if is_hdfs_scheme(&url, &configs) {
+ assert_eq!(
+ object_store_backend(&url, true).unwrap(),
+ ObjectStoreBackend::Other
+ );
+ }
+ }
+ assert_eq!(
+ object_store_backend(&url::Url::parse("file:///tmp/a").unwrap(),
false).unwrap(),
+ ObjectStoreBackend::Local
+ );
+ assert_eq!(
+ object_store_backend(&url::Url::parse("memory:///a").unwrap(),
false).unwrap(),
+ ObjectStoreBackend::Other
+ );
+ // These spellings are not accepted native backends in pinned
object_store 0.13.2.
+ for scheme in ["gcs", "wasb", "wasbs", "s3n"] {
+ let url =
url::Url::parse(&format!("{scheme}://bucket/path")).unwrap();
+ assert!(object_store_backend(&url, false).is_err());
+ assert_eq!(
+ object_store_backend(&url, true).unwrap(),
+ ObjectStoreBackend::Other
+ );
+ }
+
+ // An alias selected for libhdfs must keep its original scheme and I/O
classification,
+ // even when the same alias is also configured as S3-compatible.
+ let configs = std::collections::HashMap::from([
+ (
+ "fs.comet.s3Compliant.schemes".to_string(),
+ "blob".to_string(),
+ ),
+ ("fs.comet.libhdfs.schemes".to_string(), "blob".to_string()),
+ ]);
+ let normalized = normalize_object_store_url("blob://bucket/path",
&configs).unwrap();
+ assert_eq!(normalized.url.scheme(), "blob");
+ assert_eq!(
+ object_store_backend(&normalized.url, normalized.is_hdfs).unwrap(),
+ ObjectStoreBackend::Other
+ );
+ }
+
+ /// Normalizes original URL spellings and classifies their carried routing
decisions without
+ /// creating stores or performing I/O. Aliases remain remote unless
explicitly routed to
+ /// libhdfs, even when normalization rewrites their scheme to a configured
libhdfs scheme.
+ #[test]
+ fn classifies_s3_aliases_using_original_libhdfs_routing() {
+ use super::{normalize_object_store_url, object_store_backend,
ObjectStoreBackend};
+
+ for (input, libhdfs_schemes, expected) in [
+ ("s3a://bucket/path", "s3", ObjectStoreBackend::Remote),
+ ("blob://bucket/path", "s3", ObjectStoreBackend::Remote),
+ ("s3://bucket/path", "s3", ObjectStoreBackend::Other),
+ ("s3a://bucket/path", "s3a", ObjectStoreBackend::Other),
+ ("blob://bucket/path", "blob", ObjectStoreBackend::Other),
+ ] {
+ let configs = std::collections::HashMap::from([
+ (
+ "fs.comet.s3Compliant.schemes".to_string(),
+ "blob".to_string(),
+ ),
+ (
+ "fs.comet.libhdfs.schemes".to_string(),
+ libhdfs_schemes.to_string(),
+ ),
+ ]);
+ let normalized = normalize_object_store_url(input,
&configs).unwrap();
+ assert_eq!(
+ object_store_backend(&normalized.url,
normalized.is_hdfs).unwrap(),
+ expected,
+ "{input} with libhdfs.schemes={libhdfs_schemes}"
+ );
+ }
+ }
+
+ use super::{
+ hash_object_store_configs, object_store_cache,
prepare_object_store_with_configs,
+ ObjectStoreBackend,
+ };
use bytes::Bytes;
use datafusion::execution::object_store::ObjectStoreUrl;
use datafusion::execution::runtime_env::RuntimeEnv;
@@ -776,8 +920,10 @@ mod tests {
options: HashMap<String, String>,
}
- // Seed distinct stores so these tests exercise cache lookup, registration
and
- // actual reads without needing cloud credentials or a running Hadoop
cluster.
+ /// Seeds two distinct in-memory stores under the expected cache
identities for `cases`,
+ /// using a unique `bucket` to isolate concurrent tests. Checks
registration, returned I/O
+ /// classification, and actual reads in both planning orders without
cloud/Hadoop access.
+ /// Removes the seeded entries on success; assertion failures panic with
test evidence.
async fn check_isolated_stores(bucket: &str, cases: [StoreConfig; 2]) {
let path = Path::from("directory/part one.parquet");
let stores: [Arc<dyn ObjectStore>; 2] =
@@ -821,7 +967,7 @@ mod tests {
);
}
let prepared = prepared.map(Option::unwrap);
- let urls = prepared.each_ref().map(|(url, _)| url.clone());
+ let urls = prepared.each_ref().map(|(url, _, _)| url.clone());
assert_ne!(urls[0], urls[1]);
if let Some(previous) = &previous_urls {
assert_eq!(
@@ -830,8 +976,18 @@ mod tests {
);
}
previous_urls = Some(urls);
- for (index, (url, actual_path)) in prepared.iter().enumerate() {
+ for (index, (url, actual_path, backend)) in
prepared.iter().enumerate() {
assert_eq!(actual_path, &path);
+ // The label must follow the selected backend even when both
original URLs
+ // normalize to s3 with identical configuration and reuse
cached stores.
+ assert_eq!(
+ *backend,
+ if cases[index].hdfs_backend {
+ ObjectStoreBackend::Other
+ } else {
+ ObjectStoreBackend::Remote
+ },
+ );
let store = runtime.object_store(url).unwrap();
assert!(Arc::ptr_eq(&store, &stores[index]));
assert_eq!(
@@ -856,6 +1012,8 @@ mod tests {
}
}
+ /// Verifies that `s3a` stays Remote while explicitly Hadoop-routed `s3`
stays Other
+ /// under the same options, with distinct cached contents in either
planning order.
#[tokio::test]
async fn isolates_backends_even_when_s3_alias_and_configs_match() {
let options = HashMap::from([("fs.comet.libhdfs.schemes".into(),
"s3".into())]);
@@ -915,6 +1073,8 @@ mod tests {
.await;
}
+ /// Checks native alias cache reuse in both planning orders, including the
returned Remote
+ /// label. Seeds and removes one in-memory cache entry; no remote requests
are performed.
#[test]
fn native_s3_aliases_share_cache_and_registration_identity() {
let options = HashMap::new();
@@ -932,12 +1092,13 @@ mod tests {
for schemes in [["s3", "s3a"], ["s3a", "s3"]] {
let runtime = Arc::new(RuntimeEnv::default());
for scheme in schemes {
- let (url, _) = prepare_object_store_with_configs(
+ let (url, _, backend) = prepare_object_store_with_configs(
Arc::clone(&runtime),
format!("{scheme}://comet-isolation-native-aliases/file.parquet"),
&options,
)
.unwrap();
+ assert_eq!(backend, ObjectStoreBackend::Remote);
assert!(Arc::ptr_eq(&runtime.object_store(&url).unwrap(),
&store));
assert!(url.as_str().starts_with("s3+comet-"));
if let Some(previous) = &previous {
@@ -949,6 +1110,8 @@ mod tests {
object_store_cache().write().unwrap().remove(&key);
}
+ /// Checks that native file construction returns Local and cached Hadoop
file routing returns
+ /// Other, with distinct registered stores. Removes its synthetic Hadoop
cache entry on success.
#[test]
fn keeps_native_file_url_separate_from_explicit_hadoop_file_routing() {
let runtime = Arc::new(RuntimeEnv::default());
@@ -963,18 +1126,20 @@ mod tests {
.write()
.unwrap()
.insert(key.clone(), Arc::clone(&hdfs_store));
- let (hdfs_url, _) = prepare_object_store_with_configs(
+ let (hdfs_url, _, hdfs_backend) = prepare_object_store_with_configs(
Arc::clone(&runtime),
"file:///comet-isolation-file-routing.parquet".into(),
&options,
)
.unwrap();
- let (native_url, _) = prepare_object_store_with_configs(
+ let (native_url, _, native_backend) =
prepare_object_store_with_configs(
Arc::clone(&runtime),
"file:///comet-isolation-file-routing.parquet".into(),
&HashMap::new(),
)
.unwrap();
+ assert_eq!(native_backend, ObjectStoreBackend::Local);
+ assert_eq!(hdfs_backend, ObjectStoreBackend::Other);
assert_eq!(native_url, ObjectStoreUrl::local_filesystem());
assert_ne!(native_url, hdfs_url);
assert!(Arc::ptr_eq(
@@ -996,6 +1161,7 @@ mod tests {
) -> Result<(ObjectStoreUrl, Path), ExecutionError> {
use crate::parquet::parquet_support::prepare_object_store_with_configs;
prepare_object_store_with_configs(runtime_env, url, &HashMap::new())
+ .map(|(url, path, _)| (url, path))
}
#[cfg(not(feature = "hdfs-opendal"))]
@@ -1346,6 +1512,8 @@ mod tests {
}
}
+ /// Constructs S3 stores using anonymous credentials, without reading
remote objects, and
+ /// checks that both alias URL forms return the normalized bucket, key,
and remote I/O label.
#[cfg(not(feature = "hdfs-opendal"))]
#[test]
#[cfg_attr(miri, ignore)] // AWS credential providers and object_store
call foreign functions
@@ -1383,7 +1551,7 @@ mod tests {
"warehouse/data/part-0.snappy.parquet",
),
] {
- let (object_store_url, path) = prepare_object_store_with_configs(
+ let (object_store_url, path, backend) =
prepare_object_store_with_configs(
Arc::new(RuntimeEnv::default()),
input.to_string(),
&configs,
@@ -1398,6 +1566,7 @@ mod tests {
.unwrap()
);
assert_eq!(path, Path::from(expected_path));
+ assert_eq!(backend, super::ObjectStoreBackend::Remote);
}
}
@@ -1431,7 +1600,7 @@ mod tests {
"s3a://test_bucket/comet/part-00000.snappy.parquet",
"blob://test_bucket/comet/part-00000.snappy.parquet",
] {
- let (object_store_url, path) = prepare_object_store_with_configs(
+ let (object_store_url, path, backend) =
prepare_object_store_with_configs(
Arc::new(RuntimeEnv::default()),
input.to_string(),
&configs,
@@ -1446,6 +1615,7 @@ mod tests {
.unwrap()
);
assert_eq!(path, Path::from("/comet/part-00000.snappy.parquet"));
+ assert_eq!(backend, super::ObjectStoreBackend::Remote);
}
// Listing `s3a` is the supported way to route it through libhdfs, and
still does.
diff --git
a/spark/src/main/scala/org/apache/spark/sql/comet/CometMetricNode.scala
b/spark/src/main/scala/org/apache/spark/sql/comet/CometMetricNode.scala
index d3aaee2913..0308779ce0 100644
--- a/spark/src/main/scala/org/apache/spark/sql/comet/CometMetricNode.scala
+++ b/spark/src/main/scala/org/apache/spark/sql/comet/CometMetricNode.scala
@@ -363,6 +363,30 @@ object CometMetricNode {
SQLMetrics.createMetric(sc, "Number of row groups matched by limit
pruning (not pruned)"),
"bytes_scanned" ->
SQLMetrics.createSizeMetric(sc, "Number of bytes scanned"),
+ "scan_io_data_bytes" ->
+ SQLMetrics.createSizeMetric(
+ sc,
+ "Projected Parquet data-page bytes returned to the reader"),
+ "scan_io_metadata_bytes" ->
+ SQLMetrics.createSizeMetric(
+ sc,
+ "Footer-prefetch, page-index, and Bloom-filter bytes returned to the
reader"),
+ "scan_io_footer_reads" ->
+ SQLMetrics.createMetric(sc, "Number of Parquet footer payloads read
from storage"),
+ "scan_io_footer_bytes" ->
+ SQLMetrics.createSizeMetric(sc, "Serialized Parquet footer payload
bytes read"),
+ "scan_io_object_store_get_calls" ->
+ SQLMetrics.createMetric(sc, "ObjectStore GET operations after range
coalescing"),
+ "scan_io_object_store_get_requested_bytes" ->
+ SQLMetrics.createSizeMetric(sc, "ObjectStore GET range bytes after
range coalescing"),
+ "scan_io_object_store_response_bytes_read" ->
+ SQLMetrics.createSizeMetric(
+ sc,
+ "ObjectStore response bytes consumed after range coalescing"),
+ "scan_io_metadata_cache_hits" ->
+ SQLMetrics.createMetric(sc, "Metadata loads served without storage
I/O"),
+ "scan_io_metadata_cache_misses" ->
+ SQLMetrics.createMetric(sc, "Metadata loads requiring storage I/O"),
"pushdown_rows_pruned" ->
SQLMetrics.createMetric(sc, "Rows filtered out by predicates pushed
into parquet scan"),
"pushdown_rows_matched" ->
diff --git a/spark/src/test/scala/org/apache/comet/exec/CometExecSuite.scala
b/spark/src/test/scala/org/apache/comet/exec/CometExecSuite.scala
index 89e9d69bc5..5b5d43dbe8 100644
--- a/spark/src/test/scala/org/apache/comet/exec/CometExecSuite.scala
+++ b/spark/src/test/scala/org/apache/comet/exec/CometExecSuite.scala
@@ -2413,14 +2413,35 @@ class CometExecSuite extends CometTestBase {
metrics.contains("time_elapsed_scanning_total"),
s"Missing time_elapsed_scanning_total. Available: ${metrics.keys}")
assert(metrics.contains("bytes_scanned"))
+ assert(metrics("bytes_scanned").name.contains("Number of bytes
scanned"))
assert(metrics.contains("output_rows"))
assert(metrics.contains("time_elapsed_opening"))
assert(metrics.contains("time_elapsed_processing"))
assert(metrics.contains("time_elapsed_scanning_until_data"))
+ Seq(
+ "scan_io_data_bytes",
+ "scan_io_metadata_bytes",
+ "scan_io_footer_reads",
+ "scan_io_footer_bytes",
+ "scan_io_object_store_get_calls",
+ "scan_io_object_store_get_requested_bytes",
+ "scan_io_object_store_response_bytes_read",
+ "scan_io_metadata_cache_hits",
+ "scan_io_metadata_cache_misses").foreach { name =>
+ assert(metrics.contains(name), s"Missing $name. Available:
${metrics.keys}")
+ }
assert(
metrics("time_elapsed_scanning_total").value > 0,
"time_elapsed_scanning_total should be > 0")
assert(metrics("bytes_scanned").value > 0, "bytes_scanned should be
> 0")
+ assert(metrics("scan_io_data_bytes").value > 0)
+ assert(metrics("scan_io_metadata_bytes").value > 0)
+ assert(metrics("scan_io_footer_reads").value > 0)
+ assert(metrics("scan_io_footer_bytes").value > 0)
+ assert(metrics("scan_io_object_store_get_calls").value == 0)
+ assert(metrics("scan_io_object_store_get_requested_bytes").value ==
0)
+ assert(metrics("scan_io_object_store_response_bytes_read").value ==
0)
+ assert(metrics("scan_io_metadata_cache_misses").value > 0)
assert(metrics("output_rows").value > 0, "output_rows should be > 0")
assert(metrics("time_elapsed_opening").value > 0,
"time_elapsed_opening should be > 0")
assert(
diff --git
a/spark/src/test/scala/org/apache/spark/sql/benchmark/CometReadBenchmark.scala
b/spark/src/test/scala/org/apache/spark/sql/benchmark/CometReadBenchmark.scala
index 30090d1d12..1055240cd7 100644
---
a/spark/src/test/scala/org/apache/spark/sql/benchmark/CometReadBenchmark.scala
+++
b/spark/src/test/scala/org/apache/spark/sql/benchmark/CometReadBenchmark.scala
@@ -34,6 +34,7 @@ import org.apache.spark.TestUtils
import org.apache.spark.benchmark.Benchmark
import org.apache.spark.sql.{DataFrame, SparkSession}
import
org.apache.spark.sql.execution.datasources.parquet.VectorizedParquetRecordReader
+import org.apache.spark.sql.execution.metric.SQLMetrics
import org.apache.spark.sql.types._
import org.apache.spark.sql.vectorized.ColumnVector
@@ -47,6 +48,87 @@ import org.apache.comet.{CometConf, WithHdfsCluster}
*/
class CometReadBaseBenchmark extends CometBenchmarkBase {
+ /**
+ * Measure the nine scan I/O accumulators separately from storage work. Run
this benchmark with
+ * `--scan-metric-overhead`; add `--reverse-cases` to reverse the real-job
comparison. The first
+ * two cases isolate driver creation and task-copy/merge costs. The last
runs 10,000 Spark tasks
+ * with zero or nine extra SQL accumulators, including task serialization
and scheduler updates.
+ * This is not an end-to-end scan benchmark: it excludes native counters,
JNI traversal, the SQL
+ * UI's per-node rendering, and storage I/O.
+ */
+ def scanMetricAccumulatorBenchmark(reverseCases: Boolean): Unit = {
+ val names = Seq(
+ "scan_io_data_bytes",
+ "scan_io_metadata_bytes",
+ "scan_io_footer_reads",
+ "scan_io_footer_bytes",
+ "scan_io_object_store_get_calls",
+ "scan_io_object_store_get_requested_bytes",
+ "scan_io_object_store_response_bytes_read",
+ "scan_io_metadata_cache_hits",
+ "scan_io_metadata_cache_misses")
+ def createMetrics() = names.map { name =>
+ if (name.endsWith("bytes") || name.endsWith("bytes_read")) {
+ SQLMetrics.createSizeMetric(spark.sparkContext, name)
+ } else {
+ SQLMetrics.createMetric(spark.sparkContext, name)
+ }
+ }
+
+ val operators = 1024
+ val creation =
+ new Benchmark("Scan I/O SQL metrics: creation", operators, minNumIters =
5, output = output)
+ creation.addCase("nine metrics per operator") { _ =>
+ var count = 0
+ while (count < operators) {
+ assert(createMetrics().size == 9)
+ count += 1
+ }
+ }
+ creation.run()
+
+ val tasks = 10000
+ val driverMetrics = createMetrics()
+ val updates = new Benchmark(
+ "Scan I/O SQL metrics: task snapshots",
+ tasks,
+ minNumIters = 5,
+ output = output)
+ updates.addCase("copy, update and merge nine metrics") { _ =>
+ driverMetrics.foreach(_.reset())
+ var task = 0
+ while (task < tasks) {
+ driverMetrics.foreach { driver =>
+ val local = driver.copyAndReset()
+ local.add(64L)
+ driver.merge(local)
+ }
+ task += 1
+ }
+ assert(driverMetrics.forall(_.value == tasks * 64L))
+ }
+ updates.run()
+
+ val partitions = spark.sparkContext.parallelize(0 until tasks, tasks)
+ val jobs = new Benchmark(
+ "Scan I/O SQL metrics: 10000-task Spark job",
+ tasks,
+ minNumIters = 3,
+ output = output)
+ val cases = if (reverseCases) Seq(9, 0) else Seq(0, 9)
+ cases.foreach { count =>
+ val metrics = if (count == 0) Seq.empty else createMetrics()
+ jobs.addCase(s"$count extra SQL accumulators") { _ =>
+ metrics.foreach(_.reset())
+ partitions.foreachPartition { _ =>
+ metrics.foreach(_.add(64L))
+ }
+ assert(metrics.forall(_.value == tasks * 64L))
+ }
+ }
+ jobs.run()
+ }
+
def numericScanBenchmark(values: Int, dataType: DataType): Unit = {
val sqlBenchmark =
new Benchmark(s"SQL Single ${dataType.sql} Column Scan", values, output
= output)
@@ -316,6 +398,10 @@ class CometReadBaseBenchmark extends CometBenchmarkBase {
}
override def runCometBenchmark(mainArgs: Array[String]): Unit = {
+ if (mainArgs.contains("--scan-metric-overhead")) {
+ scanMetricAccumulatorBenchmark(mainArgs.contains("--reverse-cases"))
+ return
+ }
runBenchmarkWithTable("Parquet Reader", 1024 * 1024 * 15) { v =>
Seq(
BooleanType,
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]