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 memory() -> Self {
169 Self::new(Arc::new(sova_tasks_store::MemoryStore::new()))
170 }
171
172 #[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 #[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 pub fn queue(mut self, q: impl Into<String>) -> Self {
196 self.queues = vec![q.into()];
197 self
198 }
199
200 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 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#[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
686pub 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}