feat(core): ValueIndexExec + ValueIndexReader + shuffle FileWriter switch - #2204
feat(core): ValueIndexExec + ValueIndexReader + shuffle FileWriter switch#2204avantgardnerio wants to merge 8 commits into
Conversation
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>
0972f4e to
a5525a9
Compare
|
@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. |
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>
|
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. |
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:
|
|
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 |
|
I think having the per-format implementations of As long as the traits Also RE: the breaking change - I checked and this won't be a problem for us. |
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>
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>
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>
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>
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
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
|
Heads up that CI is red here on three jobs (
Related, and probably worth fixing in the same pass: I think the writer side is only half migrated. One thing that made both of these easy to miss: the three updated tests in I have more feedback on the rest of the PR but will hold it until CI is green. |
|
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.
The value index code itself. No concerns. On the bundled |
|
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! |
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
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
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
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
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 translatesampled_row → byte rangein 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 onlyRecordBatch; 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:
Block { offset, metadata_length, body_length }+ each RecordBatch message's row count in its metadata. What this PR implements.ValueIndexReaderdoesn'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-specificsampled_row → byte rangeresolver plugs in alongside the Arrow IPC one.So
ValueIndexExecwriting side is fully format-agnostic (it evaluates the ORDER BY expression, records rows). The read-side is a small format-specific translator on top ofValueIndexReader::leaf_row_range. This PR ships the Arrow IPC translator viaFileReader::set_index.Wins
RecordBatches come out of the reader. Sort-shuffle path is untouched.ValueIndexExecyet. Planner rule to insert it will follow as a separate PR.Regressions (honest)
.arrow.indexoffset table +StreamWriteron the consolidateddata.arrowcontinues to work as before. The tradeoff above applies only to the passthrough shuffle path.