Skip to main content

comindly_engine/
jobs.rs

1//! Work the server runs in the background, indexing for now: jobs run one at a time in order, each
2//! with a record, a status and its events kept in a [`JobStore`]; followers get them live.
3
4use 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
16/// A running job without a sign of life for this long belongs to a server that is gone.
17const STALE_SECONDS: i64 = 60;
18
19/// Running, and its server wrote a sign of life recently.
20fn 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
32/// Events kept per job.
33pub const MAX_EVENTS: usize = 400;
34/// Jobs kept in the store.
35pub const MAX_JOBS: usize = 50;
36
37/// Job records and their events.
38pub trait JobStore: Send + Sync {
39    fn save(&self, job: &JobRecord, events: &[Event]) -> Result<()>;
40    fn get(&self, id: &str) -> Option<(JobRecord, Vec<Event>)>;
41    /// Most recent first.
42    fn list(&self, limit: usize) -> Vec<JobRecord>;
43}
44
45/// Jobs kept in memory, for the local app.
46#[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
71/// A job waiting or in progress: its record, its events so far and the stream of new ones.
72pub struct Running {
73    record: Mutex<JobRecord>,
74    events: Mutex<Vec<Event>>,
75    live: broadcast::Sender<Event>,
76    pub cancel: CancellationToken,
77    /// Set once the job has ended and its record is saved.
78    done: CancellationToken,
79    /// Where events also go: the terminal or the server's log.
80    out: Arc<dyn EventSink>,
81    /// The job's span, for its log lines and trace; closed when the job ends.
82    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    /// Wait until the job has ended and its record is saved.
101    pub async fn wait(&self) {
102        self.done.cancelled().await
103    }
104
105    /// The events so far and the ones to come.
106    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        // Progress replaces the previous progress of the same step, so the record stays short.
117        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
138/// What a job runs, built when it starts.
139type Work = Box<dyn FnOnce(Arc<Running>) -> BoxFuture<'static, Result<()>> + Send>;
140
141/// A job waiting its turn; `key` says which requests are the same.
142struct Waiting {
143    job: Arc<Running>,
144    key: String,
145    work: Work,
146}
147
148#[derive(Default)]
149struct Line {
150    /// The job that runs, until the next one takes its place.
151    current: Option<Arc<Running>>,
152    queue: VecDeque<Waiting>,
153    /// Set when the server stops: nothing new starts.
154    closed: bool,
155}
156
157struct Inner {
158    store: Arc<dyn JobStore>,
159    out: Arc<dyn EventSink>,
160    line: Mutex<Line>,
161}
162
163/// What [`Jobs::submit`] did with a request.
164pub struct Submitted {
165    pub job: Arc<Running>,
166    /// Place in the queue, 1 for the next to run; `None` when it runs now.
167    pub position: Option<usize>,
168    /// An identical request was already waiting; this is that job.
169    pub coalesced: bool,
170}
171
172/// Runs jobs one at a time in the order they came, and keeps their records.
173pub struct Jobs {
174    inner: Arc<Inner>,
175}
176
177impl Jobs {
178    /// Jobs a stopped server left are paused when they can continue, else interrupted; while
179    /// another server still runs one (a deploy), they are its to pause.
180    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    /// The running job, if any.
217    pub fn running(&self) -> Option<Arc<Running>> {
218        self.line()
219            .current
220            .clone()
221            .filter(|r| !r.record().status.finished())
222    }
223
224    /// Waiting jobs, the next to run first.
225    pub fn queued(&self) -> Vec<JobRecord> {
226        self.line().queue.iter().map(|w| w.job.record()).collect()
227    }
228
229    /// Place of job `id` in the queue, 1 for the next to run.
230    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    /// The most recent job that ran: the running one, else the last kept that is not waiting.
239    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    /// The running or waiting job `id`, to follow its events.
261    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    /// Run `work` as a job of `kind` now, or queue it while another runs. A request with the
278    /// same `kind` and `key` as a waiting one returns that job instead of queueing another.
279    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    /// [`Jobs::submit`], keeping `request` so a restart can continue the job.
291    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    /// Continue `job`, which a restart paused, under its own id: it runs now or waits its turn.
334    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    /// Jobs a restart paused, and running ones whose server went quiet, oldest first: the ones
357    /// this server should continue.
358    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    /// Queue `record` or start it now; the line is held so nothing slips in between.
379    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        // Its own trace, linked to the request that started it.
402        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    /// Cancel job `id`: the running one stops at its next check, a waiting one leaves the queue.
445    /// Returns the status it had, `None` when it is neither.
446    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    /// Stop starting jobs; the waiting ones are paused, or interrupted when they cannot continue.
464    /// Returns the running one, which pauses too when it stops at its next check.
465    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
487/// Jobs that wait while one runs.
488pub const MAX_QUEUED: usize = 5;
489
490const NOT_RUN: &str = "the server stopped before this job ran; start it again";
491
492/// Run `job` in the background; when it ends, the next waiting job starts.
493fn 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                // Stopped by the server going down: it continues after the restart.
520                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
546/// Record how the job ended on its span and close it.
547fn 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
559/// Start the next waiting job, if any.
560fn 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
579/// Pause a waiting job until a server is back.
580fn 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
597/// Finish a job that never ran.
598fn 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    /// A job that waits for its cancel token, then logs `name` into `ran`.
629    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        // The running job does not absorb a new request: its input may already be stale.
703        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}