1use std::collections::VecDeque;
5use std::sync::{Arc, Mutex};
6
7use anyhow::Result;
8use futures::future::BoxFuture;
9use tokio::sync::broadcast;
10use tokio_util::sync::CancellationToken;
11
12use crate::events::{Event, EventSink, Kind};
13
14pub use comindly_api::jobs::{JobRecord, Status};
15
16const STALE_SECONDS: i64 = 60;
18
19fn alive(job: &JobRecord) -> bool {
21 job.status == Status::Running
22 && job
23 .beat
24 .as_deref()
25 .and_then(|b| chrono::DateTime::parse_from_rfc3339(b).ok())
26 .is_some_and(|b| {
27 chrono::Utc::now() - b.with_timezone(&chrono::Utc)
28 < chrono::Duration::seconds(STALE_SECONDS)
29 })
30}
31
32pub const MAX_EVENTS: usize = 400;
34pub const MAX_JOBS: usize = 50;
36
37pub trait JobStore: Send + Sync {
39 fn save(&self, job: &JobRecord, events: &[Event]) -> Result<()>;
40 fn get(&self, id: &str) -> Option<(JobRecord, Vec<Event>)>;
41 fn list(&self, limit: usize) -> Vec<JobRecord>;
43}
44
45#[derive(Default)]
47pub struct MemoryJobs {
48 jobs: Mutex<Vec<(JobRecord, Vec<Event>)>>,
49}
50
51impl JobStore for MemoryJobs {
52 fn save(&self, job: &JobRecord, events: &[Event]) -> Result<()> {
53 let mut jobs = self.jobs.lock().unwrap_or_else(|e| e.into_inner());
54 jobs.retain(|(j, _)| j.id != job.id);
55 jobs.insert(0, (job.clone(), events.to_vec()));
56 jobs.truncate(MAX_JOBS);
57 Ok(())
58 }
59
60 fn get(&self, id: &str) -> Option<(JobRecord, Vec<Event>)> {
61 let jobs = self.jobs.lock().unwrap_or_else(|e| e.into_inner());
62 jobs.iter().find(|(j, _)| j.id == id).cloned()
63 }
64
65 fn list(&self, limit: usize) -> Vec<JobRecord> {
66 let jobs = self.jobs.lock().unwrap_or_else(|e| e.into_inner());
67 jobs.iter().take(limit).map(|(j, _)| j.clone()).collect()
68 }
69}
70
71pub struct Running {
73 record: Mutex<JobRecord>,
74 events: Mutex<Vec<Event>>,
75 live: broadcast::Sender<Event>,
76 pub cancel: CancellationToken,
77 done: CancellationToken,
79 out: Arc<dyn EventSink>,
81 span: Mutex<tracing::Span>,
83}
84
85impl Running {
86 pub fn record(&self) -> JobRecord {
87 self.record
88 .lock()
89 .unwrap_or_else(|e| e.into_inner())
90 .clone()
91 }
92
93 pub fn events(&self) -> Vec<Event> {
94 self.events
95 .lock()
96 .unwrap_or_else(|e| e.into_inner())
97 .clone()
98 }
99
100 pub async fn wait(&self) {
102 self.done.cancelled().await
103 }
104
105 pub fn follow(&self) -> (Vec<Event>, broadcast::Receiver<Event>) {
107 let events = self.events.lock().unwrap_or_else(|e| e.into_inner());
108 (events.clone(), self.live.subscribe())
109 }
110}
111
112impl EventSink for Running {
113 fn emit(&self, mut e: Event) {
114 e.job_id = Some(self.record().id);
115 let mut events = self.events.lock().unwrap_or_else(|p| p.into_inner());
116 let replace = e.kind == Kind::Progress
118 && events
119 .last()
120 .is_some_and(|l| l.kind == Kind::Progress && l.message == e.message);
121 if replace {
122 if let Some(last) = events.last_mut() {
123 *last = e.clone();
124 }
125 } else {
126 events.push(e.clone());
127 let over = events.len().saturating_sub(MAX_EVENTS);
128 events.drain(..over);
129 }
130 drop(events);
131 let _ = self.live.send(e.clone());
132 let span = self.span.lock().unwrap_or_else(|p| p.into_inner()).clone();
133 let _in = span.enter();
134 self.out.emit(e);
135 }
136}
137
138type Work = Box<dyn FnOnce(Arc<Running>) -> BoxFuture<'static, Result<()>> + Send>;
140
141struct Waiting {
143 job: Arc<Running>,
144 key: String,
145 work: Work,
146}
147
148#[derive(Default)]
149struct Line {
150 current: Option<Arc<Running>>,
152 queue: VecDeque<Waiting>,
153 closed: bool,
155}
156
157struct Inner {
158 store: Arc<dyn JobStore>,
159 out: Arc<dyn EventSink>,
160 line: Mutex<Line>,
161}
162
163pub struct Submitted {
165 pub job: Arc<Running>,
166 pub position: Option<usize>,
168 pub coalesced: bool,
170}
171
172pub struct Jobs {
174 inner: Arc<Inner>,
175}
176
177impl Jobs {
178 pub fn new(store: Arc<dyn JobStore>, out: Arc<dyn EventSink>) -> Jobs {
181 let left: Vec<JobRecord> = store
182 .list(MAX_JOBS)
183 .into_iter()
184 .filter(|j| matches!(j.status, Status::Queued | Status::Running))
185 .collect();
186 if !left.iter().any(alive) {
187 for job in left {
188 if let Some((mut job, events)) = store.get(&job.id) {
189 if job.request.is_some() {
190 job.status = Status::Paused;
191 job.beat = None;
192 } else {
193 if job.status == Status::Queued {
194 job.error = Some(NOT_RUN.into());
195 }
196 job.status = Status::Interrupted;
197 job.finished = Some(now());
198 }
199 let _ = store.save(&job, &events);
200 }
201 }
202 }
203 Jobs {
204 inner: Arc::new(Inner {
205 store,
206 out,
207 line: Mutex::default(),
208 }),
209 }
210 }
211
212 fn line(&self) -> std::sync::MutexGuard<'_, Line> {
213 self.inner.line.lock().unwrap_or_else(|e| e.into_inner())
214 }
215
216 pub fn running(&self) -> Option<Arc<Running>> {
218 self.line()
219 .current
220 .clone()
221 .filter(|r| !r.record().status.finished())
222 }
223
224 pub fn queued(&self) -> Vec<JobRecord> {
226 self.line().queue.iter().map(|w| w.job.record()).collect()
227 }
228
229 pub fn position(&self, id: &str) -> Option<usize> {
231 self.line()
232 .queue
233 .iter()
234 .position(|w| w.job.record().id == id)
235 .map(|i| i + 1)
236 }
237
238 pub fn latest(&self) -> Option<(JobRecord, Vec<Event>)> {
240 if let Some(r) = self.line().current.clone() {
241 return Some((r.record(), r.events()));
242 }
243 let id = self
244 .inner
245 .store
246 .list(MAX_JOBS)
247 .into_iter()
248 .find(|j| j.status != Status::Queued)?
249 .id;
250 self.inner.store.get(&id)
251 }
252
253 pub fn get(&self, id: &str) -> Option<(JobRecord, Vec<Event>)> {
254 match self.live(id) {
255 Some(r) => Some((r.record(), r.events())),
256 None => self.inner.store.get(id),
257 }
258 }
259
260 pub fn live(&self, id: &str) -> Option<Arc<Running>> {
262 self.live_in(&self.line(), id)
263 }
264
265 fn live_in(&self, line: &Line, id: &str) -> Option<Arc<Running>> {
266 line.current
267 .iter()
268 .chain(line.queue.iter().map(|w| &w.job))
269 .find(|r| r.record().id == id && !r.record().status.finished())
270 .cloned()
271 }
272
273 pub fn list(&self, limit: usize) -> Vec<JobRecord> {
274 self.inner.store.list(limit)
275 }
276
277 pub fn submit(
280 &self,
281 kind: &str,
282 key: &str,
283 started_by: Option<String>,
284 request_id: Option<String>,
285 work: impl FnOnce(Arc<Running>) -> BoxFuture<'static, Result<()>> + Send + 'static,
286 ) -> Result<Submitted> {
287 self.submit_resumable(kind, key, started_by, request_id, None, work)
288 }
289
290 pub fn submit_resumable(
292 &self,
293 kind: &str,
294 key: &str,
295 started_by: Option<String>,
296 request_id: Option<String>,
297 request: Option<serde_json::Value>,
298 work: impl FnOnce(Arc<Running>) -> BoxFuture<'static, Result<()>> + Send + 'static,
299 ) -> Result<Submitted> {
300 let line = self.line();
301 anyhow::ensure!(!line.closed, "the server is stopping");
302 if let Some(i) = line
303 .queue
304 .iter()
305 .position(|w| w.key == key && w.job.record().kind == kind)
306 {
307 return Ok(Submitted {
308 job: line.queue[i].job.clone(),
309 position: Some(i + 1),
310 coalesced: true,
311 });
312 }
313 anyhow::ensure!(
314 line.current.is_none() || line.queue.len() < MAX_QUEUED,
315 "{MAX_QUEUED} jobs are already waiting; try again when one has run"
316 );
317 let record = JobRecord {
318 id: uuid::Uuid::new_v4().simple().to_string(),
319 kind: kind.to_string(),
320 started_by,
321 status: Status::Queued,
322 created: now(),
323 started: None,
324 finished: None,
325 error: None,
326 request_id,
327 request,
328 beat: None,
329 };
330 Ok(self.add(line, key, record, Vec::new(), Box::new(work)))
331 }
332
333 pub fn resume(
335 &self,
336 key: &str,
337 (mut job, mut events): (JobRecord, Vec<Event>),
338 work: impl FnOnce(Arc<Running>) -> BoxFuture<'static, Result<()>> + Send + 'static,
339 ) -> Result<Submitted> {
340 let line = self.line();
341 anyhow::ensure!(!line.closed, "the server is stopping");
342 anyhow::ensure!(
343 self.live_in(&line, &job.id).is_none(),
344 "job {} already runs here",
345 job.id
346 );
347 (job.finished, job.error, job.beat) = (None, None, None);
348 events.push(Event::new(
349 crate::events::Level::Info,
350 Kind::Note,
351 "Continues after a restart",
352 ));
353 Ok(self.add(line, key, job, events, Box::new(work)))
354 }
355
356 pub fn resumable(&self) -> Vec<(JobRecord, Vec<Event>)> {
359 let line = self.line();
360 if line.closed {
361 return Vec::new();
362 }
363 let mut jobs: Vec<JobRecord> = self
364 .inner
365 .store
366 .list(MAX_JOBS)
367 .into_iter()
368 .filter(|j| j.request.is_some() && self.live_in(&line, &j.id).is_none())
369 .filter(|j| j.status == Status::Paused || (j.status == Status::Running && !alive(j)))
370 .collect();
371 drop(line);
372 jobs.sort_by(|a, b| a.created.cmp(&b.created));
373 jobs.into_iter()
374 .filter_map(|j| self.inner.store.get(&j.id))
375 .collect()
376 }
377
378 fn add(
380 &self,
381 mut line: std::sync::MutexGuard<'_, Line>,
382 key: &str,
383 mut record: JobRecord,
384 events: Vec<Event>,
385 work: Work,
386 ) -> Submitted {
387 let busy = line.current.is_some();
388 if !busy {
389 record.status = Status::Running;
390 record.started = Some(now());
391 } else {
392 record.status = Status::Queued;
393 }
394 let (id, kind, request_id) = (
395 record.id.clone(),
396 record.kind.clone(),
397 record.request_id.clone(),
398 );
399 let kind = kind.as_str();
400 let request_id = request_id.as_deref();
401 let span = tracing::info_span!(
403 parent: None,
404 "job",
405 otel.name = %format!("job {kind}"),
406 job_id = %id,
407 kind,
408 request_id,
409 status = tracing::field::Empty,
410 otel.status_code = tracing::field::Empty,
411 );
412 span.follows_from(tracing::Span::current());
413 let job = Arc::new(Running {
414 record: Mutex::new(record),
415 events: Mutex::new(events),
416 live: broadcast::channel(256).0,
417 cancel: CancellationToken::new(),
418 done: CancellationToken::new(),
419 out: self.inner.out.clone(),
420 span: Mutex::new(span),
421 });
422 let position = if busy {
423 line.queue.push_back(Waiting {
424 job: job.clone(),
425 key: key.to_string(),
426 work,
427 });
428 drop(line);
429 save(&self.inner.store, &job);
430 Some(self.position(&job.record().id).unwrap_or(1))
431 } else {
432 line.current = Some(job.clone());
433 drop(line);
434 launch(self.inner.clone(), job.clone(), work);
435 None
436 };
437 Submitted {
438 job,
439 position,
440 coalesced: false,
441 }
442 }
443
444 pub fn cancel(&self, id: &str) -> Option<Status> {
447 let mut line = self.line();
448 if let Some(r) = line
449 .current
450 .clone()
451 .filter(|r| r.record().id == id && !r.record().status.finished())
452 {
453 r.cancel.cancel();
454 return Some(Status::Running);
455 }
456 let i = line.queue.iter().position(|w| w.job.record().id == id)?;
457 let w = line.queue.remove(i)?;
458 drop(line);
459 end(&self.inner.store, &w.job, Status::Cancelled, None);
460 Some(Status::Queued)
461 }
462
463 pub fn shutdown(&self) -> Option<Arc<Running>> {
466 let waiting: Vec<Waiting> = {
467 let mut line = self.line();
468 line.closed = true;
469 line.queue.drain(..).collect()
470 };
471 for w in waiting {
472 if w.job.record().request.is_some() {
473 pause(&self.inner.store, &w.job);
474 } else {
475 end(
476 &self.inner.store,
477 &w.job,
478 Status::Interrupted,
479 Some(NOT_RUN.into()),
480 );
481 }
482 }
483 self.running()
484 }
485}
486
487pub const MAX_QUEUED: usize = 5;
489
490const NOT_RUN: &str = "the server stopped before this job ran; start it again";
491
492fn launch(inner: Arc<Inner>, job: Arc<Running>, work: Work) {
494 let span = job.span.lock().unwrap_or_else(|e| e.into_inner()).clone();
495 let run = span.in_scope(|| work(job.clone()));
496 let task = async move {
497 let s = inner.store.clone();
498 beat(&job);
499 save(&s, &job);
500 let ticker = tokio::spawn({
501 let (j, s) = (job.clone(), s.clone());
502 async move {
503 loop {
504 tokio::time::sleep(std::time::Duration::from_secs(15)).await;
505 beat(&j);
506 let (j, s) = (j.clone(), s.clone());
507 let _ = tokio::task::spawn_blocking(move || save(&s, &j)).await;
508 }
509 }
510 });
511 let result = run.await;
512 ticker.abort();
513 let stopping = inner.line.lock().unwrap_or_else(|e| e.into_inner()).closed;
514 let paused = {
515 let mut r = job.record.lock().unwrap_or_else(|e| e.into_inner());
516 r.finished = Some(now());
517 r.status = match &result {
518 Ok(()) => Status::Succeeded,
519 Err(e)
521 if e.is::<crate::context::Cancelled>() && stopping && r.request.is_some() =>
522 {
523 (r.finished, r.beat) = (None, None);
524 Status::Paused
525 }
526 Err(e) if e.is::<crate::context::Cancelled>() => Status::Cancelled,
527 Err(e) => {
528 r.error = Some(crate::storage::explain(&format!("{e:#}")));
529 Status::Failed
530 }
531 };
532 r.status == Status::Paused
533 };
534 if paused {
535 job.emit(Event::new(crate::events::Level::Info, Kind::Note, PAUSED));
536 }
537 close(&job);
538 let saved = job.clone();
539 let _ = tokio::task::spawn_blocking(move || save(&s, &saved)).await;
540 job.done.cancel();
541 next(inner);
542 };
543 tokio::spawn(tracing::Instrument::instrument(task, span));
544}
545
546fn close(job: &Running) {
548 let status = job.record().status;
549 let span = std::mem::replace(
550 &mut *job.span.lock().unwrap_or_else(|e| e.into_inner()),
551 tracing::Span::none(),
552 );
553 span.record("status", format!("{status:?}").to_lowercase());
554 if status == Status::Failed {
555 span.record("otel.status_code", "ERROR");
556 }
557}
558
559fn next(inner: Arc<Inner>) {
561 let mut line = inner.line.lock().unwrap_or_else(|e| e.into_inner());
562 line.current = None;
563 if line.closed {
564 return;
565 }
566 let Some(w) = line.queue.pop_front() else {
567 return;
568 };
569 {
570 let mut r = w.job.record.lock().unwrap_or_else(|e| e.into_inner());
571 r.status = Status::Running;
572 r.started = Some(now());
573 }
574 line.current = Some(w.job.clone());
575 drop(line);
576 launch(inner.clone(), w.job, w.work);
577}
578
579fn pause(store: &Arc<dyn JobStore>, job: &Running) {
581 {
582 let mut r = job.record.lock().unwrap_or_else(|e| e.into_inner());
583 (r.status, r.beat) = (Status::Paused, None);
584 }
585 job.emit(Event::new(crate::events::Level::Info, Kind::Note, PAUSED));
586 close(job);
587 save(store, job);
588 job.done.cancel();
589}
590
591const PAUSED: &str = "Paused: the server is restarting; this continues once it is back";
592
593fn beat(job: &Running) {
594 job.record.lock().unwrap_or_else(|e| e.into_inner()).beat = Some(now());
595}
596
597fn end(store: &Arc<dyn JobStore>, job: &Running, status: Status, error: Option<String>) {
599 {
600 let mut r = job.record.lock().unwrap_or_else(|e| e.into_inner());
601 r.status = status;
602 r.finished = Some(now());
603 r.error = error;
604 }
605 close(job);
606 save(store, job);
607 job.done.cancel();
608}
609
610fn save(store: &Arc<dyn JobStore>, job: &Running) {
611 if let Err(e) = store.save(&job.record(), &job.events()) {
612 crate::ui::warn(&format!("keeping the job record: {e:#}"));
613 }
614}
615
616fn now() -> String {
617 chrono::Utc::now().to_rfc3339()
618}
619
620#[cfg(test)]
621mod tests {
622 use super::*;
623
624 fn jobs(store: Arc<MemoryJobs>) -> Jobs {
625 Jobs::new(store, Arc::new(crate::events::Discard))
626 }
627
628 fn blocking(
630 ran: Arc<Mutex<Vec<String>>>,
631 name: &str,
632 ) -> impl FnOnce(Arc<Running>) -> BoxFuture<'static, Result<()>> + Send + 'static {
633 let name = name.to_string();
634 move |j| {
635 Box::pin(async move {
636 ran.lock().unwrap().push(name);
637 j.cancel.cancelled().await;
638 Err(crate::context::Cancelled.into())
639 })
640 }
641 }
642
643 async fn until(f: impl Fn() -> bool) {
644 for _ in 0..100 {
645 if f() {
646 return;
647 }
648 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
649 }
650 panic!("timed out");
651 }
652
653 fn status(jobs: &Jobs, id: &str) -> Status {
654 jobs.get(id).unwrap().0.status
655 }
656
657 #[tokio::test(flavor = "multi_thread")]
658 async fn jobs_run_in_order_one_at_a_time() {
659 let jobs = jobs(Default::default());
660 let ran = Arc::new(Mutex::new(Vec::new()));
661 let a = jobs
662 .submit("index", "a", None, None, blocking(ran.clone(), "a"))
663 .unwrap();
664 let b = jobs
665 .submit("index", "b", None, None, blocking(ran.clone(), "b"))
666 .unwrap();
667 let c = jobs
668 .submit("index", "c", None, None, blocking(ran.clone(), "c"))
669 .unwrap();
670 assert_eq!(
671 (a.position, b.position, c.position),
672 (None, Some(1), Some(2))
673 );
674 let (a, b, c) = (a.job.record().id, b.job.record().id, c.job.record().id);
675 assert_eq!(status(&jobs, &b), Status::Queued);
676 assert_eq!(
677 jobs.list(10)
678 .iter()
679 .filter(|j| j.status == Status::Queued)
680 .count(),
681 2
682 );
683 until(|| ran.lock().unwrap().len() == 1).await;
684 jobs.cancel(&a);
685 until(|| status(&jobs, &b) == Status::Running).await;
686 assert_eq!(status(&jobs, &a), Status::Cancelled);
687 assert_eq!(jobs.position(&c), Some(1));
688 jobs.cancel(&b);
689 until(|| status(&jobs, &c) == Status::Running).await;
690 jobs.cancel(&c);
691 until(|| jobs.running().is_none()).await;
692 assert_eq!(*ran.lock().unwrap(), ["a", "b", "c"]);
693 }
694
695 #[tokio::test(flavor = "multi_thread")]
696 async fn identical_waiting_requests_coalesce_and_the_queue_is_bounded() {
697 let jobs = jobs(Default::default());
698 let ran = Arc::new(Mutex::new(Vec::new()));
699 let a = jobs
700 .submit("index", "x", None, None, blocking(ran.clone(), "a"))
701 .unwrap();
702 let b = jobs
704 .submit("index", "x", None, None, blocking(ran.clone(), "b"))
705 .unwrap();
706 let again = jobs
707 .submit("index", "x", None, None, blocking(ran.clone(), "b2"))
708 .unwrap();
709 assert!(!b.coalesced && again.coalesced);
710 assert_eq!(again.job.record().id, b.job.record().id);
711 for k in 1..MAX_QUEUED {
712 jobs.submit(
713 "index",
714 &k.to_string(),
715 None,
716 None,
717 blocking(ran.clone(), "k"),
718 )
719 .unwrap();
720 }
721 assert!(jobs
722 .submit("index", "full", None, None, blocking(ran.clone(), "f"))
723 .is_err());
724 assert_eq!(jobs.queued().len(), MAX_QUEUED);
725 jobs.shutdown();
726 jobs.cancel(&a.job.record().id);
727 until(|| jobs.running().is_none()).await;
728 let b = jobs.get(&b.job.record().id).unwrap().0;
729 assert_eq!(b.status, Status::Interrupted);
730 assert_eq!(*ran.lock().unwrap(), ["a"]);
731 }
732
733 #[tokio::test(flavor = "multi_thread")]
734 async fn cancelling_a_queued_job_never_starts_it() {
735 let jobs = jobs(Default::default());
736 let ran = Arc::new(Mutex::new(Vec::new()));
737 let a = jobs
738 .submit("index", "a", None, None, blocking(ran.clone(), "a"))
739 .unwrap();
740 let b = jobs
741 .submit("index", "b", None, None, blocking(ran.clone(), "b"))
742 .unwrap();
743 let (a, b) = (a.job.record().id, b.job.record().id);
744 assert_eq!(jobs.cancel(&b), Some(Status::Queued));
745 assert_eq!(status(&jobs, &b), Status::Cancelled);
746 assert!(jobs.live(&b).is_none() && jobs.queued().is_empty());
747 assert_eq!(jobs.cancel(&a), Some(Status::Running));
748 until(|| jobs.running().is_none()).await;
749 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
750 assert_eq!(*ran.lock().unwrap(), ["a"]);
751 assert_eq!(jobs.cancel(&a), None);
752 }
753
754 #[test]
755 fn records_from_before_request_ids_still_read() {
756 let old = r#"{"id":"a","kind":"index","started_by":null,"status":"succeeded",
757 "created":"2026-01-01T00:00:00Z","finished":null,"error":null}"#;
758 let rec: JobRecord = serde_json::from_str(old).unwrap();
759 assert_eq!(rec.request_id, None);
760 assert!(!serde_json::to_string(&rec).unwrap().contains("request_id"));
761 }
762
763 #[tokio::test(flavor = "multi_thread")]
764 async fn a_job_keeps_the_request_that_started_it() {
765 let jobs = Jobs::new(
766 Arc::new(MemoryJobs::default()),
767 Arc::new(crate::events::Discard),
768 );
769 let s = jobs
770 .submit("index", "k", None, Some("req-1".into()), |_| {
771 Box::pin(async { Ok(()) })
772 })
773 .unwrap();
774 s.job.wait().await;
775 assert_eq!(s.job.record().request_id.as_deref(), Some("req-1"));
776 }
777
778 #[test]
779 fn jobs_left_running_or_queued_are_interrupted() {
780 let store = Arc::new(MemoryJobs::default());
781 for (id, status) in [("a", Status::Running), ("b", Status::Queued)] {
782 let rec = JobRecord {
783 id: id.into(),
784 kind: "index".into(),
785 started_by: None,
786 status,
787 created: now(),
788 started: None,
789 finished: None,
790 error: None,
791 request_id: None,
792 request: None,
793 beat: None,
794 };
795 store.save(&rec, &[]).unwrap();
796 }
797 let jobs = jobs(store);
798 let (a, b) = (jobs.get("a").unwrap().0, jobs.get("b").unwrap().0);
799 assert_eq!(
800 (a.status, b.status),
801 (Status::Interrupted, Status::Interrupted)
802 );
803 assert!(a.error.is_none() && b.error.as_deref() == Some(NOT_RUN));
804 assert!(jobs.queued().is_empty() && jobs.running().is_none());
805 }
806
807 fn left(id: &str, status: Status, beat: Option<String>) -> JobRecord {
808 JobRecord {
809 id: id.into(),
810 kind: "index".into(),
811 started_by: None,
812 status,
813 created: format!("2026-01-01T00:00:0{}Z", id.len()),
814 started: None,
815 finished: None,
816 error: None,
817 request_id: None,
818 request: Some(serde_json::json!({ "llm": true })),
819 beat,
820 }
821 }
822
823 #[tokio::test(flavor = "multi_thread")]
824 async fn a_restart_pauses_jobs_that_can_continue_and_the_next_server_resumes_them() {
825 let store = Arc::new(MemoryJobs::default());
826 let jobs = jobs(store.clone());
827 let ran = Arc::new(Mutex::new(Vec::new()));
828 let request = Some(serde_json::json!({ "llm": true }));
829 let a = jobs
830 .submit_resumable(
831 "index",
832 "a",
833 None,
834 None,
835 request.clone(),
836 blocking(ran.clone(), "a"),
837 )
838 .unwrap();
839 let b = jobs
840 .submit_resumable(
841 "index",
842 "b",
843 None,
844 None,
845 request,
846 blocking(ran.clone(), "b"),
847 )
848 .unwrap();
849 let plain = jobs
850 .submit("index", "c", None, None, blocking(ran.clone(), "c"))
851 .unwrap();
852 until(|| ran.lock().unwrap().len() == 1).await;
853 let (a, b, plain) = (a.job.record().id, b.job.record().id, plain.job.record().id);
854 if let Some(r) = jobs.shutdown() {
855 r.cancel.cancel();
856 r.wait().await;
857 }
858 assert_eq!(
859 (status(&jobs, &a), status(&jobs, &b), status(&jobs, &plain)),
860 (Status::Paused, Status::Paused, Status::Interrupted)
861 );
862 assert!(jobs.get(&a).unwrap().1.iter().any(|e| e.message == PAUSED));
863 assert!(
864 jobs.resumable().is_empty(),
865 "a stopping server continues nothing"
866 );
867
868 let next = Jobs::new(store, Arc::new(crate::events::Discard));
869 let waiting: Vec<String> = next.resumable().iter().map(|(j, _)| j.id.clone()).collect();
870 assert_eq!(waiting, [a.clone(), b.clone()]);
871 let again = Arc::new(Mutex::new(Vec::new()));
872 for job in next.resumable() {
873 let name = job.0.id.clone();
874 next.resume("x", job, blocking(again.clone(), &name))
875 .unwrap();
876 }
877 assert!(
878 next.resumable().is_empty(),
879 "a resumed job is not offered twice"
880 );
881 until(|| again.lock().unwrap().len() == 1).await;
882 assert_eq!(status(&next, &a), Status::Running);
883 assert_eq!(status(&next, &b), Status::Queued);
884 assert!(next
885 .get(&a)
886 .unwrap()
887 .1
888 .iter()
889 .any(|e| e.message == "Continues after a restart"));
890 next.cancel(&a);
891 until(|| status(&next, &b) == Status::Running).await;
892 next.cancel(&b);
893 until(|| next.running().is_none()).await;
894 assert_eq!(*again.lock().unwrap(), [a, b]);
895 }
896
897 #[test]
898 fn a_starting_server_leaves_another_servers_live_job_alone() {
899 let store = Arc::new(MemoryJobs::default());
900 store
901 .save(&left("a", Status::Running, Some(now())), &[])
902 .unwrap();
903 store.save(&left("bb", Status::Queued, None), &[]).unwrap();
904 let jobs = jobs(store);
905 assert_eq!(
906 (status(&jobs, "a"), status(&jobs, "bb")),
907 (Status::Running, Status::Queued)
908 );
909 assert!(jobs.resumable().is_empty(), "its server is still alive");
910 }
911
912 #[test]
913 fn jobs_of_a_server_that_went_quiet_are_paused_and_offered() {
914 let store = Arc::new(MemoryJobs::default());
915 let old = (chrono::Utc::now() - chrono::Duration::seconds(STALE_SECONDS + 5)).to_rfc3339();
916 store
917 .save(&left("a", Status::Running, Some(old)), &[])
918 .unwrap();
919 store.save(&left("bb", Status::Queued, None), &[]).unwrap();
920 let jobs = jobs(store);
921 assert_eq!(
922 (status(&jobs, "a"), status(&jobs, "bb")),
923 (Status::Paused, Status::Paused)
924 );
925 let ids: Vec<String> = jobs.resumable().into_iter().map(|(j, _)| j.id).collect();
926 assert_eq!(ids, ["a", "bb"]);
927 }
928}