1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19pub struct SchedulerConfig {
20 #[serde(default = "default_max_concurrent")]
22 pub max_concurrent: usize,
23 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62pub struct TaskSpec {
63 pub name: String,
65 #[serde(default)]
67 pub resources: ResourceRequirements,
68 #[serde(default)]
70 pub labels: BTreeMap<String, String>,
71}
72
73impl TaskSpec {
74 #[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 #[must_use]
86 pub const fn with_resources(mut self, resources: ResourceRequirements) -> Self {
87 self.resources = resources;
88 self
89 }
90
91 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
101pub struct ResourceRequirements {
102 #[serde(default = "default_cpu_units")]
104 pub cpu_units: u32,
105 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
129pub struct SchedulingDecision {
130 pub task: String,
132 pub pool: String,
134 pub reason: String,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
140pub struct TaskBatch {
141 pub name: String,
143 pub tasks: Vec<TaskSpec>,
145}
146
147impl TaskBatch {
148 #[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 #[must_use]
159 pub fn with_task(mut self, task: TaskSpec) -> Self {
160 self.tasks.push(task);
161 self
162 }
163
164 pub fn into_stream(self) -> impl futures::Stream<Item = TaskSpec> + Send + 'static {
166 futures::stream::iter(self.tasks)
167 }
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
172pub struct ExecutionPlan {
173 pub batch: String,
175 pub decisions: Vec<SchedulingDecision>,
177}
178
179#[async_trait::async_trait]
181pub trait Scheduler: Send + Sync {
182 async fn schedule(&self, spec: &TaskSpec) -> AppResult<SchedulingDecision>;
184
185 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#[derive(Debug, Clone)]
206pub struct WorkerScheduler {
207 pool_name: String,
208 config: SchedulerConfig,
209}
210
211impl WorkerScheduler {
212 #[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 #[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}