Skip to main content

rskit_worker/
scheduler.rs

1//! Task specs and scheduling helpers built on the worker pool.
2
3use std::collections::BTreeMap;
4use std::sync::Arc;
5
6use futures::StreamExt;
7use rskit_errors::AppResult;
8use serde::{Deserialize, Serialize};
9
10use crate::{Handler, Pool, PoolConfig};
11
12/// Scheduler configuration.
13///
14/// Tasks are executed through [`Pool`], so submissions use the same bounded queue
15/// and overflow semantics as [`PoolConfig`]. `max_concurrent` maps to the pool's semaphore capacity
16/// and `queue_size` maps to the internal submit queue capacity.
17/// Values below one are clamped to one when the pool config is built.
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19pub struct SchedulerConfig {
20    /// Maximum concurrently executing tasks.
21    #[serde(default = "default_max_concurrent")]
22    pub max_concurrent: usize,
23    /// Maximum queued task submissions.
24    #[serde(default = "default_queue_size")]
25    pub queue_size: usize,
26}
27
28impl Default for SchedulerConfig {
29    fn default() -> Self {
30        Self {
31            max_concurrent: default_max_concurrent(),
32            queue_size: default_queue_size(),
33        }
34    }
35}
36
37impl SchedulerConfig {
38    /// Convert this scheduler config into the canonical worker pool config.
39    ///
40    /// The resulting pool uses a bounded queue. With the default overflow policy,
41    /// `submit` waits for queue capacity instead of buffering indefinitely;
42    /// alternate policies can be configured on the returned [`PoolConfig`] before constructing a pool.
43    #[must_use]
44    pub fn to_pool_config(&self, name: impl Into<String>) -> PoolConfig {
45        PoolConfig::new(name)
46            .with_size(self.max_concurrent.max(1))
47            .with_queue_size(self.queue_size.max(1))
48    }
49}
50
51const fn default_max_concurrent() -> usize {
52    4
53}
54
55const fn default_queue_size() -> usize {
56    256
57}
58
59/// Named task definition.
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
61pub struct TaskSpec {
62    /// Stable task name used in logs and scheduler decisions.
63    pub name: String,
64    /// Resource requirements declared by the task.
65    #[serde(default)]
66    pub resources: ResourceRequirements,
67    /// Arbitrary scheduler labels.
68    #[serde(default)]
69    pub labels: BTreeMap<String, String>,
70}
71
72impl TaskSpec {
73    /// Create a task spec with default resources.
74    #[must_use]
75    pub fn new(name: impl Into<String>) -> Self {
76        Self {
77            name: name.into(),
78            resources: ResourceRequirements::default(),
79            labels: BTreeMap::new(),
80        }
81    }
82
83    /// Set task resources.
84    #[must_use]
85    pub const fn with_resources(mut self, resources: ResourceRequirements) -> Self {
86        self.resources = resources;
87        self
88    }
89
90    /// Add a scheduler label.
91    #[must_use]
92    pub fn with_label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
93        self.labels.insert(key.into(), value.into());
94        self
95    }
96}
97
98/// Resource requirements used by scheduling decisions.
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
100pub struct ResourceRequirements {
101    /// Logical CPU units requested by the task.
102    #[serde(default = "default_cpu_units")]
103    pub cpu_units: u32,
104    /// Memory requested in mebibytes.
105    #[serde(default = "default_memory_mib")]
106    pub memory_mib: u64,
107}
108
109impl Default for ResourceRequirements {
110    fn default() -> Self {
111        Self {
112            cpu_units: default_cpu_units(),
113            memory_mib: default_memory_mib(),
114        }
115    }
116}
117
118const fn default_cpu_units() -> u32 {
119    1
120}
121
122const fn default_memory_mib() -> u64 {
123    128
124}
125
126/// Observable scheduler decision.
127#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
128pub struct SchedulingDecision {
129    /// Task selected for execution.
130    pub task: String,
131    /// Worker pool that should execute the task.
132    pub pool: String,
133    /// Human-readable reason for the placement.
134    pub reason: String,
135}
136
137/// A batch of task specs that should be scheduled together.
138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
139pub struct TaskBatch {
140    /// Stable batch name.
141    pub name: String,
142    /// Tasks in submission order.
143    pub tasks: Vec<TaskSpec>,
144}
145
146impl TaskBatch {
147    /// Create an empty task batch.
148    #[must_use]
149    pub fn new(name: impl Into<String>) -> Self {
150        Self {
151            name: name.into(),
152            tasks: Vec::new(),
153        }
154    }
155
156    /// Add a task to this batch.
157    #[must_use]
158    pub fn with_task(mut self, task: TaskSpec) -> Self {
159        self.tasks.push(task);
160        self
161    }
162
163    /// Convert this batch into a stream of task specs.
164    pub fn into_stream(self) -> impl futures::Stream<Item = TaskSpec> + Send + 'static {
165        futures::stream::iter(self.tasks)
166    }
167}
168
169/// Planned execution for a task batch.
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
171pub struct ExecutionPlan {
172    /// Batch being planned.
173    pub batch: String,
174    /// Scheduling decisions in task order.
175    pub decisions: Vec<SchedulingDecision>,
176}
177
178/// Scheduler contract for task placement.
179#[async_trait::async_trait]
180pub trait Scheduler: Send + Sync {
181    /// Decide where the task should execute.
182    async fn schedule(&self, spec: &TaskSpec) -> AppResult<SchedulingDecision>;
183
184    /// Build an execution plan for a batch using a task stream.
185    async fn plan_batch(&self, batch: TaskBatch) -> AppResult<ExecutionPlan> {
186        let name = batch.name.clone();
187        let mut stream = batch.into_stream();
188        let mut decisions = Vec::new();
189        while let Some(task) = stream.next().await {
190            decisions.push(self.schedule(&task).await?);
191        }
192        Ok(ExecutionPlan {
193            batch: name,
194            decisions,
195        })
196    }
197}
198
199/// Scheduler backed by a canonical `rskit-worker` pool configuration.
200///
201/// The scheduler is deterministic and does not create background work by itself.
202/// Execution backpressure is enforced by the bounded [`Pool`] produced by [`WorkerScheduler::pool`].
203#[derive(Debug, Clone)]
204pub struct WorkerScheduler {
205    pool_name: String,
206    config: SchedulerConfig,
207}
208
209impl WorkerScheduler {
210    /// Create a scheduler targeting a named worker pool.
211    #[must_use]
212    pub fn new(pool_name: impl Into<String>, config: SchedulerConfig) -> Self {
213        Self {
214            pool_name: pool_name.into(),
215            config,
216        }
217    }
218
219    /// Create a typed worker pool for the supplied handler.
220    #[must_use]
221    pub fn pool<I, O, H>(&self, handler: Arc<H>) -> Pool<I, O>
222    where
223        I: Send + 'static,
224        O: Clone + Send + 'static,
225        H: Handler<I, O> + 'static,
226    {
227        Pool::new(handler, self.config.to_pool_config(self.pool_name.clone()))
228    }
229}
230
231#[async_trait::async_trait]
232impl Scheduler for WorkerScheduler {
233    async fn schedule(&self, spec: &TaskSpec) -> AppResult<SchedulingDecision> {
234        Ok(SchedulingDecision {
235            task: spec.name.clone(),
236            pool: self.pool_name.clone(),
237            reason: format!(
238                "worker pool capacity={} queue={}",
239                self.config.max_concurrent, self.config.queue_size
240            ),
241        })
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[tokio::test]
250    async fn worker_scheduler_returns_observable_decision() {
251        let scheduler = WorkerScheduler::new("default", SchedulerConfig::default());
252        let decision = scheduler
253            .schedule(&TaskSpec::new("images").with_label("tier", "batch"))
254            .await
255            .unwrap();
256
257        assert_eq!(decision.task, "images");
258        assert_eq!(decision.pool, "default");
259        assert!(decision.reason.contains("capacity"));
260    }
261
262    #[tokio::test]
263    async fn worker_scheduler_plans_batch_through_stream() {
264        let scheduler = WorkerScheduler::new("default", SchedulerConfig::default());
265        let plan = scheduler
266            .plan_batch(
267                TaskBatch::new("batch")
268                    .with_task(TaskSpec::new("first"))
269                    .with_task(TaskSpec::new("second")),
270            )
271            .await
272            .unwrap();
273
274        assert_eq!(plan.batch, "batch");
275        assert_eq!(plan.decisions.len(), 2);
276        assert_eq!(plan.decisions[0].task, "first");
277        assert_eq!(plan.decisions[1].task, "second");
278    }
279}