Skip to content

feat(core): RuntimeStatsExec — passthrough tap for row counts + quantile sketches#2094

Open
avantgardnerio wants to merge 5 commits into
apache:mainfrom
avantgardnerio:brent/runtime-stats-exec
Open

feat(core): RuntimeStatsExec — passthrough tap for row counts + quantile sketches#2094
avantgardnerio wants to merge 5 commits into
apache:mainfrom
avantgardnerio:brent/runtime-stats-exec

Conversation

@avantgardnerio

Copy link
Copy Markdown
Contributor

Summary

Adds RuntimeStatsExec: a passthrough physical operator that observes input batches as they stream past and accumulates runtime state, without touching the data. Two accessor families:

  • row count — per input partition, AtomicUsize::fetch_add on the hot path (no cross-partition contention, no lock overhead). Always tracked.
  • quantile sketch — per input partition, Mutex<TDigest> (writes never contend across partitions). Only allocated when the caller passes Some(order_by) at construction.

Both accessors are readable at any point, so callers get a mid-stream snapshot when the sample is accurate enough or a post-drain snapshot when they want the fully-observed state (typical after a blocking downstream like SortExec).

Why this exists — supporting the forthcoming range-repartition operators

RuntimeStatsExec is the shared substrate for two upcoming ops (OrderedRangeRepartitionExec and UnorderedRangeRepartitionExec) that will land the distributed parallel-window path. Two modes matched to two use sites:

Sketching mode (Some(order_by)) → drives UnorderedRangeRepartitionExec.
Placed above the scan, RuntimeStatsExec streams a T-Digest over the routing column while the data is scanned. Each executor's sketch is merged (TDigest::merge_digests) to choose global cut points at a scheduler barrier, then UnorderedRangeRepartitionExec uses those cuts to route rows to sub-partitions by value range — no upfront sort required.

Row-count-only mode (None) → drives OrderedRangeRepartitionExec.
Placed above a SortExec, it exposes exact runtime row counts per sub-partition. DataFusion's partition_statistics() only returns plan-time estimates, which are too coarse for the ordered stage's bin-packer. The tap fixes that at operator level without touching DF upstream.

The full parallel-window path (Stage 1 sketch → cut selection → Stage 2 slice) is described in the parallel-window-kll-adaptive design doc on the source branch and will land alongside the range-repartition operators.

Scope of this PR

Just the operator, its proto/serde plumbing, and tests. Nothing consumes the accumulated state yet — the executor still ships SuccessfulTask unchanged, and no rule inserts RuntimeStatsExec into any plan. Those pieces land with the range-repartition operators.

Included:

  • datafusion-functions-aggregate-common = \"54\" workspace dep (for TDigest).
  • Proto: RuntimeStatsExecNode on BallistaPhysicalPlanNode.oneof (tag 6), and QuantileSketchState for the wire T-Digest (fixed 6-element Vec<ScalarValue> per TDigest::to_scalar_state).
  • sketch_to_proto / sketch_from_proto helpers with a shape guard against corrupted wire input (would otherwise panic in from_scalar_state).
  • Codec encode/decode registration on BallistaPhysicalExtensionCodec.
  • TODO in module docs: swap TDigest (Float64-only, single-column) for a generic-over-Ord KLL sketch that can cover the full Vec<PhysicalSortExpr>.

Tests (9)

  • Three wire round-trips: populated sketch, empty sketch, wrong-element-count rejection.
  • Three streaming: execute_populates_sketch_and_row_count, execute_skips_null_routing_keys_from_sketch, execute_row_count_only_no_sketch.
  • Three plan-node serde: sketching-mode roundtrip, row-count-only-mode roundtrip, empty-order_by rejection.

Test plan

  • cargo test -p ballista-core (all 123 tests + 2 doctests + 1 compile-fail pass).
  • cargo clippy --all-targets clean.
  • cargo fmt --all.

🤖 Generated with Claude Code

avantgardnerio and others added 2 commits July 19, 2026 17:57
… counts and quantile sketches

Passthrough physical operator that observes input batches as they stream
past and accumulates two families of runtime state, without changing the
data:

* per-partition row count (`AtomicUsize`, always)
* per-partition quantile sketch over the first `ORDER BY` expression's
  `Float64` values (`Mutex<TDigest>`, only when `order_by = Some`)

Both accessors are readable at any point, so callers get mid-stream
snapshots when the sample is accurate enough or post-drain snapshots
when they want the fully-observed state.

Ships in isolation: nothing wires it into a plan yet, and the executor
doesn't yet ship the accumulated state back to the scheduler. Those
pieces arrive with the range-repartition operators (the first consumer).

Includes:
* `datafusion-functions-aggregate-common` dep for `TDigest`
* proto `RuntimeStatsExecNode` (tag 6 on `BallistaPhysicalPlanNode`)
  and `QuantileSketchState` (T-Digest wire format via `to_scalar_state`)
* `sketch_to_proto` / `sketch_from_proto` helpers
* codec encode/decode registration
* nine tests: three wire round-trip, three streaming (populated sketch,
  NULL-key skip, row-count-only), and three plan-node serde roundtrips

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

Adds the transitive entry to ballista-core in python/Cargo.lock so
`cargo check --locked` succeeds in the python workspace.

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

Copy link
Copy Markdown
Contributor Author

@phillipleblanc FYI

avantgardnerio added a commit to avantgardnerio/arrow-ballista that referenced this pull request Jul 20, 2026
apache#2094's RuntimeStatsExecNode also took tag 6 (both PRs were cut off
origin/main independently). Whoever landed second would have had to
rebase; picking 7 now lets both merge without a follow-up shuffle.

No wire compatibility concerns — neither variant has shipped.

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

@phillipleblanc phillipleblanc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I have a few comments, but this is looking good!

Comment thread ballista/core/src/execution_plans/runtime_stats.rs Outdated
Comment thread ballista/core/src/execution_plans/runtime_stats.rs Outdated
Comment thread ballista/core/src/execution_plans/runtime_stats.rs
Comment thread ballista/core/src/execution_plans/runtime_stats.rs
Comment thread ballista/core/src/execution_plans/runtime_stats.rs Outdated
avantgardnerio and others added 2 commits July 20, 2026 06:15
TDigest has no NULL slot, so a nullable routing column silently
excludes NULLs from the sketch while `row_count` still counts them —
skewing the sketch away from what actually flowed through. Rejecting
nullable routing at construction closes the gap. KLL (the planned
swap) will lift this by positioning NULLs per each key's
`SortOptions::nulls_first`.

Also:
- Extend module TODO to name KLL's NULL positioning story.
- Rewrite the `ingest` comment about NULLs — it's a TDigest limitation,
  not a semantic decision (NULLs are still forwarded and counted).
- Delete `execute_skips_null_routing_keys_from_sketch` — unreachable
  under the new construction check; replaced by a rejection test in
  serde tests.

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

Copy link
Copy Markdown
Contributor Author

Thanks for your feedback @phillipleblanc . As usual it was very helpful 😄

Matches the algebraic-method surface on BufferExec — adds explicit
`required_input_distribution`, `required_input_ordering`, and
`benefits_from_input_partitioning` alongside the three
behaviour-overriding methods already there. All six now spell out the
passthrough contract so a reader doesn't have to consult trait
defaults to know how the planner will treat this node.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants