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]
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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
61pub struct TaskSpec {
62 pub name: String,
64 #[serde(default)]
66 pub resources: ResourceRequirements,
67 #[serde(default)]
69 pub labels: BTreeMap<String, String>,
70}
71
72impl TaskSpec {
73 #[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 #[must_use]
85 pub const fn with_resources(mut self, resources: ResourceRequirements) -> Self {
86 self.resources = resources;
87 self
88 }
89
90 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
100pub struct ResourceRequirements {
101 #[serde(default = "default_cpu_units")]
103 pub cpu_units: u32,
104 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
128pub struct SchedulingDecision {
129 pub task: String,
131 pub pool: String,
133 pub reason: String,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
139pub struct TaskBatch {
140 pub name: String,
142 pub tasks: Vec<TaskSpec>,
144}
145
146impl TaskBatch {
147 #[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 #[must_use]
158 pub fn with_task(mut self, task: TaskSpec) -> Self {
159 self.tasks.push(task);
160 self
161 }
162
163 pub fn into_stream(self) -> impl futures::Stream<Item = TaskSpec> + Send + 'static {
165 futures::stream::iter(self.tasks)
166 }
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
171pub struct ExecutionPlan {
172 pub batch: String,
174 pub decisions: Vec<SchedulingDecision>,
176}
177
178#[async_trait::async_trait]
180pub trait Scheduler: Send + Sync {
181 async fn schedule(&self, spec: &TaskSpec) -> AppResult<SchedulingDecision>;
183
184 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#[derive(Debug, Clone)]
204pub struct WorkerScheduler {
205 pool_name: String,
206 config: SchedulerConfig,
207}
208
209impl WorkerScheduler {
210 #[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 #[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}