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