Skip to content

feat(core): ValueIndexExec + ValueIndexReader + shuffle FileWriter switch - #2204

Draft
avantgardnerio wants to merge 8 commits into
apache:mainfrom
avantgardnerio:brent/range-index-file
Draft

feat(core): ValueIndexExec + ValueIndexReader + shuffle FileWriter switch#2204
avantgardnerio wants to merge 8 commits into
apache:mainfrom
avantgardnerio:brent/range-index-file

Conversation

@avantgardnerio

@avantgardnerio avantgardnerio commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Draft status. This PR ships the file format, reader, and search primitives as an additive lego so they can be reviewed in isolation. The ultimate shape is likely a bundled RangeShuffleWriter / RangeShuffleReader pair (parallel to SortShuffleWriter) that owns both the data file and the .value.idx file end-to-end. Bundling removes a silent alignment risk in the current tap-based shape: today's leaf_idx == batch_idx invariant would break if any future op between ValueIndexExec and ShuffleWriter re-chunked batches (coalesce, byte-budget splitter, etc.) — no panic, just wrong reads. Landing the pieces additively first lets the format and reader primitives get review; the bundled writer/reader consumes them unchanged.

Downside of the bundled shape: one bundled writer variant per data file format (RangeArrowIpcShuffleWriter, RangeVortexShuffleWriter, ...). The tap-based design is compositional — a single format-agnostic ValueIndexExec pairs with whichever data writer already exists per format. Under bundling, the value-index-writing logic can factor into a shared trait/generic so it's not literally duplicated code, but the writer struct itself is per-format. Whether that matters depends on how many formats we care about long-term.

Summary

Adds a value-range index file (.value.idx) next to each passthrough-shuffle output file, so a downstream reader with an assigned value cut range can seek into the data file without full-file read amplification.

Switches passthrough shuffle from Arrow IPC stream format to Arrow IPC file format so FileReader::set_index + footer block index can translate sampled_row → byte range in O(1) rather than the O(N) message walk stream format would force on S3.

File-format agnostic by design

The index file itself only stores (sampled_row, ORDER BY values) — no byte offsets. Byte-range resolution at read time is delegated to the data file's own format-native chunk metadata. The tap sees only RecordBatch; the reader translation step is format-specific.

Requirement on the data file's format: a footer (or equivalent index at a known location) that can be read once to enumerate batch/row-group boundaries, plus per-chunk row counts. Every serious columnar format has this.

Concretely:

  • Arrow IPC file — footer's Block { offset, metadata_length, body_length } + each RecordBatch message's row count in its metadata. What this PR implements.
  • Vortex — chunk index in the file layout, exposed by the Vortex reader. ValueIndexReader doesn't need to know the format; only whichever code sits between it and the data file does. When Vortex-format shuffle lands (Spice has this in their private Ballista fork per Phillip LeBlanc's DataFusion Community Showcase Vol. 2 talk 2026-07), the tap and reader are unchanged — a Vortex-specific sampled_row → byte range resolver plugs in alongside the Arrow IPC one.
  • Parquet, ORC — row-group index in the footer. Same shape.

So ValueIndexExec writing side is fully format-agnostic (it evaluates the ORDER BY expression, records rows). The read-side is a small format-specific translator on top of ValueIndexReader::leaf_row_range. This PR ships the Arrow IPC translator via FileReader::set_index.

Wins

  • Enables range-GET reads on object-store-backed shuffle. Consumer downloads a KB-scale index, binary-searches its assigned value cut range, then range-GETs just the matching batch. Previously impossible on stream format.
  • Design ports to any columnar format with a chunk index (Arrow IPC file today, Vortex when it lands upstream, Parquet/ORC if ever relevant).
  • No API change downstream. Same RecordBatches come out of the reader. Sort-shuffle path is untouched.
  • New surface is off by default — nothing constructs ValueIndexExec yet. Planner rule to insert it will follow as a separate PR.

Regressions (honest)

  • Full-file remote reads pay ~2 small extra range-GETs. FileReader's footer-open sequence is: read 10-byte tail marker → read footer flatbuffer → then batches. StreamReader was one sequential pass. On S3 that's 2 KB-scale GETs of overhead per shuffle file — a rounding error against typical MB-scale batch bodies, but real. Local reads (kernel page cache) are unaffected.
  • Sort-shuffle path is unchanged — its own hand-rolled .arrow.index offset table + StreamWriter on the consolidated data.arrow continues to work as before. The tradeoff above applies only to the passthrough shuffle path.
  • Not strictly "purely additive" because of the format switch, but the switch is small, motivated by the concrete downstream consumer landing in the same PR, and touches only shuffle write + local/Flight readers.

avantgardnerio and others added 6 commits July 29, 2026 01:25
Introduce a new plan node that resolves the ORDER BY expression from
its input's declared output ordering at construction, samples the
first value per batch, and logs it. Refuses to construct on unordered
input (URRE, unsorted sources) at try_new — no runtime fallback
branch. The write path for the eventual .value.idx sidecar file is
not yet wired; sampling is currently observed via debug logs on the
value_index target.

Tests cover the full Datasource -> Sort -> RuntimeStats -> ORRE ->
ValueIndexExec -> ShuffleWriter pipeline (row conservation), plus
construction-time refusal of unordered input and preservation of the
resolved order_by on the operator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Constructor now matches ShuffleWriterExec's signature (job_id, stage_id,
plan, work_dir) plus with_task_id / with_global_output_partition_ids
setters, so scheduler/executor plumbing stamps both operators from the
same identifying inputs. Sidecar path is derived deterministically via
create_shuffle_path with the `.arrow` suffix swapped for `.value.idx`,
placing the index next to the data file it describes.

On EOS the stream flushes accumulated (sampled_row, sampled_values)
leaves to an Arrow IPC file with schema metadata carrying version,
total_row_count, and row_index_scheme. Zero samples = no file, so
readers that fall back to sketch-overlap can detect absence trivially.
Test reads back each per-partition sidecar and asserts schema,
metadata, and monotonicity invariants.

Storage field naming is deliberately impl-agnostic (sampled_rows /
sampled_values) — today's policy is one sample per non-empty batch,
but the storage shape supports whatever stride we adopt tomorrow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rename the .value.idx schema field, the batch-boundary sampling helper,
and associated locals/assertions from first_row -> sampled_row to match
the storage-field vocabulary. Generalize sample_first_row into
sample_row(batch, order_by, row: usize), called with 0 today but ready
for stride sampling without another rename churn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps the .value.idx file open path with schema-envelope validation:
version, row_index_scheme, and a sampled_row: UInt64 column-0 shape
check. Exposes typed accessors (total_row_count, num_leaves,
sampled_rows, leaf_batch) plus leaf_row_range(idx) which returns the
[start, end) row range with total_row_count as the sentinel for the
last leaf.

Pipeline test now consumes the reader instead of raw FileReader —
schema-metadata assertions are the reader's responsibility, and a
leaf_row_range assertion locks the last-leaf sentinel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two search entry points:

- binary_search_by_value(target, sort_options) — primary use case.
  RowConverter over the leaves' value columns, caller supplies
  per-column SortOptions so asc/desc/nulls-first are honored. Composite
  keys, arbitrary Arrow-comparable types, and mixed sort directions all
  work by construction. Returns leaf index whose value range contains
  the target (last leaf's upper bound is +infinity under asc, or
  -infinity under desc).

- binary_search_by_sampled_row(target: u64) — utility for the ROWS-frame
  halo case in the design memo. Slice-partition_point on the UInt64
  column. Returns None for targets before the first sampled_row or at/
  past total_row_count.

Tests cover exact hits, between-leaves misses, target below first leaf,
target past last leaf, empty-past-total edges, and composite / desc
sort orderings. A write_value_idx helper fabricates minimal files so
search cases don't have to run the full operator pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The ValueIndexReader design depends on random access into the shuffle
data file — footer-driven byte-range reads translate sampled_row to
byte offset in O(1) rather than the O(N) message walk StreamWriter
would force on S3. Switch write_stream_to_disk from StreamWriter to
FileWriter, and update the local + Flight readers (shuffle_reader.rs,
flight_service.rs) to FileReader accordingly. Sort-shuffle path is
untouched — it still writes its consolidated data.arrow via
StreamWriter with its own .arrow.index offset table.

Adds an end-to-end test range_download_via_value_index_and_footer
that runs the full pipeline (Datasource -> Sort -> RuntimeStats ->
ORRE -> ValueIndexExec -> ShuffleWriter), then for each output
partition uses ValueIndexReader::binary_search_by_value to pick a
leaf and FileReader::set_index for footer-driven random-access read
of the matching batch. Asserts the target value is present in the
extracted batch — the index's row mapping is consistent with the
data file's layout. Also locks the current sampling invariant
leaf_idx == batch_idx so a future stride-sampling change trips this
test rather than silently producing wrong reads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@avantgardnerio
avantgardnerio force-pushed the brent/range-index-file branch from 0972f4e to a5525a9 Compare July 29, 2026 08:35
@avantgardnerio

Copy link
Copy Markdown
Contributor Author

@phillipleblanc I'd be curious to hear how this works with your Vortex shuffles. I cloned a few of your repos, but couldn't find them. I also considered implementing this functionality in the flight server (binary search on read), but it seems like both Corlogix and SpiceAI flush shuffles to s3 in case an executor fails? This PR solves that problem with a (round-trip-heavy) pure S3 range-request answer.

avantgardnerio and others added 2 commits July 29, 2026 08:22
Add an ASCII table walkthrough to the value_index.rs module doc: an
example ORDER BY last_name, first_name index file, its leaves, the
binary_search_by_value lookup, the FileReader footer's block index,
and the resulting single range-GET into the data file body. Calls out
the sketch-imprecision zero-fetch skip case and PerPartitionFilterExec's
row-level straddling trim role explicitly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two ambiguities in the diagram called out by Andy's review of the doc:

- Arrow's `Block` fields are bytes, not rows. Rename `offset` /
  `length` to `byte_offset` / `byte_length` and note the collapse of
  metadata_length + body_length.
- The footer has no row information — per-batch row counts live in
  each batch's IPC metadata flatbuffer. Row ranges in the diagram
  come from the value index (sampled_row[k]..sampled_row[k+1]), not
  the footer. Under today's sampling policy the leaf_idx == batch_idx
  invariant lets us skip metadata reads entirely; stride sampling
  would need them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@andygrove

Copy link
Copy Markdown
Member

I understand the motivation, and at a high level this makes sense to me.

This is obviously a breaking change - how does this impact rolling upgrades? do we need to start adding ballista version to the protocol so that nodes can reject requests from older versions? I think we discussed this in the past, but I don't remember if that got implemented already.

@avantgardnerio

Copy link
Copy Markdown
Contributor Author

I don't remember if that got implemented already.

The field is hooked up, including the scheduler side rejection, and the health check. But that only covers the normal protocol path - it's just for schedulers & executors speaking to each other. For the hard part:

This is obviously a breaking change - how does this impact rolling upgrades?

  1. Currently, we don't currently ship shared scheduler state. I'm going to assume folks run in k8s. The scheduler now has the readiness endpoint. So new scheduler fires up, new protocol version. New executors register with it. When it has enough to do work, the readiness endpoint turns green, k8s starts routing queries to it and kills the old one. During the transition, in-flight queries probably die. Solution: k8s operator, bring back KV stores.

  2. There is no persistent shuffles. If there were, this would be a breaking change. But since shuffles die with their host executor, this PR effectively falls under # 1 above.

  3. Ballista is a library. @phillipleblanc likely has better answers for how he uses it. I'm pretty sure he persists shuffles to s3 I saw in his video? In which case this PR could be a deeper breaking change if new executors try to read old shuffles. I think we could create a fallback path for that.

@avantgardnerio

Copy link
Copy Markdown
Contributor Author

I'm also increasingly leaning towards it being its own shuffle writer, only invoked for ordered range reparations, which would both: 1. Save a round trip 2. Not break backwards compatibility

But it would require another impl for vortex shuffles

@phillipleblanc

Copy link
Copy Markdown
Contributor

I think having the per-format implementations of RangeShuffleWriter and RangeShuffleReader that own both the data + index makes sense to me. Its unlikely we'll want to add a bunch of formats to ballista, and this would open the door for us to have our Vortex shuffle implementation out of our fork and fully contained in our own project code.

As long as the traits RangeShuffleWriter and RangeShuffleReader are public and there is some way for us to register our implementation of it then this makes sense to me!

Also RE: the breaking change - I checked and this won't be a problem for us.

avantgardnerio added a commit to avantgardnerio/arrow-ballista that referenced this pull request Aug 6, 2026
PerPartitionFilterExec's only caller (BallistaAdapter above ShuffleReader
for hash-agg correctness) was already using it as a range-shaped filter.
Widening it into a general per-partition arbitrary-predicate op — with
halo-widening bolted on for the parallel-window rewrite — would leak a
range concept into an arbitrary-predicate contract.

RangeFilterExec is the honest shape: routing_expr + cuts + halo_lo /
halo_hi. Per-partition semantics fall out of the local partition index,
not from a Vec of independent predicates. Ordering knowledge on the
input opens the door to a future ValueIndexReader-driven binary-search
path (PR apache#2204 direction) that a generic FilterExec can't take.

Notable pieces:

- `cuts: Arc<Mutex<Option<Vec<f64>>>>` + `resolve_cuts` API mirror
  `ExchangeExec::range_repartition_routing()` — the ParallelWindow rule
  plants a pending RangeFilterExec at plan time; the scheduler resolves
  cuts after stage 0's RuntimeStatsExec reports merge. `execute` and
  serialization both refuse while cuts are unresolved.
- `partition_indices: Vec<usize>` maps local → global partition index.
  Restrict slices this mapping without touching cuts (cuts stay whole;
  they describe the K global partitions). Replaces PPFE's per-partition
  predicate-vec slicing in task_builder's restrict path.
- Public API + proto speak `ScalarValue` (not `f64`) per the type-
  generality rule for the range-repartition family: the outer contract
  is type-agnostic so KLL can widen internal storage later without an
  API break. Internal downcast to `f64` today; non-Float64 inputs error
  with a clear message.
- Adapter builds RangeFilterExec with `halo=0` for the existing hash-agg
  case; the parallel-window rule will build it with non-zero halo.

Migration: delete `PerPartitionFilterExec`, migrate all callers, rename
proto `PerPartitionFilterExecNode` → `RangeFilterExecNode`, update doc
comments. Full test suite (597 tests) passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
avantgardnerio added a commit to avantgardnerio/arrow-ballista that referenced this pull request Aug 6, 2026
PerPartitionFilterExec's only caller (BallistaAdapter above ShuffleReader
for hash-agg correctness) was already using it as a range-shaped filter.
Widening it into a general per-partition arbitrary-predicate op — with
halo-widening bolted on for the parallel-window rewrite — would leak a
range concept into an arbitrary-predicate contract.

RangeFilterExec is the honest shape: routing_expr + cuts + halo_lo /
halo_hi. Per-partition semantics fall out of the local partition index,
not from a Vec of independent predicates. Ordering knowledge on the
input opens the door to a future ValueIndexReader-driven binary-search
path (PR apache#2204 direction) that a generic FilterExec can't take.

Notable pieces:

- `cuts: Arc<Mutex<Option<Vec<f64>>>>` + `resolve_cuts` API mirror
  `ExchangeExec::range_repartition_routing()` — the ParallelWindow rule
  plants a pending RangeFilterExec at plan time; the scheduler resolves
  cuts after stage 0's RuntimeStatsExec reports merge. `execute` and
  serialization both refuse while cuts are unresolved.
- `partition_indices: Vec<usize>` maps local → global partition index.
  Restrict slices this mapping without touching cuts (cuts stay whole;
  they describe the K global partitions). Replaces PPFE's per-partition
  predicate-vec slicing in task_builder's restrict path.
- Public API + proto speak `ScalarValue` (not `f64`) per the type-
  generality rule for the range-repartition family: the outer contract
  is type-agnostic so KLL can widen internal storage later without an
  API break. Internal downcast to `f64` today; non-Float64 inputs error
  with a clear message.
- Adapter builds RangeFilterExec with `halo=0` for the existing hash-agg
  case; the parallel-window rule will build it with non-zero halo.

Migration: delete `PerPartitionFilterExec`, migrate all callers, rename
proto `PerPartitionFilterExecNode` → `RangeFilterExecNode`, update doc
comments. Full test suite (597 tests) passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
avantgardnerio added a commit to avantgardnerio/arrow-ballista that referenced this pull request Aug 7, 2026
PerPartitionFilterExec's only caller (BallistaAdapter above ShuffleReader
for hash-agg correctness) was already using it as a range-shaped filter.
Widening it into a general per-partition arbitrary-predicate op — with
halo-widening bolted on for the parallel-window rewrite — would leak a
range concept into an arbitrary-predicate contract.

RangeFilterExec is the honest shape: routing_expr + cuts + halo_lo /
halo_hi. Per-partition semantics fall out of the local partition index,
not from a Vec of independent predicates. Ordering knowledge on the
input opens the door to a future ValueIndexReader-driven binary-search
path (PR apache#2204 direction) that a generic FilterExec can't take.

Notable pieces:

- `cuts: Arc<Mutex<Option<Vec<f64>>>>` + `resolve_cuts` API mirror
  `ExchangeExec::range_repartition_routing()` — the ParallelWindow rule
  plants a pending RangeFilterExec at plan time; the scheduler resolves
  cuts after stage 0's RuntimeStatsExec reports merge. `execute` and
  serialization both refuse while cuts are unresolved.
- `partition_indices: Vec<usize>` maps local → global partition index.
  Restrict slices this mapping without touching cuts (cuts stay whole;
  they describe the K global partitions). Replaces PPFE's per-partition
  predicate-vec slicing in task_builder's restrict path.
- Public API + proto speak `ScalarValue` (not `f64`) per the type-
  generality rule for the range-repartition family: the outer contract
  is type-agnostic so KLL can widen internal storage later without an
  API break. Internal downcast to `f64` today; non-Float64 inputs error
  with a clear message.
- Adapter builds RangeFilterExec with `halo=0` for the existing hash-agg
  case; the parallel-window rule will build it with non-zero halo.

Migration: delete `PerPartitionFilterExec`, migrate all callers, rename
proto `PerPartitionFilterExecNode` → `RangeFilterExecNode`, update doc
comments. Full test suite (597 tests) passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
avantgardnerio added a commit to avantgardnerio/arrow-ballista that referenced this pull request Aug 7, 2026
PerPartitionFilterExec's only caller (BallistaAdapter above ShuffleReader
for hash-agg correctness) was already using it as a range-shaped filter.
Widening it into a general per-partition arbitrary-predicate op — with
halo-widening bolted on for the parallel-window rewrite — would leak a
range concept into an arbitrary-predicate contract.

RangeFilterExec is the honest shape: routing_expr + cuts + halo_lo /
halo_hi. Per-partition semantics fall out of the local partition index,
not from a Vec of independent predicates. Ordering knowledge on the
input opens the door to a future ValueIndexReader-driven binary-search
path (PR apache#2204 direction) that a generic FilterExec can't take.

Notable pieces:

- `cuts: Arc<Mutex<Option<Vec<f64>>>>` + `resolve_cuts` API mirror
  `ExchangeExec::range_repartition_routing()` — the ParallelWindow rule
  plants a pending RangeFilterExec at plan time; the scheduler resolves
  cuts after stage 0's RuntimeStatsExec reports merge. `execute` and
  serialization both refuse while cuts are unresolved.
- `partition_indices: Vec<usize>` maps local → global partition index.
  Restrict slices this mapping without touching cuts (cuts stay whole;
  they describe the K global partitions). Replaces PPFE's per-partition
  predicate-vec slicing in task_builder's restrict path.
- Public API + proto speak `ScalarValue` (not `f64`) per the type-
  generality rule for the range-repartition family: the outer contract
  is type-agnostic so KLL can widen internal storage later without an
  API break. Internal downcast to `f64` today; non-Float64 inputs error
  with a clear message.
- Adapter builds RangeFilterExec with `halo=0` for the existing hash-agg
  case; the parallel-window rule will build it with non-zero halo.

Migration: delete `PerPartitionFilterExec`, migrate all callers, rename
proto `PerPartitionFilterExecNode` → `RangeFilterExecNode`, update doc
comments. Full test suite (597 tests) passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
avantgardnerio added a commit to avantgardnerio/arrow-ballista that referenced this pull request Aug 8, 2026
PerPartitionFilterExec's only caller (BallistaAdapter above ShuffleReader
for hash-agg correctness) was already using it as a range-shaped filter.
Widening it into a general per-partition arbitrary-predicate op — with
halo-widening bolted on for the parallel-window rewrite — would leak a
range concept into an arbitrary-predicate contract.

RangeFilterExec is the honest shape: routing_expr + cuts + halo_lo /
halo_hi. Per-partition semantics fall out of the local partition index,
not from a Vec of independent predicates. Ordering knowledge on the
input opens the door to a future ValueIndexReader-driven binary-search
path (PR apache#2204 direction) that a generic FilterExec can't take.

Notable pieces:

- `cuts: Arc<Mutex<Option<Vec<f64>>>>` + `resolve_cuts` API mirror
  `ExchangeExec::range_repartition_routing()` — the ParallelWindow rule
  plants a pending RangeFilterExec at plan time; the scheduler resolves
  cuts after stage 0's RuntimeStatsExec reports merge. `execute` and
  serialization both refuse while cuts are unresolved.
- `partition_indices: Vec<usize>` maps local → global partition index.
  Restrict slices this mapping without touching cuts (cuts stay whole;
  they describe the K global partitions). Replaces PPFE's per-partition
  predicate-vec slicing in task_builder's restrict path.
- Public API + proto speak `ScalarValue` (not `f64`) per the type-
  generality rule for the range-repartition family: the outer contract
  is type-agnostic so KLL can widen internal storage later without an
  API break. Internal downcast to `f64` today; non-Float64 inputs error
  with a clear message.
- Adapter builds RangeFilterExec with `halo=0` for the existing hash-agg
  case; the parallel-window rule will build it with non-zero halo.

Migration: delete `PerPartitionFilterExec`, migrate all callers, rename
proto `PerPartitionFilterExecNode` → `RangeFilterExecNode`, update doc
comments. Full test suite (597 tests) passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(scheduler): ParallelWindowRule — distributed range-shuffle for BWAG

Adds `ParallelWindowRule` to the AQE default_optimizers chain (position 2,
before `SelectJoinRule` and DF's own optimizers, before `DistributedExchangeRule`).
The rule matches bounded RANGE-frame windows with no PARTITION BY and a
single-column Float64 ORDER BY, and rewrites them into a range-shuffle
so BoundedWindowAggExec's SinglePartition requirement isn't a serial
bottleneck. Shape:

  RangeFilterExec (narrow, halo=0, cuts=pending)
    BoundedWindowAggExec
      SortPreservingMergeExec
        RangeFilterExec (wide, halo=frame bounds, cuts=pending)
          RuntimeStatsExec (post-ORRE per-partition sketch → scheduler)
            OrderedRangeRepartitionExec (K sorted disjoint outputs)
              RuntimeStatsExec (local sketch; feeds ORRE's cut walker)
                SortExec (preserve_partitioning=true)
                  <source>

Both RangeFilterExecs are planted with cuts=None. After stage 0's tasks
complete and their RSE reports are merged into K-1 quantile cuts, the
scheduler's `resolve_range_filter_cuts` walker (in `adapt_to_ballista`)
finds every pending RangeFilterExec in the downstream stage's plan and
resolves it against the matching ExchangeExec's routing_expr. Adapter no
longer injects RangeFilterExec — the rule is the sole planter, single
source of truth. Idempotency guard on the rule bails when the BWAG's
subtree already contains our own ORRE/RangeFilter (AQE re-plans fire the
chain again on the already-rewritten plan).

Also relaxes `ORRE::try_new` — the child-claims-sortedness check moved
from construction to `execute()`. Rule-time construction races with
`EnforceSorting` (which planted a SortExec on ORRE's declared
`required_input_ordering` *after* the rule ran), so refusing at try_new
was too strict. The runtime check at execute() still catches invariant
breaks; two tests moved from `try_new_rejects_*` to `execute_rejects_*`.

Verified on h2o Q8 at 1e7 under a 2G/exec cgroup cap: stage 0 (8 tasks)
and stage 1 (8 tasks) both parallelize across both executors, no OOM.
Stage 2 still collapses to a single task doing SPM+BWAG+narrow — DE
inserts a shuffle boundary below the SPM, putting BWAG in the final
stage. That collapse is the next follow-up; the machinery for the
range-shuffle itself is in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(scheduler): stop DE inserting Exchange between SPM and rule-planted RangeFilterExec

`ParallelWindowRule` plants a `RangeFilterExec` directly on the resolved
range-repartition `ExchangeExec`, with `SortPreservingMergeExec` above.
`DistributedExchangeRule`'s SPM branch was checking whether SPM's
immediate child was an `ExchangeExec` — seeing the `RangeFilterExec`, it
injected another `ExchangeExec`, cutting the plan into an extra collapse
stage.

Introduce `is_stage_boundary` and treat a `RangeFilterExec` sitting
directly on an `ExchangeExec` as part of the boundary. That is a
conscious design shape — we chose not to fold range-filtering into
`ShuffleReader`/`ExchangeExec`, so the filter is part of the boundary
by construction. This matches the pre-`fcb31520` behaviour where the
adapter injected the filter after DE had already run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

refactor(scheduler): size ParallelWindowRule's K from source partitions

K was `config.execution.target_partitions.max(2)` — a placeholder
chosen while writing the rule. The natural sizing is
`source.output_partitioning().partition_count()`: ORRE re-slices each
input partition into a range-disjoint output partition, so K = input
partitions is the 1:1 rearrangement.

No behaviour change on h2o Q8 (`target_partitions` and source
partitions both settle at 8), but the rule no longer depends on the
config knob or its `.max(2)` fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(core, scheduler): PartitionedBoundedWindowAggExec — parallel BWAG for the range-window shape

DataFusion's `BoundedWindowAggExec` declares `SinglePartition` when no
PARTITION BY is present, forcing `EnforceDistribution` to collapse K→1
via `SortPreservingMergeExec`. With `ParallelWindowRule`'s range-
repartition upstream, each ORRE output partition is a globally
range-disjoint slice + halo — BWAG can safely run per-partition on those
K slices and produce K correct outputs. `PartitionedBoundedWindowAggExec`
wraps BWAG, exposes only the input as its plan-tree child (BWAG itself
is hidden from tree walkers), and overrides `required_input_distribution`
to `UnspecifiedDistribution`. `execute(i)` delegates to the wrapped BWAG,
which already processes each partition independently.

- `ballista_core::execution_plans::partitioned_bounded_window_agg`: the
  new operator. `InputOrderMode` and `can_repartition` are hardcoded
  (`Sorted` / `false`) per the rule's shape gates.
- `BallistaPhysicalPlanNode::PartitionedBoundedWindowAgg`: proto message
  carrying only `window_expr` — the rest is implicit from the rule's
  invariants. Round-trip goes through DF's
  `serialize_physical_window_expr` / `parse_physical_window_expr`.
- `ParallelWindowRule::rewrite_bwag`: drops the SPM the previous
  rewrite planted between BWAG and the wide `RangeFilterExec`, and
  swaps BWAG for `PartitionedBoundedWindowAggExec`. K is sourced from
  `config.execution.target_partitions.max(2)` — at rule-fire time
  `DataSourceExec` still has 1 file_group (splits happen later in the
  AQE chain), so the plan tree can't yet tell us the true source
  width. Reverts the "size K from source" refactor.
- `rewrites_q8_shape` test now asserts `PartitionedBoundedWindowAggExec`
  and NO `SortPreservingMergeExec` in the output.

On h2o Q8 @ 1e7 under a 2G/exec cgroup cap, 2 execs × 4 vcores,
`ballista.scheduler.max_partitions_per_task=4`: 41 s (down from 155 s)
and returns the full 10M rows (previous runs returned only 1.55M —
the K→1 collapse dropped ~87% of the output because the narrow
`RangeFilterExec` above the collapsed BWAG kept only partition-0's
range). Both stages run 2 MPT tasks (one per exec).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(core, scheduler): gate ParallelWindowRule behind ballista.planner.parallel_window.enabled

Adds an opt-in config flag so users of AQE don't inherit the range-window
rewrite by default. Matches the shape of `ballista.planner.coalesce.enabled`:
new AQE rule → new opt-in flag. Default `false`.

- `BALLISTA_PARALLEL_WINDOW_ENABLED` + registry entry + getter on
  `BallistaConfig`.
- Guard clause at the top of `ParallelWindowRule::optimize` returns the
  plan untouched when the flag is off.
- Existing shape tests keep the rule enabled through the local `optimize`
  helper; a new `disabled_by_default` test asserts the rewrite is inert
  without the extension registered.
- Regenerated `docs/source/user-guide/configs.md`.

notes

feat(core, scheduler, executor): RangeShuffleReaderExec — ordered k-way merge at stage boundary [WIP]

Closes the RANGE-frame correctness gap: the regular ShuffleReaderExec
concatenates upstream sources in arrival order, breaking the monotonicity
BWAG's Range-frame cursor assumes. RangeShuffleReaderExec keeps each source
alive as its own stream and feeds them all into StreamingMerge on the
child's declared ordering.

- new RangeShuffleReaderExec (fetch reuses shuffle_reader helpers, backpressure
  via merge demand; no permit governor, no per-source buffering)
- adapter plants it whenever exchange.input().output_ordering().is_some()
- proto + codec round-trip; executor work_dir/client_pool late binding;
  task_builder partition-slice restriction

h2o Q8 @ 1e7 SUM diff: parallel_window=true/false now agree to 5e-14 relative
(FP noise floor). Previously diverged at run boundaries.

Follow-ups (see next-session TODO): planner.rs::rollback_resolved_shuffles
falls through, cluster/mod.rs::stage_has_input_collapse falls through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

refactor(scheduler): audit-driven RangeShuffleReaderExec whitelist fills

Fills two production downcast sites that fell through the RangeShuffleReaderExec
shape, plus fmt fallout from the initial slice.

- planner::rollback_resolved_shuffles: rolls range readers back to plain
  UnresolvedShuffleExec. Range-ness is derived at plan time from the child's
  ordering, so a re-plan's adapter walk re-plants a fresh range reader — no
  proto extension needed.
- cluster::stage_has_input_collapse: range reader is a stage boundary; the
  walker must stop there, else a single-output-partition range reader
  spuriously trips the `partition_count == 1` collapse arm.

Tests:
- rollback_resolved_shuffles_reduces_range_reader_to_plain_unresolved
- stage_has_input_collapse_stops_at_range_reader

Fmt: adapter's #[cfg(test)] mod moved below resolve_range_filter_cuts to
satisfy clippy::items_after_test_module.

Follow-up still open: execution_graph_dot.rs graphviz — will render generic
node label for the range reader. Diagnostic only, safe to punt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

docs(parallel-range-window): tick Ordered ShuffleReader, drop correctness-gap section

The gap is closed — RangeShuffleReaderExec ships in d87321d + c8f3f20, and
the h2o Q8 SUM diff between parallel_window=true/false lands at 5e-14 relative
(Float64 noise floor). Rewrite the ticked line to describe the landed shape
and note the writer-vs-demand-driven follow-up. Drop the "Dot-product check"
bullet for the reader (now landed) and the whole correctness-gap section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

perf(core): RangeFilterExec min/max fast paths + binary-search slice

When `input.output_ordering()` leads with `routing_expr` ascending, take one
of three shortcuts on each batch before `filter_record_batch`:

  - `last < lo` or `first >= hi` → drop the whole batch (skip).
  - `first >= lo && last < hi`   → pass the batch through unchanged (Arc-clone).
  - mixed                         → `partition_point` on the Float64Array values
                                    for lo/hi indices + `RecordBatch::slice`
                                    (zero-copy view).

Nullable routing columns fall back to `filter_record_batch` on a per-batch
basis (Float64Array::values() returns garbage for null slots, breaking
partition_point). `sorted_on_key` is derived at construction — no config knob.

h2o Q8 with 2 execs × 4 vcores × MPT=4:

  scale  cap  parallel_window=false  parallel_window=true  speedup
  1e7    2G   7.6 s                  2.5 s                 3.0×
  1e8    4G   143 s                  92 s                  1.55×

The 1e8 delta is smaller because the bottleneck shifts to shuffle IO /
whole-file merge memory — the ValueIndex + per-task halo work next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(docs): rustdoc + prettier CI

- rustdoc: three `[`resolve_cuts`]` references in `range_filter.rs` (module
  doc + two item docs) resolved to no target; qualify as
  `RangeFilterExec::resolve_cuts` / `Self::resolve_cuts` so cargo doc no
  longer errors on ballista-core.
- prettier: `docs/developer/parallel-range-window.md` had two
  `*emphasis*` spans (`*shape*`, `*task-level*`) and a stray blank line —
  prettier wants `_emphasis_` + single blank. No content change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(core): RangeFilterExec metrics — fast-path counters + baseline

Was returning `None` from `metrics()`, so the operator was invisible in
the scheduler's stage-metrics dump. Adds `ExecutionPlanMetricsSet` with
`BaselineMetrics` (elapsed_compute, output_rows via record_poll) and
five path counters: `fast_skip_batches`, `fast_pass_batches`,
`fast_slice_batches`, `slow_batches`, `input_rows`. Timer scoped
post-poll so upstream shuffle IO isn't billed to this op.

Scout on h2o Q8 @ 1e8 confirms fast path is firing as intended
(99%+ pass-through on the narrow filter, 85% skip on the wide one,
zero slow-path fallbacks) — filter is not the perf bottleneck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(docs): drop unresolved intra-doc link in parallel_window

`resolve_range_filter_cuts` is private to the adapter module and not
in scope from `parallel_window.rs`, so rustdoc rejects the intra-doc
link under `-D warnings`. Keep it as plain inline code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

� Conflicts:
�	ballista/core/proto/ballista.proto
�	ballista/core/src/execution_plans/mod.rs
�	ballista/core/src/execution_plans/partitioned_bounded_window_agg.rs
�	ballista/core/src/serde/generated/ballista.rs
�	ballista/core/src/serde/mod.rs
avantgardnerio added a commit to avantgardnerio/arrow-ballista that referenced this pull request Aug 8, 2026
PerPartitionFilterExec's only caller (BallistaAdapter above ShuffleReader
for hash-agg correctness) was already using it as a range-shaped filter.
Widening it into a general per-partition arbitrary-predicate op — with
halo-widening bolted on for the parallel-window rewrite — would leak a
range concept into an arbitrary-predicate contract.

RangeFilterExec is the honest shape: routing_expr + cuts + halo_lo /
halo_hi. Per-partition semantics fall out of the local partition index,
not from a Vec of independent predicates. Ordering knowledge on the
input opens the door to a future ValueIndexReader-driven binary-search
path (PR apache#2204 direction) that a generic FilterExec can't take.

Notable pieces:

- `cuts: Arc<Mutex<Option<Vec<f64>>>>` + `resolve_cuts` API mirror
  `ExchangeExec::range_repartition_routing()` — the ParallelWindow rule
  plants a pending RangeFilterExec at plan time; the scheduler resolves
  cuts after stage 0's RuntimeStatsExec reports merge. `execute` and
  serialization both refuse while cuts are unresolved.
- `partition_indices: Vec<usize>` maps local → global partition index.
  Restrict slices this mapping without touching cuts (cuts stay whole;
  they describe the K global partitions). Replaces PPFE's per-partition
  predicate-vec slicing in task_builder's restrict path.
- Public API + proto speak `ScalarValue` (not `f64`) per the type-
  generality rule for the range-repartition family: the outer contract
  is type-agnostic so KLL can widen internal storage later without an
  API break. Internal downcast to `f64` today; non-Float64 inputs error
  with a clear message.
- Adapter builds RangeFilterExec with `halo=0` for the existing hash-agg
  case; the parallel-window rule will build it with non-zero halo.

Migration: delete `PerPartitionFilterExec`, migrate all callers, rename
proto `PerPartitionFilterExecNode` → `RangeFilterExecNode`, update doc
comments. Full test suite (597 tests) passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(scheduler): ParallelWindowRule — distributed range-shuffle for BWAG

Adds `ParallelWindowRule` to the AQE default_optimizers chain (position 2,
before `SelectJoinRule` and DF's own optimizers, before `DistributedExchangeRule`).
The rule matches bounded RANGE-frame windows with no PARTITION BY and a
single-column Float64 ORDER BY, and rewrites them into a range-shuffle
so BoundedWindowAggExec's SinglePartition requirement isn't a serial
bottleneck. Shape:

  RangeFilterExec (narrow, halo=0, cuts=pending)
    BoundedWindowAggExec
      SortPreservingMergeExec
        RangeFilterExec (wide, halo=frame bounds, cuts=pending)
          RuntimeStatsExec (post-ORRE per-partition sketch → scheduler)
            OrderedRangeRepartitionExec (K sorted disjoint outputs)
              RuntimeStatsExec (local sketch; feeds ORRE's cut walker)
                SortExec (preserve_partitioning=true)
                  <source>

Both RangeFilterExecs are planted with cuts=None. After stage 0's tasks
complete and their RSE reports are merged into K-1 quantile cuts, the
scheduler's `resolve_range_filter_cuts` walker (in `adapt_to_ballista`)
finds every pending RangeFilterExec in the downstream stage's plan and
resolves it against the matching ExchangeExec's routing_expr. Adapter no
longer injects RangeFilterExec — the rule is the sole planter, single
source of truth. Idempotency guard on the rule bails when the BWAG's
subtree already contains our own ORRE/RangeFilter (AQE re-plans fire the
chain again on the already-rewritten plan).

Also relaxes `ORRE::try_new` — the child-claims-sortedness check moved
from construction to `execute()`. Rule-time construction races with
`EnforceSorting` (which planted a SortExec on ORRE's declared
`required_input_ordering` *after* the rule ran), so refusing at try_new
was too strict. The runtime check at execute() still catches invariant
breaks; two tests moved from `try_new_rejects_*` to `execute_rejects_*`.

Verified on h2o Q8 at 1e7 under a 2G/exec cgroup cap: stage 0 (8 tasks)
and stage 1 (8 tasks) both parallelize across both executors, no OOM.
Stage 2 still collapses to a single task doing SPM+BWAG+narrow — DE
inserts a shuffle boundary below the SPM, putting BWAG in the final
stage. That collapse is the next follow-up; the machinery for the
range-shuffle itself is in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(scheduler): stop DE inserting Exchange between SPM and rule-planted RangeFilterExec

`ParallelWindowRule` plants a `RangeFilterExec` directly on the resolved
range-repartition `ExchangeExec`, with `SortPreservingMergeExec` above.
`DistributedExchangeRule`'s SPM branch was checking whether SPM's
immediate child was an `ExchangeExec` — seeing the `RangeFilterExec`, it
injected another `ExchangeExec`, cutting the plan into an extra collapse
stage.

Introduce `is_stage_boundary` and treat a `RangeFilterExec` sitting
directly on an `ExchangeExec` as part of the boundary. That is a
conscious design shape — we chose not to fold range-filtering into
`ShuffleReader`/`ExchangeExec`, so the filter is part of the boundary
by construction. This matches the pre-`fcb31520` behaviour where the
adapter injected the filter after DE had already run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

refactor(scheduler): size ParallelWindowRule's K from source partitions

K was `config.execution.target_partitions.max(2)` — a placeholder
chosen while writing the rule. The natural sizing is
`source.output_partitioning().partition_count()`: ORRE re-slices each
input partition into a range-disjoint output partition, so K = input
partitions is the 1:1 rearrangement.

No behaviour change on h2o Q8 (`target_partitions` and source
partitions both settle at 8), but the rule no longer depends on the
config knob or its `.max(2)` fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(core, scheduler): PartitionedBoundedWindowAggExec — parallel BWAG for the range-window shape

DataFusion's `BoundedWindowAggExec` declares `SinglePartition` when no
PARTITION BY is present, forcing `EnforceDistribution` to collapse K→1
via `SortPreservingMergeExec`. With `ParallelWindowRule`'s range-
repartition upstream, each ORRE output partition is a globally
range-disjoint slice + halo — BWAG can safely run per-partition on those
K slices and produce K correct outputs. `PartitionedBoundedWindowAggExec`
wraps BWAG, exposes only the input as its plan-tree child (BWAG itself
is hidden from tree walkers), and overrides `required_input_distribution`
to `UnspecifiedDistribution`. `execute(i)` delegates to the wrapped BWAG,
which already processes each partition independently.

- `ballista_core::execution_plans::partitioned_bounded_window_agg`: the
  new operator. `InputOrderMode` and `can_repartition` are hardcoded
  (`Sorted` / `false`) per the rule's shape gates.
- `BallistaPhysicalPlanNode::PartitionedBoundedWindowAgg`: proto message
  carrying only `window_expr` — the rest is implicit from the rule's
  invariants. Round-trip goes through DF's
  `serialize_physical_window_expr` / `parse_physical_window_expr`.
- `ParallelWindowRule::rewrite_bwag`: drops the SPM the previous
  rewrite planted between BWAG and the wide `RangeFilterExec`, and
  swaps BWAG for `PartitionedBoundedWindowAggExec`. K is sourced from
  `config.execution.target_partitions.max(2)` — at rule-fire time
  `DataSourceExec` still has 1 file_group (splits happen later in the
  AQE chain), so the plan tree can't yet tell us the true source
  width. Reverts the "size K from source" refactor.
- `rewrites_q8_shape` test now asserts `PartitionedBoundedWindowAggExec`
  and NO `SortPreservingMergeExec` in the output.

On h2o Q8 @ 1e7 under a 2G/exec cgroup cap, 2 execs × 4 vcores,
`ballista.scheduler.max_partitions_per_task=4`: 41 s (down from 155 s)
and returns the full 10M rows (previous runs returned only 1.55M —
the K→1 collapse dropped ~87% of the output because the narrow
`RangeFilterExec` above the collapsed BWAG kept only partition-0's
range). Both stages run 2 MPT tasks (one per exec).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(core, scheduler): gate ParallelWindowRule behind ballista.planner.parallel_window.enabled

Adds an opt-in config flag so users of AQE don't inherit the range-window
rewrite by default. Matches the shape of `ballista.planner.coalesce.enabled`:
new AQE rule → new opt-in flag. Default `false`.

- `BALLISTA_PARALLEL_WINDOW_ENABLED` + registry entry + getter on
  `BallistaConfig`.
- Guard clause at the top of `ParallelWindowRule::optimize` returns the
  plan untouched when the flag is off.
- Existing shape tests keep the rule enabled through the local `optimize`
  helper; a new `disabled_by_default` test asserts the rewrite is inert
  without the extension registered.
- Regenerated `docs/source/user-guide/configs.md`.

notes

feat(core, scheduler, executor): RangeShuffleReaderExec — ordered k-way merge at stage boundary [WIP]

Closes the RANGE-frame correctness gap: the regular ShuffleReaderExec
concatenates upstream sources in arrival order, breaking the monotonicity
BWAG's Range-frame cursor assumes. RangeShuffleReaderExec keeps each source
alive as its own stream and feeds them all into StreamingMerge on the
child's declared ordering.

- new RangeShuffleReaderExec (fetch reuses shuffle_reader helpers, backpressure
  via merge demand; no permit governor, no per-source buffering)
- adapter plants it whenever exchange.input().output_ordering().is_some()
- proto + codec round-trip; executor work_dir/client_pool late binding;
  task_builder partition-slice restriction

h2o Q8 @ 1e7 SUM diff: parallel_window=true/false now agree to 5e-14 relative
(FP noise floor). Previously diverged at run boundaries.

Follow-ups (see next-session TODO): planner.rs::rollback_resolved_shuffles
falls through, cluster/mod.rs::stage_has_input_collapse falls through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

refactor(scheduler): audit-driven RangeShuffleReaderExec whitelist fills

Fills two production downcast sites that fell through the RangeShuffleReaderExec
shape, plus fmt fallout from the initial slice.

- planner::rollback_resolved_shuffles: rolls range readers back to plain
  UnresolvedShuffleExec. Range-ness is derived at plan time from the child's
  ordering, so a re-plan's adapter walk re-plants a fresh range reader — no
  proto extension needed.
- cluster::stage_has_input_collapse: range reader is a stage boundary; the
  walker must stop there, else a single-output-partition range reader
  spuriously trips the `partition_count == 1` collapse arm.

Tests:
- rollback_resolved_shuffles_reduces_range_reader_to_plain_unresolved
- stage_has_input_collapse_stops_at_range_reader

Fmt: adapter's #[cfg(test)] mod moved below resolve_range_filter_cuts to
satisfy clippy::items_after_test_module.

Follow-up still open: execution_graph_dot.rs graphviz — will render generic
node label for the range reader. Diagnostic only, safe to punt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

docs(parallel-range-window): tick Ordered ShuffleReader, drop correctness-gap section

The gap is closed — RangeShuffleReaderExec ships in d87321d + c8f3f20, and
the h2o Q8 SUM diff between parallel_window=true/false lands at 5e-14 relative
(Float64 noise floor). Rewrite the ticked line to describe the landed shape
and note the writer-vs-demand-driven follow-up. Drop the "Dot-product check"
bullet for the reader (now landed) and the whole correctness-gap section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

perf(core): RangeFilterExec min/max fast paths + binary-search slice

When `input.output_ordering()` leads with `routing_expr` ascending, take one
of three shortcuts on each batch before `filter_record_batch`:

  - `last < lo` or `first >= hi` → drop the whole batch (skip).
  - `first >= lo && last < hi`   → pass the batch through unchanged (Arc-clone).
  - mixed                         → `partition_point` on the Float64Array values
                                    for lo/hi indices + `RecordBatch::slice`
                                    (zero-copy view).

Nullable routing columns fall back to `filter_record_batch` on a per-batch
basis (Float64Array::values() returns garbage for null slots, breaking
partition_point). `sorted_on_key` is derived at construction — no config knob.

h2o Q8 with 2 execs × 4 vcores × MPT=4:

  scale  cap  parallel_window=false  parallel_window=true  speedup
  1e7    2G   7.6 s                  2.5 s                 3.0×
  1e8    4G   143 s                  92 s                  1.55×

The 1e8 delta is smaller because the bottleneck shifts to shuffle IO /
whole-file merge memory — the ValueIndex + per-task halo work next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(docs): rustdoc + prettier CI

- rustdoc: three `[`resolve_cuts`]` references in `range_filter.rs` (module
  doc + two item docs) resolved to no target; qualify as
  `RangeFilterExec::resolve_cuts` / `Self::resolve_cuts` so cargo doc no
  longer errors on ballista-core.
- prettier: `docs/developer/parallel-range-window.md` had two
  `*emphasis*` spans (`*shape*`, `*task-level*`) and a stray blank line —
  prettier wants `_emphasis_` + single blank. No content change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(core): RangeFilterExec metrics — fast-path counters + baseline

Was returning `None` from `metrics()`, so the operator was invisible in
the scheduler's stage-metrics dump. Adds `ExecutionPlanMetricsSet` with
`BaselineMetrics` (elapsed_compute, output_rows via record_poll) and
five path counters: `fast_skip_batches`, `fast_pass_batches`,
`fast_slice_batches`, `slow_batches`, `input_rows`. Timer scoped
post-poll so upstream shuffle IO isn't billed to this op.

Scout on h2o Q8 @ 1e8 confirms fast path is firing as intended
(99%+ pass-through on the narrow filter, 85% skip on the wide one,
zero slow-path fallbacks) — filter is not the perf bottleneck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(docs): drop unresolved intra-doc link in parallel_window

`resolve_range_filter_cuts` is private to the adapter module and not
in scope from `parallel_window.rs`, so rustdoc rejects the intra-doc
link under `-D warnings`. Keep it as plain inline code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

� Conflicts:
�	ballista/core/proto/ballista.proto
�	ballista/core/src/execution_plans/mod.rs
�	ballista/core/src/execution_plans/partitioned_bounded_window_agg.rs
�	ballista/core/src/serde/generated/ballista.rs
�	ballista/core/src/serde/mod.rs
@andygrove

Copy link
Copy Markdown
Member

Heads up that CI is red here on three jobs (macos test, test linux ballista, test linux crates), and I think it's real rather than flaky.

should_force_local_read::case_1_standalone and case_2_remote fail with IpcError("Premature end of the stream while decoding schema"). That traces back to the block transfer path, which I don't think the format switch covers. flight_service.rs around line 340 opens the shuffle file with tokio::fs::File and ships the raw bytes over ReaderStream for the do_action block transfer from #1315, and the client decodes those bytes with StreamDecoder in client.rs. StreamDecoder only speaks stream format, so once write_stream_to_disk emits file format the bytes lead with the ARROW1 magic and trail a footer, and BlockDataStream::try_new never finds a schema. That one isn't a type swap unfortunately, since raw block transfer is inherently stream shaped. The client would need to skip the magic and footer, or use a different decoder.

Related, and probably worth fixing in the same pass: I think the writer side is only half migrated. write_stream_to_disk covers the output_partitioning: None branch of ShuffleWriterExec, but the hash repartition branch builds its own writer inline around shuffle_writer.rs:325 and still uses StreamWriter, while both readers moved to FileReader. That path would write stream and read file. It isn't failing in CI today because sort shuffle is the default and has its own untouched writer and reader, which is also why TPC-H SF10 stayed green.

One thing that made both of these easy to miss: the three updated tests in shuffle_reader.rs fabricate their files with FileWriter directly, so they only assert the reader agrees with itself. A round trip that drives ShuffleWriterExec through both branches into the reader, plus one exercising forced remote block transfer, would cover the gap.

I have more feedback on the rest of the PR but will hold it until CI is green.

@andygrove

Copy link
Copy Markdown
Member

Here's the rest of my read, separate from the CI issues above.

On the format switch and the personas. This trips a red flag in user-personas.md under Persona 3, specifically "wire-format or serialization changes that break externally-produced plans with no compatibility path". The on disk shuffle format moves from Arrow IPC stream to Arrow IPC file with no version marker and no fallback for reading what a previous version wrote. That doesn't make it forbidden, it just means it needs a deliberate call rather than landing quietly, and an entry in the upgrade guide. I appreciate that the description already says this isn't purely additive, I'd only push back on the framing of the cost as roughly two extra range GETs. The bigger cost is that the switch has to land atomically across every writer and reader of that file, and as the CI failures show there are more of those than it first looks.

Personas 1 and 2 look fine to me. No stage or task model changes, no AQE changes, and results are unaffected once the format is consistent everywhere.

Crash semantics, one for the "Regressions (honest)" list. I liked that you wrote that section, so here's one more for it. A stream format file is readable up to the last complete message, and a file format file with no footer doesn't open at all. So a task killed partway through a write now leaves behind a file that hard fails on open rather than one that partially reads. Task level retry probably makes that a non issue in practice, but it's a real behavior change and I'd rather see it named than discovered later.

write_stream_to_disk is public. Its signature is unchanged while its output format changes, so anyone using ballista-core as a library gets a silent behavior change with nothing at the type level to warn them. Worth an upgrade guide entry alongside the format switch itself.

The value index code itself. No concerns. ValueIndexExec and ValueIndexReader are cleanly additive, nothing constructs the exec yet, and the format agnostic split between "index stores sampled rows and ORDER BY values" and "a per format resolver turns a row into a byte range" reads well to me. The Vortex and Parquet cases in the description make a convincing argument that the split lands in the right place.

On the bundled RangeShuffleWriter / RangeShuffleReader question you raise at the top: I lean toward the tap based shape you have, and I'd rather fix the leaf_idx == batch_idx alignment risk directly than give it up. A debug assert comparing the recorded row counts against what the writer actually emitted would turn that silent wrong read into a loud failure, and it keeps the compositional property of one format agnostic ValueIndexExec per data writer. Happy to be argued out of it though, since you've been closer to this than I have.

@avantgardnerio

Copy link
Copy Markdown
Contributor Author

Thanks @andygrove ! There's a lot to think about here. I hope to land e2e parallel windows then come back to this shortly thereafter. I appreciate the discussion!

avantgardnerio added a commit to avantgardnerio/arrow-ballista that referenced this pull request Aug 8, 2026
PerPartitionFilterExec's only caller (BallistaAdapter above ShuffleReader
for hash-agg correctness) was already using it as a range-shaped filter.
Widening it into a general per-partition arbitrary-predicate op — with
halo-widening bolted on for the parallel-window rewrite — would leak a
range concept into an arbitrary-predicate contract.

RangeFilterExec is the honest shape: routing_expr + cuts + halo_lo /
halo_hi. Per-partition semantics fall out of the local partition index,
not from a Vec of independent predicates. Ordering knowledge on the
input opens the door to a future ValueIndexReader-driven binary-search
path (PR apache#2204 direction) that a generic FilterExec can't take.

Notable pieces:

- `cuts: Arc<Mutex<Option<Vec<f64>>>>` + `resolve_cuts` API mirror
  `ExchangeExec::range_repartition_routing()` — the ParallelWindow rule
  plants a pending RangeFilterExec at plan time; the scheduler resolves
  cuts after stage 0's RuntimeStatsExec reports merge. `execute` and
  serialization both refuse while cuts are unresolved.
- `partition_indices: Vec<usize>` maps local → global partition index.
  Restrict slices this mapping without touching cuts (cuts stay whole;
  they describe the K global partitions). Replaces PPFE's per-partition
  predicate-vec slicing in task_builder's restrict path.
- Public API + proto speak `ScalarValue` (not `f64`) per the type-
  generality rule for the range-repartition family: the outer contract
  is type-agnostic so KLL can widen internal storage later without an
  API break. Internal downcast to `f64` today; non-Float64 inputs error
  with a clear message.
- Adapter builds RangeFilterExec with `halo=0` for the existing hash-agg
  case; the parallel-window rule will build it with non-zero halo.

Migration: delete `PerPartitionFilterExec`, migrate all callers, rename
proto `PerPartitionFilterExecNode` → `RangeFilterExecNode`, update doc
comments. Full test suite (597 tests) passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(scheduler): ParallelWindowRule — distributed range-shuffle for BWAG

Adds `ParallelWindowRule` to the AQE default_optimizers chain (position 2,
before `SelectJoinRule` and DF's own optimizers, before `DistributedExchangeRule`).
The rule matches bounded RANGE-frame windows with no PARTITION BY and a
single-column Float64 ORDER BY, and rewrites them into a range-shuffle
so BoundedWindowAggExec's SinglePartition requirement isn't a serial
bottleneck. Shape:

  RangeFilterExec (narrow, halo=0, cuts=pending)
    BoundedWindowAggExec
      SortPreservingMergeExec
        RangeFilterExec (wide, halo=frame bounds, cuts=pending)
          RuntimeStatsExec (post-ORRE per-partition sketch → scheduler)
            OrderedRangeRepartitionExec (K sorted disjoint outputs)
              RuntimeStatsExec (local sketch; feeds ORRE's cut walker)
                SortExec (preserve_partitioning=true)
                  <source>

Both RangeFilterExecs are planted with cuts=None. After stage 0's tasks
complete and their RSE reports are merged into K-1 quantile cuts, the
scheduler's `resolve_range_filter_cuts` walker (in `adapt_to_ballista`)
finds every pending RangeFilterExec in the downstream stage's plan and
resolves it against the matching ExchangeExec's routing_expr. Adapter no
longer injects RangeFilterExec — the rule is the sole planter, single
source of truth. Idempotency guard on the rule bails when the BWAG's
subtree already contains our own ORRE/RangeFilter (AQE re-plans fire the
chain again on the already-rewritten plan).

Also relaxes `ORRE::try_new` — the child-claims-sortedness check moved
from construction to `execute()`. Rule-time construction races with
`EnforceSorting` (which planted a SortExec on ORRE's declared
`required_input_ordering` *after* the rule ran), so refusing at try_new
was too strict. The runtime check at execute() still catches invariant
breaks; two tests moved from `try_new_rejects_*` to `execute_rejects_*`.

Verified on h2o Q8 at 1e7 under a 2G/exec cgroup cap: stage 0 (8 tasks)
and stage 1 (8 tasks) both parallelize across both executors, no OOM.
Stage 2 still collapses to a single task doing SPM+BWAG+narrow — DE
inserts a shuffle boundary below the SPM, putting BWAG in the final
stage. That collapse is the next follow-up; the machinery for the
range-shuffle itself is in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(scheduler): stop DE inserting Exchange between SPM and rule-planted RangeFilterExec

`ParallelWindowRule` plants a `RangeFilterExec` directly on the resolved
range-repartition `ExchangeExec`, with `SortPreservingMergeExec` above.
`DistributedExchangeRule`'s SPM branch was checking whether SPM's
immediate child was an `ExchangeExec` — seeing the `RangeFilterExec`, it
injected another `ExchangeExec`, cutting the plan into an extra collapse
stage.

Introduce `is_stage_boundary` and treat a `RangeFilterExec` sitting
directly on an `ExchangeExec` as part of the boundary. That is a
conscious design shape — we chose not to fold range-filtering into
`ShuffleReader`/`ExchangeExec`, so the filter is part of the boundary
by construction. This matches the pre-`fcb31520` behaviour where the
adapter injected the filter after DE had already run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

refactor(scheduler): size ParallelWindowRule's K from source partitions

K was `config.execution.target_partitions.max(2)` — a placeholder
chosen while writing the rule. The natural sizing is
`source.output_partitioning().partition_count()`: ORRE re-slices each
input partition into a range-disjoint output partition, so K = input
partitions is the 1:1 rearrangement.

No behaviour change on h2o Q8 (`target_partitions` and source
partitions both settle at 8), but the rule no longer depends on the
config knob or its `.max(2)` fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(core, scheduler): PartitionedBoundedWindowAggExec — parallel BWAG for the range-window shape

DataFusion's `BoundedWindowAggExec` declares `SinglePartition` when no
PARTITION BY is present, forcing `EnforceDistribution` to collapse K→1
via `SortPreservingMergeExec`. With `ParallelWindowRule`'s range-
repartition upstream, each ORRE output partition is a globally
range-disjoint slice + halo — BWAG can safely run per-partition on those
K slices and produce K correct outputs. `PartitionedBoundedWindowAggExec`
wraps BWAG, exposes only the input as its plan-tree child (BWAG itself
is hidden from tree walkers), and overrides `required_input_distribution`
to `UnspecifiedDistribution`. `execute(i)` delegates to the wrapped BWAG,
which already processes each partition independently.

- `ballista_core::execution_plans::partitioned_bounded_window_agg`: the
  new operator. `InputOrderMode` and `can_repartition` are hardcoded
  (`Sorted` / `false`) per the rule's shape gates.
- `BallistaPhysicalPlanNode::PartitionedBoundedWindowAgg`: proto message
  carrying only `window_expr` — the rest is implicit from the rule's
  invariants. Round-trip goes through DF's
  `serialize_physical_window_expr` / `parse_physical_window_expr`.
- `ParallelWindowRule::rewrite_bwag`: drops the SPM the previous
  rewrite planted between BWAG and the wide `RangeFilterExec`, and
  swaps BWAG for `PartitionedBoundedWindowAggExec`. K is sourced from
  `config.execution.target_partitions.max(2)` — at rule-fire time
  `DataSourceExec` still has 1 file_group (splits happen later in the
  AQE chain), so the plan tree can't yet tell us the true source
  width. Reverts the "size K from source" refactor.
- `rewrites_q8_shape` test now asserts `PartitionedBoundedWindowAggExec`
  and NO `SortPreservingMergeExec` in the output.

On h2o Q8 @ 1e7 under a 2G/exec cgroup cap, 2 execs × 4 vcores,
`ballista.scheduler.max_partitions_per_task=4`: 41 s (down from 155 s)
and returns the full 10M rows (previous runs returned only 1.55M —
the K→1 collapse dropped ~87% of the output because the narrow
`RangeFilterExec` above the collapsed BWAG kept only partition-0's
range). Both stages run 2 MPT tasks (one per exec).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(core, scheduler): gate ParallelWindowRule behind ballista.planner.parallel_window.enabled

Adds an opt-in config flag so users of AQE don't inherit the range-window
rewrite by default. Matches the shape of `ballista.planner.coalesce.enabled`:
new AQE rule → new opt-in flag. Default `false`.

- `BALLISTA_PARALLEL_WINDOW_ENABLED` + registry entry + getter on
  `BallistaConfig`.
- Guard clause at the top of `ParallelWindowRule::optimize` returns the
  plan untouched when the flag is off.
- Existing shape tests keep the rule enabled through the local `optimize`
  helper; a new `disabled_by_default` test asserts the rewrite is inert
  without the extension registered.
- Regenerated `docs/source/user-guide/configs.md`.

notes

feat(core, scheduler, executor): RangeShuffleReaderExec — ordered k-way merge at stage boundary [WIP]

Closes the RANGE-frame correctness gap: the regular ShuffleReaderExec
concatenates upstream sources in arrival order, breaking the monotonicity
BWAG's Range-frame cursor assumes. RangeShuffleReaderExec keeps each source
alive as its own stream and feeds them all into StreamingMerge on the
child's declared ordering.

- new RangeShuffleReaderExec (fetch reuses shuffle_reader helpers, backpressure
  via merge demand; no permit governor, no per-source buffering)
- adapter plants it whenever exchange.input().output_ordering().is_some()
- proto + codec round-trip; executor work_dir/client_pool late binding;
  task_builder partition-slice restriction

h2o Q8 @ 1e7 SUM diff: parallel_window=true/false now agree to 5e-14 relative
(FP noise floor). Previously diverged at run boundaries.

Follow-ups (see next-session TODO): planner.rs::rollback_resolved_shuffles
falls through, cluster/mod.rs::stage_has_input_collapse falls through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

refactor(scheduler): audit-driven RangeShuffleReaderExec whitelist fills

Fills two production downcast sites that fell through the RangeShuffleReaderExec
shape, plus fmt fallout from the initial slice.

- planner::rollback_resolved_shuffles: rolls range readers back to plain
  UnresolvedShuffleExec. Range-ness is derived at plan time from the child's
  ordering, so a re-plan's adapter walk re-plants a fresh range reader — no
  proto extension needed.
- cluster::stage_has_input_collapse: range reader is a stage boundary; the
  walker must stop there, else a single-output-partition range reader
  spuriously trips the `partition_count == 1` collapse arm.

Tests:
- rollback_resolved_shuffles_reduces_range_reader_to_plain_unresolved
- stage_has_input_collapse_stops_at_range_reader

Fmt: adapter's #[cfg(test)] mod moved below resolve_range_filter_cuts to
satisfy clippy::items_after_test_module.

Follow-up still open: execution_graph_dot.rs graphviz — will render generic
node label for the range reader. Diagnostic only, safe to punt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

docs(parallel-range-window): tick Ordered ShuffleReader, drop correctness-gap section

The gap is closed — RangeShuffleReaderExec ships in d87321d + c8f3f20, and
the h2o Q8 SUM diff between parallel_window=true/false lands at 5e-14 relative
(Float64 noise floor). Rewrite the ticked line to describe the landed shape
and note the writer-vs-demand-driven follow-up. Drop the "Dot-product check"
bullet for the reader (now landed) and the whole correctness-gap section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

perf(core): RangeFilterExec min/max fast paths + binary-search slice

When `input.output_ordering()` leads with `routing_expr` ascending, take one
of three shortcuts on each batch before `filter_record_batch`:

  - `last < lo` or `first >= hi` → drop the whole batch (skip).
  - `first >= lo && last < hi`   → pass the batch through unchanged (Arc-clone).
  - mixed                         → `partition_point` on the Float64Array values
                                    for lo/hi indices + `RecordBatch::slice`
                                    (zero-copy view).

Nullable routing columns fall back to `filter_record_batch` on a per-batch
basis (Float64Array::values() returns garbage for null slots, breaking
partition_point). `sorted_on_key` is derived at construction — no config knob.

h2o Q8 with 2 execs × 4 vcores × MPT=4:

  scale  cap  parallel_window=false  parallel_window=true  speedup
  1e7    2G   7.6 s                  2.5 s                 3.0×
  1e8    4G   143 s                  92 s                  1.55×

The 1e8 delta is smaller because the bottleneck shifts to shuffle IO /
whole-file merge memory — the ValueIndex + per-task halo work next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(docs): rustdoc + prettier CI

- rustdoc: three `[`resolve_cuts`]` references in `range_filter.rs` (module
  doc + two item docs) resolved to no target; qualify as
  `RangeFilterExec::resolve_cuts` / `Self::resolve_cuts` so cargo doc no
  longer errors on ballista-core.
- prettier: `docs/developer/parallel-range-window.md` had two
  `*emphasis*` spans (`*shape*`, `*task-level*`) and a stray blank line —
  prettier wants `_emphasis_` + single blank. No content change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(core): RangeFilterExec metrics — fast-path counters + baseline

Was returning `None` from `metrics()`, so the operator was invisible in
the scheduler's stage-metrics dump. Adds `ExecutionPlanMetricsSet` with
`BaselineMetrics` (elapsed_compute, output_rows via record_poll) and
five path counters: `fast_skip_batches`, `fast_pass_batches`,
`fast_slice_batches`, `slow_batches`, `input_rows`. Timer scoped
post-poll so upstream shuffle IO isn't billed to this op.

Scout on h2o Q8 @ 1e8 confirms fast path is firing as intended
(99%+ pass-through on the narrow filter, 85% skip on the wide one,
zero slow-path fallbacks) — filter is not the perf bottleneck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(docs): drop unresolved intra-doc link in parallel_window

`resolve_range_filter_cuts` is private to the adapter module and not
in scope from `parallel_window.rs`, so rustdoc rejects the intra-doc
link under `-D warnings`. Keep it as plain inline code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

style(core): join split struct decl to satisfy rustfmt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

� Conflicts:
�	ballista/core/proto/ballista.proto
�	ballista/core/src/execution_plans/mod.rs
�	ballista/core/src/execution_plans/range_shuffle_reader.rs
�	ballista/core/src/serde/generated/ballista.rs
�	ballista/core/src/serde/mod.rs
avantgardnerio added a commit to avantgardnerio/arrow-ballista that referenced this pull request Aug 9, 2026
PerPartitionFilterExec's only caller (BallistaAdapter above ShuffleReader
for hash-agg correctness) was already using it as a range-shaped filter.
Widening it into a general per-partition arbitrary-predicate op — with
halo-widening bolted on for the parallel-window rewrite — would leak a
range concept into an arbitrary-predicate contract.

RangeFilterExec is the honest shape: routing_expr + cuts + halo_lo /
halo_hi. Per-partition semantics fall out of the local partition index,
not from a Vec of independent predicates. Ordering knowledge on the
input opens the door to a future ValueIndexReader-driven binary-search
path (PR apache#2204 direction) that a generic FilterExec can't take.

Notable pieces:

- `cuts: Arc<Mutex<Option<Vec<f64>>>>` + `resolve_cuts` API mirror
  `ExchangeExec::range_repartition_routing()` — the ParallelWindow rule
  plants a pending RangeFilterExec at plan time; the scheduler resolves
  cuts after stage 0's RuntimeStatsExec reports merge. `execute` and
  serialization both refuse while cuts are unresolved.
- `partition_indices: Vec<usize>` maps local → global partition index.
  Restrict slices this mapping without touching cuts (cuts stay whole;
  they describe the K global partitions). Replaces PPFE's per-partition
  predicate-vec slicing in task_builder's restrict path.
- Public API + proto speak `ScalarValue` (not `f64`) per the type-
  generality rule for the range-repartition family: the outer contract
  is type-agnostic so KLL can widen internal storage later without an
  API break. Internal downcast to `f64` today; non-Float64 inputs error
  with a clear message.
- Adapter builds RangeFilterExec with `halo=0` for the existing hash-agg
  case; the parallel-window rule will build it with non-zero halo.

Migration: delete `PerPartitionFilterExec`, migrate all callers, rename
proto `PerPartitionFilterExecNode` → `RangeFilterExecNode`, update doc
comments. Full test suite (597 tests) passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(scheduler): ParallelWindowRule — distributed range-shuffle for BWAG

Adds `ParallelWindowRule` to the AQE default_optimizers chain (position 2,
before `SelectJoinRule` and DF's own optimizers, before `DistributedExchangeRule`).
The rule matches bounded RANGE-frame windows with no PARTITION BY and a
single-column Float64 ORDER BY, and rewrites them into a range-shuffle
so BoundedWindowAggExec's SinglePartition requirement isn't a serial
bottleneck. Shape:

  RangeFilterExec (narrow, halo=0, cuts=pending)
    BoundedWindowAggExec
      SortPreservingMergeExec
        RangeFilterExec (wide, halo=frame bounds, cuts=pending)
          RuntimeStatsExec (post-ORRE per-partition sketch → scheduler)
            OrderedRangeRepartitionExec (K sorted disjoint outputs)
              RuntimeStatsExec (local sketch; feeds ORRE's cut walker)
                SortExec (preserve_partitioning=true)
                  <source>

Both RangeFilterExecs are planted with cuts=None. After stage 0's tasks
complete and their RSE reports are merged into K-1 quantile cuts, the
scheduler's `resolve_range_filter_cuts` walker (in `adapt_to_ballista`)
finds every pending RangeFilterExec in the downstream stage's plan and
resolves it against the matching ExchangeExec's routing_expr. Adapter no
longer injects RangeFilterExec — the rule is the sole planter, single
source of truth. Idempotency guard on the rule bails when the BWAG's
subtree already contains our own ORRE/RangeFilter (AQE re-plans fire the
chain again on the already-rewritten plan).

Also relaxes `ORRE::try_new` — the child-claims-sortedness check moved
from construction to `execute()`. Rule-time construction races with
`EnforceSorting` (which planted a SortExec on ORRE's declared
`required_input_ordering` *after* the rule ran), so refusing at try_new
was too strict. The runtime check at execute() still catches invariant
breaks; two tests moved from `try_new_rejects_*` to `execute_rejects_*`.

Verified on h2o Q8 at 1e7 under a 2G/exec cgroup cap: stage 0 (8 tasks)
and stage 1 (8 tasks) both parallelize across both executors, no OOM.
Stage 2 still collapses to a single task doing SPM+BWAG+narrow — DE
inserts a shuffle boundary below the SPM, putting BWAG in the final
stage. That collapse is the next follow-up; the machinery for the
range-shuffle itself is in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(scheduler): stop DE inserting Exchange between SPM and rule-planted RangeFilterExec

`ParallelWindowRule` plants a `RangeFilterExec` directly on the resolved
range-repartition `ExchangeExec`, with `SortPreservingMergeExec` above.
`DistributedExchangeRule`'s SPM branch was checking whether SPM's
immediate child was an `ExchangeExec` — seeing the `RangeFilterExec`, it
injected another `ExchangeExec`, cutting the plan into an extra collapse
stage.

Introduce `is_stage_boundary` and treat a `RangeFilterExec` sitting
directly on an `ExchangeExec` as part of the boundary. That is a
conscious design shape — we chose not to fold range-filtering into
`ShuffleReader`/`ExchangeExec`, so the filter is part of the boundary
by construction. This matches the pre-`fcb31520` behaviour where the
adapter injected the filter after DE had already run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

refactor(scheduler): size ParallelWindowRule's K from source partitions

K was `config.execution.target_partitions.max(2)` — a placeholder
chosen while writing the rule. The natural sizing is
`source.output_partitioning().partition_count()`: ORRE re-slices each
input partition into a range-disjoint output partition, so K = input
partitions is the 1:1 rearrangement.

No behaviour change on h2o Q8 (`target_partitions` and source
partitions both settle at 8), but the rule no longer depends on the
config knob or its `.max(2)` fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(core, scheduler): PartitionedBoundedWindowAggExec — parallel BWAG for the range-window shape

DataFusion's `BoundedWindowAggExec` declares `SinglePartition` when no
PARTITION BY is present, forcing `EnforceDistribution` to collapse K→1
via `SortPreservingMergeExec`. With `ParallelWindowRule`'s range-
repartition upstream, each ORRE output partition is a globally
range-disjoint slice + halo — BWAG can safely run per-partition on those
K slices and produce K correct outputs. `PartitionedBoundedWindowAggExec`
wraps BWAG, exposes only the input as its plan-tree child (BWAG itself
is hidden from tree walkers), and overrides `required_input_distribution`
to `UnspecifiedDistribution`. `execute(i)` delegates to the wrapped BWAG,
which already processes each partition independently.

- `ballista_core::execution_plans::partitioned_bounded_window_agg`: the
  new operator. `InputOrderMode` and `can_repartition` are hardcoded
  (`Sorted` / `false`) per the rule's shape gates.
- `BallistaPhysicalPlanNode::PartitionedBoundedWindowAgg`: proto message
  carrying only `window_expr` — the rest is implicit from the rule's
  invariants. Round-trip goes through DF's
  `serialize_physical_window_expr` / `parse_physical_window_expr`.
- `ParallelWindowRule::rewrite_bwag`: drops the SPM the previous
  rewrite planted between BWAG and the wide `RangeFilterExec`, and
  swaps BWAG for `PartitionedBoundedWindowAggExec`. K is sourced from
  `config.execution.target_partitions.max(2)` — at rule-fire time
  `DataSourceExec` still has 1 file_group (splits happen later in the
  AQE chain), so the plan tree can't yet tell us the true source
  width. Reverts the "size K from source" refactor.
- `rewrites_q8_shape` test now asserts `PartitionedBoundedWindowAggExec`
  and NO `SortPreservingMergeExec` in the output.

On h2o Q8 @ 1e7 under a 2G/exec cgroup cap, 2 execs × 4 vcores,
`ballista.scheduler.max_partitions_per_task=4`: 41 s (down from 155 s)
and returns the full 10M rows (previous runs returned only 1.55M —
the K→1 collapse dropped ~87% of the output because the narrow
`RangeFilterExec` above the collapsed BWAG kept only partition-0's
range). Both stages run 2 MPT tasks (one per exec).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(core, scheduler): gate ParallelWindowRule behind ballista.planner.parallel_window.enabled

Adds an opt-in config flag so users of AQE don't inherit the range-window
rewrite by default. Matches the shape of `ballista.planner.coalesce.enabled`:
new AQE rule → new opt-in flag. Default `false`.

- `BALLISTA_PARALLEL_WINDOW_ENABLED` + registry entry + getter on
  `BallistaConfig`.
- Guard clause at the top of `ParallelWindowRule::optimize` returns the
  plan untouched when the flag is off.
- Existing shape tests keep the rule enabled through the local `optimize`
  helper; a new `disabled_by_default` test asserts the rewrite is inert
  without the extension registered.
- Regenerated `docs/source/user-guide/configs.md`.

notes

feat(core, scheduler, executor): RangeShuffleReaderExec — ordered k-way merge at stage boundary [WIP]

Closes the RANGE-frame correctness gap: the regular ShuffleReaderExec
concatenates upstream sources in arrival order, breaking the monotonicity
BWAG's Range-frame cursor assumes. RangeShuffleReaderExec keeps each source
alive as its own stream and feeds them all into StreamingMerge on the
child's declared ordering.

- new RangeShuffleReaderExec (fetch reuses shuffle_reader helpers, backpressure
  via merge demand; no permit governor, no per-source buffering)
- adapter plants it whenever exchange.input().output_ordering().is_some()
- proto + codec round-trip; executor work_dir/client_pool late binding;
  task_builder partition-slice restriction

h2o Q8 @ 1e7 SUM diff: parallel_window=true/false now agree to 5e-14 relative
(FP noise floor). Previously diverged at run boundaries.

Follow-ups (see next-session TODO): planner.rs::rollback_resolved_shuffles
falls through, cluster/mod.rs::stage_has_input_collapse falls through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

refactor(scheduler): audit-driven RangeShuffleReaderExec whitelist fills

Fills two production downcast sites that fell through the RangeShuffleReaderExec
shape, plus fmt fallout from the initial slice.

- planner::rollback_resolved_shuffles: rolls range readers back to plain
  UnresolvedShuffleExec. Range-ness is derived at plan time from the child's
  ordering, so a re-plan's adapter walk re-plants a fresh range reader — no
  proto extension needed.
- cluster::stage_has_input_collapse: range reader is a stage boundary; the
  walker must stop there, else a single-output-partition range reader
  spuriously trips the `partition_count == 1` collapse arm.

Tests:
- rollback_resolved_shuffles_reduces_range_reader_to_plain_unresolved
- stage_has_input_collapse_stops_at_range_reader

Fmt: adapter's #[cfg(test)] mod moved below resolve_range_filter_cuts to
satisfy clippy::items_after_test_module.

Follow-up still open: execution_graph_dot.rs graphviz — will render generic
node label for the range reader. Diagnostic only, safe to punt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

docs(parallel-range-window): tick Ordered ShuffleReader, drop correctness-gap section

The gap is closed — RangeShuffleReaderExec ships in d87321d + c8f3f20, and
the h2o Q8 SUM diff between parallel_window=true/false lands at 5e-14 relative
(Float64 noise floor). Rewrite the ticked line to describe the landed shape
and note the writer-vs-demand-driven follow-up. Drop the "Dot-product check"
bullet for the reader (now landed) and the whole correctness-gap section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

perf(core): RangeFilterExec min/max fast paths + binary-search slice

When `input.output_ordering()` leads with `routing_expr` ascending, take one
of three shortcuts on each batch before `filter_record_batch`:

  - `last < lo` or `first >= hi` → drop the whole batch (skip).
  - `first >= lo && last < hi`   → pass the batch through unchanged (Arc-clone).
  - mixed                         → `partition_point` on the Float64Array values
                                    for lo/hi indices + `RecordBatch::slice`
                                    (zero-copy view).

Nullable routing columns fall back to `filter_record_batch` on a per-batch
basis (Float64Array::values() returns garbage for null slots, breaking
partition_point). `sorted_on_key` is derived at construction — no config knob.

h2o Q8 with 2 execs × 4 vcores × MPT=4:

  scale  cap  parallel_window=false  parallel_window=true  speedup
  1e7    2G   7.6 s                  2.5 s                 3.0×
  1e8    4G   143 s                  92 s                  1.55×

The 1e8 delta is smaller because the bottleneck shifts to shuffle IO /
whole-file merge memory — the ValueIndex + per-task halo work next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(docs): rustdoc + prettier CI

- rustdoc: three `[`resolve_cuts`]` references in `range_filter.rs` (module
  doc + two item docs) resolved to no target; qualify as
  `RangeFilterExec::resolve_cuts` / `Self::resolve_cuts` so cargo doc no
  longer errors on ballista-core.
- prettier: `docs/developer/parallel-range-window.md` had two
  `*emphasis*` spans (`*shape*`, `*task-level*`) and a stray blank line —
  prettier wants `_emphasis_` + single blank. No content change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(core): RangeFilterExec metrics — fast-path counters + baseline

Was returning `None` from `metrics()`, so the operator was invisible in
the scheduler's stage-metrics dump. Adds `ExecutionPlanMetricsSet` with
`BaselineMetrics` (elapsed_compute, output_rows via record_poll) and
five path counters: `fast_skip_batches`, `fast_pass_batches`,
`fast_slice_batches`, `slow_batches`, `input_rows`. Timer scoped
post-poll so upstream shuffle IO isn't billed to this op.

Scout on h2o Q8 @ 1e8 confirms fast path is firing as intended
(99%+ pass-through on the narrow filter, 85% skip on the wide one,
zero slow-path fallbacks) — filter is not the perf bottleneck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(docs): drop unresolved intra-doc link in parallel_window

`resolve_range_filter_cuts` is private to the adapter module and not
in scope from `parallel_window.rs`, so rustdoc rejects the intra-doc
link under `-D warnings`. Keep it as plain inline code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

style(core): join split struct decl to satisfy rustfmt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

refactor(core): slim RangeFilterExec — scheduler owns cuts/partition indices, RFE widens by halo

Split the range-partitioning concerns out of RangeFilterExec so it looks
like PerPartitionFilterExec's counterpart for sorted-key filtering:

- RFE fields: input, routing_expr, halo_lo/hi (ScalarValue), raw_bounds
  (late-bound), sorted_on_key detection, metrics.
- Gone: cuts, partition_indices, resolve_cuts, restrict_partitions,
  try_new_with_indices. Widening from cuts+halo to per-partition bounds
  moves scheduler-side (adapter builds raw_bounds from cuts; RFE widens
  by its own halos internally at resolve_bounds time).
- task_builder RFE branch is now a plain "slice raw_bounds parallel to
  input restriction" — no partition_indices remap.
- All APIs and proto fields are ScalarValue (arrow-primitive-generic);
  internal downcast to f64 with Err for non-Float64 until KLL widens.

Halos are functional on RFE (widens raw→widened at resolve time), not
write-only decoration. The scheduler-side cut_partitions also needs
halo-widened overlap for correct file routing to RANGE-frame consumers;
that's a separate cross-stage lookup left as a TODO here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

docs(core): fix RangeFilterExec intra-doc link at module scope

`[\`Self::resolve_bounds\`]` on line 39 was in the module-level `//!`
comment where `Self` is not defined. CI runs cargo doc with -D warnings
so it fails; local runs pass silently. Use the fully-qualified path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

docs(scheduler): revert PPFE→RFE comment renames to reduce PR diff

Three files carried only doc/comment renames from PerPartitionFilterExec to
RangeFilterExec — no code changes. PPFE still exists in the tree, so the
original phrasing remains accurate. Reverting shrinks the PR's review
surface without touching semantics; a follow-up sweep can update these
comments after PPFE is fully retired.

- exchange.rs: 3 comment mentions of PPFE-as-cuts-consumer
- test/coalesce_rule.rs: 1 test comment
- test/range_repartition.rs: 2 test comments

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(scheduler): plant RSE#1 below SortExec so cuts land before ORRE routes

`ParallelWindowRule` used to leave `RSE#1` above `SortExec`, so the local
sketch only started ingesting after Sort had fully materialized. ORRE
consumed from RSE#1 as soon as Sort emitted, meaning the scheduler often
handed ORRE a still-being-built sketch → approximate cuts → skewed
shuffle files.

Two changes to close this:

1. Move the rule to run *after* the DataFusion optimizer chain. At the
   old position the input was `BWAG → DataSource` (sources with
   `sort_order_for_reorder` satisfy BWAG's ordering natively, no Sort
   inserted yet); the SortExec placement we care about is only
   materialized once EnforceSorting / RepartitionFileScans have run.
   Running earlier also lets DF's later sort-pushdown move any Sort we
   plant down through the passthrough RSE#1, undoing the intended order.

2. Strip whatever DF planted for BWAG's SinglePartition + Sorted
   requirements (SPM and/or SortExec) and plant a fresh
   `SortExec → RSE#1 → source` chain below `ORRE`. The fresh Sort is
   the pipeline break: it consumes all input before emitting the first
   row, so RSE#1's sketch fully reports while Sort buffers.

Q8 (h2o, SF=1e7, 8 vcores):
- rule skipped (buggy pattern):  61s
- RSE#1 above Sort (prior):      24s
- RSE#1 below Sort (this):       17s  ← ~1.4× speedup over prior

Wide-RFE metrics on the new plan: input=18.84M / output=11.01M against a
10M-row dataset → 1.88× row-level read amplification, matching the
theoretical (1 + halo/cut_width) ≈ 1.24× floor plus batch-granularity
overhead from RangeShuffleReader.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

� Conflicts:
�	ballista/core/proto/ballista.proto
�	ballista/core/src/execution_plans/mod.rs
�	ballista/core/src/execution_plans/range_filter.rs
�	ballista/core/src/serde/generated/ballista.rs
avantgardnerio added a commit to avantgardnerio/arrow-ballista that referenced this pull request Aug 9, 2026
PerPartitionFilterExec's only caller (BallistaAdapter above ShuffleReader
for hash-agg correctness) was already using it as a range-shaped filter.
Widening it into a general per-partition arbitrary-predicate op — with
halo-widening bolted on for the parallel-window rewrite — would leak a
range concept into an arbitrary-predicate contract.

RangeFilterExec is the honest shape: routing_expr + cuts + halo_lo /
halo_hi. Per-partition semantics fall out of the local partition index,
not from a Vec of independent predicates. Ordering knowledge on the
input opens the door to a future ValueIndexReader-driven binary-search
path (PR apache#2204 direction) that a generic FilterExec can't take.

Notable pieces:

- `cuts: Arc<Mutex<Option<Vec<f64>>>>` + `resolve_cuts` API mirror
  `ExchangeExec::range_repartition_routing()` — the ParallelWindow rule
  plants a pending RangeFilterExec at plan time; the scheduler resolves
  cuts after stage 0's RuntimeStatsExec reports merge. `execute` and
  serialization both refuse while cuts are unresolved.
- `partition_indices: Vec<usize>` maps local → global partition index.
  Restrict slices this mapping without touching cuts (cuts stay whole;
  they describe the K global partitions). Replaces PPFE's per-partition
  predicate-vec slicing in task_builder's restrict path.
- Public API + proto speak `ScalarValue` (not `f64`) per the type-
  generality rule for the range-repartition family: the outer contract
  is type-agnostic so KLL can widen internal storage later without an
  API break. Internal downcast to `f64` today; non-Float64 inputs error
  with a clear message.
- Adapter builds RangeFilterExec with `halo=0` for the existing hash-agg
  case; the parallel-window rule will build it with non-zero halo.

Migration: delete `PerPartitionFilterExec`, migrate all callers, rename
proto `PerPartitionFilterExecNode` → `RangeFilterExecNode`, update doc
comments. Full test suite (597 tests) passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(scheduler): ParallelWindowRule — distributed range-shuffle for BWAG

Adds `ParallelWindowRule` to the AQE default_optimizers chain (position 2,
before `SelectJoinRule` and DF's own optimizers, before `DistributedExchangeRule`).
The rule matches bounded RANGE-frame windows with no PARTITION BY and a
single-column Float64 ORDER BY, and rewrites them into a range-shuffle
so BoundedWindowAggExec's SinglePartition requirement isn't a serial
bottleneck. Shape:

  RangeFilterExec (narrow, halo=0, cuts=pending)
    BoundedWindowAggExec
      SortPreservingMergeExec
        RangeFilterExec (wide, halo=frame bounds, cuts=pending)
          RuntimeStatsExec (post-ORRE per-partition sketch → scheduler)
            OrderedRangeRepartitionExec (K sorted disjoint outputs)
              RuntimeStatsExec (local sketch; feeds ORRE's cut walker)
                SortExec (preserve_partitioning=true)
                  <source>

Both RangeFilterExecs are planted with cuts=None. After stage 0's tasks
complete and their RSE reports are merged into K-1 quantile cuts, the
scheduler's `resolve_range_filter_cuts` walker (in `adapt_to_ballista`)
finds every pending RangeFilterExec in the downstream stage's plan and
resolves it against the matching ExchangeExec's routing_expr. Adapter no
longer injects RangeFilterExec — the rule is the sole planter, single
source of truth. Idempotency guard on the rule bails when the BWAG's
subtree already contains our own ORRE/RangeFilter (AQE re-plans fire the
chain again on the already-rewritten plan).

Also relaxes `ORRE::try_new` — the child-claims-sortedness check moved
from construction to `execute()`. Rule-time construction races with
`EnforceSorting` (which planted a SortExec on ORRE's declared
`required_input_ordering` *after* the rule ran), so refusing at try_new
was too strict. The runtime check at execute() still catches invariant
breaks; two tests moved from `try_new_rejects_*` to `execute_rejects_*`.

Verified on h2o Q8 at 1e7 under a 2G/exec cgroup cap: stage 0 (8 tasks)
and stage 1 (8 tasks) both parallelize across both executors, no OOM.
Stage 2 still collapses to a single task doing SPM+BWAG+narrow — DE
inserts a shuffle boundary below the SPM, putting BWAG in the final
stage. That collapse is the next follow-up; the machinery for the
range-shuffle itself is in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(scheduler): stop DE inserting Exchange between SPM and rule-planted RangeFilterExec

`ParallelWindowRule` plants a `RangeFilterExec` directly on the resolved
range-repartition `ExchangeExec`, with `SortPreservingMergeExec` above.
`DistributedExchangeRule`'s SPM branch was checking whether SPM's
immediate child was an `ExchangeExec` — seeing the `RangeFilterExec`, it
injected another `ExchangeExec`, cutting the plan into an extra collapse
stage.

Introduce `is_stage_boundary` and treat a `RangeFilterExec` sitting
directly on an `ExchangeExec` as part of the boundary. That is a
conscious design shape — we chose not to fold range-filtering into
`ShuffleReader`/`ExchangeExec`, so the filter is part of the boundary
by construction. This matches the pre-`fcb31520` behaviour where the
adapter injected the filter after DE had already run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

refactor(scheduler): size ParallelWindowRule's K from source partitions

K was `config.execution.target_partitions.max(2)` — a placeholder
chosen while writing the rule. The natural sizing is
`source.output_partitioning().partition_count()`: ORRE re-slices each
input partition into a range-disjoint output partition, so K = input
partitions is the 1:1 rearrangement.

No behaviour change on h2o Q8 (`target_partitions` and source
partitions both settle at 8), but the rule no longer depends on the
config knob or its `.max(2)` fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(core, scheduler): PartitionedBoundedWindowAggExec — parallel BWAG for the range-window shape

DataFusion's `BoundedWindowAggExec` declares `SinglePartition` when no
PARTITION BY is present, forcing `EnforceDistribution` to collapse K→1
via `SortPreservingMergeExec`. With `ParallelWindowRule`'s range-
repartition upstream, each ORRE output partition is a globally
range-disjoint slice + halo — BWAG can safely run per-partition on those
K slices and produce K correct outputs. `PartitionedBoundedWindowAggExec`
wraps BWAG, exposes only the input as its plan-tree child (BWAG itself
is hidden from tree walkers), and overrides `required_input_distribution`
to `UnspecifiedDistribution`. `execute(i)` delegates to the wrapped BWAG,
which already processes each partition independently.

- `ballista_core::execution_plans::partitioned_bounded_window_agg`: the
  new operator. `InputOrderMode` and `can_repartition` are hardcoded
  (`Sorted` / `false`) per the rule's shape gates.
- `BallistaPhysicalPlanNode::PartitionedBoundedWindowAgg`: proto message
  carrying only `window_expr` — the rest is implicit from the rule's
  invariants. Round-trip goes through DF's
  `serialize_physical_window_expr` / `parse_physical_window_expr`.
- `ParallelWindowRule::rewrite_bwag`: drops the SPM the previous
  rewrite planted between BWAG and the wide `RangeFilterExec`, and
  swaps BWAG for `PartitionedBoundedWindowAggExec`. K is sourced from
  `config.execution.target_partitions.max(2)` — at rule-fire time
  `DataSourceExec` still has 1 file_group (splits happen later in the
  AQE chain), so the plan tree can't yet tell us the true source
  width. Reverts the "size K from source" refactor.
- `rewrites_q8_shape` test now asserts `PartitionedBoundedWindowAggExec`
  and NO `SortPreservingMergeExec` in the output.

On h2o Q8 @ 1e7 under a 2G/exec cgroup cap, 2 execs × 4 vcores,
`ballista.scheduler.max_partitions_per_task=4`: 41 s (down from 155 s)
and returns the full 10M rows (previous runs returned only 1.55M —
the K→1 collapse dropped ~87% of the output because the narrow
`RangeFilterExec` above the collapsed BWAG kept only partition-0's
range). Both stages run 2 MPT tasks (one per exec).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(core, scheduler): gate ParallelWindowRule behind ballista.planner.parallel_window.enabled

Adds an opt-in config flag so users of AQE don't inherit the range-window
rewrite by default. Matches the shape of `ballista.planner.coalesce.enabled`:
new AQE rule → new opt-in flag. Default `false`.

- `BALLISTA_PARALLEL_WINDOW_ENABLED` + registry entry + getter on
  `BallistaConfig`.
- Guard clause at the top of `ParallelWindowRule::optimize` returns the
  plan untouched when the flag is off.
- Existing shape tests keep the rule enabled through the local `optimize`
  helper; a new `disabled_by_default` test asserts the rewrite is inert
  without the extension registered.
- Regenerated `docs/source/user-guide/configs.md`.

notes

feat(core, scheduler, executor): RangeShuffleReaderExec — ordered k-way merge at stage boundary [WIP]

Closes the RANGE-frame correctness gap: the regular ShuffleReaderExec
concatenates upstream sources in arrival order, breaking the monotonicity
BWAG's Range-frame cursor assumes. RangeShuffleReaderExec keeps each source
alive as its own stream and feeds them all into StreamingMerge on the
child's declared ordering.

- new RangeShuffleReaderExec (fetch reuses shuffle_reader helpers, backpressure
  via merge demand; no permit governor, no per-source buffering)
- adapter plants it whenever exchange.input().output_ordering().is_some()
- proto + codec round-trip; executor work_dir/client_pool late binding;
  task_builder partition-slice restriction

h2o Q8 @ 1e7 SUM diff: parallel_window=true/false now agree to 5e-14 relative
(FP noise floor). Previously diverged at run boundaries.

Follow-ups (see next-session TODO): planner.rs::rollback_resolved_shuffles
falls through, cluster/mod.rs::stage_has_input_collapse falls through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

refactor(scheduler): audit-driven RangeShuffleReaderExec whitelist fills

Fills two production downcast sites that fell through the RangeShuffleReaderExec
shape, plus fmt fallout from the initial slice.

- planner::rollback_resolved_shuffles: rolls range readers back to plain
  UnresolvedShuffleExec. Range-ness is derived at plan time from the child's
  ordering, so a re-plan's adapter walk re-plants a fresh range reader — no
  proto extension needed.
- cluster::stage_has_input_collapse: range reader is a stage boundary; the
  walker must stop there, else a single-output-partition range reader
  spuriously trips the `partition_count == 1` collapse arm.

Tests:
- rollback_resolved_shuffles_reduces_range_reader_to_plain_unresolved
- stage_has_input_collapse_stops_at_range_reader

Fmt: adapter's #[cfg(test)] mod moved below resolve_range_filter_cuts to
satisfy clippy::items_after_test_module.

Follow-up still open: execution_graph_dot.rs graphviz — will render generic
node label for the range reader. Diagnostic only, safe to punt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

docs(parallel-range-window): tick Ordered ShuffleReader, drop correctness-gap section

The gap is closed — RangeShuffleReaderExec ships in d87321d + c8f3f20, and
the h2o Q8 SUM diff between parallel_window=true/false lands at 5e-14 relative
(Float64 noise floor). Rewrite the ticked line to describe the landed shape
and note the writer-vs-demand-driven follow-up. Drop the "Dot-product check"
bullet for the reader (now landed) and the whole correctness-gap section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

perf(core): RangeFilterExec min/max fast paths + binary-search slice

When `input.output_ordering()` leads with `routing_expr` ascending, take one
of three shortcuts on each batch before `filter_record_batch`:

  - `last < lo` or `first >= hi` → drop the whole batch (skip).
  - `first >= lo && last < hi`   → pass the batch through unchanged (Arc-clone).
  - mixed                         → `partition_point` on the Float64Array values
                                    for lo/hi indices + `RecordBatch::slice`
                                    (zero-copy view).

Nullable routing columns fall back to `filter_record_batch` on a per-batch
basis (Float64Array::values() returns garbage for null slots, breaking
partition_point). `sorted_on_key` is derived at construction — no config knob.

h2o Q8 with 2 execs × 4 vcores × MPT=4:

  scale  cap  parallel_window=false  parallel_window=true  speedup
  1e7    2G   7.6 s                  2.5 s                 3.0×
  1e8    4G   143 s                  92 s                  1.55×

The 1e8 delta is smaller because the bottleneck shifts to shuffle IO /
whole-file merge memory — the ValueIndex + per-task halo work next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(docs): rustdoc + prettier CI

- rustdoc: three `[`resolve_cuts`]` references in `range_filter.rs` (module
  doc + two item docs) resolved to no target; qualify as
  `RangeFilterExec::resolve_cuts` / `Self::resolve_cuts` so cargo doc no
  longer errors on ballista-core.
- prettier: `docs/developer/parallel-range-window.md` had two
  `*emphasis*` spans (`*shape*`, `*task-level*`) and a stray blank line —
  prettier wants `_emphasis_` + single blank. No content change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(core): RangeFilterExec metrics — fast-path counters + baseline

Was returning `None` from `metrics()`, so the operator was invisible in
the scheduler's stage-metrics dump. Adds `ExecutionPlanMetricsSet` with
`BaselineMetrics` (elapsed_compute, output_rows via record_poll) and
five path counters: `fast_skip_batches`, `fast_pass_batches`,
`fast_slice_batches`, `slow_batches`, `input_rows`. Timer scoped
post-poll so upstream shuffle IO isn't billed to this op.

Scout on h2o Q8 @ 1e8 confirms fast path is firing as intended
(99%+ pass-through on the narrow filter, 85% skip on the wide one,
zero slow-path fallbacks) — filter is not the perf bottleneck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(docs): drop unresolved intra-doc link in parallel_window

`resolve_range_filter_cuts` is private to the adapter module and not
in scope from `parallel_window.rs`, so rustdoc rejects the intra-doc
link under `-D warnings`. Keep it as plain inline code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

style(core): join split struct decl to satisfy rustfmt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

refactor(core): slim RangeFilterExec — scheduler owns cuts/partition indices, RFE widens by halo

Split the range-partitioning concerns out of RangeFilterExec so it looks
like PerPartitionFilterExec's counterpart for sorted-key filtering:

- RFE fields: input, routing_expr, halo_lo/hi (ScalarValue), raw_bounds
  (late-bound), sorted_on_key detection, metrics.
- Gone: cuts, partition_indices, resolve_cuts, restrict_partitions,
  try_new_with_indices. Widening from cuts+halo to per-partition bounds
  moves scheduler-side (adapter builds raw_bounds from cuts; RFE widens
  by its own halos internally at resolve_bounds time).
- task_builder RFE branch is now a plain "slice raw_bounds parallel to
  input restriction" — no partition_indices remap.
- All APIs and proto fields are ScalarValue (arrow-primitive-generic);
  internal downcast to f64 with Err for non-Float64 until KLL widens.

Halos are functional on RFE (widens raw→widened at resolve time), not
write-only decoration. The scheduler-side cut_partitions also needs
halo-widened overlap for correct file routing to RANGE-frame consumers;
that's a separate cross-stage lookup left as a TODO here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

docs(core): fix RangeFilterExec intra-doc link at module scope

`[\`Self::resolve_bounds\`]` on line 39 was in the module-level `//!`
comment where `Self` is not defined. CI runs cargo doc with -D warnings
so it fails; local runs pass silently. Use the fully-qualified path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

docs(scheduler): revert PPFE→RFE comment renames to reduce PR diff

Three files carried only doc/comment renames from PerPartitionFilterExec to
RangeFilterExec — no code changes. PPFE still exists in the tree, so the
original phrasing remains accurate. Reverting shrinks the PR's review
surface without touching semantics; a follow-up sweep can update these
comments after PPFE is fully retired.

- exchange.rs: 3 comment mentions of PPFE-as-cuts-consumer
- test/coalesce_rule.rs: 1 test comment
- test/range_repartition.rs: 2 test comments

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(scheduler): plant RSE#1 below SortExec so cuts land before ORRE routes

`ParallelWindowRule` used to leave `RSE#1` above `SortExec`, so the local
sketch only started ingesting after Sort had fully materialized. ORRE
consumed from RSE#1 as soon as Sort emitted, meaning the scheduler often
handed ORRE a still-being-built sketch → approximate cuts → skewed
shuffle files.

Two changes to close this:

1. Move the rule to run *after* the DataFusion optimizer chain. At the
   old position the input was `BWAG → DataSource` (sources with
   `sort_order_for_reorder` satisfy BWAG's ordering natively, no Sort
   inserted yet); the SortExec placement we care about is only
   materialized once EnforceSorting / RepartitionFileScans have run.
   Running earlier also lets DF's later sort-pushdown move any Sort we
   plant down through the passthrough RSE#1, undoing the intended order.

2. Strip whatever DF planted for BWAG's SinglePartition + Sorted
   requirements (SPM and/or SortExec) and plant a fresh
   `SortExec → RSE#1 → source` chain below `ORRE`. The fresh Sort is
   the pipeline break: it consumes all input before emitting the first
   row, so RSE#1's sketch fully reports while Sort buffers.

Q8 (h2o, SF=1e7, 8 vcores):
- rule skipped (buggy pattern):  61s
- RSE#1 above Sort (prior):      24s
- RSE#1 below Sort (this):       17s  ← ~1.4× speedup over prior

Wide-RFE metrics on the new plan: input=18.84M / output=11.01M against a
10M-row dataset → 1.88× row-level read amplification, matching the
theoretical (1 + halo/cut_width) ≈ 1.24× floor plus batch-granularity
overhead from RangeShuffleReader.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

� Conflicts:
�	ballista/core/proto/ballista.proto
�	ballista/core/src/execution_plans/mod.rs
�	ballista/core/src/execution_plans/range_filter.rs
�	ballista/core/src/serde/generated/ballista.rs
avantgardnerio added a commit to avantgardnerio/arrow-ballista that referenced this pull request Aug 9, 2026
PerPartitionFilterExec's only caller (BallistaAdapter above ShuffleReader
for hash-agg correctness) was already using it as a range-shaped filter.
Widening it into a general per-partition arbitrary-predicate op — with
halo-widening bolted on for the parallel-window rewrite — would leak a
range concept into an arbitrary-predicate contract.

RangeFilterExec is the honest shape: routing_expr + cuts + halo_lo /
halo_hi. Per-partition semantics fall out of the local partition index,
not from a Vec of independent predicates. Ordering knowledge on the
input opens the door to a future ValueIndexReader-driven binary-search
path (PR apache#2204 direction) that a generic FilterExec can't take.

Notable pieces:

- `cuts: Arc<Mutex<Option<Vec<f64>>>>` + `resolve_cuts` API mirror
  `ExchangeExec::range_repartition_routing()` — the ParallelWindow rule
  plants a pending RangeFilterExec at plan time; the scheduler resolves
  cuts after stage 0's RuntimeStatsExec reports merge. `execute` and
  serialization both refuse while cuts are unresolved.
- `partition_indices: Vec<usize>` maps local → global partition index.
  Restrict slices this mapping without touching cuts (cuts stay whole;
  they describe the K global partitions). Replaces PPFE's per-partition
  predicate-vec slicing in task_builder's restrict path.
- Public API + proto speak `ScalarValue` (not `f64`) per the type-
  generality rule for the range-repartition family: the outer contract
  is type-agnostic so KLL can widen internal storage later without an
  API break. Internal downcast to `f64` today; non-Float64 inputs error
  with a clear message.
- Adapter builds RangeFilterExec with `halo=0` for the existing hash-agg
  case; the parallel-window rule will build it with non-zero halo.

Migration: delete `PerPartitionFilterExec`, migrate all callers, rename
proto `PerPartitionFilterExecNode` → `RangeFilterExecNode`, update doc
comments. Full test suite (597 tests) passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(scheduler): ParallelWindowRule — distributed range-shuffle for BWAG

Adds `ParallelWindowRule` to the AQE default_optimizers chain (position 2,
before `SelectJoinRule` and DF's own optimizers, before `DistributedExchangeRule`).
The rule matches bounded RANGE-frame windows with no PARTITION BY and a
single-column Float64 ORDER BY, and rewrites them into a range-shuffle
so BoundedWindowAggExec's SinglePartition requirement isn't a serial
bottleneck. Shape:

  RangeFilterExec (narrow, halo=0, cuts=pending)
    BoundedWindowAggExec
      SortPreservingMergeExec
        RangeFilterExec (wide, halo=frame bounds, cuts=pending)
          RuntimeStatsExec (post-ORRE per-partition sketch → scheduler)
            OrderedRangeRepartitionExec (K sorted disjoint outputs)
              RuntimeStatsExec (local sketch; feeds ORRE's cut walker)
                SortExec (preserve_partitioning=true)
                  <source>

Both RangeFilterExecs are planted with cuts=None. After stage 0's tasks
complete and their RSE reports are merged into K-1 quantile cuts, the
scheduler's `resolve_range_filter_cuts` walker (in `adapt_to_ballista`)
finds every pending RangeFilterExec in the downstream stage's plan and
resolves it against the matching ExchangeExec's routing_expr. Adapter no
longer injects RangeFilterExec — the rule is the sole planter, single
source of truth. Idempotency guard on the rule bails when the BWAG's
subtree already contains our own ORRE/RangeFilter (AQE re-plans fire the
chain again on the already-rewritten plan).

Also relaxes `ORRE::try_new` — the child-claims-sortedness check moved
from construction to `execute()`. Rule-time construction races with
`EnforceSorting` (which planted a SortExec on ORRE's declared
`required_input_ordering` *after* the rule ran), so refusing at try_new
was too strict. The runtime check at execute() still catches invariant
breaks; two tests moved from `try_new_rejects_*` to `execute_rejects_*`.

Verified on h2o Q8 at 1e7 under a 2G/exec cgroup cap: stage 0 (8 tasks)
and stage 1 (8 tasks) both parallelize across both executors, no OOM.
Stage 2 still collapses to a single task doing SPM+BWAG+narrow — DE
inserts a shuffle boundary below the SPM, putting BWAG in the final
stage. That collapse is the next follow-up; the machinery for the
range-shuffle itself is in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(scheduler): stop DE inserting Exchange between SPM and rule-planted RangeFilterExec

`ParallelWindowRule` plants a `RangeFilterExec` directly on the resolved
range-repartition `ExchangeExec`, with `SortPreservingMergeExec` above.
`DistributedExchangeRule`'s SPM branch was checking whether SPM's
immediate child was an `ExchangeExec` — seeing the `RangeFilterExec`, it
injected another `ExchangeExec`, cutting the plan into an extra collapse
stage.

Introduce `is_stage_boundary` and treat a `RangeFilterExec` sitting
directly on an `ExchangeExec` as part of the boundary. That is a
conscious design shape — we chose not to fold range-filtering into
`ShuffleReader`/`ExchangeExec`, so the filter is part of the boundary
by construction. This matches the pre-`fcb31520` behaviour where the
adapter injected the filter after DE had already run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

refactor(scheduler): size ParallelWindowRule's K from source partitions

K was `config.execution.target_partitions.max(2)` — a placeholder
chosen while writing the rule. The natural sizing is
`source.output_partitioning().partition_count()`: ORRE re-slices each
input partition into a range-disjoint output partition, so K = input
partitions is the 1:1 rearrangement.

No behaviour change on h2o Q8 (`target_partitions` and source
partitions both settle at 8), but the rule no longer depends on the
config knob or its `.max(2)` fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(core, scheduler): PartitionedBoundedWindowAggExec — parallel BWAG for the range-window shape

DataFusion's `BoundedWindowAggExec` declares `SinglePartition` when no
PARTITION BY is present, forcing `EnforceDistribution` to collapse K→1
via `SortPreservingMergeExec`. With `ParallelWindowRule`'s range-
repartition upstream, each ORRE output partition is a globally
range-disjoint slice + halo — BWAG can safely run per-partition on those
K slices and produce K correct outputs. `PartitionedBoundedWindowAggExec`
wraps BWAG, exposes only the input as its plan-tree child (BWAG itself
is hidden from tree walkers), and overrides `required_input_distribution`
to `UnspecifiedDistribution`. `execute(i)` delegates to the wrapped BWAG,
which already processes each partition independently.

- `ballista_core::execution_plans::partitioned_bounded_window_agg`: the
  new operator. `InputOrderMode` and `can_repartition` are hardcoded
  (`Sorted` / `false`) per the rule's shape gates.
- `BallistaPhysicalPlanNode::PartitionedBoundedWindowAgg`: proto message
  carrying only `window_expr` — the rest is implicit from the rule's
  invariants. Round-trip goes through DF's
  `serialize_physical_window_expr` / `parse_physical_window_expr`.
- `ParallelWindowRule::rewrite_bwag`: drops the SPM the previous
  rewrite planted between BWAG and the wide `RangeFilterExec`, and
  swaps BWAG for `PartitionedBoundedWindowAggExec`. K is sourced from
  `config.execution.target_partitions.max(2)` — at rule-fire time
  `DataSourceExec` still has 1 file_group (splits happen later in the
  AQE chain), so the plan tree can't yet tell us the true source
  width. Reverts the "size K from source" refactor.
- `rewrites_q8_shape` test now asserts `PartitionedBoundedWindowAggExec`
  and NO `SortPreservingMergeExec` in the output.

On h2o Q8 @ 1e7 under a 2G/exec cgroup cap, 2 execs × 4 vcores,
`ballista.scheduler.max_partitions_per_task=4`: 41 s (down from 155 s)
and returns the full 10M rows (previous runs returned only 1.55M —
the K→1 collapse dropped ~87% of the output because the narrow
`RangeFilterExec` above the collapsed BWAG kept only partition-0's
range). Both stages run 2 MPT tasks (one per exec).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(core, scheduler): gate ParallelWindowRule behind ballista.planner.parallel_window.enabled

Adds an opt-in config flag so users of AQE don't inherit the range-window
rewrite by default. Matches the shape of `ballista.planner.coalesce.enabled`:
new AQE rule → new opt-in flag. Default `false`.

- `BALLISTA_PARALLEL_WINDOW_ENABLED` + registry entry + getter on
  `BallistaConfig`.
- Guard clause at the top of `ParallelWindowRule::optimize` returns the
  plan untouched when the flag is off.
- Existing shape tests keep the rule enabled through the local `optimize`
  helper; a new `disabled_by_default` test asserts the rewrite is inert
  without the extension registered.
- Regenerated `docs/source/user-guide/configs.md`.

notes

feat(core, scheduler, executor): RangeShuffleReaderExec — ordered k-way merge at stage boundary [WIP]

Closes the RANGE-frame correctness gap: the regular ShuffleReaderExec
concatenates upstream sources in arrival order, breaking the monotonicity
BWAG's Range-frame cursor assumes. RangeShuffleReaderExec keeps each source
alive as its own stream and feeds them all into StreamingMerge on the
child's declared ordering.

- new RangeShuffleReaderExec (fetch reuses shuffle_reader helpers, backpressure
  via merge demand; no permit governor, no per-source buffering)
- adapter plants it whenever exchange.input().output_ordering().is_some()
- proto + codec round-trip; executor work_dir/client_pool late binding;
  task_builder partition-slice restriction

h2o Q8 @ 1e7 SUM diff: parallel_window=true/false now agree to 5e-14 relative
(FP noise floor). Previously diverged at run boundaries.

Follow-ups (see next-session TODO): planner.rs::rollback_resolved_shuffles
falls through, cluster/mod.rs::stage_has_input_collapse falls through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

refactor(scheduler): audit-driven RangeShuffleReaderExec whitelist fills

Fills two production downcast sites that fell through the RangeShuffleReaderExec
shape, plus fmt fallout from the initial slice.

- planner::rollback_resolved_shuffles: rolls range readers back to plain
  UnresolvedShuffleExec. Range-ness is derived at plan time from the child's
  ordering, so a re-plan's adapter walk re-plants a fresh range reader — no
  proto extension needed.
- cluster::stage_has_input_collapse: range reader is a stage boundary; the
  walker must stop there, else a single-output-partition range reader
  spuriously trips the `partition_count == 1` collapse arm.

Tests:
- rollback_resolved_shuffles_reduces_range_reader_to_plain_unresolved
- stage_has_input_collapse_stops_at_range_reader

Fmt: adapter's #[cfg(test)] mod moved below resolve_range_filter_cuts to
satisfy clippy::items_after_test_module.

Follow-up still open: execution_graph_dot.rs graphviz — will render generic
node label for the range reader. Diagnostic only, safe to punt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

docs(parallel-range-window): tick Ordered ShuffleReader, drop correctness-gap section

The gap is closed — RangeShuffleReaderExec ships in d87321d + c8f3f20, and
the h2o Q8 SUM diff between parallel_window=true/false lands at 5e-14 relative
(Float64 noise floor). Rewrite the ticked line to describe the landed shape
and note the writer-vs-demand-driven follow-up. Drop the "Dot-product check"
bullet for the reader (now landed) and the whole correctness-gap section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

perf(core): RangeFilterExec min/max fast paths + binary-search slice

When `input.output_ordering()` leads with `routing_expr` ascending, take one
of three shortcuts on each batch before `filter_record_batch`:

  - `last < lo` or `first >= hi` → drop the whole batch (skip).
  - `first >= lo && last < hi`   → pass the batch through unchanged (Arc-clone).
  - mixed                         → `partition_point` on the Float64Array values
                                    for lo/hi indices + `RecordBatch::slice`
                                    (zero-copy view).

Nullable routing columns fall back to `filter_record_batch` on a per-batch
basis (Float64Array::values() returns garbage for null slots, breaking
partition_point). `sorted_on_key` is derived at construction — no config knob.

h2o Q8 with 2 execs × 4 vcores × MPT=4:

  scale  cap  parallel_window=false  parallel_window=true  speedup
  1e7    2G   7.6 s                  2.5 s                 3.0×
  1e8    4G   143 s                  92 s                  1.55×

The 1e8 delta is smaller because the bottleneck shifts to shuffle IO /
whole-file merge memory — the ValueIndex + per-task halo work next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(docs): rustdoc + prettier CI

- rustdoc: three `[`resolve_cuts`]` references in `range_filter.rs` (module
  doc + two item docs) resolved to no target; qualify as
  `RangeFilterExec::resolve_cuts` / `Self::resolve_cuts` so cargo doc no
  longer errors on ballista-core.
- prettier: `docs/developer/parallel-range-window.md` had two
  `*emphasis*` spans (`*shape*`, `*task-level*`) and a stray blank line —
  prettier wants `_emphasis_` + single blank. No content change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(core): RangeFilterExec metrics — fast-path counters + baseline

Was returning `None` from `metrics()`, so the operator was invisible in
the scheduler's stage-metrics dump. Adds `ExecutionPlanMetricsSet` with
`BaselineMetrics` (elapsed_compute, output_rows via record_poll) and
five path counters: `fast_skip_batches`, `fast_pass_batches`,
`fast_slice_batches`, `slow_batches`, `input_rows`. Timer scoped
post-poll so upstream shuffle IO isn't billed to this op.

Scout on h2o Q8 @ 1e8 confirms fast path is firing as intended
(99%+ pass-through on the narrow filter, 85% skip on the wide one,
zero slow-path fallbacks) — filter is not the perf bottleneck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(docs): drop unresolved intra-doc link in parallel_window

`resolve_range_filter_cuts` is private to the adapter module and not
in scope from `parallel_window.rs`, so rustdoc rejects the intra-doc
link under `-D warnings`. Keep it as plain inline code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

style(core): join split struct decl to satisfy rustfmt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

refactor(core): slim RangeFilterExec — scheduler owns cuts/partition indices, RFE widens by halo

Split the range-partitioning concerns out of RangeFilterExec so it looks
like PerPartitionFilterExec's counterpart for sorted-key filtering:

- RFE fields: input, routing_expr, halo_lo/hi (ScalarValue), raw_bounds
  (late-bound), sorted_on_key detection, metrics.
- Gone: cuts, partition_indices, resolve_cuts, restrict_partitions,
  try_new_with_indices. Widening from cuts+halo to per-partition bounds
  moves scheduler-side (adapter builds raw_bounds from cuts; RFE widens
  by its own halos internally at resolve_bounds time).
- task_builder RFE branch is now a plain "slice raw_bounds parallel to
  input restriction" — no partition_indices remap.
- All APIs and proto fields are ScalarValue (arrow-primitive-generic);
  internal downcast to f64 with Err for non-Float64 until KLL widens.

Halos are functional on RFE (widens raw→widened at resolve time), not
write-only decoration. The scheduler-side cut_partitions also needs
halo-widened overlap for correct file routing to RANGE-frame consumers;
that's a separate cross-stage lookup left as a TODO here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

docs(core): fix RangeFilterExec intra-doc link at module scope

`[\`Self::resolve_bounds\`]` on line 39 was in the module-level `//!`
comment where `Self` is not defined. CI runs cargo doc with -D warnings
so it fails; local runs pass silently. Use the fully-qualified path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

docs(scheduler): revert PPFE→RFE comment renames to reduce PR diff

Three files carried only doc/comment renames from PerPartitionFilterExec to
RangeFilterExec — no code changes. PPFE still exists in the tree, so the
original phrasing remains accurate. Reverting shrinks the PR's review
surface without touching semantics; a follow-up sweep can update these
comments after PPFE is fully retired.

- exchange.rs: 3 comment mentions of PPFE-as-cuts-consumer
- test/coalesce_rule.rs: 1 test comment
- test/range_repartition.rs: 2 test comments

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(scheduler): plant RSE#1 below SortExec so cuts land before ORRE routes

`ParallelWindowRule` used to leave `RSE#1` above `SortExec`, so the local
sketch only started ingesting after Sort had fully materialized. ORRE
consumed from RSE#1 as soon as Sort emitted, meaning the scheduler often
handed ORRE a still-being-built sketch → approximate cuts → skewed
shuffle files.

Two changes to close this:

1. Move the rule to run *after* the DataFusion optimizer chain. At the
   old position the input was `BWAG → DataSource` (sources with
   `sort_order_for_reorder` satisfy BWAG's ordering natively, no Sort
   inserted yet); the SortExec placement we care about is only
   materialized once EnforceSorting / RepartitionFileScans have run.
   Running earlier also lets DF's later sort-pushdown move any Sort we
   plant down through the passthrough RSE#1, undoing the intended order.

2. Strip whatever DF planted for BWAG's SinglePartition + Sorted
   requirements (SPM and/or SortExec) and plant a fresh
   `SortExec → RSE#1 → source` chain below `ORRE`. The fresh Sort is
   the pipeline break: it consumes all input before emitting the first
   row, so RSE#1's sketch fully reports while Sort buffers.

Q8 (h2o, SF=1e7, 8 vcores):
- rule skipped (buggy pattern):  61s
- RSE#1 above Sort (prior):      24s
- RSE#1 below Sort (this):       17s  ← ~1.4× speedup over prior

Wide-RFE metrics on the new plan: input=18.84M / output=11.01M against a
10M-row dataset → 1.88× row-level read amplification, matching the
theoretical (1 + halo/cut_width) ≈ 1.24× floor plus batch-granularity
overhead from RangeShuffleReader.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

� Conflicts:
�	ballista/core/proto/ballista.proto
�	ballista/core/src/execution_plans/mod.rs
�	ballista/core/src/execution_plans/range_filter.rs
�	ballista/core/src/serde/generated/ballista.rs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants