1mod console;
4mod job;
5mod schedule;
6mod schedule_toml;
7mod worker;
8
9pub use console::{
10 ask, confirm, enter_cli, error, info, is_interactive, line, table, warn, ConsoleGuard,
11};
12pub use job::{parse_cron, priority, Job, Schedule};
13pub use schedule::every_slot;
14
15use bytes::Bytes;
16use job::Job as JobDef;
17use job::priority as prio;
18use sova_core::{App, Error, IntoResponse, Plugin, Request, Response};
19use sova_tasks_store::{EnqueueOpts, Task, TaskError, TaskStatus, TaskStore};
20use schedule::{ScheduledJob, TaskScheduler};
21use schedule_toml::{merge_schedules, parse_schedule_toml, schedule_label};
22use serde_json::Value;
23use std::collections::HashMap;
24use std::future::Future;
25use std::pin::Pin;
26use std::sync::Arc;
27use std::time::{Duration, SystemTime};
28use worker::TaskWorker;
29
30pub(crate) type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
31pub(crate) type Handler = Arc<dyn Fn(Task) -> BoxFuture<Result<(), String>> + Send + Sync>;
32pub(crate) type HandlerMap = Arc<HashMap<String, Handler>>;
33type Guard = Arc<dyn Fn(&Request) -> bool + Send + Sync>;
34
35#[derive(Clone, Debug)]
37pub struct JobInfo {
38 pub name: String,
39 pub queue: String,
40 pub priority: i32,
41 pub schedule: Option<String>,
42}
43
44#[derive(Clone)]
46pub struct TaskRegistry {
47 pub handlers: HandlerMap,
48 pub jobs: Vec<JobInfo>,
49}
50#[derive(Debug)]
52pub struct HttpTaskError(pub TaskError);
53
54impl From<TaskError> for HttpTaskError {
55 fn from(err: TaskError) -> Self {
56 Self(err)
57 }
58}
59
60impl IntoResponse for HttpTaskError {
61 fn into_response(self) -> Response {
62 match self.0 {
63 TaskError::NotFound => Response::text("not found").status(404),
64 TaskError::Duplicate => Response::text("duplicate").status(409),
65 TaskError::Msg(msg) => Response::text(msg).status(500),
66 }
67 }
68}
69
70impl From<HttpTaskError> for Error {
71 fn from(err: HttpTaskError) -> Self {
72 Error::Response(Box::new(err.into_response()))
73 }
74}
75
76#[derive(Debug, Clone)]
78pub struct Dispatch {
79 pub name: String,
80 pub data: Value,
81 pub queue: Option<String>,
82 pub run_at: Option<SystemTime>,
83 pub delay: Option<Duration>,
84 pub dedup_key: Option<String>,
85 pub priority: Option<i32>,
86}
87
88impl Dispatch {
89 pub fn new(name: impl Into<String>) -> Self {
90 Self {
91 name: name.into(),
92 data: Value::Object(Default::default()),
93 queue: None,
94 run_at: None,
95 delay: None,
96 dedup_key: None,
97 priority: None,
98 }
99 }
100
101 pub fn data(mut self, data: Value) -> Self {
102 self.data = data;
103 self
104 }
105
106 pub fn queue(mut self, q: impl Into<String>) -> Self {
107 self.queue = Some(q.into());
108 self
109 }
110
111 pub fn at(mut self, when: SystemTime) -> Self {
112 self.run_at = Some(when);
113 self
114 }
115
116 pub fn delay(mut self, d: Duration) -> Self {
118 self.delay = Some(d);
119 self
120 }
121
122 pub fn dedup(mut self, key: impl Into<String>) -> Self {
123 self.dedup_key = Some(key.into());
124 self
125 }
126
127 pub fn priority(mut self, p: i32) -> Self {
128 self.priority = Some(p);
129 self
130 }
131}
132
133pub struct Tasks {
135 store: Arc<dyn TaskStore>,
136 queues: Vec<String>,
137 worker_id: String,
138 lease: Duration,
139 poll: Duration,
140 jobs: Vec<JobDef>,
141 exposed: bool,
142 http_guard: Option<Guard>,
143 max_attempts: u32,
144 retry_base: Duration,
145 scheduler_tick: Duration,
146}
147
148impl Tasks {
149 pub fn new(store: Arc<dyn TaskStore>) -> Self {
150 Self {
151 store,
152 queues: vec!["default".into()],
153 worker_id: format!("w-{}", std::process::id()),
154 lease: Duration::from_secs(30),
155 poll: Duration::from_millis(200),
156 jobs: Vec::new(),
157 exposed: false,
158 http_guard: None,
159 max_attempts: 5,
160 retry_base: Duration::from_secs(5),
161 scheduler_tick: Duration::from_secs(1),
162 }
163 }
164
165 pub fn queue(mut self, q: impl Into<String>) -> Self {
167 self.queues = vec![q.into()];
168 self
169 }
170
171 pub fn queues<I, S>(mut self, qs: I) -> Self
173 where
174 I: IntoIterator<Item = S>,
175 S: Into<String>,
176 {
177 self.queues = qs.into_iter().map(Into::into).collect();
178 self
179 }
180
181 pub fn lease(mut self, d: Duration) -> Self {
182 self.lease = d;
183 self
184 }
185
186 pub fn poll_interval(mut self, d: Duration) -> Self {
187 self.poll = d;
188 self
189 }
190
191 pub fn max_attempts(mut self, n: u32) -> Self {
192 self.max_attempts = n.max(1);
193 self
194 }
195
196 pub fn retry_base(mut self, d: Duration) -> Self {
197 self.retry_base = d;
198 self
199 }
200
201 pub fn scheduler_tick(mut self, d: Duration) -> Self {
202 self.scheduler_tick = d.max(Duration::from_millis(50));
203 self
204 }
205
206 pub fn job(mut self, job: JobDef) -> Self {
207 self.jobs.push(job);
208 self
209 }
210
211 pub fn exposed(mut self) -> Self {
212 self.exposed = true;
213 self
214 }
215
216 pub fn guard<F>(mut self, f: F) -> Self
217 where
218 F: Fn(&Request) -> bool + Send + Sync + 'static,
219 {
220 self.http_guard = Some(Arc::new(f));
221 self
222 }
223
224 fn default_queue(&self) -> &str {
225 self.queues.first().map(String::as_str).unwrap_or("default")
226 }
227}
228
229impl Plugin for Tasks {
230 fn id(&self) -> &'static str {
231 "tasks"
232 }
233
234 fn meta(&self) -> sova_core::PluginMeta {
235 sova_core::PluginMeta::new("Tasks")
236 .description("Job worker, priorities, and optional cron/interval scheduler")
237 .version(env!("CARGO_PKG_VERSION"))
238 }
239
240 fn install(self, app: &mut App) {
241 if self.queues.is_empty() {
242 panic!("Tasks::queues must be non-empty");
243 }
244
245 let mut job_queues = HashMap::new();
246 let mut job_priorities = HashMap::new();
247 let mut handlers = HashMap::new();
248 let mut scheduled_map: HashMap<String, ScheduledJob> = HashMap::new();
249 let default_q = self.default_queue().to_string();
250
251 for job in self.jobs {
252 if let Some(ref q) = job.queue {
253 job_queues.insert(job.name.clone(), q.clone());
254 }
255 job_priorities.insert(job.name.clone(), job.priority);
256 handlers.insert(job.name.clone(), job.handler);
257 if let Some(schedule) = job.schedule {
258 let queue = job.queue.clone().unwrap_or_else(|| default_q.clone());
259 scheduled_map.insert(
260 job.name.clone(),
261 ScheduledJob {
262 name: job.name,
263 schedule,
264 payload: job.payload,
265 queue,
266 priority: job.priority,
267 },
268 );
269 }
270 }
271
272 let toml_entries = match app.config_doc() {
273 Some(doc) => match parse_schedule_toml(doc.as_ref()) {
274 Ok(v) => v,
275 Err(e) => {
276 let msg = e.clone();
277 app.on_startup(move |_| {
278 let msg = msg.clone();
279 async move {
280 Err(Error::Internal(format!("tasks schedule toml: {msg}")))
281 }
282 });
283 Vec::new()
284 }
285 },
286 None => Vec::new(),
287 };
288
289 let scheduled = match merge_schedules(
290 scheduled_map,
291 toml_entries,
292 &handlers,
293 &default_q,
294 &job_queues,
295 &job_priorities,
296 ) {
297 Ok(v) => v,
298 Err(unknown) => {
299 let msg = format!(
300 "tasks schedule references unknown job(s): {}",
301 unknown.join(", ")
302 );
303 let msg2 = msg.clone();
304 app.register_audit("tasks-schedule", move |_| {
305 let msg2 = msg2.clone();
306 async move { Err(Error::Internal(msg2)) }
307 });
308 app.on_startup(move |_| {
309 let msg = msg.clone();
310 async move { Err(Error::Internal(msg)) }
311 });
312 Vec::new()
313 }
314 };
315
316 let mut job_infos: Vec<JobInfo> = handlers
317 .keys()
318 .map(|name| {
319 let queue = job_queues
320 .get(name)
321 .cloned()
322 .unwrap_or_else(|| default_q.clone());
323 let priority = job_priorities.get(name).copied().unwrap_or(prio::NORMAL);
324 let schedule = scheduled
325 .iter()
326 .find(|s| &s.name == name)
327 .map(|s| schedule_label(&s.schedule));
328 JobInfo {
329 name: name.clone(),
330 queue,
331 priority,
332 schedule,
333 }
334 })
335 .collect();
336 job_infos.sort_by(|a, b| a.name.cmp(&b.name));
337
338 let handlers = Arc::new(handlers);
339 let registry = TaskRegistry {
340 handlers: Arc::clone(&handlers),
341 jobs: job_infos,
342 };
343 app.state(registry.clone());
344
345 let store = self.store.clone();
346 let backend = TaskBackend {
347 store: store.clone(),
348 queues: self.queues.clone(),
349 job_queues: job_queues.clone(),
350 job_priorities: job_priorities.clone(),
351 };
352 app.state(backend.clone());
353
354 let store_check = store.clone();
355 let queue_check = self.queues[0].clone();
356 app.register_check("tasks", move |_state| {
357 let store = Arc::clone(&store_check);
358 let queue = queue_check.clone();
359 async move {
360 store
361 .list(&queue, 1)
362 .await
363 .map_err(|e| sova_core::Error::Internal(format!("tasks store: {e}")))?;
364 Ok(())
365 }
366 });
367
368 register_tasks_cli(app, registry);
369
370 if self.exposed {
371 let Some(guard) = self.http_guard.clone() else {
372 panic!("Tasks::exposed() requires .guard(...)");
373 };
374 let backend = backend.clone();
375 app.post("/_tasks/enqueue", move |mut req: Request| {
376 let guard = Arc::clone(&guard);
377 let backend = backend.clone();
378 async move {
379 if !guard(&req) {
380 return Response::text("forbidden").status(403);
381 }
382 #[derive(serde::Deserialize)]
383 struct Body {
384 name: String,
385 #[serde(default)]
386 payload: Value,
387 #[serde(default)]
388 run_at: Option<String>,
389 #[serde(default)]
390 delay_secs: Option<u64>,
391 #[serde(default)]
392 dedup_key: Option<String>,
393 #[serde(default)]
394 queue: Option<String>,
395 #[serde(default)]
396 priority: Option<i32>,
397 }
398 let parsed: Body = match req.json().await {
399 Ok(v) => v,
400 Err(e) => return Response::text(e.to_string()).status(400),
401 };
402 let mut d = Dispatch::new(parsed.name).data(parsed.payload);
403 if let Some(q) = parsed.queue {
404 d = d.queue(q);
405 }
406 if let Some(s) = parsed.run_at {
407 match chrono::DateTime::parse_from_rfc3339(&s) {
408 Ok(dt) => {
409 d = d.at(SystemTime::from(dt.with_timezone(&chrono::Utc)));
410 }
411 Err(e) => {
412 return Response::text(format!("run_at: {e}")).status(400);
413 }
414 }
415 } else if let Some(secs) = parsed.delay_secs {
416 d = d.delay(Duration::from_secs(secs));
417 }
418 if let Some(k) = parsed.dedup_key {
419 d = d.dedup(k);
420 }
421 if let Some(p) = parsed.priority {
422 d = d.priority(p);
423 }
424 match backend.dispatch(d).await {
425 Ok(id) => Response::json(&serde_json::json!({ "id": id })),
426 Err(e) => Response::text(e.to_string()).status(500),
427 }
428 }
429 });
430 }
431
432 if !scheduled.is_empty() {
433 app.service(TaskScheduler {
434 store: store.clone(),
435 jobs: scheduled,
436 tick: self.scheduler_tick,
437 });
438 }
439
440 app.service(TaskWorker {
441 store: self.store,
442 queues: self.queues,
443 worker_id: self.worker_id,
444 lease: self.lease,
445 poll: self.poll,
446 handlers,
447 max_attempts: self.max_attempts,
448 retry_base: self.retry_base,
449 });
450 }
451}
452
453fn register_tasks_cli(app: &mut App, registry: TaskRegistry) {
454 app.register_cli("tasks", move |state, args| {
455 let registry = state
456 .get::<TaskRegistry>()
457 .map(|a| (*a).clone())
458 .unwrap_or_else(|| registry.clone());
459 async move {
460 let sub = args.first().map(String::as_str).unwrap_or("list");
461 match sub {
462 "list" | "" => {
463 let rows: Vec<Vec<String>> = registry
464 .jobs
465 .iter()
466 .map(|j| {
467 vec![
468 j.name.clone(),
469 j.queue.clone(),
470 j.priority.to_string(),
471 j.schedule.clone().unwrap_or_else(|| "-".into()),
472 ]
473 })
474 .collect();
475 println!(
477 "{:<24} {:<12} {:<8} SCHEDULE",
478 "NAME", "QUEUE", "PRIO"
479 );
480 for r in &rows {
481 println!(
482 "{:<24} {:<12} {:<8} {}",
483 r[0], r[1], r[2], r[3]
484 );
485 }
486 if rows.is_empty() {
487 println!("(no jobs registered)");
488 }
489 Ok(())
490 }
491 "schedule" => {
492 let scheduled: Vec<_> = registry
493 .jobs
494 .iter()
495 .filter(|j| j.schedule.is_some())
496 .collect();
497 if scheduled.is_empty() {
498 println!("(no scheduled jobs)");
499 } else {
500 for j in scheduled {
501 println!(
502 "{:<24} {} queue={} priority={}",
503 j.name,
504 j.schedule.as_deref().unwrap_or("-"),
505 j.queue,
506 j.priority
507 );
508 }
509 }
510 Ok(())
511 }
512 "run" => {
513 let name = args
514 .get(1)
515 .cloned()
516 .ok_or_else(|| Error::Internal("usage: tasks run NAME [--json '{…}']".into()))?;
517 let mut data = Value::Object(Default::default());
518 if let Some(idx) = args.iter().position(|a| a == "--json") {
519 let raw = args.get(idx + 1).ok_or_else(|| {
520 Error::Internal("tasks run: --json requires a value".into())
521 })?;
522 data = serde_json::from_str(raw).map_err(|e| {
523 Error::Internal(format!("tasks run --json: {e}"))
524 })?;
525 }
526 let Some(handler) = registry.handlers.get(&name) else {
527 return Err(Error::Internal(format!(
528 "unknown job `{name}` (try `tasks list`)"
529 )));
530 };
531 let payload = Bytes::from(
532 serde_json::to_vec(&serde_json::json!({
533 "name": name,
534 "data": data,
535 }))
536 .map_err(|e| Error::Internal(e.to_string()))?,
537 );
538 let task = Task {
539 id: format!("cli-{}", name),
540 queue: registry
541 .jobs
542 .iter()
543 .find(|j| j.name == name)
544 .map(|j| j.queue.clone())
545 .unwrap_or_else(|| "default".into()),
546 payload,
547 run_at: SystemTime::now(),
548 attempts: 1,
549 lease_until: None,
550 dedup_key: None,
551 status: TaskStatus::Running,
552 worker: Some("cli".into()),
553 priority: registry
554 .jobs
555 .iter()
556 .find(|j| j.name == name)
557 .map(|j| j.priority)
558 .unwrap_or(0),
559 };
560 let _guard = enter_cli();
561 handler(task)
562 .await
563 .map_err(|e| Error::Internal(format!("job `{name}` failed: {e}")))?;
564 println!("ok {name}");
565 Ok(())
566 }
567 "help" | "-h" | "--help" => {
568 println!("usage:");
569 println!(" tasks list");
570 println!(" tasks schedule");
571 println!(" tasks run NAME [--json '{{…}}']");
572 Ok(())
573 }
574 other => Err(Error::Internal(format!(
575 "unknown tasks subcommand `{other}` (list|schedule|run)"
576 ))),
577 }
578 }
579 });
580}
581
582#[derive(Clone)]
584pub struct TaskBackend {
585 pub store: Arc<dyn TaskStore>,
586 pub queues: Vec<String>,
587 pub job_queues: HashMap<String, String>,
588 pub job_priorities: HashMap<String, i32>,
589}
590
591impl TaskBackend {
592 pub async fn dispatch(&self, d: Dispatch) -> Result<String, TaskError> {
593 let started = std::time::Instant::now();
594 let name = d.name.clone();
595 let queue = d
596 .queue
597 .clone()
598 .or_else(|| self.job_queues.get(&d.name).cloned())
599 .unwrap_or_else(|| {
600 self.queues
601 .first()
602 .cloned()
603 .unwrap_or_else(|| "default".into())
604 });
605 let priority = d
606 .priority
607 .or_else(|| self.job_priorities.get(&d.name).copied())
608 .unwrap_or(prio::NORMAL);
609 let run_at = match (d.run_at, d.delay) {
610 (Some(at), _) => Some(at),
611 (None, Some(delay)) => Some(SystemTime::now() + delay),
612 (None, None) => None,
613 };
614 let payload = Bytes::from(
615 serde_json::to_vec(&serde_json::json!({
616 "name": d.name,
617 "data": d.data,
618 }))
619 .map_err(|e| TaskError::Msg(e.to_string()))?,
620 );
621 let queue_for_log = queue.clone();
622 let id = self
623 .store
624 .enqueue(EnqueueOpts {
625 queue,
626 payload,
627 run_at,
628 dedup_key: d.dedup_key,
629 priority,
630 })
631 .await?;
632 tracing::debug!(
633 target: "sova.tasks",
634 name = %name,
635 queue = %queue_for_log,
636 id = %id,
637 status = "enqueued",
638 duration_ms = started.elapsed().as_secs_f64() * 1000.0,
639 request_id = sova_core::current_request_id().as_deref().unwrap_or(""),
640 "sova.tasks enqueue"
641 );
642 Ok(id)
643 }
644}
645
646pub fn bearer_guard(token: impl Into<String>) -> impl Fn(&Request) -> bool + Send + Sync + 'static {
648 let token = token.into();
649 move |req: &Request| {
650 req.header("authorization")
651 .map(|v| v == format!("Bearer {token}"))
652 .unwrap_or(false)
653 }
654}
655
656#[cfg(test)]
657mod tests {
658 use super::*;
659 use sova_tasks_store::MemoryStore;
660
661 #[tokio::test]
662 async fn dispatch_via_backend() {
663 let store = Arc::new(MemoryStore::new());
664 let backend = TaskBackend {
665 store: store.clone(),
666 queues: vec!["default".into()],
667 job_queues: HashMap::new(),
668 job_priorities: HashMap::new(),
669 };
670 let id = backend
671 .dispatch(Dispatch::new("ping").data(serde_json::json!({})))
672 .await
673 .unwrap();
674 assert!(id.starts_with('t'));
675 assert_eq!(store.list("default", 10).await.unwrap().len(), 1);
676 }
677
678 #[tokio::test]
679 async fn dispatch_uses_job_defaults_and_delay() {
680 let store = Arc::new(MemoryStore::new());
681 let mut job_queues = HashMap::new();
682 job_queues.insert("mail".into(), "mailer".into());
683 let mut job_priorities = HashMap::new();
684 job_priorities.insert("mail".into(), prio::LOW);
685 let backend = TaskBackend {
686 store: store.clone(),
687 queues: vec!["default".into(), "mailer".into()],
688 job_queues,
689 job_priorities,
690 };
691 let id = backend
692 .dispatch(Dispatch::new("mail").delay(Duration::from_secs(60)))
693 .await
694 .unwrap();
695 let listed = store.list("mailer", 10).await.unwrap();
696 assert_eq!(listed.len(), 1);
697 assert_eq!(listed[0].id, id);
698 assert_eq!(listed[0].priority, prio::LOW);
699 assert!(listed[0].run_at > SystemTime::now());
700 }
701
702 #[test]
703 #[should_panic(expected = "Tasks::exposed() requires .guard")]
704 fn exposed_without_guard_panics_on_install() {
705 let store = Arc::new(MemoryStore::new());
706 let mut app = App::new();
707 Tasks::new(store).exposed().install(&mut app);
708 }
709
710 #[tokio::test]
711 async fn http_enqueue_then_worker_runs_handler() {
712 use http::Method;
713 use sova_core::BackgroundService;
714 use sova_tasks_store::TaskStatus;
715 use std::sync::atomic::{AtomicUsize, Ordering};
716
717 let store = Arc::new(MemoryStore::new());
718 let ran = Arc::new(AtomicUsize::new(0));
719 let ran2 = Arc::clone(&ran);
720
721 let mut app = App::new();
722 Tasks::new(store.clone())
723 .exposed()
724 .guard(bearer_guard("secret"))
725 .job(Job::new("job", move |_| {
726 let ran2 = Arc::clone(&ran2);
727 async move {
728 ran2.fetch_add(1, Ordering::SeqCst);
729 Ok(())
730 }
731 }))
732 .install(&mut app);
733
734 let body = r#"{"name":"job","payload":{}}"#;
735 let res = app
736 .handle(
737 Request::builder()
738 .method(Method::POST)
739 .path("/_tasks/enqueue")
740 .header("authorization", "Bearer secret")
741 .body(body)
742 .build(),
743 )
744 .await;
745 assert_eq!(res.status_code().as_u16(), 200);
746
747 let (tx, shutdown) = sova_core::shutdown_channel();
748 let mut handlers = HashMap::new();
749 let ran3 = Arc::clone(&ran);
750 handlers.insert(
751 "job".into(),
752 Arc::new(move |_t| {
753 let ran3 = Arc::clone(&ran3);
754 Box::pin(async move {
755 ran3.fetch_add(1, Ordering::SeqCst);
756 Ok(())
757 }) as crate::BoxFuture<Result<(), String>>
758 }) as Handler,
759 );
760 let worker = Box::new(TaskWorker {
761 store: store.clone(),
762 queues: vec!["default".into()],
763 worker_id: "test".into(),
764 lease: Duration::from_secs(2),
765 poll: Duration::from_millis(10),
766 handlers: Arc::new(handlers),
767 max_attempts: 5,
768 retry_base: Duration::from_millis(50),
769 });
770 let handle = tokio::spawn(worker.run(
771 Arc::new(sova_core::extend::StateMap::new()),
772 shutdown,
773 ));
774
775 let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
776 while tokio::time::Instant::now() < deadline {
777 if ran.load(Ordering::SeqCst) >= 1 {
778 break;
779 }
780 tokio::time::sleep(Duration::from_millis(20)).await;
781 }
782 assert_eq!(ran.load(Ordering::SeqCst), 1);
783 let listed = store.list("default", 10).await.unwrap();
784 assert_eq!(listed[0].status, TaskStatus::Done);
785
786 let _ = tx.send(true);
787 let _ = handle.await;
788 }
789}