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    /// Single queue (shorthand for [`Self::queues`]).
168    pub fn queue(mut self, q: impl Into<String>) -> Self {
169        self.queues = vec![q.into()];
170        self
171    }
172
173    /// Claim order = priority across queues (first is highest).
174    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                    // Always print for list (CLI already interactive at process level).
481                    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/// App state handle for dispatch from request handlers.
588#[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
659/// Require `Authorization: Bearer <token>` for task HTTP routes.
660pub 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}