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 f1bdc9f3b feat(scheduler): expose existing job state event stream 
(#1891)
f1bdc9f3b is described below

commit f1bdc9f3b73adac1512fed10f64a0849b7fa61d0
Author: Jack Eadie <[email protected]>
AuthorDate: Wed Aug 5 20:04:46 2026 +1000

    feat(scheduler): expose existing job state event stream (#1891)
    
    * feat: add a broadcast channel for job state event notifications
    
    Adds a tokio::sync::broadcast Sender to SchedulerServer, subscribable via
    subscribe_job_updates(). On job state changes (queued/running/completed/
    failed/cancelled) the scheduler broadcasts a JobStateEvent carrying the job
    id and new state.
    
    Ported from spiceai/datafusion-ballista#15 for upstreaming.
    
    * fix(core): add From<&JobId> for String so borrowed ids satisfy 
Into<String>
    
    * fix(scheduler): address PR review feedback on job state broadcast channel
    
    - Add job_state_channel_capacity to SchedulerConfig (default 256) so
      operators with high job throughput can tune the buffer; removes the
      hardcoded const from SchedulerServer
    - Move all job state broadcasts to after their state transitions succeed,
      so subscribers never see an event for a commit that failed; aligns
      Queued/Failed/Completed/Cancelled with how Running already behaved
    - Add From<job_status::Status> for JobState to keep the two enums in
      sync; document that Cancelled has no protobuf counterpart
    - Expand module doc with guidance on when to use JobStateEvent vs
      JobStatusSubscriber, and reference job_state_channel_capacity for tuning
    - Add integration tests covering the broadcast wiring: lifecycle order
      (Queued -> Running -> Completed) and cancel path
    
    * docs: link JobStateEvent by absolute path so rustdoc resolves it
    
    * refactor(scheduler): expose existing job state event stream
    
    * fix(scheduler): expose cluster state event stream
    
    ---------
    
    Co-authored-by: Phillip LeBlanc 
<[email protected]>
---
 ballista/scheduler/src/cluster/memory.rs           | 66 ++++++++++++++++------
 ballista/scheduler/src/scheduler_server/mod.rs     | 40 ++++++++++++-
 .../src/scheduler_server/query_stage_scheduler.rs  | 44 +++++++++++++++
 ballista/scheduler/src/state/executor_manager.rs   |  7 ++-
 ballista/scheduler/src/state/task_manager.rs       |  7 ++-
 ballista/scheduler/src/test_utils.rs               |  7 ++-
 6 files changed, 151 insertions(+), 20 deletions(-)

diff --git a/ballista/scheduler/src/cluster/memory.rs 
b/ballista/scheduler/src/cluster/memory.rs
index 51dfa37dc..3baef1ddd 100644
--- a/ballista/scheduler/src/cluster/memory.rs
+++ b/ballista/scheduler/src/cluster/memory.rs
@@ -493,22 +493,20 @@ impl JobState for InMemoryJobState {
 
     async fn fail_unscheduled_job(&self, job_id: &JobId, reason: String) -> 
Result<()> {
         if let Some((job_id, (job_name, queued_at))) = 
self.queued_jobs.remove(job_id) {
-            self.completed_jobs.insert(
-                job_id.clone(),
-                (
-                    JobStatus {
-                        job_id: job_id.into(),
-                        job_name,
-                        status: Some(Status::Failed(FailedJob {
-                            error: reason,
-                            queued_at,
-                            started_at: 0,
-                            ended_at: timestamp_millis(),
-                        })),
-                    },
-                    None,
-                ),
-            );
+            let status = JobStatus {
+                job_id: job_id.clone().into(),
+                job_name,
+                status: Some(Status::Failed(FailedJob {
+                    error: reason,
+                    queued_at,
+                    started_at: 0,
+                    ended_at: timestamp_millis(),
+                })),
+            };
+            self.completed_jobs
+                .insert(job_id.clone(), (status.clone(), None));
+            self.job_event_sender
+                .send(&JobStateEvent::JobUpdated { job_id, status });
 
             Ok(())
         } else {
@@ -533,6 +531,7 @@ mod test {
     use crate::test_utils::{
         test_aggregation_plan, test_join_plan, test_two_aggregations_plan,
     };
+    use ballista_core::JobId;
     use ballista_core::error::Result;
     use ballista_core::serde::protobuf::JobStatus;
     use ballista_core::serde::scheduler::{
@@ -609,6 +608,41 @@ mod test {
         Ok(())
     }
 
+    #[tokio::test]
+    async fn test_in_memory_job_planning_failure_notification() -> Result<()> {
+        let state = InMemoryJobState::new(
+            "",
+            Arc::new(default_session_builder),
+            Arc::new(default_config_producer),
+        );
+        let mut events = state.job_state_events().await?;
+        let job_id = JobId::from("job-1");
+
+        state.accept_job(&job_id, "", 0)?;
+        state
+            .fail_unscheduled_job(&job_id, "failed planning".to_owned())
+            .await?;
+
+        let event =
+            tokio::time::timeout(std::time::Duration::from_secs(1), 
events.next())
+                .await
+                .expect("job state event should arrive")
+                .expect("job state event stream should remain open");
+
+        assert!(matches!(
+            event,
+            JobStateEvent::JobUpdated {
+                job_id: event_job_id,
+                status: JobStatus {
+                    status: 
Some(ballista_core::serde::protobuf::job_status::Status::Failed(_)),
+                    ..
+                },
+            } if event_job_id == job_id
+        ));
+
+        Ok(())
+    }
+
     #[tokio::test]
     async fn test_in_memory_job_notification() -> Result<()> {
         let state = InMemoryJobState::new(
diff --git a/ballista/scheduler/src/scheduler_server/mod.rs 
b/ballista/scheduler/src/scheduler_server/mod.rs
index 6fac8b0e4..ca439dfeb 100644
--- a/ballista/scheduler/src/scheduler_server/mod.rs
+++ b/ballista/scheduler/src/scheduler_server/mod.rs
@@ -31,7 +31,7 @@ use datafusion::prelude::{SessionConfig, SessionContext};
 use datafusion_proto::logical_plan::AsLogicalPlan;
 use datafusion_proto::physical_plan::AsExecutionPlan;
 
-use crate::cluster::BallistaCluster;
+use crate::cluster::{BallistaCluster, ClusterStateEventStream, 
JobStateEventStream};
 use crate::config::SchedulerConfig;
 use crate::metrics::SchedulerMetricsCollector;
 use ballista_core::serde::scheduler::{ExecutorData, ExecutorMetadata};
@@ -183,6 +183,16 @@ impl<T: 'static + AsLogicalPlan, U: 'static + 
AsExecutionPlan> SchedulerServer<T
         self.state.task_manager.running_job_number()
     }
 
+    /// Returns a stream of job state events from the configured state backend.
+    pub async fn job_state_events(&self) -> Result<JobStateEventStream> {
+        self.state.task_manager.job_state_events().await
+    }
+
+    /// Returns a stream of cluster state events from the configured state 
backend.
+    pub async fn cluster_state_events(&self) -> 
Result<ClusterStateEventStream> {
+        self.state.executor_manager.cluster_state_events().await
+    }
+
     /// True when at least `min_ready_executors` executors currently have
     /// live heartbeats. Embedders can call this from their own 
health/readiness
     /// handler and AND it with whatever app-specific state they track. The
@@ -451,6 +461,7 @@ pub fn timestamp_millis() -> u64 {
 #[cfg(test)]
 mod test {
     use std::sync::Arc;
+    use std::time::Duration;
 
     use ballista_core::JobId;
     use ballista_core::extension::SessionConfigExt;
@@ -463,7 +474,9 @@ mod test {
     use datafusion::test_util::scan_empty_with_partitions;
     use datafusion_proto::protobuf::LogicalPlanNode;
     use datafusion_proto::protobuf::PhysicalPlanNode;
+    use futures::StreamExt;
 
+    use crate::cluster::ClusterStateEvent;
     use crate::config::SchedulerConfig;
     use ballista_core::config::TaskSchedulingPolicy;
     use ballista_core::error::Result;
@@ -487,6 +500,31 @@ mod test {
         assert_submitted_event, test_cluster_context,
     };
 
+    #[tokio::test]
+    async fn test_scheduler_exposes_cluster_state_events() -> Result<()> {
+        let scheduler = 
test_scheduler(TaskSchedulingPolicy::PushStaged).await?;
+        let mut events = scheduler.cluster_state_events().await?;
+        let (executor_metadata, executor_data) =
+            test_executors(2).into_iter().next().unwrap();
+        let executor_id = executor_metadata.id.clone();
+
+        scheduler
+            .state
+            .executor_manager
+            .register_executor(executor_metadata, executor_data)
+            .await?;
+
+        let event = tokio::time::timeout(Duration::from_secs(5), events.next())
+            .await
+            .expect("cluster state event should arrive");
+        assert_eq!(
+            event,
+            Some(ClusterStateEvent::RegisteredExecutor { executor_id })
+        );
+
+        Ok(())
+    }
+
     #[tokio::test]
     async fn test_pull_scheduling() -> Result<()> {
         let plan = test_plan();
diff --git a/ballista/scheduler/src/scheduler_server/query_stage_scheduler.rs 
b/ballista/scheduler/src/scheduler_server/query_stage_scheduler.rs
index d4cb11fc2..ef3d118e8 100644
--- a/ballista/scheduler/src/scheduler_server/query_stage_scheduler.rs
+++ b/ballista/scheduler/src/scheduler_server/query_stage_scheduler.rs
@@ -482,6 +482,7 @@ impl<T: 'static + AsLogicalPlan, U: 'static + 
AsExecutionPlan>
 
 #[cfg(test)]
 mod tests {
+    use crate::cluster::JobStateEvent;
     use crate::config::{SchedulerConfig, WorkAvailableReason};
     use crate::scheduler_server::SchedulerServer;
     use crate::test_utils::{
@@ -507,6 +508,7 @@ mod tests {
     use datafusion::prelude::SessionConfig;
     use datafusion::test_util::scan_empty_with_partitions;
     use datafusion_proto::protobuf::{LogicalPlanNode, PhysicalPlanNode};
+    use futures::StreamExt;
     use std::sync::{Arc, Mutex};
     use std::time::Duration;
     use tonic::Request;
@@ -565,6 +567,48 @@ mod tests {
         Ok(())
     }
 
+    #[tokio::test]
+    async fn test_scheduler_exposes_job_state_events() -> Result<()> {
+        let plan = test_plan(1);
+        let metrics_collector = Arc::new(TestMetricsCollector::default());
+        let mut test = SchedulerTest::new(
+            SchedulerConfig::default()
+                .with_scheduler_policy(TaskSchedulingPolicy::PushStaged),
+            metrics_collector,
+            1,
+            1,
+            None,
+        )
+        .await?;
+
+        let mut events = test.job_state_events().await?;
+        let (_, job_id) = test.run("", &plan).await?;
+        let received_success = tokio::time::timeout(Duration::from_secs(5), 
async {
+            while let Some(event) = events.next().await {
+                if matches!(
+                    event,
+                    JobStateEvent::JobUpdated {
+                        job_id: event_job_id,
+                        status,
+                    } if event_job_id == job_id
+                        && matches!(
+                            status.status,
+                            Some(job_status::Status::Successful(_))
+                        )
+                ) {
+                    return true;
+                }
+            }
+            false
+        })
+        .await
+        .expect("successful job state event should arrive");
+        assert!(
+            received_success,
+            "job state event stream closed unexpectedly"
+        );
+        Ok(())
+    }
     #[tokio::test]
     async fn test_on_work_available_callback() -> Result<()> {
         let reasons: Arc<Mutex<Vec<WorkAvailableReason>>> = Arc::default();
diff --git a/ballista/scheduler/src/state/executor_manager.rs 
b/ballista/scheduler/src/state/executor_manager.rs
index 2f0b162b0..1da756566 100644
--- a/ballista/scheduler/src/state/executor_manager.rs
+++ b/ballista/scheduler/src/state/executor_manager.rs
@@ -25,7 +25,7 @@ use ballista_core::serde::protobuf::ExecutorMetric;
 use ballista_core::serde::protobuf::executor_metric::Metric;
 use log::trace;
 
-use crate::cluster::{BoundTask, ClusterState, ExecutorSlot};
+use crate::cluster::{BoundTask, ClusterState, ClusterStateEventStream, 
ExecutorSlot};
 use crate::config::SchedulerConfig;
 
 use crate::state::execution_graph::RunningTaskInfo;
@@ -112,6 +112,11 @@ impl ExecutorManager {
         Ok(())
     }
 
+    /// Returns a stream of cluster state events from the configured state 
backend.
+    pub async fn cluster_state_events(&self) -> 
Result<ClusterStateEventStream> {
+        self.cluster_state.cluster_state_events().await
+    }
+
     /// Binds ready-to-run tasks from active jobs to available executor slots.
     ///
     /// Returns a list of bound tasks that can be launched on executors.
diff --git a/ballista/scheduler/src/state/task_manager.rs 
b/ballista/scheduler/src/state/task_manager.rs
index 7d46488c7..dea5af100 100644
--- a/ballista/scheduler/src/state/task_manager.rs
+++ b/ballista/scheduler/src/state/task_manager.rs
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use crate::cluster::JobState;
+use crate::cluster::{JobState, JobStateEventStream};
 use crate::config::SchedulerConfig;
 use crate::planner::DefaultDistributedPlanner;
 use crate::scheduler_server::event::{QueryStageSchedulerEvent, SubmitPlan};
@@ -243,6 +243,11 @@ impl<T: 'static + AsLogicalPlan, U: 'static + 
AsExecutionPlan> TaskManager<T, U>
             .count()
     }
 
+    /// Returns a stream of job state events from the configured state backend.
+    pub async fn job_state_events(&self) -> Result<JobStateEventStream> {
+        self.state.job_state_events().await
+    }
+
     /// Generate an ExecutionGraph for the job and save it to the persistent 
state.
     /// By default, this job will be curated by the scheduler which receives 
it.
     /// Then we will also save it to the active execution graph
diff --git a/ballista/scheduler/src/test_utils.rs 
b/ballista/scheduler/src/test_utils.rs
index c794a2a92..496aa8d58 100644
--- a/ballista/scheduler/src/test_utils.rs
+++ b/ballista/scheduler/src/test_utils.rs
@@ -53,7 +53,7 @@ use 
datafusion::physical_plan::display::DisplayableExecutionPlan;
 use datafusion::prelude::{CsvReadOptions, JoinType, col};
 use datafusion::test_util::scan_empty_with_partitions;
 
-use crate::cluster::BallistaCluster;
+use crate::cluster::{BallistaCluster, JobStateEventStream};
 use crate::scheduler_server::event::QueryStageSchedulerEvent;
 
 use crate::state::execution_graph::{
@@ -479,6 +479,11 @@ impl SchedulerTest {
         self.scheduler.running_job_number()
     }
 
+    /// Returns job state events from the underlying scheduler.
+    pub async fn job_state_events(&self) -> Result<JobStateEventStream> {
+        self.scheduler.job_state_events().await
+    }
+
     /// Returns the session context for tests.
     pub async fn ctx(&self) -> Result<Arc<SessionContext>> {
         self.scheduler


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

Reply via email to