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