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 10d4d0877 feat: add `poll_now_notify` to `poll_loop` and
`on_work_available` callback (#1893)
10d4d0877 is described below
commit 10d4d0877c0bc705d9896e12bac26d88147fb83f
Author: Jack Eadie <[email protected]>
AuthorDate: Wed Aug 5 16:17:21 2026 +1000
feat: add `poll_now_notify` to `poll_loop` and `on_work_available` callback
(#1893)
---
ballista/executor/src/execution_loop.rs | 30 +++-
ballista/executor/src/executor_process.rs | 1 +
ballista/executor/src/standalone.rs | 1 +
ballista/scheduler/src/config.rs | 48 +++++-
.../src/scheduler_server/query_stage_scheduler.rs | 176 ++++++++++++++++++++-
5 files changed, 247 insertions(+), 9 deletions(-)
diff --git a/ballista/executor/src/execution_loop.rs
b/ballista/executor/src/execution_loop.rs
index b84c83b9d..2390364c1 100644
--- a/ballista/executor/src/execution_loop.rs
+++ b/ballista/executor/src/execution_loop.rs
@@ -46,9 +46,16 @@ use std::error::Error;
use std::sync::mpsc::{Receiver, Sender, TryRecvError};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use std::{sync::Arc, time::Duration};
-use tokio::sync::{OwnedSemaphorePermit, Semaphore};
+use tokio::sync::{Notify, OwnedSemaphorePermit, Semaphore};
use tonic::codegen::{Body, Bytes, StdError};
+/// Idle sleep between polls when polling is the only way to learn of new work.
+const IDLE_POLL_INTERVAL: Duration = Duration::from_millis(50);
+
+/// Idle sleep when a `poll_now_notify` wake-up is wired and the timer is only
+/// a fallback.
+const NOTIFIED_IDLE_POLL_INTERVAL: Duration = Duration::from_secs(1);
+
/// Maximum time the poll loop waits for a free vcore before polling the
/// scheduler anyway. `poll_work` doubles as the executor's heartbeat under
/// pull-based scheduling, so a fully-busy executor must keep polling
(reporting
@@ -68,6 +75,12 @@ const HEARTBEAT_POLL_INTERVAL: Duration =
Duration::from_secs(5);
/// Pass `None` to have the loop create a semaphore sized to the executor's
/// configured vcore count.
///
+/// `poll_now_notify`, when provided, wakes an idle poll loop immediately
+/// (typically wired to the scheduler's `on_work_available` callback) instead
+/// of waiting out the idle interval. A notification sent mid-poll is not
+/// lost: `Notify` stores the permit and the next `notified().await` returns
+/// immediately.
+///
/// **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
@@ -89,6 +102,7 @@ pub async fn poll_loop<T: 'static + AsLogicalPlan, U:
'static + AsExecutionPlan,
mut scheduler: SchedulerGrpcClient<C>,
executor: Arc<Executor>,
codec: BallistaCodec<T, U>,
+ poll_now_notify: Option<Arc<Notify>>,
free_vcores: Option<Arc<Semaphore>>,
health: crate::health::ExecutorHealth,
) -> Result<(), BallistaError>
@@ -263,7 +277,19 @@ where
}
if !active_job {
- tokio::time::sleep(Duration::from_millis(50)).await;
+ match &poll_now_notify {
+ Some(notify) => {
+ tokio::select! {
+ () = tokio::time::sleep(NOTIFIED_IDLE_POLL_INTERVAL)
=> {}
+ () = notify.notified() => {
+ debug!("Received poll_now notification, polling
immediately");
+ }
+ }
+ }
+ None => {
+ tokio::time::sleep(IDLE_POLL_INTERVAL).await;
+ }
+ }
}
}
}
diff --git a/ballista/executor/src/executor_process.rs
b/ballista/executor/src/executor_process.rs
index 091a94c5f..32c80afc9 100644
--- a/ballista/executor/src/executor_process.rs
+++ b/ballista/executor/src/executor_process.rs
@@ -728,6 +728,7 @@ pub async fn start_executor_process(
scheduler.clone(),
executor.clone(),
default_codec,
+ None, // poll_now_notify
None, // free_vcores: use internal semaphore
health,
)));
diff --git a/ballista/executor/src/standalone.rs
b/ballista/executor/src/standalone.rs
index fd9841a0e..78e704295 100644
--- a/ballista/executor/src/standalone.rs
+++ b/ballista/executor/src/standalone.rs
@@ -148,6 +148,7 @@ pub async fn new_standalone_executor_from_builder(
executor,
codec,
None,
+ None,
crate::health::ExecutorHealth::new(),
));
Ok(())
diff --git a/ballista/scheduler/src/config.rs b/ballista/scheduler/src/config.rs
index ff935374b..28320783f 100644
--- a/ballista/scheduler/src/config.rs
+++ b/ballista/scheduler/src/config.rs
@@ -28,12 +28,43 @@
use crate::SessionBuilder;
use crate::cluster::DistributionPolicy;
use ballista_core::extension::EndpointOverrideFn;
-use ballista_core::{ConfigProducer, config::TaskSchedulingPolicy};
+use ballista_core::{ConfigProducer, JobId, config::TaskSchedulingPolicy};
use datafusion_proto::logical_plan::LogicalExtensionCodec;
use datafusion_proto::physical_plan::PhysicalExtensionCodec;
use std::fmt::Display;
use std::sync::Arc;
+/// Why the scheduler believes new work has become available for executors.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum WorkAvailableReason {
+ /// A job was submitted and its initial tasks are ready to be scheduled.
+ JobSubmitted {
+ /// Identifier of the submitted job.
+ job_id: JobId,
+ },
+ /// Completed tasks resolved downstream stages of a job, and the tasks of
+ /// those stages are now schedulable.
+ NewStagesRunnable {
+ /// Identifier of the job that gained schedulable tasks.
+ job_id: JobId,
+ },
+}
+
+/// Callback invoked when new work becomes available for executors, e.g. to
+/// wake idle pull-based executors via the poll loop's `poll_now_notify`.
+///
+/// It fires only after the work is visible to a polling executor, so waking
+/// one cannot race the scheduler's internal event processing.
+///
+/// # Warning
+///
+/// The callback runs synchronously inside the scheduler's main event loop.
+/// Implementations **must be non-blocking**; offload blocking or long-running
+/// work (such as network I/O) to a separate task or thread.
+///
+/// `Arc` rather than `Box` so [`SchedulerConfig`] remains [`Clone`].
+pub type OnWorkAvailableFn = Arc<dyn Fn(WorkAvailableReason) + Send + Sync>;
+
/// Command-line configuration for the scheduler binary.
#[cfg(feature = "build-binary")]
#[derive(clap::Parser, Debug)]
@@ -313,6 +344,9 @@ pub struct SchedulerConfig {
#[cfg(feature = "rest-api")]
/// Comma-separated list of allowed methods for CORS
pub cors_allowed_methods: String,
+ /// Callback invoked when new work becomes available for executors.
+ /// See [`OnWorkAvailableFn`].
+ pub on_work_available: Option<OnWorkAvailableFn>,
}
impl Default for SchedulerConfig {
@@ -352,6 +386,7 @@ impl Default for SchedulerConfig {
cors_allowed_origins: String::default(),
#[cfg(feature = "rest-api")]
cors_allowed_methods: String::default(),
+ on_work_available: None,
}
}
}
@@ -500,6 +535,16 @@ impl SchedulerConfig {
self.use_tls = use_tls;
self
}
+
+ /// Sets the callback invoked when new work becomes available for
+ /// executors. See [`OnWorkAvailableFn`].
+ pub fn with_on_work_available(
+ mut self,
+ on_work_available: OnWorkAvailableFn,
+ ) -> Self {
+ self.on_work_available = Some(on_work_available);
+ self
+ }
}
/// Policy of distributing tasks to available executor slots
@@ -600,6 +645,7 @@ impl TryFrom<Config> for SchedulerConfig {
cors_allowed_origins: opt.cors_allowed_origins,
#[cfg(feature = "rest-api")]
cors_allowed_methods: opt.cors_allowed_methods,
+ on_work_available: None,
};
Ok(config)
diff --git a/ballista/scheduler/src/scheduler_server/query_stage_scheduler.rs
b/ballista/scheduler/src/scheduler_server/query_stage_scheduler.rs
index 4b64e205a..d4cb11fc2 100644
--- a/ballista/scheduler/src/scheduler_server/query_stage_scheduler.rs
+++ b/ballista/scheduler/src/scheduler_server/query_stage_scheduler.rs
@@ -27,7 +27,7 @@ use ballista_core::error::{BallistaError, Result};
use ballista_core::event_loop::{EventAction, EventSender};
use tokio::sync::mpsc::error::TrySendError;
-use crate::config::SchedulerConfig;
+use crate::config::{SchedulerConfig, WorkAvailableReason};
use crate::metrics::SchedulerMetricsCollector;
use crate::scheduler_server::timestamp_millis;
use datafusion_proto::logical_plan::AsLogicalPlan;
@@ -204,6 +204,12 @@ impl<T: 'static + AsLogicalPlan, U: 'static +
AsExecutionPlan>
.post_event(QueryStageSchedulerEvent::ReviveOffers)
.await?;
}
+
+ // The graph was revived before caching, so the job's tasks
+ // are already visible to polling executors.
+ if let Some(callback) = &self.config.on_work_available {
+ callback(WorkAvailableReason::JobSubmitted { job_id });
+ }
}
QueryStageSchedulerEvent::JobPlanningFailed {
job_id,
@@ -265,8 +271,19 @@ impl<T: 'static + AsLogicalPlan, U: 'static +
AsExecutionPlan>
}
QueryStageSchedulerEvent::JobUpdated(job_id) => {
debug!("Job updated, job_id: [{job_id}]");
- if let Err(e) =
self.state.task_manager.update_job(&job_id).await {
- error!("Fail to invoke update_job for job {job_id} due to
{e:?}");
+ match self.state.task_manager.update_job(&job_id).await {
+ Ok(new_tasks) => {
+ // update_job revived the graph: the new tasks are
+ // already visible to polling executors.
+ if new_tasks > 0
+ && let Some(callback) =
&self.config.on_work_available
+ {
+ callback(WorkAvailableReason::NewStagesRunnable {
job_id });
+ }
+ }
+ Err(e) => {
+ error!("Fail to invoke update_job for job {job_id} due
to {e:?}");
+ }
}
}
QueryStageSchedulerEvent::JobCancel(job_id) => {
@@ -465,17 +482,34 @@ impl<T: 'static + AsLogicalPlan, U: 'static +
AsExecutionPlan>
#[cfg(test)]
mod tests {
- use crate::config::SchedulerConfig;
- use crate::test_utils::{SchedulerTest, TestMetricsCollector,
await_condition};
+ use crate::config::{SchedulerConfig, WorkAvailableReason};
+ use crate::scheduler_server::SchedulerServer;
+ use crate::test_utils::{
+ SchedulerTest, TestMetricsCollector, await_condition,
test_cluster_context,
+ };
+ use ballista_core::BALLISTA_PROTOCOL_VERSION;
use ballista_core::config::TaskSchedulingPolicy;
use ballista_core::error::Result;
+ use ballista_core::extension::SessionConfigExt;
+ use ballista_core::serde::BallistaCodec;
use ballista_core::serde::protobuf::job_status;
+ use ballista_core::serde::protobuf::scheduler_grpc_server::SchedulerGrpc;
+ use ballista_core::serde::protobuf::{
+ ExecutorRegistration, PollWorkParams, ShuffleWritePartition,
SuccessfulTask,
+ TaskStatus, task_status,
+ };
+ use ballista_core::serde::scheduler::{
+ ExecutorOperatingSystemSpecification, ExecutorSpecification,
+ };
use datafusion::arrow::datatypes::{DataType, Field, Schema};
use datafusion::functions_aggregate::sum::sum;
use datafusion::logical_expr::{LogicalPlan, col};
+ use datafusion::prelude::SessionConfig;
use datafusion::test_util::scan_empty_with_partitions;
- use std::sync::Arc;
+ use datafusion_proto::protobuf::{LogicalPlanNode, PhysicalPlanNode};
+ use std::sync::{Arc, Mutex};
use std::time::Duration;
+ use tonic::Request;
#[tokio::test]
async fn test_pending_job_metric() -> Result<()> {
@@ -531,6 +565,136 @@ mod tests {
Ok(())
}
+ #[tokio::test]
+ async fn test_on_work_available_callback() -> Result<()> {
+ let reasons: Arc<Mutex<Vec<WorkAvailableReason>>> = Arc::default();
+ let captured = reasons.clone();
+
+ let mut scheduler: SchedulerServer<LogicalPlanNode, PhysicalPlanNode> =
+ SchedulerServer::new(
+ "localhost:50050".to_owned(),
+ test_cluster_context(),
+ BallistaCodec::default(),
+ Arc::new(
+ SchedulerConfig {
+ scheduling_policy: TaskSchedulingPolicy::PullStaged,
+ ..Default::default()
+ }
+ .with_on_work_available(Arc::new(
+ move |reason| {
+ captured.lock().unwrap().push(reason);
+ },
+ )),
+ ),
+ Arc::new(TestMetricsCollector::default()),
+ );
+ scheduler.init().await?;
+
+ let ctx = scheduler
+ .state
+ .session_manager
+ .create_or_update_session(
+ "session",
+ &SessionConfig::new_with_ballista().with_target_partitions(2),
+ )
+ .await?;
+ let job_id = scheduler.submit_job("", ctx, &test_plan(2), None).await?;
+
+ // Job submission runs asynchronously through the event loop.
+ let submitted = await_condition(Duration::from_millis(10), 100, || {
+ futures::future::ready(Ok(!reasons.lock().unwrap().is_empty()))
+ })
+ .await?;
+ assert!(submitted, "JobSubmitted callback never fired");
+ assert_eq!(
+ reasons.lock().unwrap().first(),
+ Some(&WorkAvailableReason::JobSubmitted {
+ job_id: job_id.clone()
+ })
+ );
+
+ // Pull the shuffle stage's tasks; the callback promised they are
+ // visible by the time it fired.
+ let exec_meta = ExecutorRegistration {
+ id: "executor-1".to_owned(),
+ host: Some("localhost".to_owned()),
+ port: 50051,
+ grpc_port: 50052,
+ specification:
Some(ExecutorSpecification::default().with_vcores(2).into()),
+ os_info:
Some(ExecutorOperatingSystemSpecification::default().into()),
+ ballista_protocol_version: BALLISTA_PROTOCOL_VERSION,
+ };
+ let polled = scheduler
+ .poll_work(Request::new(PollWorkParams {
+ metadata: Some(exec_meta.clone()),
+ num_free_vcores: 2,
+ task_status: vec![],
+ }))
+ .await
+ .expect("poll_work failed")
+ .into_inner();
+ assert!(
+ !polled.tasks.is_empty(),
+ "expected tasks after JobSubmitted"
+ );
+
+ // Report the pulled tasks as successful; each writes the plan's two
+ // shuffle output partitions.
+ let task_status = polled
+ .tasks
+ .iter()
+ .map(|task| TaskStatus {
+ task_id: task.task_id,
+ job_id: task.job_id.clone(),
+ stage_id: task.stage_id,
+ stage_attempt_num: task.stage_attempt_num,
+ launch_time: 0,
+ start_exec_time: 0,
+ end_exec_time: 0,
+ metrics: vec![],
+ status: Some(task_status::Status::Successful(SuccessfulTask {
+ executor_id: exec_meta.id.clone(),
+ partitions: (0..2)
+ .map(|partition_id| ShuffleWritePartition {
+ partition_id,
+ num_batches: 1,
+ num_rows: 1,
+ num_bytes: 1,
+ file_id: None,
+ is_sort_shuffle: false,
+ })
+ .collect(),
+ runtime_stats: vec![],
+ })),
+ })
+ .collect();
+ scheduler
+ .poll_work(Request::new(PollWorkParams {
+ metadata: Some(exec_meta),
+ num_free_vcores: 2,
+ task_status,
+ }))
+ .await
+ .expect("poll_work with task status failed");
+
+ // Completing the shuffle stage resolves the final stage.
+ let resolved = await_condition(Duration::from_millis(10), 100, || {
+ futures::future::ready(Ok(reasons.lock().unwrap().contains(
+ &WorkAvailableReason::NewStagesRunnable {
+ job_id: job_id.clone(),
+ },
+ )))
+ })
+ .await?;
+ assert!(
+ resolved,
+ "expected NewStagesRunnable, got {:?}",
+ reasons.lock().unwrap()
+ );
+
+ Ok(())
+ }
+
#[tokio::test]
async fn test_running_job_fails_when_all_executors_are_lost() ->
Result<()> {
let plan = test_plan(10);
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]