You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@datafusion.apache.org by mi...@apache.org on 2026/07/20 11:42:11 UTC

(datafusion-ballista) branch main updated: feat: allow an external semaphore for executor task concurrency (#1892)

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

milenkovicm 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 5408c7613 feat: allow an external semaphore for executor task concurrency (#1892)
5408c7613 is described below

commit 5408c7613e01dbc33781d267feabdd212a18ed1a
Author: Jack Eadie <ja...@gmail.com>
AuthorDate: Mon Jul 20 21:42:07 2026 +1000

    feat: allow an external semaphore for executor task concurrency (#1892)
    
    * feat: allow an external semaphore for executor task concurrency
    
    Adds an optional available_task_slots: Option<Arc<Semaphore>> parameter to
    the executor poll_loop. When provided, the caller supplies the semaphore
    that bounds concurrent task execution; this enables sharing it across poll
    loops connected to different schedulers and observing executor busy state
    via available_permits(). When None, the loop creates one internally sized
    to the executor's task slots (unchanged behavior).
    
    Ported from spiceai/datafusion-ballista#14 for upstreaming. Note: layers
    on the poll_loop signature; if landed after #12 (poll_now_notify) it needs
    a trivial rebase to add both parameters.
    
    * fix(examples): pass optional semaphore arg in mtls-cluster and reformat standalone
    
    * fix(executor): address PR review feedback on shared-semaphore poll_loop
    
    - Handle AcquireError from semaphore acquire/acquire_owned by mapping to
      BallistaError instead of panicking, since the semaphore may now be
      externally owned and closed by the caller
    - Assert that the provided semaphore has at least one permit on entry to
      fail fast rather than deadlock
    - Rewrite poll_loop doc comment to be public-facing: remove references
      to internal variable names and implementation types, document shared-
      semaphore over-commit semantics and sizing tradeoffs in behavioral
      terms, and add a # Panics section for the zero-permit case
    
    * style: cargo fmt execution_loop.rs
    
    ---------
    
    Co-authored-by: Phillip LeBlanc <87...@users.noreply.github.com>
---
 ballista/executor/src/execution_loop.rs   | 50 ++++++++++++++++++++++++++-----
 ballista/executor/src/executor_process.rs |  1 +
 ballista/executor/src/standalone.rs       |  1 +
 examples/examples/mtls-cluster.rs         |  2 +-
 4 files changed, 45 insertions(+), 9 deletions(-)

diff --git a/ballista/executor/src/execution_loop.rs b/ballista/executor/src/execution_loop.rs
index da4ff19cd..c4fac99d2 100644
--- a/ballista/executor/src/execution_loop.rs
+++ b/ballista/executor/src/execution_loop.rs
@@ -51,16 +51,38 @@ use tonic::codegen::{Body, Bytes, StdError};
 
 /// Main execution loop that polls the scheduler for available tasks.
 ///
-/// This function runs indefinitely, periodically asking the scheduler for
-/// work. When tasks are received, they are executed on a dedicated thread
-/// pool and results are reported back to the scheduler.
+/// Runs indefinitely, periodically asking the scheduler for work. When tasks
+/// are received they are executed concurrently and results are reported back
+/// to the scheduler.
 ///
-/// The loop respects the executor's concurrent task limit via a semaphore,
-/// ensuring no more than the configured number of tasks run simultaneously.
+/// Concurrency is bounded by a semaphore. Pass `free_vcores` to supply your
+/// own semaphore — useful for sharing a single concurrency limit across
+/// multiple poll loops or for observing executor load from outside.
+/// Pass `None` to have the loop create a semaphore sized to the executor's
+/// configured vcore count.
+///
+/// **Shared semaphores**: when one semaphore is shared across loops that
+/// connect to different schedulers, each scheduler independently sees the
+/// current free capacity and may dispatch up to that many tasks. The semaphore
+/// still caps total concurrent execution — tasks that cannot run immediately
+/// wait for capacity — but both schedulers may over-commit relative to what
+/// the semaphore can actually admit at once. This is intentional: the
+/// semaphore acts as an execution throttle, not a reservation system.
+///
+/// **Semaphore sizing**: if the provided semaphore allows more concurrent
+/// tasks than the executor's thread pool has threads, excess admitted tasks
+/// will queue behind running ones. The caller is responsible for sizing the
+/// semaphore appropriately for their thread pool.
+///
+/// # Panics
+///
+/// Panics on startup if `free_vcores` is a semaphore with zero permits,
+/// which would cause the loop to deadlock immediately.
 pub async fn poll_loop<T: 'static + AsLogicalPlan, U: 'static + AsExecutionPlan, C>(
     mut scheduler: SchedulerGrpcClient<C>,
     executor: Arc<Executor>,
     codec: BallistaCodec<T, U>,
+    free_vcores: Option<Arc<Semaphore>>,
     health: crate::health::ExecutorHealth,
 ) -> Result<(), BallistaError>
 where
@@ -76,7 +98,13 @@ where
         .unwrap()
         .clone()
         .into();
-    let free_vcores = Arc::new(Semaphore::new(executor_specification.vcores as usize));
+    let free_vcores = free_vcores.unwrap_or_else(|| {
+        Arc::new(Semaphore::new(executor_specification.vcores as usize))
+    });
+    assert!(
+        free_vcores.available_permits() > 0,
+        "free_vcores semaphore must have at least one permit; passing a closed or zero-permit semaphore would deadlock the poll loop"
+    );
 
     let (task_status_sender, mut task_status_receiver) =
         std::sync::mpsc::channel::<TaskStatus>();
@@ -87,7 +115,10 @@ where
 
     loop {
         // Wait for a vcore permit before asking for new work.
-        let permit = free_vcores.acquire().await.unwrap();
+        let permit = free_vcores
+            .acquire()
+            .await
+            .map_err(|_| BallistaError::Internal("vcore semaphore closed".to_string()))?;
         // Make the vcore available again for the actual bind below.
         drop(permit);
 
@@ -137,7 +168,10 @@ where
                     let task_status_sender = task_status_sender.clone();
 
                     // Acquire a vcore permit for the task.
-                    let permit = free_vcores.clone().acquire_owned().await.unwrap();
+                    let permit =
+                        free_vcores.clone().acquire_owned().await.map_err(|_| {
+                            BallistaError::Internal("vcore semaphore closed".to_string())
+                        })?;
 
                     let start_exec_time = SystemTime::now()
                         .duration_since(UNIX_EPOCH)
diff --git a/ballista/executor/src/executor_process.rs b/ballista/executor/src/executor_process.rs
index 9bce0d549..d8f30163b 100644
--- a/ballista/executor/src/executor_process.rs
+++ b/ballista/executor/src/executor_process.rs
@@ -564,6 +564,7 @@ pub async fn start_executor_process(
                 scheduler.clone(),
                 executor.clone(),
                 default_codec,
+                None, // free_vcores: use internal semaphore
                 health,
             )));
         }
diff --git a/ballista/executor/src/standalone.rs b/ballista/executor/src/standalone.rs
index d39841e8b..fd9841a0e 100644
--- a/ballista/executor/src/standalone.rs
+++ b/ballista/executor/src/standalone.rs
@@ -147,6 +147,7 @@ pub async fn new_standalone_executor_from_builder(
         scheduler,
         executor,
         codec,
+        None,
         crate::health::ExecutorHealth::new(),
     ));
     Ok(())
diff --git a/examples/examples/mtls-cluster.rs b/examples/examples/mtls-cluster.rs
index 3533019b9..609935d6b 100644
--- a/examples/examples/mtls-cluster.rs
+++ b/examples/examples/mtls-cluster.rs
@@ -433,7 +433,7 @@ async fn run_executor() -> Result<(), Box<dyn std::error::Error>> {
     info!("Starting execution poll loop...");
     let health = ballista_executor::health::ExecutorHealth::new();
     let poll_handle = tokio::spawn(async move {
-        execution_loop::poll_loop(scheduler, executor, codec, health).await
+        execution_loop::poll_loop(scheduler, executor, codec, None, health).await
     });
 
     tokio::select! {


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@datafusion.apache.org
For additional commands, e-mail: commits-help@datafusion.apache.org