Skip to main content

runnerd_core/
store.rs

1//! The job history: a SQLite database of jobs, their runners, steps, samples and logs, the events clients resume
2//! from, cache sizes and the paused repositories. Owns the schema and its migrations, the one writer and the event
3//! broadcast. Writes are messages to a writer thread, so the engine never waits on the disk; reads and pruning run
4//! on Tokio's blocking pool, pruning on its own connection, a batch at a time. It decides nothing about scheduling
5//! and stores no secret: no token, no JIT config, no runner `_diag` log.
6
7use std::collections::BTreeMap;
8use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
9use std::path::{Path, PathBuf};
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::{Arc, Mutex, TryLockError};
12use std::time::Duration;
13
14use rusqlite::{Connection, OpenFlags};
15use tokio::sync::{OwnedSemaphorePermit, Semaphore, broadcast, mpsc, oneshot};
16use tracing::warn;
17
18use crate::api::{self, Event, EventEnvelope};
19use crate::config::History;
20use crate::github::{JobDetails, QueuedJob};
21use crate::sizing;
22use crate::sync::lock;
23
24mod prune;
25mod read;
26mod write;
27
28/// The schema's migrations, in order; the database's `user_version` is how many ran.
29const MIGRATIONS: &[&str] = &[
30    include_str!("store/migrations/0001_history.sql"),
31    include_str!("store/migrations/0002_runner_caps.sql"),
32    include_str!("store/migrations/0003_paused.sql"),
33    include_str!("store/migrations/0004_prune.sql"),
34    include_str!("store/migrations/0005_announced_profiles.sql"),
35    include_str!("store/migrations/0006_records.sql"),
36    include_str!("store/migrations/0007_admission.sql"),
37];
38
39/// Bytes of log lines sent and not yet stored, at most: a job's capture waits beyond it, so a noisy job or a slow
40/// disk holds back that job's log, not the daemon's memory.
41pub(crate) const LOG_BACKLOG: u64 = 8 << 20;
42
43/// Host samples the writer holds before it stores them in one transaction: a minute's worth. One transaction per
44/// sample wrote about 300 MB a day to an idle daemon's WAL. A crash loses the samples held.
45const HOST_SAMPLE_BATCH: usize = 30;
46
47/// How many events a slow client may fall behind before its stream ends; it reconnects with `Last-Event-ID`.
48const EVENT_BUFFER: usize = 4096;
49
50/// Why the database cannot be used.
51#[derive(Debug, thiserror::Error)]
52pub enum StoreError {
53    /// SQLite failed.
54    #[error("{0}")]
55    Sqlite(#[from] rusqlite::Error),
56    /// A newer runnerd wrote the database.
57    #[error(
58        "the job history database is version {found}, newer than the {supported} this runnerd reads; \
59         run a newer runnerd or move the database aside"
60    )]
61    Newer {
62        /// The database's version.
63        found: u32,
64        /// The newest version this build knows.
65        supported: u32,
66    },
67    /// The directory or file cannot be created.
68    #[error("{0}")]
69    Io(#[from] std::io::Error),
70    /// A blocking read did not finish.
71    #[error("a database read stopped: {0}")]
72    Join(#[from] tokio::task::JoinError),
73    /// The database's `-wal` file holds changes not yet in it, and this user cannot open the database the way that
74    /// reads them.
75    #[error(
76        "{} holds changes not yet in the job history, which this user cannot read; run runnerd doctor as the user \
77         the daemon runs as",
78        .0.display()
79    )]
80    Unread(PathBuf),
81}
82
83/// A runner container as the history records it.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct RunnerRecord {
86    /// The runner's name, also its container's.
87    pub name: String,
88    /// `owner/name`.
89    pub repository: String,
90    /// The profile it runs in.
91    pub profile: String,
92    /// 1-based.
93    pub slot: usize,
94    /// The Docker container ID.
95    pub container: String,
96    /// When the container started, in milliseconds since the epoch.
97    pub started_at: u64,
98    /// The container's memory cap in bytes.
99    pub memory_cap: Option<u64>,
100    /// The container's CPU limit in millicores.
101    pub cpu_cap: Option<u64>,
102    /// How it was admitted; `None` for one a restart adopted.
103    pub admission: Option<api::Admission>,
104}
105
106/// One sample of one runner's container.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct SampleRecord {
109    /// The runner.
110    pub runner: String,
111    /// When, in milliseconds since the epoch.
112    pub at: u64,
113    /// Millicores over the interval before it.
114    pub cpu: u64,
115    /// CPU time so far, in microseconds.
116    pub cpu_usage: u64,
117    /// Bytes without page cache.
118    pub memory: u64,
119    /// Bytes received since the container started.
120    pub network_received: u64,
121    /// Bytes sent since the container started.
122    pub network_sent: u64,
123    /// Processes the kernel killed in the container for memory so far.
124    pub oom_kills: u64,
125}
126
127/// The last measurement of the cache volumes.
128#[derive(Debug, Clone, Default, PartialEq, Eq)]
129pub struct Measured {
130    /// When it was taken (seconds); none before the first measurement.
131    pub at: Option<u64>,
132    /// Each volume's size in bytes, by name.
133    pub bytes: BTreeMap<String, u64>,
134}
135
136/// The profiles and repositories as the stored events last announced them: what clients resuming after those
137/// events have.
138#[derive(Debug, Clone, Default, PartialEq, Eq)]
139pub struct Announced {
140    /// The last `profile.changed` of each profile not removed since, by name.
141    pub profiles: Vec<api::ProfileStatus>,
142    /// The last `repository.changed` of each repository not removed since, by name.
143    pub repositories: Vec<api::RepositoryStatus>,
144}
145
146/// A job the history has not closed, as the engine needs it after a restart.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct OpenJob {
149    /// GitHub's job ID.
150    pub id: u64,
151    /// `owner/name`.
152    pub repository: String,
153    /// `Queued` or `Running`.
154    pub state: api::JobState,
155    /// Its runner, once one took it.
156    pub runner: Option<String>,
157    /// When its runner was removed, in milliseconds since the epoch.
158    pub runner_removed_at: Option<u64>,
159}
160
161/// Where a job's log capture continues: the runner's page file and the byte offset in it.
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub struct LogPosition {
164    /// The page number, from 1.
165    pub page: u64,
166    /// Bytes of that page already stored as lines.
167    pub offset: u64,
168}
169
170/// How far the mirror's log was counted: the time of the last response counted, in nanoseconds since the epoch as
171/// Docker logs it, and how many responses logged at exactly that time were counted, so the next read counts the
172/// ones it was too early to see.
173#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
174pub struct MirrorPosition {
175    /// Nanoseconds since the epoch.
176    pub at: u64,
177    /// Responses logged at `at` already counted.
178    pub seen: u64,
179}
180
181/// What the writer thread is asked to do, in order.
182#[derive(Debug)]
183enum Write {
184    Queued {
185        repository: String,
186        job: QueuedJob,
187    },
188    RunnerStarted(RunnerRecord),
189    RunnerAdmission {
190        runner: String,
191        admission: api::Admission,
192    },
193    RunnerOomKilled(String),
194    RunnerRemoved {
195        runner: String,
196        at: u64,
197    },
198    RunnersGone {
199        running: Vec<String>,
200        at: u64,
201    },
202    Claimed {
203        job: u64,
204        run_id: u64,
205        runner: String,
206        at: u64,
207    },
208    Details {
209        details: JobDetails,
210    },
211    Elsewhere {
212        details: JobDetails,
213    },
214    Lost {
215        job: u64,
216        at: u64,
217    },
218    Log {
219        job: u64,
220        lines: Vec<String>,
221        /// Where the capture resumes once these lines are stored; `None` for all but the last part of a batch.
222        position: Option<LogPosition>,
223        max_bytes: u64,
224        /// The lines' share of `LOG_BACKLOG`, given back once they are stored.
225        _backlog: OwnedSemaphorePermit,
226    },
227    Samples(Vec<SampleRecord>),
228    HostSample(api::HostSample),
229    MirrorBytes {
230        bytes: Vec<(String, u64)>,
231        until: MirrorPosition,
232        /// Whether the count committed.
233        done: oneshot::Sender<bool>,
234    },
235    CacheSizes {
236        at: u64,
237        sizes: BTreeMap<String, u64>,
238    },
239    Event(Event),
240    RecordMade(api::Record),
241    RecordSkipped {
242        job: u64,
243        why: String,
244    },
245    RecordRemoved {
246        job: u64,
247        at: u64,
248        by: api::RecordRemoval,
249    },
250    Paused {
251        repositories: Vec<String>,
252        /// Whether it committed.
253        done: oneshot::Sender<bool>,
254    },
255    Flush(oneshot::Sender<()>),
256}
257
258/// The job history. Cloning shares it.
259#[derive(Debug, Clone)]
260pub struct Store {
261    writes: mpsc::UnboundedSender<Write>,
262    reader: Arc<Mutex<Connection>>,
263    /// Taken with `try_lock`, so one prune runs at a time; one that panicked midway left rows the next one deletes.
264    pruner: Arc<Mutex<Connection>>,
265    /// Set by `close`: a running prune stops after its batch, and none starts.
266    closed: Arc<AtomicBool>,
267    /// Host samples sent and not yet stored, oldest first; reads include them. The writer removes them once stored.
268    // Locked with `sync::lock`: every critical section is a push, a copy or a drain.
269    held: Arc<Mutex<Vec<api::HostSample>>>,
270    /// The last host sample sent, for admission to read without a query.
271    latest_host: Arc<Mutex<Option<api::HostSample>>>,
272    /// Each running runner's last sample, for admission to read without a query.
273    latest_samples: Arc<Mutex<BTreeMap<String, SampleRecord>>>,
274    events: broadcast::Sender<Arc<EventEnvelope>>,
275    backlog: Arc<Semaphore>,
276}
277
278impl Store {
279    /// Opens the database at `path`, creating it and its directory (mode 0700) if missing, and migrates it to the
280    /// latest version. Refuses a database a newer runnerd wrote.
281    pub fn open(path: &Path) -> Result<Store, StoreError> {
282        if let Some(dir) = path.parent().filter(|dir| !dir.as_os_str().is_empty()) {
283            std::fs::DirBuilder::new()
284                .recursive(true)
285                .mode(0o700)
286                .create(dir)?;
287        }
288        // Owner-only, as SQLite then makes its `-wal` and `-shm` files: logs may say more than the owner wants shared.
289        std::fs::OpenOptions::new()
290            .create(true)
291            .append(true)
292            .mode(0o600)
293            .open(path)?;
294        let mut db = Connection::open(path)?;
295        configure(&db)?;
296        migrate(&mut db)?;
297        let reader = Connection::open_with_flags(
298            path,
299            OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
300        )?;
301        configure(&reader)?;
302        let pruner = Connection::open(path)?;
303        configure(&pruner)?;
304        let (events, _) = broadcast::channel(EVENT_BUFFER);
305        let (writes, receiver) = mpsc::unbounded_channel();
306        let thread_events = events.clone();
307        let held = Arc::new(Mutex::new(Vec::new()));
308        let thread_held = held.clone();
309        std::thread::Builder::new()
310            .name("runnerd-store".to_owned())
311            .spawn(move || write::run(db, receiver, &thread_events, &thread_held))?;
312        Ok(Store {
313            writes,
314            reader: Arc::new(Mutex::new(reader)),
315            pruner: Arc::new(Mutex::new(pruner)),
316            closed: Arc::new(AtomicBool::new(false)),
317            held,
318            latest_host: Arc::new(Mutex::new(None)),
319            latest_samples: Arc::new(Mutex::new(BTreeMap::new())),
320            events,
321            backlog: Arc::new(Semaphore::new(
322                usize::try_from(LOG_BACKLOG).unwrap_or(usize::MAX),
323            )),
324        })
325    }
326
327    fn send(&self, write: Write) {
328        // Fails only once the writer thread is gone, after a SQLite error it logged.
329        let _ = self.writes.send(write);
330    }
331
332    /// Records a queued job the repository's runners may run; announces it the first time.
333    pub fn queued(&self, repository: &str, job: &QueuedJob) {
334        self.send(Write::Queued {
335            repository: repository.to_owned(),
336            job: job.clone(),
337        });
338    }
339
340    /// Records a runner container that started, or one an earlier daemon started and this one adopted.
341    pub fn runner_started(&self, runner: RunnerRecord) {
342        self.send(Write::RunnerStarted(runner));
343    }
344
345    /// Records that a runner's container and registration were removed at `at` (ms).
346    pub fn runner_removed(&self, runner: &str, at: u64) {
347        lock(&self.latest_samples).remove(runner);
348        self.send(Write::RunnerRemoved {
349            runner: runner.to_owned(),
350            at,
351        });
352    }
353
354    /// Records every runner the history holds open, except the `running` ones, removed at `at` (ms): what recovery
355    /// found gone, so it stops counting as holding a slot.
356    pub fn runners_gone(&self, running: Vec<String>, at: u64) {
357        lock(&self.latest_samples).retain(|runner, _| running.contains(runner));
358        self.send(Write::RunnersGone { running, at });
359    }
360
361    /// Records that a runner took a job at `at` (ms).
362    pub fn claimed(&self, job: u64, run_id: u64, runner: &str, at: u64) {
363        self.send(Write::Claimed {
364            job,
365            run_id,
366            runner: runner.to_owned(),
367            at,
368        });
369    }
370
371    /// Records a running job's progress and steps as GitHub reports them; announces changed steps.
372    pub fn details(&self, details: JobDetails) {
373        self.send(Write::Details { details });
374    }
375
376    /// Closes a queued job that left the queue without a runner of this daemon.
377    pub fn elsewhere(&self, details: JobDetails) {
378        self.send(Write::Elsewhere { details });
379    }
380
381    /// Closes a job whose container is gone and that GitHub did not report completed in time.
382    pub fn lost(&self, job: u64, at: u64) {
383        self.send(Write::Lost { job, at });
384    }
385
386    /// Appends log lines to a job, up to `max_bytes` per job, and records where the capture continues. Waits while
387    /// `LOG_BACKLOG` bytes of lines are waiting to be stored; a larger batch goes in parts, each waiting its turn.
388    pub async fn log(&self, job: u64, lines: Vec<String>, position: LogPosition, max_bytes: u64) {
389        let size = |line: &String| line.len() as u64 + 1;
390        let mut lines = lines.into_iter().map(|mut line| {
391            // No line takes the whole backlog; the capture already cuts lines far shorter.
392            if size(&line) > LOG_BACKLOG {
393                let mut end = usize::try_from(LOG_BACKLOG / 2).unwrap_or(usize::MAX);
394                while !line.is_char_boundary(end) {
395                    end -= 1;
396                }
397                line.truncate(end);
398            }
399            line
400        });
401        let mut next = lines.next();
402        loop {
403            let (mut part, mut bytes) = (Vec::new(), 0);
404            while let Some(line) = next.take_if(|line| bytes + size(line) <= LOG_BACKLOG) {
405                bytes += size(&line);
406                part.push(line);
407                next = lines.next();
408            }
409            let last = next.is_none();
410            let share = u32::try_from(bytes.max(1)).unwrap_or(u32::MAX);
411            // The semaphore is never closed.
412            let Ok(backlog) = self.backlog.clone().acquire_many_owned(share).await else {
413                return;
414            };
415            self.send(Write::Log {
416                job,
417                lines: part,
418                position: last.then_some(position),
419                max_bytes,
420                _backlog: backlog,
421            });
422            if last {
423                return;
424            }
425        }
426    }
427
428    /// Records samples of runner containers.
429    pub fn samples(&self, samples: Vec<SampleRecord>) {
430        let mut latest = lock(&self.latest_samples);
431        for sample in &samples {
432            latest.insert(sample.runner.clone(), sample.clone());
433        }
434        drop(latest);
435        self.send(Write::Samples(samples));
436    }
437
438    /// Records a sample of the slice and sends it to every subscriber at once; it is stored with the next ones, a
439    /// minute's worth in one transaction, or at `flush`.
440    pub fn host_sample(&self, sample: api::HostSample) {
441        *lock(&self.latest_host) = Some(sample.clone());
442        lock(&self.held).push(sample.clone());
443        self.send(Write::HostSample(sample));
444    }
445
446    /// A runner's last sample, stored or not; `None` before its first sample or once it is removed.
447    pub fn latest_sample(&self, runner: &str) -> Option<SampleRecord> {
448        lock(&self.latest_samples).get(runner).cloned()
449    }
450
451    /// Records what a runner reserves now, when measured admission changed it after the start.
452    pub fn runner_admission(&self, runner: &str, admission: api::Admission) {
453        self.send(Write::RunnerAdmission {
454            runner: runner.to_owned(),
455            admission,
456        });
457    }
458
459    /// Records that Docker saw the kernel kill a process in a runner's container for memory, which its samples may
460    /// have missed: one in the last interval before the container stopped.
461    pub fn runner_oom_killed(&self, runner: &str) {
462        self.send(Write::RunnerOomKilled(runner.to_owned()));
463    }
464
465    /// The last host sample sent, stored or not; `None` before the first.
466    pub fn latest_host_sample(&self) -> Option<api::HostSample> {
467        lock(&self.latest_host).clone()
468    }
469
470    /// Adds bytes the mirror served to runners, counted from its log up to `until`, and says whether that was
471    /// stored: if not, the caller counts the same lines again.
472    pub async fn mirror_bytes(&self, bytes: Vec<(String, u64)>, until: MirrorPosition) -> bool {
473        let (done, committed) = oneshot::channel();
474        self.send(Write::MirrorBytes { bytes, until, done });
475        committed.await.unwrap_or(false)
476    }
477
478    /// Records volume sizes in bytes, by name, measured at `at` (ms).
479    pub fn cache_sizes(&self, at: u64, sizes: BTreeMap<String, u64>) {
480        self.send(Write::CacheSizes { at, sizes });
481    }
482
483    /// Stores a durable event, or sends a live one, to every subscriber.
484    pub fn event(&self, event: Event) {
485        self.send(Write::Event(event));
486    }
487
488    /// Records that a failed job's runner was kept as `record`, and announces it.
489    pub fn record_made(&self, record: api::Record) {
490        self.send(Write::RecordMade(record));
491    }
492
493    /// Records why a failed job's runner was not kept.
494    pub fn record_skipped(&self, job: u64, why: String) {
495        self.send(Write::RecordSkipped { job, why });
496    }
497
498    /// Records that a job's record was removed at `at` (ms), and why, and announces it.
499    pub fn record_removed(&self, job: u64, at: u64, by: api::RecordRemoval) {
500        self.send(Write::RecordRemoved { job, at, by });
501    }
502
503    /// Records the paused repositories, all of them, in place of the ones recorded before. The write is sent at
504    /// once, in order with every other; the future says whether it committed.
505    pub fn paused(&self, repositories: Vec<String>) -> impl Future<Output = bool> + use<> {
506        let (done, committed) = oneshot::channel();
507        self.send(Write::Paused { repositories, done });
508        async move { committed.await.unwrap_or(false) }
509    }
510
511    /// Deletes what is older than `history` keeps, at `now` (ms), off the writer, in batches the writer's
512    /// transactions fit between. A prune while the last one runs does nothing. The handle finishes with it; dropping
513    /// it lets it run.
514    pub fn prune(&self, now: u64, history: &History) -> tokio::task::JoinHandle<()> {
515        let pruner = self.pruner.clone();
516        let closed = self.closed.clone();
517        let history = history.clone();
518        tokio::task::spawn_blocking(move || {
519            let db = match pruner.try_lock() {
520                Ok(db) => db,
521                Err(TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
522                Err(TryLockError::WouldBlock) => return,
523            };
524            let db = prune::Db {
525                connection: &db,
526                stop: &closed,
527            };
528            if !db.stop.load(Ordering::Relaxed) {
529                prune::run(&db, now, &history);
530            }
531        })
532    }
533
534    /// Stops a running prune after its batch, and any later one before it starts, so a shutdown does not wait for
535    /// one. What a stopped prune left, the next daemon's deletes. Writes go on as before.
536    pub fn close(&self) {
537        self.closed.store(true, Ordering::Relaxed);
538    }
539
540    /// Waits until every write sent before is done.
541    pub async fn flush(&self) {
542        let (done, wait) = oneshot::channel();
543        self.send(Write::Flush(done));
544        let _ = wait.await;
545    }
546
547    /// Every event from now on, durable or live. Subscribe before reading what was stored, then skip what the read
548    /// returned, and nothing is missed or sent twice.
549    pub fn subscribe(&self) -> broadcast::Receiver<Arc<EventEnvelope>> {
550        self.events.subscribe()
551    }
552
553    async fn read<T: Send + 'static>(
554        &self,
555        query: impl FnOnce(&Connection) -> rusqlite::Result<T> + Send + 'static,
556    ) -> Result<T, StoreError> {
557        let reader = self.reader.clone();
558        // A panic during a read leaves the connection usable: reads change nothing.
559        Ok(tokio::task::spawn_blocking(move || query(&lock(&reader))).await??)
560    }
561
562    /// One page of jobs, newest first.
563    pub async fn jobs(&self, query: api::JobQuery) -> Result<api::JobPage, StoreError> {
564        self.read(move |db| read::jobs(db, &query)).await
565    }
566
567    /// A job with its steps.
568    pub async fn job(&self, id: u64) -> Result<Option<api::Job>, StoreError> {
569        self.read(move |db| read::job(db, id)).await
570    }
571
572    /// A job's samples, oldest first; `None` if there is no such job.
573    pub async fn job_samples(
574        &self,
575        id: u64,
576        interval: Duration,
577    ) -> Result<Option<api::SampleSeries>, StoreError> {
578        self.read(move |db| read::job_samples(db, id, interval))
579            .await
580    }
581
582    /// Up to `limit` log lines of a job from line `from`, with their numbers.
583    pub async fn log_lines(
584        &self,
585        job: u64,
586        from: u64,
587        limit: u64,
588    ) -> Result<Vec<(u64, String)>, StoreError> {
589        self.read(move |db| read::log_lines(db, job, from, limit))
590            .await
591    }
592
593    /// Up to `limit` durable events after `after`, oldest first.
594    pub async fn events_after(
595        &self,
596        after: u64,
597        limit: u64,
598    ) -> Result<Vec<EventEnvelope>, StoreError> {
599        self.read(move |db| read::events_after(db, after, limit))
600            .await
601    }
602
603    /// The profiles and repositories as the stored events last announced them, so a restarted daemon announces
604    /// only what changed since, including what its config no longer has. Blocks on the database: for startup.
605    pub fn announced(&self) -> Result<Announced, StoreError> {
606        Ok(read::announced(&lock(&self.reader))?)
607    }
608
609    /// The repositories recorded paused, for the engine to start with. Blocks on the database: for startup.
610    pub fn paused_repositories(&self) -> Result<Vec<String>, StoreError> {
611        Ok(read::paused(&lock(&self.reader))?)
612    }
613
614    /// The slice's samples since `since` (seconds), oldest first, the ones not yet stored included.
615    pub async fn host_samples(&self, since: u64) -> Result<Vec<api::HostSample>, StoreError> {
616        // Copied before the read: the writer removes a sample only once it is stored, so each one is in the copy,
617        // the read, or both.
618        let held = lock(&self.held).clone();
619        let mut samples = self.read(move |db| read::host_samples(db, since)).await?;
620        let stored = samples.last().map_or(0, |sample| sample.at);
621        samples.extend(
622            held.into_iter()
623                .filter(|sample| sample.at >= since && sample.at > stored),
624        );
625        Ok(samples)
626    }
627
628    /// Finished jobs per repository and bucket.
629    pub async fn stats(&self, query: api::StatsQuery, now: u64) -> Result<api::Stats, StoreError> {
630        self.read(move |db| read::stats(db, &query, now)).await
631    }
632
633    /// What sizing reads: the finished jobs queued at or after `since` (ms) and the runners since.
634    pub async fn sizing(&self, since: u64) -> Result<sizing::History, StoreError> {
635        self.read(move |db| read::sizing(db, since)).await
636    }
637
638    /// The last measurement of every volume, with its time (seconds), by name.
639    pub async fn cache_sizes_now(&self) -> Result<Measured, StoreError> {
640        self.read(read::latest_cache_sizes).await
641    }
642
643    /// Every volume measurement since `since` (seconds).
644    pub async fn cache_history(&self, since: u64) -> Result<Vec<api::CacheSize>, StoreError> {
645        self.read(move |db| read::cache_history(db, since)).await
646    }
647
648    /// The jobs not yet closed, for the engine after a restart.
649    pub async fn open_jobs(&self) -> Result<Vec<OpenJob>, StoreError> {
650        self.read(read::open_jobs).await
651    }
652
653    /// The job a runner took, if the history knows it.
654    pub async fn runner_job(&self, runner: &str) -> Result<Option<u64>, StoreError> {
655        let runner = runner.to_owned();
656        self.read(move |db| read::runner_job(db, &runner)).await
657    }
658
659    /// Where a job's log capture continues.
660    pub async fn log_position(&self, job: u64) -> Result<LogPosition, StoreError> {
661        self.read(move |db| read::log_position(db, job)).await
662    }
663
664    /// How far the mirror's log was counted.
665    pub async fn mirror_log_until(&self) -> Result<MirrorPosition, StoreError> {
666        self.read(read::mirror_log_until).await
667    }
668}
669
670/// What sizing reads from the database at `path`, opened read-only and not migrated, as `runnerd doctor` reads it
671/// while no daemon runs; `None` when there is no database yet. In a directory it may write, SQLite keeps the
672/// `-wal` and `-shm` files a read of a WAL database needs next to it; in one it may not, it reads the file as it is,
673/// or refuses when the `-wal` holds changes the file does not have yet.
674pub fn sizing_at(path: &Path, since: u64) -> Result<Option<sizing::History>, StoreError> {
675    if !path.exists() {
676        return Ok(None);
677    }
678    let read = |path: &str, flags| -> Result<sizing::History, StoreError> {
679        let db = Connection::open_with_flags(path, flags)?;
680        db.busy_timeout(Duration::from_secs(5))?;
681        sizing_of(&db, since)
682    };
683    let name = path.to_string_lossy();
684    match read(&name, OpenFlags::SQLITE_OPEN_READ_ONLY) {
685        Err(StoreError::Sqlite(error))
686            if matches!(
687                error.sqlite_error_code(),
688                Some(rusqlite::ErrorCode::ReadOnly | rusqlite::ErrorCode::CannotOpen)
689            ) =>
690        {
691            // No `-shm` can be made: read the file alone, unless the `-wal` holds what it lacks.
692            let mut wal = path.as_os_str().to_owned();
693            wal.push("-wal");
694            let wal = PathBuf::from(wal);
695            if std::fs::metadata(&wal).is_ok_and(|wal| wal.len() > 0) {
696                return Err(StoreError::Unread(wal));
697            }
698            let uri = format!(
699                "file:{}?mode=ro&immutable=1",
700                name.replace('%', "%25")
701                    .replace('?', "%3f")
702                    .replace('#', "%23")
703            );
704            read(
705                &uri,
706                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
707            )
708        }
709        history => history,
710    }
711    .map(Some)
712}
713
714/// What sizing reads from a database of any version this build knows, without migrating it.
715fn sizing_of(db: &Connection, since: u64) -> Result<sizing::History, StoreError> {
716    let found: u32 = db.pragma_query_value(None, "user_version", |row| row.get(0))?;
717    let supported = u32::try_from(MIGRATIONS.len()).unwrap_or(u32::MAX);
718    if found > supported {
719        return Err(StoreError::Newer { found, supported });
720    }
721    if found == 0 {
722        return Ok(sizing::History::default());
723    }
724    // Columns sizing reads that later versions added: before them, a temporary view gives each runner none.
725    let added = match found {
726        1 => "NULL AS memory_cap, NULL AS cpu_cap, NULL AS admission, NULL AS oom_kills",
727        2..=6 => "NULL AS admission, NULL AS oom_kills",
728        _ => "",
729    };
730    if !added.is_empty() {
731        db.execute_batch(&format!(
732            "CREATE TEMP VIEW runners AS SELECT *, {added} FROM main.runners"
733        ))?;
734    }
735    Ok(read::sizing(db, since)?)
736}
737
738/// WAL, so the API reads while the writer writes; a busy timeout instead of an immediate error.
739fn configure(db: &Connection) -> rusqlite::Result<()> {
740    db.pragma_update(None, "journal_mode", "wal")?;
741    db.pragma_update(None, "synchronous", "normal")?;
742    db.pragma_update(None, "foreign_keys", "on")?;
743    db.busy_timeout(Duration::from_secs(5))
744}
745
746/// Runs the migrations the database lacks, each in its own transaction with its new version.
747fn migrate(db: &mut Connection) -> Result<(), StoreError> {
748    let found: u32 = db.pragma_query_value(None, "user_version", |row| row.get(0))?;
749    let supported = u32::try_from(MIGRATIONS.len()).unwrap_or(u32::MAX);
750    if found > supported {
751        return Err(StoreError::Newer { found, supported });
752    }
753    for (version, migration) in (1..).zip(MIGRATIONS).skip(found as usize) {
754        let transaction = db.transaction()?;
755        transaction.execute_batch(migration)?;
756        transaction.pragma_update(None, "user_version", version)?;
757        transaction.commit()?;
758    }
759    Ok(())
760}
761
762/// Logs a failed write; the writer goes on with the next one.
763fn logged<T>(what: &str, result: rusqlite::Result<T>) -> Option<T> {
764    result
765        .map_err(|error| warn!("job history: cannot {what}: {error}"))
766        .ok()
767}
768
769#[cfg(test)]
770mod tests;