Skip to main content

sova_tasks/
lib.rs

1//! Background task worker + optional scheduler + HTTP dispatch for Sova.
2
3mod 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/// One registered job (for CLI `tasks list` / `tasks schedule`).
38#[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/// App-state registry for CLI and introspection.
47#[derive(Clone)]
48pub struct TaskRegistry {
49    pub handlers: HandlerMap,
50    pub jobs: Vec<JobInfo>,
51}
52/// HTTP-facing wrapper around [`TaskError`] that maps into [`Error::Response`].
53#[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/// One-shot (or delayed) enqueue request.
79#[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    /// Relative delay; ignored when [`Self::at`] is also set.
119    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
135/// Registers jobs and runs a worker (and optional scheduler) as [`BackgroundService`]s.
136pub 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    /// In-memory queue (tests / single process).
168    pub fn memory() -> Self {
169        Self::new(Arc::new(sova_tasks_store::MemoryStore::new()))
170    }
171
172    /// SQL queue — requires Db installed (`feature = "sql"`).
173    #[cfg(feature = "sql")]
174    pub fn sql(app: &sova_core::App) -> Self {
175        let pool = app.try_state::<sova_db::DbPool>().unwrap_or_else(|| {
176            panic!("Tasks::sql requires Db plugin installed first")
177        });
178        Self::new(Arc::new(sova_tasks_store::SqlTaskStore::from_db_pool(
179            pool.as_ref(),
180        )))
181    }
182
183    /// Redis queue — requires Redis installed (`feature = "redis"`).
184    #[cfg(feature = "redis")]
185    pub fn redis(app: &sova_core::App) -> Self {
186        let pool = app.try_state::<sova_redis::RedisPool>().unwrap_or_else(|| {
187            panic!("Tasks::redis requires Redis plugin installed first")
188        });
189        Self::new(Arc::new(sova_tasks_store::RedisTaskStore::from_redis_pool(
190            pool.as_ref(),
191        )))
192    }
193
194    /// Single queue (shorthand for [`Self::queues`]).
195    pub fn queue(mut self, q: impl Into<String>) -> Self {
196        self.queues = vec![q.into()];
197        self
198    }
199
200    /// Claim order = priority across queues (first is highest).
201    pub fn queues<I, S>(mut self, qs: I) -> Self
202    where
203        I: IntoIterator<Item = S>,
204        S: Into<String>,
205    {
206        self.queues = qs.into_iter().map(Into::into).collect();
207        self
208    }
209
210    pub fn lease(mut self, d: Duration) -> Self {
211        self.lease = d;
212        self
213    }
214
215    pub fn poll_interval(mut self, d: Duration) -> Self {
216        self.poll = d;
217        self
218    }
219
220    pub fn max_attempts(mut self, n: u32) -> Self {
221        self.max_attempts = n.max(1);
222        self
223    }
224
225    pub fn retry_base(mut self, d: Duration) -> Self {
226        self.retry_base = d;
227        self
228    }
229
230    pub fn scheduler_tick(mut self, d: Duration) -> Self {
231        self.scheduler_tick = d.max(Duration::from_millis(50));
232        self
233    }
234
235    pub fn job(mut self, job: JobDef) -> Self {
236        self.jobs.push(job);
237        self
238    }
239
240    pub fn exposed(mut self) -> Self {
241        self.exposed = true;
242        self
243    }
244
245    pub fn guard<F>(mut self, f: F) -> Self
246    where
247        F: Fn(&Request) -> bool + Send + Sync + 'static,
248    {
249        self.http_guard = Some(Arc::new(f));
250        self
251    }
252
253    fn default_queue(&self) -> &str {
254        self.queues.first().map(String::as_str).unwrap_or("default")
255    }
256}
257
258impl Plugin for Tasks {
259    fn id(&self) -> &'static str {
260        "tasks"
261    }
262
263    fn meta(&self) -> sova_core::PluginMeta {
264        sova_core::PluginMeta::new("Tasks")
265            .description("Job worker, priorities, and optional cron/interval scheduler")
266            .version(env!("CARGO_PKG_VERSION"))
267    }
268
269    fn install(self, app: &mut App) {
270        if self.queues.is_empty() {
271            panic!("Tasks::queues must be non-empty");
272        }
273
274        let mut job_queues = HashMap::new();
275        let mut job_priorities = HashMap::new();
276        let mut handlers = HashMap::new();
277        let mut scheduled_map: HashMap<String, ScheduledJob> = HashMap::new();
278        let default_q = self.default_queue().to_string();
279
280        for job in self.jobs {
281            if let Some(ref q) = job.queue {
282                job_queues.insert(job.name.clone(), q.clone());
283            }
284            job_priorities.insert(job.name.clone(), job.priority);
285            handlers.insert(job.name.clone(), job.handler);
286            if let Some(schedule) = job.schedule {
287                let queue = job.queue.clone().unwrap_or_else(|| default_q.clone());
288                scheduled_map.insert(
289                    job.name.clone(),
290                    ScheduledJob {
291                        name: job.name,
292                        schedule,
293                        payload: job.payload,
294                        queue,
295                        priority: job.priority,
296                    },
297                );
298            }
299        }
300
301        let toml_entries = match app.config_doc() {
302            Some(doc) => match parse_schedule_toml(doc.as_ref()) {
303                Ok(v) => v,
304                Err(e) => {
305                    let msg = e.clone();
306                    app.on_startup(move |_| {
307                        let msg = msg.clone();
308                        async move {
309                            Err(Error::Internal(format!("tasks schedule toml: {msg}")))
310                        }
311                    });
312                    Vec::new()
313                }
314            },
315            None => Vec::new(),
316        };
317
318        let scheduled = match merge_schedules(
319            scheduled_map,
320            toml_entries,
321            &handlers,
322            &default_q,
323            &job_queues,
324            &job_priorities,
325        ) {
326            Ok(v) => v,
327            Err(unknown) => {
328                let msg = format!(
329                    "tasks schedule references unknown job(s): {}",
330                    unknown.join(", ")
331                );
332                let msg2 = msg.clone();
333                app.register_audit("tasks-schedule", move |_| {
334                    let msg2 = msg2.clone();
335                    async move { Err(Error::Internal(msg2)) }
336                });
337                app.on_startup(move |_| {
338                    let msg = msg.clone();
339                    async move { Err(Error::Internal(msg)) }
340                });
341                Vec::new()
342            }
343        };
344
345        let mut job_infos: Vec<JobInfo> = handlers
346            .keys()
347            .map(|name| {
348                let queue = job_queues
349                    .get(name)
350                    .cloned()
351                    .unwrap_or_else(|| default_q.clone());
352                let priority = job_priorities.get(name).copied().unwrap_or(prio::NORMAL);
353                let schedule = scheduled
354                    .iter()
355                    .find(|s| &s.name == name)
356                    .map(|s| schedule_label(&s.schedule));
357                JobInfo {
358                    name: name.clone(),
359                    queue,
360                    priority,
361                    schedule,
362                }
363            })
364            .collect();
365        job_infos.sort_by(|a, b| a.name.cmp(&b.name));
366
367        let handlers = Arc::new(handlers);
368        let registry = TaskRegistry {
369            handlers: Arc::clone(&handlers),
370            jobs: job_infos,
371        };
372        app.state(registry.clone());
373
374        let store = self.store.clone();
375        let events = Some(app.events());
376        let backend = TaskBackend {
377            store: store.clone(),
378            queues: self.queues.clone(),
379            job_queues: job_queues.clone(),
380            job_priorities: job_priorities.clone(),
381            events: events.clone(),
382        };
383        app.state(backend.clone());
384
385        let store_check = store.clone();
386        let queue_check = self.queues[0].clone();
387        app.register_check("tasks", move |_state| {
388            let store = Arc::clone(&store_check);
389            let queue = queue_check.clone();
390            async move {
391                store
392                    .list(&queue, 1)
393                    .await
394                    .map_err(|e| sova_core::Error::Internal(format!("tasks store: {e}")))?;
395                Ok(())
396            }
397        });
398
399        register_tasks_cli(app, registry);
400
401        if self.exposed {
402            let Some(guard) = self.http_guard.clone() else {
403                panic!("Tasks::exposed() requires .guard(...)");
404            };
405            let backend = backend.clone();
406            app.post("/_tasks/enqueue", move |mut req: Request| {
407                let guard = Arc::clone(&guard);
408                let backend = backend.clone();
409                async move {
410                    if !guard(&req) {
411                        return Response::text("forbidden").status(403);
412                    }
413                    #[derive(serde::Deserialize)]
414                    struct Body {
415                        name: String,
416                        #[serde(default)]
417                        payload: Value,
418                        #[serde(default)]
419                        run_at: Option<String>,
420                        #[serde(default)]
421                        delay_secs: Option<u64>,
422                        #[serde(default)]
423                        dedup_key: Option<String>,
424                        #[serde(default)]
425                        queue: Option<String>,
426                        #[serde(default)]
427                        priority: Option<i32>,
428                    }
429                    let parsed: Body = match req.json().await {
430                        Ok(v) => v,
431                        Err(e) => return Response::text(e.to_string()).status(400),
432                    };
433                    let mut d = Dispatch::new(parsed.name).data(parsed.payload);
434                    if let Some(q) = parsed.queue {
435                        d = d.queue(q);
436                    }
437                    if let Some(s) = parsed.run_at {
438                        match chrono::DateTime::parse_from_rfc3339(&s) {
439                            Ok(dt) => {
440                                d = d.at(SystemTime::from(dt.with_timezone(&chrono::Utc)));
441                            }
442                            Err(e) => {
443                                return Response::text(format!("run_at: {e}")).status(400);
444                            }
445                        }
446                    } else if let Some(secs) = parsed.delay_secs {
447                        d = d.delay(Duration::from_secs(secs));
448                    }
449                    if let Some(k) = parsed.dedup_key {
450                        d = d.dedup(k);
451                    }
452                    if let Some(p) = parsed.priority {
453                        d = d.priority(p);
454                    }
455                    match backend.dispatch(d).await {
456                        Ok(id) => Response::json(&serde_json::json!({ "id": id })),
457                        Err(e) => Response::text(e.to_string()).status(500),
458                    }
459                }
460            });
461        }
462
463        if !scheduled.is_empty() {
464            app.service(TaskScheduler {
465                store: store.clone(),
466                jobs: scheduled,
467                tick: self.scheduler_tick,
468            });
469        }
470
471        app.service(TaskWorker {
472            store: self.store,
473            queues: self.queues,
474            worker_id: self.worker_id,
475            lease: self.lease,
476            poll: self.poll,
477            handlers,
478            max_attempts: self.max_attempts,
479            retry_base: self.retry_base,
480            events,
481        });
482    }
483}
484
485fn register_tasks_cli(app: &mut App, registry: TaskRegistry) {
486    app.register_cli("tasks", move |state, args| {
487        let registry = state
488            .get::<TaskRegistry>()
489            .map(|a| (*a).clone())
490            .unwrap_or_else(|| registry.clone());
491        async move {
492            let sub = args.first().map(String::as_str).unwrap_or("list");
493            match sub {
494                "list" | "" => {
495                    let rows: Vec<Vec<String>> = registry
496                        .jobs
497                        .iter()
498                        .map(|j| {
499                            vec![
500                                j.name.clone(),
501                                j.queue.clone(),
502                                j.priority.to_string(),
503                                j.schedule.clone().unwrap_or_else(|| "-".into()),
504                            ]
505                        })
506                        .collect();
507                    // Always print for list (CLI already interactive at process level).
508                    println!(
509                        "{:<24} {:<12} {:<8} SCHEDULE",
510                        "NAME", "QUEUE", "PRIO"
511                    );
512                    for r in &rows {
513                        println!(
514                            "{:<24} {:<12} {:<8} {}",
515                            r[0], r[1], r[2], r[3]
516                        );
517                    }
518                    if rows.is_empty() {
519                        println!("(no jobs registered)");
520                    }
521                    Ok(())
522                }
523                "schedule" => {
524                    let scheduled: Vec<_> = registry
525                        .jobs
526                        .iter()
527                        .filter(|j| j.schedule.is_some())
528                        .collect();
529                    if scheduled.is_empty() {
530                        println!("(no scheduled jobs)");
531                    } else {
532                        for j in scheduled {
533                            println!(
534                                "{:<24} {}  queue={}  priority={}",
535                                j.name,
536                                j.schedule.as_deref().unwrap_or("-"),
537                                j.queue,
538                                j.priority
539                            );
540                        }
541                    }
542                    Ok(())
543                }
544                "run" => {
545                    let name = args
546                        .get(1)
547                        .cloned()
548                        .ok_or_else(|| Error::Internal("usage: tasks run NAME [--json '{…}']".into()))?;
549                    let mut data = Value::Object(Default::default());
550                    if let Some(idx) = args.iter().position(|a| a == "--json") {
551                        let raw = args.get(idx + 1).ok_or_else(|| {
552                            Error::Internal("tasks run: --json requires a value".into())
553                        })?;
554                        data = serde_json::from_str(raw).map_err(|e| {
555                            Error::Internal(format!("tasks run --json: {e}"))
556                        })?;
557                    }
558                    let Some(handler) = registry.handlers.get(&name) else {
559                        return Err(Error::Internal(format!(
560                            "unknown job `{name}` (try `tasks list`)"
561                        )));
562                    };
563                    let payload = Bytes::from(
564                        serde_json::to_vec(&serde_json::json!({
565                            "name": name,
566                            "data": data,
567                        }))
568                        .map_err(|e| Error::Internal(e.to_string()))?,
569                    );
570                    let task = Task {
571                        id: format!("cli-{}", name),
572                        queue: registry
573                            .jobs
574                            .iter()
575                            .find(|j| j.name == name)
576                            .map(|j| j.queue.clone())
577                            .unwrap_or_else(|| "default".into()),
578                        payload,
579                        run_at: SystemTime::now(),
580                        attempts: 1,
581                        lease_until: None,
582                        dedup_key: None,
583                        status: TaskStatus::Running,
584                        worker: Some("cli".into()),
585                        priority: registry
586                            .jobs
587                            .iter()
588                            .find(|j| j.name == name)
589                            .map(|j| j.priority)
590                            .unwrap_or(0),
591                    };
592                    let _guard = enter_cli();
593                    handler(task)
594                        .await
595                        .map_err(|e| Error::Internal(format!("job `{name}` failed: {e}")))?;
596                    println!("ok {name}");
597                    Ok(())
598                }
599                "help" | "-h" | "--help" => {
600                    println!("usage:");
601                    println!("  tasks list");
602                    println!("  tasks schedule");
603                    println!("  tasks run NAME [--json '{{…}}']");
604                    Ok(())
605                }
606                other => Err(Error::Internal(format!(
607                    "unknown tasks subcommand `{other}` (list|schedule|run)"
608                ))),
609            }
610        }
611    });
612}
613
614/// App state handle for dispatch from request handlers.
615#[derive(Clone)]
616pub struct TaskBackend {
617    pub store: Arc<dyn TaskStore>,
618    pub queues: Vec<String>,
619    pub job_queues: HashMap<String, String>,
620    pub job_priorities: HashMap<String, i32>,
621    events: Option<EventBus>,
622}
623
624impl TaskBackend {
625    pub async fn dispatch(&self, d: Dispatch) -> Result<String, TaskError> {
626        let started = std::time::Instant::now();
627        let name = d.name.clone();
628        let queue = d
629            .queue
630            .clone()
631            .or_else(|| self.job_queues.get(&d.name).cloned())
632            .unwrap_or_else(|| {
633                self.queues
634                    .first()
635                    .cloned()
636                    .unwrap_or_else(|| "default".into())
637            });
638        let priority = d
639            .priority
640            .or_else(|| self.job_priorities.get(&d.name).copied())
641            .unwrap_or(prio::NORMAL);
642        let run_at = match (d.run_at, d.delay) {
643            (Some(at), _) => Some(at),
644            (None, Some(delay)) => Some(SystemTime::now() + delay),
645            (None, None) => None,
646        };
647        let payload = Bytes::from(
648            serde_json::to_vec(&serde_json::json!({
649                "name": d.name,
650                "data": d.data,
651            }))
652            .map_err(|e| TaskError::Msg(e.to_string()))?,
653        );
654        let queue_for_log = queue.clone();
655        let id = self
656            .store
657            .enqueue(EnqueueOpts {
658                queue,
659                payload,
660                run_at,
661                dedup_key: d.dedup_key,
662                priority,
663            })
664            .await?;
665        tracing::debug!(
666            target: "sova.tasks",
667            name = %name,
668            queue = %queue_for_log,
669            id = %id,
670            status = "enqueued",
671            duration_ms = started.elapsed().as_secs_f64() * 1000.0,
672            request_id = sova_core::current_request_id().as_deref().unwrap_or(""),
673            "sova.tasks enqueue"
674        );
675        if let Some(bus) = &self.events {
676            bus.dispatch(TaskDispatched {
677                id: id.clone(),
678                name,
679                queue: queue_for_log,
680            });
681        }
682        Ok(id)
683    }
684}
685
686/// Require `Authorization: Bearer <token>` for task HTTP routes.
687pub fn bearer_guard(token: impl Into<String>) -> impl Fn(&Request) -> bool + Send + Sync + 'static {
688    let token = token.into();
689    move |req: &Request| {
690        req.header("authorization")
691            .map(|v| v == format!("Bearer {token}"))
692            .unwrap_or(false)
693    }
694}
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699    use sova_tasks_store::MemoryStore;
700
701    #[tokio::test]
702    async fn dispatch_via_backend() {
703        let store = Arc::new(MemoryStore::new());
704        let backend = TaskBackend {
705            store: store.clone(),
706            queues: vec!["default".into()],
707            job_queues: HashMap::new(),
708            job_priorities: HashMap::new(),
709            events: None,
710        };
711        let id = backend
712            .dispatch(Dispatch::new("ping").data(serde_json::json!({})))
713            .await
714            .unwrap();
715        assert!(id.starts_with('t'));
716        assert_eq!(store.list("default", 10).await.unwrap().len(), 1);
717    }
718
719    #[tokio::test]
720    async fn dispatch_uses_job_defaults_and_delay() {
721        let store = Arc::new(MemoryStore::new());
722        let mut job_queues = HashMap::new();
723        job_queues.insert("mail".into(), "mailer".into());
724        let mut job_priorities = HashMap::new();
725        job_priorities.insert("mail".into(), prio::LOW);
726        let backend = TaskBackend {
727            store: store.clone(),
728            queues: vec!["default".into(), "mailer".into()],
729            job_queues,
730            job_priorities,
731            events: None,
732        };
733        let id = backend
734            .dispatch(Dispatch::new("mail").delay(Duration::from_secs(60)))
735            .await
736            .unwrap();
737        let listed = store.list("mailer", 10).await.unwrap();
738        assert_eq!(listed.len(), 1);
739        assert_eq!(listed[0].id, id);
740        assert_eq!(listed[0].priority, prio::LOW);
741        assert!(listed[0].run_at > SystemTime::now());
742    }
743
744    #[test]
745    #[should_panic(expected = "Tasks::exposed() requires .guard")]
746    fn exposed_without_guard_panics_on_install() {
747        let store = Arc::new(MemoryStore::new());
748        let mut app = App::new();
749        Tasks::new(store).exposed().install(&mut app);
750    }
751
752    #[tokio::test]
753    async fn http_enqueue_then_worker_runs_handler() {
754        use http::Method;
755        use sova_core::BackgroundService;
756        use sova_tasks_store::TaskStatus;
757        use std::sync::atomic::{AtomicUsize, Ordering};
758
759        let store = Arc::new(MemoryStore::new());
760        let ran = Arc::new(AtomicUsize::new(0));
761        let ran2 = Arc::clone(&ran);
762
763        let mut app = App::new();
764        Tasks::new(store.clone())
765            .exposed()
766            .guard(bearer_guard("secret"))
767            .job(Job::new("job", move |_| {
768                let ran2 = Arc::clone(&ran2);
769                async move {
770                    ran2.fetch_add(1, Ordering::SeqCst);
771                    Ok(())
772                }
773            }))
774            .install(&mut app);
775
776        let body = r#"{"name":"job","payload":{}}"#;
777        let res = app
778            .handle(
779                Request::builder()
780                    .method(Method::POST)
781                    .path("/_tasks/enqueue")
782                    .header("authorization", "Bearer secret")
783                    .body(body)
784                    .build(),
785            )
786            .await;
787        assert_eq!(res.status_code().as_u16(), 200);
788
789        let (tx, shutdown) = sova_core::shutdown_channel();
790        let mut handlers = HashMap::new();
791        let ran3 = Arc::clone(&ran);
792        handlers.insert(
793            "job".into(),
794            Arc::new(move |_t| {
795                let ran3 = Arc::clone(&ran3);
796                Box::pin(async move {
797                    ran3.fetch_add(1, Ordering::SeqCst);
798                    Ok(())
799                }) as crate::BoxFuture<Result<(), String>>
800            }) as Handler,
801        );
802        let worker = Box::new(TaskWorker {
803            store: store.clone(),
804            queues: vec!["default".into()],
805            worker_id: "test".into(),
806            lease: Duration::from_secs(2),
807            poll: Duration::from_millis(10),
808            handlers: Arc::new(handlers),
809            max_attempts: 5,
810            retry_base: Duration::from_millis(50),
811            events: None,
812        });
813        let handle = tokio::spawn(worker.run(
814            Arc::new(sova_core::extend::StateMap::new()),
815            shutdown,
816        ));
817
818        let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
819        while tokio::time::Instant::now() < deadline {
820            if ran.load(Ordering::SeqCst) >= 1 {
821                break;
822            }
823            tokio::time::sleep(Duration::from_millis(20)).await;
824        }
825        assert_eq!(ran.load(Ordering::SeqCst), 1);
826        let listed = store.list("default", 10).await.unwrap();
827        assert_eq!(listed[0].status, TaskStatus::Done);
828
829        let _ = tx.send(true);
830        let _ = handle.await;
831    }
832}