Skip to content

prototype: capture Parquet scan operations with tracing in I/O tests - #27110

Draft
adriangb wants to merge 17 commits into
apache:mainfrom
adriangb:io-tracing-prototype
Draft

adriangb wants to merge 17 commits into
apache:mainfrom
adriangb:io-tracing-prototype

Conversation

@adriangb

@adriangb adriangb commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

Note

Draft prototype stacked on #27022. Only the last two commits are new; review those.

Which issue does this PR close?

Rationale for this change

The I/O tests in #27022 only see object store requests. They cannot say which scan step caused a request. The review suggested tracing::debug! events at important places, so tests can snapshot planning and scan operations as well as I/O.

What changes are included in this PR?

  1. datafusion-datasource-parquet depends on tracing (no default features). The scan emits spans and events at these places:
    • opening a file
    • loading metadata, with metadata cache hits and misses
    • loading the page index and the Bloom filters, with one event per Bloom filter read
    • row group pruning with statistics and with Bloom filters, and page index pruning
    • column chunk fetches and row filter evaluation
  2. Each call site records its own fields. The library formats no strings for tests.
  3. The test harness (core/tests/parquet/io/trace_capture.rs) is a tracing_subscriber layer. It renders spans and events as an indented tree and prints every recorded field. The test object store emits a span for every operation and names the Parquet structure that each byte range covers.

The tests look like this:

let test = filter_pushdown_test(true).await;
let traces = test.capture_traces();
test.query("SELECT * FROM t WHERE val = 7").await.unwrap();
assert_snapshot!(traces.render(), @"
Query (sql=SELECT * FROM t WHERE val = 7, rows=1)
    ...
    Fetch column chunk data (ranges=1)
        ObjectStore::get_ranges (location=table.parquet, ranges=1)
            Read byte range (range=145..192, parquet=dictionary page and data page 0 for column 'val' (rg 1))
    Evaluate row filter (predicate=val@0 = 7, rows=3, rows_matched=1)
    ...
");

Two findings came out of this:

What is the testing strategy for this PR?

The 14 snapshot tests in datafusion/core/tests/parquet/io/ were converted to the new API and regenerated with cargo insta accept.

The capture uses tracing::subscriber::set_default, which only applies to the current thread. This works because #[tokio::test] runs every task on the test thread.

Are there any user-facing changes?

There is a new tracing dependency in datafusion-datasource-parquet. Events are at debug level and cost almost nothing when no subscriber is installed. The API is unchanged.

🤖 Generated with Claude Code

mohitgurav20 and others added 15 commits October 31, 2026 12:54
…ng baseline

- Add InstrumentedObjectStore that correlates requested byte ranges to Parquet file structures (footers, page indexes, bloom filters, data pages)

- Add test_parquet_bloom_filter_io_baseline using public SessionContext SQL APIs

- Use insta snapshot to capture and compare the storage I/O trace

- Documents baseline on main where all candidate column Bloom filters are loaded upfront, establishing the target diff for incremental pruning (apache#25811)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Split the Bloom filter I/O test into reusable setup code in
tests/parquet/io/mod.rs and the test itself in
tests/parquet/io/bloom_filter_io.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ushdown and TopK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The I/O snapshot tests only saw object store requests, so they could not
say why a byte range was read. This adds tracing::debug! events with
structured fields (offset_start, offset_end, column, row_group) at the
parquet read sites, and a test-only tracing Layer that renders the
events and the object store's request spans as a snapshot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A fixed set of fields limited what each call site could report. Call
sites now record whatever fields fit, and the test layer prints every
field. The scan emits spans for metadata load, page index load, Bloom
filter load, row group and page pruning, column chunk fetches, and
events for row filter evaluation and metadata cache hits. The test
object store emits a span for every operation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added core Core DataFusion crate datasource Changes to the datasource crate labels Nov 4, 2026
CI dropped spans and events at random when tests ran in parallel.
tracing caches callsite interest across threads, so a test thread
without a thread-local subscriber disabled callsites for the others.
One global subscriber now writes into the capture that is active on
the current thread. Also format the Cargo.toml files with taplo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@alamb alamb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @adriangb -- I really like this idea of instrumenting the parquet opener with key events to understand it better

However, I don't like the use of the tracing crate as I don't think everyone uses tracing (some use otel, for example, and we have our own thing at InfluxData)

I wonder if we could instead add our own tracing/span API in DataFusion , maybe something like

/// Receives scan events; default impl is a no-op
pub trait ParquetScanObserver: Debug + Send + Sync {
    /// Called at a point of interest with structured fields
    fn event(&self, event: ParquetScanEvent<'_>) {}
    /// Begin a nested region; dropping the guard ends it
    fn span(&self, span: ParquetScanSpan<'_>) -> Option<Box<dyn Any + Send>> { None }
}

pub enum ParquetScanEvent<'a> {
    OpenFile { path: &'a str, size: u64 },
    RowGroupsPruned { stage: PruneStage, before: usize, after: usize },
    BloomFilterRead { column: &'a str, row_group: usize, range: Range<i64> },
    RowFilterEvaluated { predicate: &'a dyn PhysicalExpr, rows: usize, matched: usize },
}

Load page index into cached metadata
Read Parquet metadata (range=192..213)
ObjectStore::get (location=table.parquet)
Read byte range (range=192..213, parquet=OffsetIndex for column 'a' (rg 0))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A nit is I would probably combine this into one line

            ObjectStore::get (location=table.parquet)
                Read byte range (range=192..213, parquet=OffsetIndex for column 'a' (rg 0))

into

            ObjectStore::get (location=table.parquet) (range=192..213, parquet=OffsetIndex for column 'a' (rg 0))

Metadata cache hit (has_page_index=true)
Open Parquet file (file=table.parquet, size=1402)
Load Parquet metadata
Metadata cache hit (has_page_index=true)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is cool to show the metadata cache hit

env_logger = { workspace = true }
glob = { workspace = true }
insta = { workspace = true }
tracing = { workspace = true }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is my biggest hesitation -- to add this new tracing dependency (I know it is just a dev-dependency, but still I feel like it would be easy to accidentally turn into a real dependnecy, and not everyone uses the tracing crate

Ok(ParquetOpenState::LoadMetadata(
prepared
.load()
.instrument(tracing::debug_span!("Load Parquet metadata"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like the idea of instrumenting the parquet opener, but I don't like tying the instrumentation to the tracing crate

parking_lot = { workspace = true }
parquet = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a bad idea to pick up the tracing dependency

@alamb

alamb commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

As an alternative, we could maybe even add a more general API for tracing to cover other important events (like the sql planning, analyzer, optimizer passes, etc)

Something like

#[non_exhaustive]
pub enum TraceEvent<'a> {
    // ---- planning ----
    /// Span: parsing SQL text into a statement
    ParseSql { sql: &'a str },
    /// Event: one analyzer rule ran
    AnalyzerRule { rule: &'a str, plan: &'a dyn Display },
...
    // ---- parquet scan ----
    /// Event: the scan started opening a file
    ParquetOpenFile { path: &'a str, size: u64 },
    /// Span: footer metadata load; `cached` says whether the metadata cache hit
    ParquetLoadMetadata { path: &'a str, cached: bool },
...
}

/// Registered once on the session; every subsystem reports to it
pub trait TraceObserver: Debug + Send + Sync {
...
}

// ---- registration: one place ----
let ctx = SessionContext::new_with_state(
    SessionStateBuilder::new()
        .with_trace_observer(Arc::new(MyObserver))   // stored on SessionState
        .build(),
);
// SessionState::task_ctx() copies it onto TaskContext, like the function registry.

Then we could instrument call sites like

Planner

// EXPLAIN VERBOSE and tracing consume the same points.
self.optimizer().optimize(plan, self, |plan, rule| {
    observer.event(&TraceEvent::OptimizerRule { rule: rule.name(), pass, transformed, plan });
})?;

or in the Parquet opener

observer.event(&TraceEvent::ParquetOpenFile { path: &file_name, size });
...
let _guard = observer.span(&TraceEvent::ParquetRowGroupsPruned { .. }); // sync regio

Main has the merged version of apache#27022. It describes multi-structure
reads, sets the metadata size hint to the footer size, and clears the
metadata cache before each query. This merge keeps those changes and
ports them to the tracing-based harness, then regenerates the snapshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@adriangb

adriangb commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

I understand the hesitation to bring in tracing. As you already pointed our right now this is a dev only dep so there is zero impact / direct decision being made about what event / tracing API Datafusion exposes more generally. I think we both agree that it is somewhat of a slippery slope, but only in the sense that it's easy to say "hey all of these operations are instrumented, why not just expose them to users!".

That said, if we do want to discuss what should Datafusion offer as it's event / tracing implementation I see 3 options:

  1. Pick a single crate (which IMO should be tracing or opentelemetry given https://lib.rs/crates/ numbers). You can bridge between them, e.g. we use opentelemetry but have plenty of crates that offer only tracing instrumentation. For example using https://github.com/tokio-rs/tracing-opentelemetry.
  2. Offer multiple instrumentation. We offer feature flags for various instrumentations. You can turn on one or many. It'd be pretty straightforward to tell an LLM to add a new instrumentation. This is mechanical code that would not be hard to maintain and doesn't need to be perfect. Realistically I think this is 2-3 instrumenations. Companies that use custom instrumentations would need to build a bridge between a standard open source instrumenation and their internal implementation (which IMO is a reasonable ask).
  3. Create our own events API. IMO this requires design work, compatibility guarantees, etc. And every single consumer needs to build some sort of bridge into whatever they use for tracing. I find this to be the worst of all worlds: more work for us, more work for users.

Thus my vote would be to:

  1. If we find this approach useful for testing (and maybe we don't, that should be discussed more on it's own merits) make changes to it / touch it up and adopt it.
  2. Gradually expand to planning, other areas of the codebase like you indicated & add utilities like filtering events, selecting which attributes to show, other views of the data that we can snapshot in tests.
  3. If it makes sense we can later decide to make this a feature flagged feature exposed to users.
  4. If users request other integrations e.g. opentelemetry instead of tracing (and there are good arguments for not just using a bridge) we can add a second feature flag.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Core DataFusion crate datasource Changes to the datasource crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants