Skip to content

feat(core): BufferExec — generic flow-control op with a memory-pool Dam mode#2095

Merged
andygrove merged 1 commit into
apache:mainfrom
avantgardnerio:brent/buffer-exec
Jul 21, 2026
Merged

feat(core): BufferExec — generic flow-control op with a memory-pool Dam mode#2095
andygrove merged 1 commit into
apache:mainfrom
avantgardnerio:brent/buffer-exec

Conversation

@avantgardnerio

Copy link
Copy Markdown
Contributor

Summary

Adds BufferExec: a passthrough physical operator that buffers upstream batches with a policy-configurable release trigger. Currently supports one mode:

  • BufferMode::Dam — buffer batches while a MemoryReservation grows against DataFusion's MemoryPool. Break the dam when MemoryPool::try_grow refuses further allocation (any pressure — this operator, or contention from elsewhere in the query). After the dam breaks, buffered batches drain in order and the remainder of the input passes through unchanged. No user-tunable byte threshold; the pool decides pressure, mirroring the mechanism SortExec uses to trigger spill.

Growth path is documented on the mode enum: PassThrough, BufferAll, SpillOnPressure, BufferAndSpill, StageBoundary. Only Dam is implemented today; the other names are fixed so future variants slot in without renaming callers. Ultimately this operator should land upstream in DataFusion so every blocking op (SortExec, HashAggregateExec, joins, ...) can share the same materialise/spill primitive instead of each rolling its own.

Why this exists — pairing with RuntimeStatsExec (#2094)

Sketch-based routing has a chicken-and-egg problem: a range-repartition operator wants to pick cut points from an upstream RuntimeStatsExec T-Digest, but a streaming pipeline pulls one batch through the tap and hands it straight to the router — the router runs with a one-batch sample.

BufferMode::Dam inserted between RuntimeStatsExec and the sketch-consuming router closes the gap without a plan-time size guess:

  1. Batches accumulate above the router, feeding the tap's T-Digest as they arrive.
  2. Growth continues until MemoryPool::try_grow refuses — the pool decides "enough sample".
  3. The dam breaks; the sketch now reflects representative data.
  4. The router picks cuts once, then buffered batches (plus the input remainder) drain through in order.

The pool — not a magic byte count in code — decides when the sample is large enough. Same mechanism SortExec uses to decide spill timing; no new tuning knob.

Scope of this PR

Just the operator, its proto/serde plumbing, and tests. Nothing inserts BufferExec into a plan yet — no rewrite rule, no scheduler hook. It lands alongside the parallel-window detection rule and the range-repartition operators (OrderedRangeRepartitionExec / UnorderedRangeRepartitionExec) that will consume the sketch.

Included:

  • Proto: BufferExecNode { BufferMode mode } on BallistaPhysicalPlanNode.oneof (tag 6) and enum BufferMode { BUFFER_MODE_DAM = 0; }.
  • Codec encode/decode registration on BallistaPhysicalExtensionCodec. Unknown mode discriminants surface as a decode error rather than a panic.
  • No new external deps.

Tests (4)

  • dam_drains_full_buffer_when_pool_has_headroom — input fits within the pool, everything buffers and drains at end-of-stream.
  • dam_breaks_on_pool_pressure_and_passes_remainder — pool tight enough that try_grow refuses partway through the stream; buffered rows plus the remainder all pass through, none lost or duplicated.
  • dam_propagates_oom_when_first_batch_wont_fit — pool so tight even the first batch's reservation fails; operator surfaces OOM (mirrors SortExec behavior with nothing already buffered).
  • test_buffer_exec_dam_roundtrip — plan-node serde roundtrip through the codec.

All three streaming tests use GreedyMemoryPool with a per-test byte cap so the dam-break branch fires without hardcoded thresholds in the operator.

Test plan

  • cargo test -p ballista-core (121 lib + 2 doc tests pass; 4 new).
  • cargo check --workspace --all-targets --locked clean (no lock churn — no new external deps).
  • cargo clippy --all-targets clean.
  • cargo fmt --all.

Relation to #2094

Independent PR. If #2094 (RuntimeStatsExec) lands first, this rebases to tag 7 for BufferExecNode; if this lands first, RuntimeStatsExec rebases the same way. No wire compatibility concerns since neither has shipped.

🤖 Generated with Claude Code

@avantgardnerio

Copy link
Copy Markdown
Contributor Author

@phillipleblanc FYI

Comment thread ballista/core/src/execution_plans/buffer.rs
Comment thread ballista/core/src/execution_plans/buffer.rs Outdated
avantgardnerio added a commit to avantgardnerio/arrow-ballista that referenced this pull request Jul 20, 2026
…n shrink

Two changes from phillipleblanc's review on apache#2095:

1. Explicit `ExecutionPlan` methods for the algebraic behaviour of a
   passthrough operator (`required_input_distribution`,
   `required_input_ordering`, `maintains_input_order`,
   `benefits_from_input_partitioning`, `partition_statistics`,
   `cardinality_effect`). Two actively override defaults —
   `maintains_input_order` (true, not false) and `cardinality_effect`
   (Equal, not Unknown). Punts on the pushdown/state/rewrite hooks.

2. Drain now holds the `MemoryReservation` inside a `stream::unfold`
   state and shrinks it by each batch's fill-time size as the batch is
   yielded, instead of dropping the reservation at end of fill. Pool
   accounting stays in sync with what's physically buffered right up
   until the last batch leaves.

New test `dam_reservation_shrinks_incrementally_during_drain` pins the
invariant — asserts `pool.reserved()` progression across the drain and
lands at 0 on stream end. Regressing to the old "drop at end of fill"
would fail on the first assertion.

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.

This makes sense to me, thanks @avantgardnerio.

@andygrove

Copy link
Copy Markdown
Member

This one needs a rebase @avantgardnerio

…ool Dam mode

� Conflicts:
�	ballista/core/proto/ballista.proto
�	ballista/core/src/serde/generated/ballista.rs
�	ballista/core/src/serde/mod.rs
@avantgardnerio

Copy link
Copy Markdown
Contributor Author

@andygrove rebase complete, if you have a moment for review 🙇

@andygrove
andygrove merged commit b809475 into apache:main Jul 21, 2026
20 checks passed
@andygrove

Copy link
Copy Markdown
Member

Thanks @avantgardnerio and @phillipleblanc

avantgardnerio added a commit to avantgardnerio/arrow-ballista that referenced this pull request Jul 21, 2026
…Exec

Preemptive polish before opening the PR for review — matches the pattern of
reviewer asks Phillip made on apache#2094 and apache#2095:

- Implement all 6 algebraic ExecutionPlan methods explicitly (required_input_
  distribution, required_input_ordering, maintains_input_order, benefits_from_
  input_partitioning, partition_statistics, cardinality_effect), each with a
  docstring justifying the value. maintains_input_order=false and
  partition_statistics=Unknown are the two that materially differ from the
  merged passthrough ops (RuntimeStatsExec, BufferExec).
- Reject nullable routing expressions in try_new. The sibling RuntimeStatsExec
  already enforces this at plan-assembly time, but defense-in-depth catches
  the no-stats-pair path where discovery would silently fall back to
  single-bucket. TODO marker points at the KLL swap that lifts the restriction.
- Add try_new_rejects_nullable_routing_key covering the new invariant.

Includes two authorial touch-ups picked up along the way: a tone softening on
the preserves_distribution docstring and a TODO on split_batch_by_range's
row-by-row loop pointing at a vectorized Arrow rewrite.

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants