Skip to main content

kernel/jobs/
history.rs

1//! The terminal-job history: a `jobs.json` store of concluded jobs, newest
2//! first, trimmed to a limit. Only terminal jobs are ever recorded.
3
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8use crate::persistence::{self, StoreError};
9
10use super::job::Job;
11
12const STORE_FILE: &str = "jobs.json";
13const SCHEMA_VERSION: u32 = 1;
14const DEFAULT_LIMIT: usize = 50;
15
16#[derive(Serialize, Deserialize)]
17struct Envelope {
18    schema_version: u32,
19    jobs: Vec<Job>,
20}
21
22/// A bounded, newest-first store of concluded jobs. Lazily loaded; a corrupt
23/// `jobs.json` is quarantined and the store starts empty rather than failing.
24pub struct JobHistoryStore {
25    directory: PathBuf,
26    limit: usize,
27    jobs: Vec<Job>,
28    loaded: bool,
29}
30
31impl JobHistoryStore {
32    /// A store rooted at `directory`, keeping at most `limit` jobs (floored at 1).
33    pub fn new(directory: &Path, limit: usize) -> Self {
34        Self {
35            directory: directory.to_path_buf(),
36            limit: limit.max(1),
37            jobs: Vec::new(),
38            loaded: false,
39        }
40    }
41
42    /// A store with the default limit of 50 jobs.
43    pub fn with_default_limit(directory: &Path) -> Self {
44        Self::new(directory, DEFAULT_LIMIT)
45    }
46
47    /// The current retention limit.
48    pub fn limit(&self) -> usize {
49        self.limit
50    }
51
52    /// Set the retention limit (floored at 1). Takes effect on the next `record`.
53    pub fn set_limit(&mut self, limit: usize) {
54        self.limit = limit.max(1);
55    }
56
57    /// Record a concluded `job`, replacing any prior entry with the same id,
58    /// re-sorting newest-first, and trimming to the limit.
59    pub fn record(&mut self, job: Job) -> Result<(), StoreError> {
60        self.load_if_needed();
61        self.jobs.retain(|existing| existing.id != job.id);
62        self.jobs.push(job);
63        self.jobs
64            .sort_by(|a, b| (b.submitted_at, &b.id).cmp(&(a.submitted_at, &a.id)));
65        self.jobs.truncate(self.limit);
66        self.save()
67    }
68
69    /// Every recorded job, newest first.
70    pub fn list(&mut self) -> &[Job] {
71        self.load_if_needed();
72        &self.jobs
73    }
74
75    /// The recorded job with `id`, if any.
76    pub fn get(&mut self, id: &str) -> Option<Job> {
77        self.load_if_needed();
78        self.jobs.iter().find(|job| job.id == id).cloned()
79    }
80
81    fn store_file(&self) -> PathBuf {
82        self.directory.join(STORE_FILE)
83    }
84
85    fn load_if_needed(&mut self) {
86        if self.loaded {
87            return;
88        }
89        self.loaded = true;
90        // A missing store, a corrupt one (quarantined inside read_json), or an io
91        // error all leave the store empty (best-effort load).
92        if let Ok(Some(envelope)) = persistence::read_json::<Envelope>(&self.store_file()) {
93            self.jobs = envelope.jobs;
94        }
95    }
96
97    fn save(&self) -> Result<(), StoreError> {
98        let envelope = Envelope {
99            schema_version: SCHEMA_VERSION,
100            jobs: self.jobs.clone(),
101        };
102        persistence::write_json_atomic(&self.store_file(), &envelope)
103    }
104}