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