Skip to content

Re-check dynamic filters before a Parquet file's first data read - #170

Merged
Vedin merged 2 commits into
df55-physical-udf-instancefrom
rustice/topk-aware-open-ahead
Oct 28, 2026
Merged

Vedin merged 2 commits into
df55-physical-udf-instancefrom
rustice/topk-aware-open-ahead

Conversation

@Vedin

@Vedin Vedin commented Oct 28, 2026

Copy link
Copy Markdown

Summary

Adds datafusion.execution.recheck_dynamic_filter_before_read (default false). When it is on, a Parquet scan whose predicate holds a dynamic filter that can still change re-checks the file pruner once, right before the file's first data read. A file range claimed early is then skipped without fetching its row group if the threshold published meanwhile excludes it. A range is claimed early by file_stream_open_ahead, or by a partition that drew an empty byte-range sliver.

Why: ORDER BY key LIMIT k over many single-row-group files. Each partition claims and opens its next file, and evaluates prune_file, before the TopK heap holds k rows. So those files pass pruning against the empty lit(true) filter, and their row groups are fetched and decoded even though the early-stop check then drops them after the first batch.

Measured on SPCS through Rustice (SF1 lineitem, 10 files, LIMIT 10):

Setting Ranges matched Rows decoded Bytes scanned
defaults (5 partitions, open-ahead on) 7–8 / 14 0.6–1.2 M 50 MB
open-ahead off 3 / 14 0.6 M 28 MB
1 partition, open-ahead off 1 / 10 0.6 M 5.6 MB

Mechanism

  • EarlyStoppingStream runs its existing FilePruner::should_prune check once before the first inner poll. The pruning predicate is rebuilt only when a watched filter changed; otherwise the check is an atomic load. A file stopped this way counts like an early-stopped file in files_ranges_pruned_statistics.
  • The check is attached only to ranges with a row group left to fetch and no RowGroupPruner. Multi-row-group files are already checked before each row group.
  • New metric files_ranges_pruned_before_read. It is registered only when the option is on and the predicate holds a dynamic filter, so default EXPLAIN ANALYZE output is unchanged.
  • Plumbing: FileSource::with_execution_options(&ExecutionOptions) -> Option<Arc<dyn FileSource>>, default None. FileScanConfig::open_with_args calls it with the task's session config, so sources built with default options get the setting too. ParquetSource clones itself only when the value differs.
  • Soundness: every dynamic filter producer only tightens (TopK, aggregate MIN/MAX bounds; hash-join filters are complete before the probe side runs). So a file pruned before its first read would also have been pruned by the existing first-batch check. This adds no new way to drop rows.

With the option off, behaviour is unchanged: no wrapper, metric or clone.

Tests

  • Gated-store integration tests (core/tests/parquet/topk_recheck_before_read.rs): metadata reads return at once and data reads yield first.
    • 1 partition, 6 files: option off fetches 2 row groups, option on fetches 1.
    • 5 partitions, 10 files → 14 ranges including empty slivers: off fetches 9 row groups, on fetches 5.
    • No dynamic filter: identical metrics, bytes and results.
    • These fail with the check made a no-op.
  • Opener unit test: a stopped ahead file makes 0 data reads.
  • Fuzz test (core/tests/fuzz_cases/topk_recheck_before_read_fuzz.rs, extended_tests). It runs on a multi-thread runtime over a delayed store, comparing TopK with the option on vs off vs a full-sort oracle, every column by row id. Covered: one/two keys, ASC/DESC, NULLS FIRST/LAST, ties, Int64/Float64/Utf8/Decimal128/Date keys, files with and without statistics, constant-key files, batch sizes 1..8192. Seed and config are logged (TOPK_RECHECK_FUZZ_SEED, TOPK_RECHECK_FUZZ_CASES). Run on 3 × 256 seeded cases after the last change, and on ~1,900 cases before it: all pass.
  • cargo test -p datafusion-datasource-parquet, parquet_integration (topk_recheck), information_schema.slt settings rows, configs.md. cargo clippy --no-deps -D warnings on the changed crates. cargo fmt.

Found while fuzzing, not changed here: NaN rows. Parquet min/max exclude NaN, so constant_value_from_stats treats {1.0, NaN} as the constant 1.0, and max-side pruning can drop NaN rows. NaN cases are therefore written only to files without statistics, and a separate fix will follow.

🤖 Generated with Claude Code

Vedin and others added 2 commits October 28, 2026 05:29
A Parquet file range claimed before a TopK dynamic filter is first updated
(opened ahead by `file_stream_open_ahead`, or claimed by a partition whose
first byte range was an empty sliver) passes file and row-group pruning
against the still-empty filter. For a single-row-group file the per-batch
early stop then drops it only after its whole row group has been fetched.

Add `datafusion.execution.recheck_dynamic_filter_before_read` (default
false). When enabled, `EarlyStoppingStream` runs its existing file-pruner
check once before the first poll of the inner stream, so such a file is
skipped without any row-group I/O when the filter has since been updated to
exclude it. The pruner only rebuilds its pruning predicate when a watched
filter changed, and a file stopped this way is counted like an early-stopped
file in `files_ranges_pruned_statistics`. A new
`files_ranges_pruned_before_read` metric counts these ranges; it is only
registered when the option is on and the scan predicate holds a dynamic
filter, so EXPLAIN ANALYZE output is otherwise unchanged.

The option is read from the session when a scan partition is opened,
through a new `FileSource::with_execution_options` hook, so it also reaches
Parquet sources that custom table providers build with default options.

The re-check only runs where the early-stop wrapper already exists (the
predicate holds a dynamic filter that is still incomplete when the file's
stream is built) and applies the same pruning rule as the per-batch check.
Hash-join filters are marked complete before the probe side is first polled
(unless probe buffering is enabled), so join probe scans are unaffected.

Tests:
- opener unit tests: a file stopped before its first read makes zero data
  reads, while without the re-check the same file is fetched first
- gated object store integration tests at 1 partition and at 5 partitions
  (10 files split into 14 byte ranges, 4 of them empty) asserting row-group
  fetches, bytes_scanned and pruning metrics with the option off and on,
  plus a check that scans without a dynamic filter are unchanged
- randomized differential fuzz test comparing option off and on against a
  full-sort oracle on a multi-threaded runtime with delayed I/O; it asserts
  that the re-check skipped files in some cases. NaN keys are only written
  to files without statistics: Parquet min/max exclude NaN, and the scan's
  constant-column rewrite and statistics pruning currently mishandle NaN
  independently of this option.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ranges with no row group left (empty byte-range slivers, fully pruned
ranges) and files with a RowGroupPruner no longer get the pre-read
re-check, so files_ranges_pruned_before_read counts only skipped fetches.
The option doc now says which statistics the check uses, which filters
it applies to, and that multi-row-group files are already re-checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation core common sqllogictest datasource labels Oct 28, 2026
@Vedin
Vedin merged commit 4574392 into df55-physical-udf-instance Oct 28, 2026
16 of 17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

common core datasource documentation Improvements or additions to documentation sqllogictest

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant