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