This is an automated email from the ASF dual-hosted git repository.

andygrove pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-ballista.git


The following commit(s) were added to refs/heads/main by this push:
     new b80947575     feat(core): add BufferExec — generic flow-control op 
with a memory-pool Dam mode (#2095)
b80947575 is described below

commit b80947575e10bc992230d470c69820de2fb10c57
Author: Brent Gardner <[email protected]>
AuthorDate: Tue Jul 21 07:43:25 2026 -0600

        feat(core): add BufferExec — generic flow-control op with a memory-pool 
Dam mode (#2095)
---
 ballista/core/proto/ballista.proto            |  15 +
 ballista/core/src/execution_plans/buffer.rs   | 515 ++++++++++++++++++++++++++
 ballista/core/src/execution_plans/mod.rs      |   2 +
 ballista/core/src/serde/generated/ballista.rs |  39 +-
 ballista/core/src/serde/mod.rs                |  60 ++-
 5 files changed, 628 insertions(+), 3 deletions(-)

diff --git a/ballista/core/proto/ballista.proto 
b/ballista/core/proto/ballista.proto
index 2728fcc0b..846fd708d 100644
--- a/ballista/core/proto/ballista.proto
+++ b/ballista/core/proto/ballista.proto
@@ -52,6 +52,7 @@ message BallistaPhysicalPlanNode {
     SortShuffleWriterExecNode sort_shuffle_writer = 4;
     ChaosExecNode chaos_exec = 5;
     RuntimeStatsExecNode runtime_stats = 6;
+    BufferExecNode buffer = 7;
   }
 }
 
@@ -76,6 +77,20 @@ message QuantileSketchState {
   repeated datafusion_common.ScalarValue state = 1;
 }
 
+// Flow-control operator with an operator-mode enum. The child plan is
+// plumbed by the framework as `inputs[0]` during decode.
+message BufferExecNode {
+  BufferMode mode = 1;
+}
+
+enum BufferMode {
+  // Buffers input while a MemoryReservation grows against the runtime's
+  // MemoryPool; the dam breaks when `try_grow` refuses further allocation.
+  BUFFER_MODE_DAM = 0;
+  // Future modes: PASS_THROUGH, BUFFER_ALL, SPILL_ON_PRESSURE, ...
+  // Add here as they land in Rust.
+}
+
 message ChaosExecNode {
   double failure_probability = 1;
   string fault_type = 2;
diff --git a/ballista/core/src/execution_plans/buffer.rs 
b/ballista/core/src/execution_plans/buffer.rs
new file mode 100644
index 000000000..ae1a68517
--- /dev/null
+++ b/ballista/core/src/execution_plans/buffer.rs
@@ -0,0 +1,515 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Generic flow-control operator that DataFusion doesn't have (yet).
+//! Buffers upstream batches with a policy-configurable release trigger,
+//! then drains buffered rows and passes remaining input through.
+//!
+//! Currently supports one mode:
+//!
+//! - **`BufferMode::Dam`** — buffer input while a `MemoryReservation`
+//!   grows against DataFusion's `MemoryPool`. Break the dam when
+//!   `try_grow` refuses further allocation (any memory pressure — this
+//!   operator, or contention from elsewhere in the query). No
+//!   hardcoded byte threshold: the pool decides pressure, same mechanism
+//!   [`SortExec`] uses to trigger spill (`sort.rs:887-907`).
+//!
+//! Growth path (intentionally not built today; documented so the shape
+//! and future mode names are visible to reviewers):
+//!
+//! - `BufferMode::PassThrough` — noop, present so upstream rules can
+//!   drop the operator without restructuring the plan.
+//! - `BufferMode::BufferAll` — buffer until end-of-stream, then drain.
+//! - `BufferMode::SpillOnPressure` — same as `Dam`, but writes to disk
+//!   when the pool refuses rather than releasing to downstream. Fills
+//!   the "generic materialise/spill primitive" gap in DataFusion.
+//! - `BufferMode::StageBoundary` — the buffer IS a shuffle write.
+//!
+//! Only `Dam` is implemented for now. The other variants are named so
+//! that when they land, they slot in without renaming or restructuring.
+//!
+//! ## How this pairs with `RuntimeStatsExec`
+//!
+//! Sketch-based routing has a chicken-and-egg problem: the router
+//! wants to pick cut points from a `RuntimeStatsExec` sketch, 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: batches accumulate above the router, feeding the tap's
+//! T-Digest as they go, until the memory pool refuses further growth.
+//! At that point the dam breaks, the sketch already reflects
+//! representative data, the router picks cuts, and buffered batches
+//! (plus the input remainder) drain through in order. The pool — not
+//! a magic byte count in code — decides "enough sample".
+//!
+//! Ships in isolation here: nothing wires `BufferExec` into a plan
+//! yet. It lands alongside the parallel-window detection rule and
+//! the range-repartition operators that consume the sketch.
+//!
+//! Ultimately this operator should land upstream in DataFusion so
+//! every blocking operator (`SortExec`, `HashAggregateExec`, joins,
+//! ...) can share the same materialise/spill primitive instead of
+//! each rolling its own.
+//!
+//! [`SortExec`]: datafusion::physical_plan::sorts::sort::SortExec
+
+use std::collections::VecDeque;
+use std::fmt::{self, Debug, Formatter};
+use std::sync::Arc;
+
+use datafusion::arrow::datatypes::SchemaRef;
+use datafusion::arrow::record_batch::RecordBatch;
+use datafusion::common::{DataFusionError, Result, Statistics, internal_err};
+use datafusion::execution::TaskContext;
+use datafusion::execution::memory_pool::{MemoryConsumer, MemoryReservation};
+use datafusion::physical_expr::{Distribution, OrderingRequirements};
+use datafusion::physical_plan::execution_plan::CardinalityEffect;
+use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
+use datafusion::physical_plan::{
+    DisplayAs, DisplayFormatType, ExecutionPlan, ExecutionPlanProperties, 
PlanProperties,
+    SendableRecordBatchStream,
+};
+use futures::stream::{Stream, StreamExt, TryStreamExt};
+
+/// How the operator decides when to release buffered input.
+///
+/// Only `Dam` is implemented today; the other variants are documented
+/// here (rather than only in the module docstring) so that when they
+/// land, the growth path is visible right at the enum definition.
+///
+/// ```ignore
+/// // Aspirational; NOT implemented today. Each is a natural evolution
+/// // of the shape we already have — same operator, different release
+/// // policy.
+/// pub enum BufferMode {
+///     Dam,                          // ← implemented
+///
+///     /// Pass batches through unchanged. Present so a rewrite rule can
+///     /// disable the operator without restructuring the plan tree.
+///     PassThrough,
+///
+///     /// Buffer until end-of-stream, then drain. Equivalent to what
+///     /// `SortExec` does today but without the sort — the "generic
+///     /// materialise" primitive DataFusion doesn't have.
+///     BufferAll,
+///
+///     /// Same trigger as `Dam` (memory-pool pressure), but on
+///     /// pressure spill buffered batches to disk instead of releasing
+///     /// downstream. Fills the "generic spill" primitive gap; would
+///     /// share plumbing with `SortExec::spill` machinery.
+///     SpillOnPressure,
+///
+///     /// Buffer until an explicit row/byte threshold OR pool pressure,
+///     /// then spill. The two-trigger variant of the above for callers
+///     /// who want a hard cap on in-memory buffering time.
+///     BufferAndSpill { max_rows: usize },
+/// }
+/// ```
+#[derive(Debug, Clone)]
+pub enum BufferMode {
+    /// Buffer batches while a `MemoryReservation` grows against
+    /// DataFusion's `MemoryPool`. The dam breaks when
+    /// `MemoryPool::try_grow` refuses further allocation (pressure from
+    /// anywhere in the query). After the dam breaks, buffered batches
+    /// drain in order and the remaining input passes through unchanged;
+    /// the reservation shrinks per-batch as the drain yields, so pool
+    /// accounting tracks what's still live in this operator right up
+    /// until the last batch leaves.
+    ///
+    /// No user-tunable byte threshold — the pool decides pressure,
+    /// matching `SortExec::reserve_memory_for_batch_and_maybe_spill`.
+    Dam,
+}
+
+/// Generic flow-control operator. See module-level docs.
+pub struct BufferExec {
+    input: Arc<dyn ExecutionPlan>,
+    mode: BufferMode,
+    properties: Arc<PlanProperties>,
+}
+
+impl BufferExec {
+    /// Wrap `input` with the given buffering `mode`.
+    pub fn try_new(input: Arc<dyn ExecutionPlan>, mode: BufferMode) -> 
Result<Self> {
+        let properties = Arc::new(PlanProperties::new(
+            input.equivalence_properties().clone(),
+            input.output_partitioning().clone(),
+            input.pipeline_behavior(),
+            input.boundedness(),
+        ));
+        Ok(Self {
+            input,
+            mode,
+            properties,
+        })
+    }
+
+    /// The configured buffering policy.
+    pub fn mode(&self) -> &BufferMode {
+        &self.mode
+    }
+}
+
+impl Debug for BufferExec {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        f.debug_struct("BufferExec")
+            .field("mode", &self.mode)
+            .finish()
+    }
+}
+
+impl DisplayAs for BufferExec {
+    fn fmt_as(&self, _t: DisplayFormatType, f: &mut Formatter<'_>) -> 
fmt::Result {
+        match &self.mode {
+            BufferMode::Dam => write!(f, "BufferExec: mode=Dam"),
+        }
+    }
+}
+
+impl ExecutionPlan for BufferExec {
+    fn name(&self) -> &str {
+        "BufferExec"
+    }
+
+    fn schema(&self) -> SchemaRef {
+        self.input.schema()
+    }
+
+    fn properties(&self) -> &Arc<PlanProperties> {
+        &self.properties
+    }
+
+    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
+        vec![&self.input]
+    }
+
+    fn with_new_children(
+        self: Arc<Self>,
+        children: Vec<Arc<dyn ExecutionPlan>>,
+    ) -> Result<Arc<dyn ExecutionPlan>> {
+        let [input] = children.as_slice() else {
+            return internal_err!(
+                "BufferExec expects exactly one child, got {}",
+                children.len()
+            );
+        };
+        Ok(Arc::new(BufferExec::try_new(
+            input.clone(),
+            self.mode.clone(),
+        )?))
+    }
+
+    /// Passthrough: no distribution requirement on the child.
+    fn required_input_distribution(&self) -> Vec<Distribution> {
+        vec![Distribution::UnspecifiedDistribution]
+    }
+
+    /// Passthrough: no ordering requirement on the child.
+    fn required_input_ordering(&self) -> Vec<Option<OrderingRequirements>> {
+        vec![None]
+    }
+
+    /// Buffered batches drain in insertion order and the remainder passes
+    /// through unchanged, so input order is preserved. Overrides default 
`false`.
+    fn maintains_input_order(&self) -> Vec<bool> {
+        vec![true]
+    }
+
+    /// Wrapping this operator doesn't change how the child benefits from
+    /// its own input partitioning.
+    fn benefits_from_input_partitioning(&self) -> Vec<bool> {
+        vec![false]
+    }
+
+    /// Row count and per-column stats pass through unchanged.
+    fn partition_statistics(&self, partition: Option<usize>) -> 
Result<Arc<Statistics>> {
+        self.input.partition_statistics(partition)
+    }
+
+    /// Every input row is emitted exactly once. Overrides default `Unknown`.
+    fn cardinality_effect(&self) -> CardinalityEffect {
+        CardinalityEffect::Equal
+    }
+
+    fn execute(
+        &self,
+        partition: usize,
+        ctx: Arc<TaskContext>,
+    ) -> Result<SendableRecordBatchStream> {
+        let schema = self.schema();
+        let input_stream = self.input.execute(partition, ctx.clone())?;
+        match &self.mode {
+            BufferMode::Dam => {
+                // Register a per-partition consumer against the runtime's
+                // memory pool. Same shape SortExec uses; participates in
+                // whatever pool policy is configured (Greedy, FairSpill,
+                // Unbounded, ...) without picking numbers ourselves.
+                let reservation = MemoryConsumer::new(format!(
+                    "BufferExec::Dam[partition={partition}]"
+                ))
+                .register(&ctx.runtime_env().memory_pool);
+                let out = build_dam_stream(input_stream, reservation);
+                Ok(Box::pin(RecordBatchStreamAdapter::new(schema, out)))
+            }
+        }
+    }
+}
+
+/// Build the async stream that implements `BufferMode::Dam`.
+///
+/// Semantics (mirrors `SortExec::reserve_memory_for_batch_and_maybe_spill`):
+/// 1. For each incoming batch, 
`reservation.try_grow(batch.get_array_memory_size())`.
+///    - `Ok`: append to buffer.
+///    - `Err` and buffer empty: propagate the OOM (nothing to release).
+///    - `Err` and buffer non-empty: **dam breaks**; drain buffer, then
+///      passthrough. Reservation shrinks per-batch as the buffer drains.
+/// 2. On stream-end without dam-break: drain buffer, done.
+///
+/// The reservation is held by the drain stream itself (not the fill
+/// closure), and each yielded batch shrinks it by the exact amount that
+/// was grown at fill time — so pool accounting stays in sync with what's
+/// physically buffered right up until the last batch leaves. The final
+/// `Drop` on the reservation happens after the last batch is consumed
+/// downstream.
+fn build_dam_stream(
+    mut input: SendableRecordBatchStream,
+    reservation: MemoryReservation,
+) -> impl Stream<Item = Result<RecordBatch>> + Send {
+    futures::stream::once(async move {
+        // (size_at_fill, batch) — storing the fill-time size guarantees
+        // shrink amounts match grow amounts exactly, even if
+        // `get_array_memory_size()` ever changed between calls.
+        let mut buffered: VecDeque<(usize, RecordBatch)> = VecDeque::new();
+        let mut dam_broken = false;
+
+        // Fill phase: read and buffer until pool pressure or end-of-stream.
+        while let Some(batch) = input.next().await {
+            let batch = batch?;
+            let size = batch.get_array_memory_size();
+            match reservation.try_grow(size) {
+                Ok(_) => buffered.push_back((size, batch)),
+                Err(err) => {
+                    if buffered.is_empty() {
+                        return Err(oom_context(err));
+                    }
+                    // Dam breaks: this batch was pulled but not reserved;
+                    // let it ride through with the buffered rows (accepting
+                    // a one-batch reservation overshoot at the moment of
+                    // break — same as SortExec's behaviour on the batch
+                    // that triggers spill).
+                    buffered.push_back((0, batch));
+                    dam_broken = true;
+                    break;
+                }
+            }
+        }
+
+        // Drain phase: yield each buffered batch and shrink the reservation
+        // by the amount it took at fill time. `unfold` owns the reservation,
+        // so the final `Drop` fires after the last batch has been consumed
+        // downstream — no window where the reservation is released while
+        // batches are still live in this operator.
+        let drained = futures::stream::unfold(
+            (buffered, reservation),
+            |(mut buf, res)| async move {
+                let (size, batch) = buf.pop_front()?;
+                res.shrink(size);
+                Some((Ok::<_, DataFusionError>(batch), (buf, res)))
+            },
+        );
+        let out: futures::stream::BoxStream<'static, Result<RecordBatch>> = if 
dam_broken
+        {
+            drained.chain(input).boxed()
+        } else {
+            drained.boxed()
+        };
+        Ok::<_, DataFusionError>(out)
+    })
+    .try_flatten()
+}
+
+/// Wrap an OOM error with the same context message SortExec uses so the
+/// operator surfaces uniform "tune your memory limit" advice.
+fn oom_context(e: DataFusionError) -> DataFusionError {
+    match e {
+        DataFusionError::ResourcesExhausted(_) => e.context(
+            "Not enough memory to buffer a single batch in BufferExec::Dam. \
+             Consider increasing 'datafusion.runtime.memory_limit'.",
+        ),
+        _ => e,
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use datafusion::arrow::array::Int64Array;
+    use datafusion::arrow::datatypes::{DataType, Field, Schema};
+    use datafusion::datasource::memory::MemorySourceConfig;
+    use datafusion::datasource::source::DataSourceExec;
+    use datafusion::execution::memory_pool::{GreedyMemoryPool, MemoryPool};
+    use datafusion::execution::runtime_env::RuntimeEnvBuilder;
+    use datafusion::prelude::{SessionConfig, SessionContext};
+    use futures::TryStreamExt;
+
+    fn one_col_schema() -> SchemaRef {
+        Arc::new(Schema::new(vec![Field::new("v", DataType::Int64, false)]))
+    }
+
+    fn batches(rows_per: usize, num_batches: usize) -> Vec<RecordBatch> {
+        (0..num_batches)
+            .map(|b| {
+                let start = (b * rows_per) as i64;
+                let array = Int64Array::from_iter_values(start..start + 
rows_per as i64);
+                RecordBatch::try_new(one_col_schema(), 
vec![Arc::new(array)]).unwrap()
+            })
+            .collect()
+    }
+
+    fn memory_source(batches: Vec<RecordBatch>) -> Arc<dyn ExecutionPlan> {
+        let schema = one_col_schema();
+        let src =
+            MemorySourceConfig::try_new(&[batches], schema, None).expect("mem 
source");
+        Arc::new(DataSourceExec::new(Arc::new(src)))
+    }
+
+    /// Runtime context whose memory pool has a hard `pool_bytes` limit.
+    /// The dam is triggered by pool refusal, so a tight pool is how we
+    /// make tests exercise the dam-break branch without hardcoded
+    /// thresholds.
+    fn ctx_with_pool_limit(pool_bytes: usize) -> Arc<TaskContext> {
+        let pool = Arc::new(GreedyMemoryPool::new(pool_bytes));
+        let runtime = RuntimeEnvBuilder::new()
+            .with_memory_pool(pool)
+            .build_arc()
+            .expect("runtime");
+        let cfg = SessionConfig::new();
+        let session = SessionContext::new_with_config_rt(cfg, runtime);
+        session.task_ctx()
+    }
+
+    async fn collect_rows(
+        plan: Arc<dyn ExecutionPlan>,
+        ctx: Arc<TaskContext>,
+    ) -> Result<usize> {
+        let stream = plan.execute(0, ctx)?;
+        let out: Vec<RecordBatch> = stream.try_collect().await?;
+        Ok(out.iter().map(|b| b.num_rows()).sum())
+    }
+
+    /// Input fits within the pool → everything buffers, drains at
+    /// end-of-stream. No rows lost.
+    #[tokio::test]
+    async fn dam_drains_full_buffer_when_pool_has_headroom() -> Result<()> {
+        let src = memory_source(batches(100, 10));
+        let dam = BufferExec::try_new(src, BufferMode::Dam)?;
+        // 10 MB pool — trivially fits ~800 bytes per batch × 10.
+        let ctx = ctx_with_pool_limit(10 * 1024 * 1024);
+        assert_eq!(collect_rows(Arc::new(dam), ctx).await?, 1000);
+        Ok(())
+    }
+
+    /// Pool tight enough that reservation growth fails partway through
+    /// the stream → dam breaks, buffered rows plus the remainder all
+    /// pass through. No rows lost, none duplicated.
+    #[tokio::test]
+    async fn dam_breaks_on_pool_pressure_and_passes_remainder() -> Result<()> {
+        // 200 rows/batch of Int64 ≈ 1.6 KB payload per batch, but arrow's
+        // reported memory footprint per batch here is ~1-2 KB. A ~5 KB
+        // pool buffers 2-3 batches, then try_grow refuses.
+        let src = memory_source(batches(200, 50));
+        let dam = BufferExec::try_new(src, BufferMode::Dam)?;
+        let ctx = ctx_with_pool_limit(5 * 1024);
+        assert_eq!(collect_rows(Arc::new(dam), ctx).await?, 200 * 50);
+        Ok(())
+    }
+
+    /// Pool so tight that even the first batch's reservation fails →
+    /// operator surfaces OOM (mirrors SortExec behaviour when there's
+    /// nothing already buffered that could be spilled/released).
+    #[tokio::test]
+    async fn dam_propagates_oom_when_first_batch_wont_fit() -> Result<()> {
+        let src = memory_source(batches(1000, 5));
+        let dam = BufferExec::try_new(src, BufferMode::Dam)?;
+        // 1-byte pool — no batch can be reserved.
+        let ctx = ctx_with_pool_limit(1);
+        let result = collect_rows(Arc::new(dam), ctx).await;
+        assert!(
+            matches!(&result, Err(e) if 
e.to_string().contains("BufferExec::Dam") || e.to_string().contains("memory")),
+            "expected OOM, got {result:?}"
+        );
+        Ok(())
+    }
+
+    /// Pool accounting stays in sync with what's physically buffered:
+    /// after fill, `reserved()` equals the sum of buffered batch sizes;
+    /// after each yielded batch, `reserved()` drops by that batch's
+    /// size; after stream end, `reserved()` is 0.
+    ///
+    /// Directly pins the invariant behind the reviewer's concern —
+    /// regressing to "drop the reservation at end of fill" would leave
+    /// `reserved()` at 0 mid-drain while batches were still live.
+    #[tokio::test]
+    async fn dam_reservation_shrinks_incrementally_during_drain() -> 
Result<()> {
+        let src_batches = batches(100, 5);
+        let sizes: Vec<usize> = src_batches
+            .iter()
+            .map(|b| b.get_array_memory_size())
+            .collect();
+        let total: usize = sizes.iter().sum();
+
+        let src = memory_source(src_batches);
+        let dam: Arc<dyn ExecutionPlan> =
+            Arc::new(BufferExec::try_new(src, BufferMode::Dam)?);
+
+        // Hold our own handle to the pool so we can observe `reserved()`.
+        let pool: Arc<dyn MemoryPool> = Arc::new(GreedyMemoryPool::new(10 * 
1024 * 1024));
+        let runtime = RuntimeEnvBuilder::new()
+            .with_memory_pool(pool.clone())
+            .build_arc()
+            .expect("runtime");
+        let session = SessionContext::new_with_config_rt(SessionConfig::new(), 
runtime);
+        let ctx = session.task_ctx();
+
+        let mut stream = dam.execute(0, ctx)?;
+
+        // Pulling the first item drives the whole fill phase (reserve all
+        // batches), then yields batch 0 after shrinking by sizes[0].
+        let first = stream.next().await.expect("first batch")?;
+        assert_eq!(first.num_rows(), 100);
+        let mut expected = total - sizes[0];
+        assert_eq!(
+            pool.reserved(),
+            expected,
+            "after fill + first drain: total {total} - sizes[0] {}",
+            sizes[0]
+        );
+
+        for (i, size) in sizes.iter().enumerate().skip(1) {
+            let batch = stream.next().await.expect("more batches")?;
+            assert_eq!(batch.num_rows(), 100, "batch {i}");
+            expected -= size;
+            assert_eq!(pool.reserved(), expected, "after batch {i}");
+        }
+
+        assert!(stream.next().await.is_none(), "stream exhausted");
+        assert_eq!(pool.reserved(), 0, "reservation released after stream 
end");
+        Ok(())
+    }
+}
diff --git a/ballista/core/src/execution_plans/mod.rs 
b/ballista/core/src/execution_plans/mod.rs
index 1cad2faed..0062f9709 100644
--- a/ballista/core/src/execution_plans/mod.rs
+++ b/ballista/core/src/execution_plans/mod.rs
@@ -18,6 +18,7 @@
 //! This module contains execution plans that are needed to distribute 
DataFusion's execution plans into
 //! several Ballista executors.
 
+mod buffer;
 mod chaos_exec;
 mod distributed_explain_analyze;
 mod distributed_query;
@@ -30,6 +31,7 @@ mod unresolved_shuffle;
 
 use std::path::{Path, PathBuf};
 
+pub use buffer::{BufferExec, BufferMode};
 pub use chaos_exec::ChaosExec;
 use datafusion::common::exec_err;
 pub use distributed_explain_analyze::DistributedExplainAnalyzeExec;
diff --git a/ballista/core/src/serde/generated/ballista.rs 
b/ballista/core/src/serde/generated/ballista.rs
index 670f936bb..9fa2aedb0 100644
--- a/ballista/core/src/serde/generated/ballista.rs
+++ b/ballista/core/src/serde/generated/ballista.rs
@@ -31,7 +31,7 @@ pub struct LogicalPlanCacheNode {
 pub struct BallistaPhysicalPlanNode {
     #[prost(
         oneof = "ballista_physical_plan_node::PhysicalPlanType",
-        tags = "1, 2, 3, 4, 5, 6"
+        tags = "1, 2, 3, 4, 5, 6, 7"
     )]
     pub physical_plan_type: ::core::option::Option<
         ballista_physical_plan_node::PhysicalPlanType,
@@ -53,6 +53,8 @@ pub mod ballista_physical_plan_node {
         ChaosExec(super::ChaosExecNode),
         #[prost(message, tag = "6")]
         RuntimeStats(super::RuntimeStatsExecNode),
+        #[prost(message, tag = "7")]
+        Buffer(super::BufferExecNode),
     }
 }
 /// Runtime-stats tap. Passes batches through unmodified while accumulating:
@@ -81,6 +83,13 @@ pub struct QuantileSketchState {
     #[prost(message, repeated, tag = "1")]
     pub state: 
::prost::alloc::vec::Vec<::datafusion_proto_common::ScalarValue>,
 }
+/// Flow-control operator with an operator-mode enum. The child plan is
+/// plumbed by the framework as `inputs\[0\]` during decode.
+#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)]
+pub struct BufferExecNode {
+    #[prost(enumeration = "BufferMode", tag = "1")]
+    pub mode: i32,
+}
 #[derive(Clone, PartialEq, ::prost::Message)]
 pub struct ChaosExecNode {
     #[prost(double, tag = "1")]
@@ -1343,6 +1352,34 @@ pub struct RunningTaskInfo {
     #[prost(uint32, tag = "3")]
     pub stage_id: u32,
 }
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, 
::prost::Enumeration)]
+#[repr(i32)]
+pub enum BufferMode {
+    /// Buffers input while a MemoryReservation grows against the runtime's
+    /// MemoryPool; the dam breaks when `try_grow` refuses further allocation.
+    ///
+    /// Future modes: PASS_THROUGH, BUFFER_ALL, SPILL_ON_PRESSURE, ...
+    /// Add here as they land in Rust.
+    Dam = 0,
+}
+impl BufferMode {
+    /// String value of the enum field names used in the ProtoBuf definition.
+    ///
+    /// The values are not transformed in any way and thus are considered 
stable
+    /// (if the ProtoBuf definition does not change) and safe for programmatic 
use.
+    pub fn as_str_name(&self) -> &'static str {
+        match self {
+            Self::Dam => "BUFFER_MODE_DAM",
+        }
+    }
+    /// Creates an enum from field names used in the ProtoBuf definition.
+    pub fn from_str_name(value: &str) -> ::core::option::Option<Self> {
+        match value {
+            "BUFFER_MODE_DAM" => Some(Self::Dam),
+            _ => None,
+        }
+    }
+}
 /// Generated client implementations.
 pub mod scheduler_grpc_client {
     #![allow(
diff --git a/ballista/core/src/serde/mod.rs b/ballista/core/src/serde/mod.rs
index a433f0ac5..ae2b85c93 100644
--- a/ballista/core/src/serde/mod.rs
+++ b/ballista/core/src/serde/mod.rs
@@ -56,8 +56,8 @@ use std::{convert::TryInto, io::Cursor};
 
 use crate::execution_plans::sort_shuffle::SortShuffleConfig;
 use crate::execution_plans::{
-    ChaosExec, CoalescePlan, PartitionGroup, RuntimeStatsExec, 
ShuffleReaderExec,
-    ShuffleWriterExec, SortShuffleWriterExec, UnresolvedShuffleExec,
+    BufferExec, BufferMode, ChaosExec, CoalescePlan, PartitionGroup, 
RuntimeStatsExec,
+    ShuffleReaderExec, ShuffleWriterExec, SortShuffleWriterExec, 
UnresolvedShuffleExec,
 };
 use crate::serde::protobuf::{
     ballista_logical_plan_node::LogicalPlanType,
@@ -576,6 +576,24 @@ impl PhysicalExtensionCodec for 
BallistaPhysicalExtensionCodec {
                     order_by,
                 )?))
             }
+            PhysicalPlanType::Buffer(node) => {
+                let [input] = inputs else {
+                    return Err(DataFusionError::Internal(format!(
+                        "BufferExec expects exactly 1 input, got {}",
+                        inputs.len()
+                    )));
+                };
+                let mode = match protobuf::BufferMode::try_from(node.mode) {
+                    Ok(protobuf::BufferMode::Dam) => BufferMode::Dam,
+                    Err(_) => {
+                        return Err(DataFusionError::Internal(format!(
+                            "BufferExec: unknown mode enum discriminant {}",
+                            node.mode
+                        )));
+                    }
+                };
+                Ok(Arc::new(BufferExec::try_new(input.clone(), mode)?))
+            }
         }
     }
 
@@ -777,6 +795,19 @@ impl PhysicalExtensionCodec for 
BallistaPhysicalExtensionCodec {
                 ))
             })?;
             Ok(())
+        } else if let Some(exec) = node.downcast_ref::<BufferExec>() {
+            let mode = match *exec.mode() {
+                BufferMode::Dam => protobuf::BufferMode::Dam,
+            };
+            let proto = protobuf::BallistaPhysicalPlanNode {
+                physical_plan_type: Some(PhysicalPlanType::Buffer(
+                    protobuf::BufferExecNode { mode: mode.into() },
+                )),
+            };
+            proto.encode(buf).map_err(|e| {
+                DataFusionError::Internal(format!("failed to encode 
BufferExec: {e:?}"))
+            })?;
+            Ok(())
         } else {
             Err(DataFusionError::Internal(format!(
                 "Unsupported plan node, name: [{}] ",
@@ -1558,4 +1589,29 @@ mod test {
             "got: {err}"
         );
     }
+
+    /// `BufferExec` in `Dam` mode round-trips through the codec.
+    /// The child is decoded from `inputs[0]` by the framework, so the
+    /// wire only carries the mode discriminant — a wrong discriminant
+    /// (added but not handled) would surface as a decode error.
+    #[tokio::test]
+    async fn test_buffer_exec_dam_roundtrip() {
+        use crate::execution_plans::{BufferExec, BufferMode};
+        use datafusion::physical_plan::empty::EmptyExec;
+
+        let schema = Arc::new(Schema::new(vec![Field::new("v", 
DataType::Int64, false)]));
+        let input: Arc<dyn ExecutionPlan> = Arc::new(EmptyExec::new(schema));
+        let original = BufferExec::try_new(input.clone(), 
BufferMode::Dam).unwrap();
+
+        let codec = BallistaPhysicalExtensionCodec::default();
+        let mut buf: Vec<u8> = vec![];
+        codec.try_encode(Arc::new(original), &mut buf).unwrap();
+
+        let ctx = SessionContext::new().task_ctx();
+        let decoded_plan = codec.try_decode(&buf, &[input], &ctx).unwrap();
+        let decoded = decoded_plan
+            .downcast_ref::<BufferExec>()
+            .expect("Expected BufferExec");
+        assert!(matches!(decoded.mode(), BufferMode::Dam));
+    }
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to