1use 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
30const 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];
45
46pub(crate) const LOG_BACKLOG: u64 = 8 << 20;
49
50const HOST_SAMPLE_BATCH: usize = 30;
53
54const EVENT_BUFFER: usize = 4096;
56
57#[derive(Debug, thiserror::Error)]
59pub enum StoreError {
60 #[error("{0}")]
62 Sqlite(#[from] rusqlite::Error),
63 #[error(
65 "the job history database is version {found}, newer than the {supported} this runnerd reads; \
66 run a newer runnerd or move the database aside"
67 )]
68 Newer {
69 found: u32,
71 supported: u32,
73 },
74 #[error("{0}")]
76 Io(#[from] std::io::Error),
77 #[error("a database read stopped: {0}")]
79 Join(#[from] tokio::task::JoinError),
80 #[error(
83 "{} holds changes not yet in the job history, which this user cannot read; run runnerd doctor as the user \
84 the daemon runs as",
85 .0.display()
86 )]
87 Unread(PathBuf),
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct RunnerRecord {
93 pub name: String,
95 pub repository: String,
97 pub profile: String,
99 pub slot: usize,
101 pub container: String,
103 pub started_at: u64,
105 pub memory_cap: Option<u64>,
107 pub cpu_cap: Option<u64>,
109 pub admission: Option<api::Admission>,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct SampleRecord {
116 pub runner: String,
118 pub at: u64,
120 pub cpu: u64,
122 pub cpu_usage: u64,
124 pub memory: u64,
126 pub network_received: u64,
128 pub network_sent: u64,
130 pub events: Events,
132}
133
134#[derive(Debug, Clone, Default, PartialEq, Eq)]
136pub struct Measured {
137 pub at: Option<u64>,
139 pub bytes: BTreeMap<String, u64>,
141}
142
143#[derive(Debug, Clone, Default, PartialEq, Eq)]
146pub struct Announced {
147 pub profiles: Vec<api::ProfileStatus>,
149 pub repositories: Vec<api::RepositoryStatus>,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct OpenJob {
156 pub id: u64,
158 pub repository: String,
160 pub state: api::JobState,
162 pub runner: Option<String>,
164 pub runner_removed_at: Option<u64>,
166 pub run_id: u64,
168 pub run_attempt: u32,
170 pub workflow: Option<String>,
172 pub queued_at: u64,
174}
175
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub struct LogPosition {
179 pub page: u64,
181 pub offset: u64,
183}
184
185#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
189pub struct MirrorPosition {
190 pub at: u64,
192 pub seen: u64,
194}
195
196#[derive(Debug)]
198enum Write {
199 Queued {
200 repository: String,
201 job: QueuedJob,
202 },
203 RunnerStarted(RunnerRecord),
204 RunnerAdmission {
205 runner: String,
206 admission: api::Admission,
207 },
208 RunnerOomKilled(String),
209 BuildCache {
210 runner: String,
211 counts: api::BuildCacheCounts,
212 },
213 RunnerRemoved {
214 runner: String,
215 at: u64,
216 },
217 RunnersGone {
218 running: Vec<String>,
219 at: u64,
220 },
221 Claimed {
222 job: u64,
223 run_id: u64,
224 runner: String,
225 at: u64,
226 },
227 Details {
228 details: JobDetails,
229 },
230 Elsewhere {
231 details: JobDetails,
232 },
233 Lost {
234 job: u64,
235 at: u64,
236 },
237 Log {
238 job: u64,
239 lines: Vec<String>,
240 position: Option<LogPosition>,
242 max_bytes: u64,
243 _backlog: OwnedSemaphorePermit,
245 },
246 Samples(Vec<SampleRecord>),
247 HostSample(api::HostSample),
248 MirrorBytes {
249 bytes: Vec<(String, u64)>,
250 until: MirrorPosition,
251 done: oneshot::Sender<bool>,
253 },
254 CacheSizes {
255 at: u64,
256 sizes: BTreeMap<String, u64>,
257 },
258 Event(Box<Event>),
260 RecordMade(api::Record),
261 RecordSkipped {
262 job: u64,
263 why: String,
264 },
265 RecordRemoved {
266 job: u64,
267 at: u64,
268 by: api::RecordRemoval,
269 },
270 Paused {
271 repositories: Vec<String>,
272 done: oneshot::Sender<bool>,
274 },
275 SizingIntent {
276 change: api::SizingChange,
277 done: oneshot::Sender<Option<i64>>,
279 },
280 SizingResolved {
281 id: i64,
282 resolution: Resolution,
283 done: oneshot::Sender<bool>,
285 },
286 Notify {
287 change: NotifyChange,
288 done: oneshot::Sender<bool>,
290 },
291 Flush(oneshot::Sender<()>),
292}
293
294#[derive(Debug)]
296enum NotifyChange {
297 Record {
299 target: String,
300 set: Vec<(String, u64)>,
301 remove: Vec<String>,
302 },
303 Keep(Vec<String>),
305}
306
307#[derive(Debug, Clone, Copy, PartialEq, Eq)]
309pub enum Resolution {
310 Done,
312 Failed,
314 Uncertain,
317}
318
319#[derive(Debug, Clone, Default, PartialEq, Eq)]
321pub struct AutoSizing {
322 pub last: BTreeMap<String, u64>,
324 pub pending: Vec<(i64, api::SizingChange)>,
326}
327
328#[derive(Debug, Clone)]
330pub struct Store {
331 writes: mpsc::UnboundedSender<Write>,
332 reader: Arc<Mutex<Connection>>,
333 pruner: Arc<Mutex<Connection>>,
335 closed: Arc<AtomicBool>,
337 held: Arc<Mutex<Vec<api::HostSample>>>,
340 latest_host: Arc<Mutex<Option<api::HostSample>>>,
342 latest_samples: Arc<Mutex<BTreeMap<String, SampleRecord>>>,
344 events: broadcast::Sender<Arc<EventEnvelope>>,
345 backlog: Arc<Semaphore>,
346}
347
348impl Store {
349 pub fn open(path: &Path) -> Result<Store, StoreError> {
352 if let Some(dir) = path.parent().filter(|dir| !dir.as_os_str().is_empty()) {
353 std::fs::DirBuilder::new()
354 .recursive(true)
355 .mode(0o700)
356 .create(dir)?;
357 }
358 std::fs::OpenOptions::new()
360 .create(true)
361 .append(true)
362 .mode(0o600)
363 .open(path)?;
364 let mut db = Connection::open(path)?;
365 configure(&db)?;
366 migrate(&mut db)?;
367 let reader = Connection::open_with_flags(
368 path,
369 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
370 )?;
371 configure(&reader)?;
372 let pruner = Connection::open(path)?;
373 configure(&pruner)?;
374 let (events, _) = broadcast::channel(EVENT_BUFFER);
375 let (writes, receiver) = mpsc::unbounded_channel();
376 let thread_events = events.clone();
377 let held = Arc::new(Mutex::new(Vec::new()));
378 let thread_held = held.clone();
379 std::thread::Builder::new()
380 .name("runnerd-store".to_owned())
381 .spawn(move || write::run(db, receiver, &thread_events, &thread_held))?;
382 Ok(Store {
383 writes,
384 reader: Arc::new(Mutex::new(reader)),
385 pruner: Arc::new(Mutex::new(pruner)),
386 closed: Arc::new(AtomicBool::new(false)),
387 held,
388 latest_host: Arc::new(Mutex::new(None)),
389 latest_samples: Arc::new(Mutex::new(BTreeMap::new())),
390 events,
391 backlog: Arc::new(Semaphore::new(
392 usize::try_from(LOG_BACKLOG).unwrap_or(usize::MAX),
393 )),
394 })
395 }
396
397 fn send(&self, write: Write) {
398 let _ = self.writes.send(write);
400 }
401
402 pub fn queued(&self, repository: &str, job: &QueuedJob) {
404 self.send(Write::Queued {
405 repository: repository.to_owned(),
406 job: job.clone(),
407 });
408 }
409
410 pub fn runner_started(&self, runner: RunnerRecord) {
412 self.send(Write::RunnerStarted(runner));
413 }
414
415 pub fn runner_removed(&self, runner: &str, at: u64) {
417 lock(&self.latest_samples).remove(runner);
418 self.send(Write::RunnerRemoved {
419 runner: runner.to_owned(),
420 at,
421 });
422 }
423
424 pub fn runners_gone(&self, running: Vec<String>, at: u64) {
427 lock(&self.latest_samples).retain(|runner, _| running.contains(runner));
428 self.send(Write::RunnersGone { running, at });
429 }
430
431 pub fn claimed(&self, job: u64, run_id: u64, runner: &str, at: u64) {
433 self.send(Write::Claimed {
434 job,
435 run_id,
436 runner: runner.to_owned(),
437 at,
438 });
439 }
440
441 pub fn details(&self, details: JobDetails) {
443 self.send(Write::Details { details });
444 }
445
446 pub fn elsewhere(&self, details: JobDetails) {
448 self.send(Write::Elsewhere { details });
449 }
450
451 pub fn lost(&self, job: u64, at: u64) {
453 self.send(Write::Lost { job, at });
454 }
455
456 pub async fn log(&self, job: u64, lines: Vec<String>, position: LogPosition, max_bytes: u64) {
459 let size = |line: &String| line.len() as u64 + 1;
460 let mut lines = lines.into_iter().map(|mut line| {
461 if size(&line) > LOG_BACKLOG {
463 let mut end = usize::try_from(LOG_BACKLOG / 2).unwrap_or(usize::MAX);
464 while !line.is_char_boundary(end) {
465 end -= 1;
466 }
467 line.truncate(end);
468 }
469 line
470 });
471 let mut next = lines.next();
472 loop {
473 let (mut part, mut bytes) = (Vec::new(), 0);
474 while let Some(line) = next.take_if(|line| bytes + size(line) <= LOG_BACKLOG) {
475 bytes += size(&line);
476 part.push(line);
477 next = lines.next();
478 }
479 let last = next.is_none();
480 let share = u32::try_from(bytes.max(1)).unwrap_or(u32::MAX);
481 let Ok(backlog) = self.backlog.clone().acquire_many_owned(share).await else {
483 return;
484 };
485 self.send(Write::Log {
486 job,
487 lines: part,
488 position: last.then_some(position),
489 max_bytes,
490 _backlog: backlog,
491 });
492 if last {
493 return;
494 }
495 }
496 }
497
498 pub fn samples(&self, samples: Vec<SampleRecord>) {
500 let mut latest = lock(&self.latest_samples);
501 for sample in &samples {
502 latest.insert(sample.runner.clone(), sample.clone());
503 }
504 drop(latest);
505 self.send(Write::Samples(samples));
506 }
507
508 pub fn host_sample(&self, sample: api::HostSample) {
511 *lock(&self.latest_host) = Some(sample.clone());
512 lock(&self.held).push(sample.clone());
513 self.send(Write::HostSample(sample));
514 }
515
516 pub fn latest_sample(&self, runner: &str) -> Option<SampleRecord> {
518 lock(&self.latest_samples).get(runner).cloned()
519 }
520
521 pub fn runner_admission(&self, runner: &str, admission: api::Admission) {
523 self.send(Write::RunnerAdmission {
524 runner: runner.to_owned(),
525 admission,
526 });
527 }
528
529 pub fn runner_oom_killed(&self, runner: &str) {
532 self.send(Write::RunnerOomKilled(runner.to_owned()));
533 }
534
535 pub fn build_cache(&self, runner: &str, counts: api::BuildCacheCounts) {
537 self.send(Write::BuildCache {
538 runner: runner.to_owned(),
539 counts,
540 });
541 }
542
543 pub fn latest_host_sample(&self) -> Option<api::HostSample> {
545 lock(&self.latest_host).clone()
546 }
547
548 pub async fn mirror_bytes(&self, bytes: Vec<(String, u64)>, until: MirrorPosition) -> bool {
551 let (done, committed) = oneshot::channel();
552 self.send(Write::MirrorBytes { bytes, until, done });
553 committed.await.unwrap_or(false)
554 }
555
556 pub fn cache_sizes(&self, at: u64, sizes: BTreeMap<String, u64>) {
558 self.send(Write::CacheSizes { at, sizes });
559 }
560
561 pub fn event(&self, event: Event) {
563 self.send(Write::Event(Box::new(event)));
564 }
565
566 pub fn record_made(&self, record: api::Record) {
568 self.send(Write::RecordMade(record));
569 }
570
571 pub fn record_skipped(&self, job: u64, why: String) {
573 self.send(Write::RecordSkipped { job, why });
574 }
575
576 pub fn record_removed(&self, job: u64, at: u64, by: api::RecordRemoval) {
578 self.send(Write::RecordRemoved { job, at, by });
579 }
580
581 pub fn paused(&self, repositories: Vec<String>) -> impl Future<Output = bool> + use<> {
584 let (done, committed) = oneshot::channel();
585 self.send(Write::Paused { repositories, done });
586 async move { committed.await.unwrap_or(false) }
587 }
588
589 pub fn sizing_intent(
592 &self,
593 change: api::SizingChange,
594 ) -> impl Future<Output = Option<i64>> + use<> {
595 let (done, committed) = oneshot::channel();
596 self.send(Write::SizingIntent { change, done });
597 async move { committed.await.ok().flatten() }
598 }
599
600 pub fn sizing_resolved(
603 &self,
604 id: i64,
605 resolution: Resolution,
606 ) -> impl Future<Output = bool> + use<> {
607 let (done, committed) = oneshot::channel();
608 self.send(Write::SizingResolved {
609 id,
610 resolution,
611 done,
612 });
613 async move { committed.await.unwrap_or(false) }
614 }
615
616 pub fn notify_record(
619 &self,
620 target: &str,
621 set: Vec<(String, u64)>,
622 remove: Vec<String>,
623 ) -> impl Future<Output = bool> + use<> {
624 let (done, committed) = oneshot::channel();
625 self.send(Write::Notify {
626 change: NotifyChange::Record {
627 target: target.to_owned(),
628 set,
629 remove,
630 },
631 done,
632 });
633 async move { committed.await.unwrap_or(false) }
634 }
635
636 pub fn notify_keep(&self, targets: Vec<String>) -> impl Future<Output = bool> + use<> {
638 let (done, committed) = oneshot::channel();
639 self.send(Write::Notify {
640 change: NotifyChange::Keep(targets),
641 done,
642 });
643 async move { committed.await.unwrap_or(false) }
644 }
645
646 pub fn prune(&self, now: u64, history: &History) -> tokio::task::JoinHandle<()> {
650 let pruner = self.pruner.clone();
651 let closed = self.closed.clone();
652 let history = history.clone();
653 tokio::task::spawn_blocking(move || {
654 let db = match pruner.try_lock() {
655 Ok(db) => db,
656 Err(TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
657 Err(TryLockError::WouldBlock) => return,
658 };
659 let db = prune::Db {
660 connection: &db,
661 stop: &closed,
662 };
663 if !db.stop.load(Ordering::Relaxed) {
664 prune::run(&db, now, &history);
665 }
666 })
667 }
668
669 pub fn close(&self) {
672 self.closed.store(true, Ordering::Relaxed);
673 }
674
675 pub async fn flush(&self) {
677 let (done, wait) = oneshot::channel();
678 self.send(Write::Flush(done));
679 let _ = wait.await;
680 }
681
682 pub fn subscribe(&self) -> broadcast::Receiver<Arc<EventEnvelope>> {
685 self.events.subscribe()
686 }
687
688 async fn read<T: Send + 'static>(
689 &self,
690 query: impl FnOnce(&Connection) -> rusqlite::Result<T> + Send + 'static,
691 ) -> Result<T, StoreError> {
692 let reader = self.reader.clone();
693 Ok(tokio::task::spawn_blocking(move || query(&lock(&reader))).await??)
695 }
696
697 pub async fn jobs(&self, query: api::JobQuery) -> Result<api::JobPage, StoreError> {
699 self.read(move |db| read::jobs(db, &query)).await
700 }
701
702 pub async fn job(&self, id: u64) -> Result<Option<api::Job>, StoreError> {
704 self.read(move |db| read::job(db, id)).await
705 }
706
707 pub async fn job_samples(
709 &self,
710 id: u64,
711 interval: Duration,
712 ) -> Result<Option<api::SampleSeries>, StoreError> {
713 self.read(move |db| read::job_samples(db, id, interval))
714 .await
715 }
716
717 pub async fn log_lines(
719 &self,
720 job: u64,
721 from: u64,
722 limit: u64,
723 ) -> Result<Vec<(u64, String)>, StoreError> {
724 self.read(move |db| read::log_lines(db, job, from, limit))
725 .await
726 }
727
728 pub async fn events_after(
730 &self,
731 after: u64,
732 limit: u64,
733 ) -> Result<Vec<EventEnvelope>, StoreError> {
734 self.read(move |db| read::events_after(db, after, limit))
735 .await
736 }
737
738 pub async fn sizing_changes(&self, since: u64) -> Result<Vec<api::SizingChange>, StoreError> {
740 self.read(move |db| read::sizing_changes(db, since)).await
741 }
742
743 pub async fn auto_sizing(&self) -> Result<AutoSizing, StoreError> {
745 self.read(read::auto_sizing).await
746 }
747
748 pub fn announced(&self) -> Result<Announced, StoreError> {
751 Ok(read::announced(&lock(&self.reader))?)
752 }
753
754 pub fn paused_repositories(&self) -> Result<Vec<String>, StoreError> {
756 Ok(read::paused(&lock(&self.reader))?)
757 }
758
759 pub async fn host_samples(&self, since: u64) -> Result<Vec<api::HostSample>, StoreError> {
761 let held = lock(&self.held).clone();
764 let mut samples = self.read(move |db| read::host_samples(db, since)).await?;
765 let stored = samples.last().map_or(0, |sample| sample.at);
766 samples.extend(
767 held.into_iter()
768 .filter(|sample| sample.at >= since && sample.at > stored),
769 );
770 Ok(samples)
771 }
772
773 pub async fn activity(&self, since: u64, now: u64) -> Result<api::Activity, StoreError> {
775 self.read(move |db| read::activity(db, since, now)).await
776 }
777
778 pub async fn stats(&self, query: api::StatsQuery, now: u64) -> Result<api::Stats, StoreError> {
780 self.read(move |db| read::stats(db, &query, now)).await
781 }
782
783 pub async fn sizing(&self, since: u64) -> Result<sizing::History, StoreError> {
785 self.read(move |db| read::sizing(db, since)).await
786 }
787
788 pub async fn insights(&self, since: u64) -> Result<Vec<crate::insights::Run>, StoreError> {
790 self.read(move |db| read::insights(db, since)).await
791 }
792
793 pub async fn last_event(&self) -> Result<u64, StoreError> {
795 self.read(read::last_event).await
796 }
797
798 pub async fn notify_state(
800 &self,
801 ) -> Result<BTreeMap<String, BTreeMap<String, u64>>, StoreError> {
802 self.read(read::notify_state).await
803 }
804
805 pub async fn warm(&self, since: u64) -> Result<Vec<warm::Seen>, StoreError> {
807 self.read(move |db| read::warm(db, since)).await
808 }
809
810 pub async fn cache_sizes_now(&self) -> Result<Measured, StoreError> {
812 self.read(read::latest_cache_sizes).await
813 }
814
815 pub async fn build_cache_since(&self, since: u64) -> Result<api::BuildCacheCounts, StoreError> {
817 self.read(move |db| read::build_cache_since(db, since))
818 .await
819 }
820
821 pub async fn cache_history(&self, since: u64) -> Result<Vec<api::CacheSize>, StoreError> {
823 self.read(move |db| read::cache_history(db, since)).await
824 }
825
826 pub async fn open_jobs(&self) -> Result<Vec<OpenJob>, StoreError> {
828 self.read(read::open_jobs).await
829 }
830
831 pub async fn runner_job(&self, runner: &str) -> Result<Option<u64>, StoreError> {
833 let runner = runner.to_owned();
834 self.read(move |db| read::runner_job(db, &runner)).await
835 }
836
837 pub async fn log_position(&self, job: u64) -> Result<LogPosition, StoreError> {
839 self.read(move |db| read::log_position(db, job)).await
840 }
841
842 pub async fn mirror_log_until(&self) -> Result<MirrorPosition, StoreError> {
844 self.read(read::mirror_log_until).await
845 }
846}
847
848pub fn sizing_at(path: &Path, since: u64) -> Result<Option<sizing::History>, StoreError> {
853 if !path.exists() {
854 return Ok(None);
855 }
856 let read = |path: &str, flags| -> Result<sizing::History, StoreError> {
857 let db = Connection::open_with_flags(path, flags)?;
858 db.busy_timeout(Duration::from_secs(5))?;
859 sizing_of(&db, since)
860 };
861 let name = path.to_string_lossy();
862 match read(&name, OpenFlags::SQLITE_OPEN_READ_ONLY) {
863 Err(StoreError::Sqlite(error))
864 if matches!(
865 error.sqlite_error_code(),
866 Some(rusqlite::ErrorCode::ReadOnly | rusqlite::ErrorCode::CannotOpen)
867 ) =>
868 {
869 let mut wal = path.as_os_str().to_owned();
871 wal.push("-wal");
872 let wal = PathBuf::from(wal);
873 if std::fs::metadata(&wal).is_ok_and(|wal| wal.len() > 0) {
874 return Err(StoreError::Unread(wal));
875 }
876 let uri = format!(
877 "file:{}?mode=ro&immutable=1",
878 name.replace('%', "%25")
879 .replace('?', "%3f")
880 .replace('#', "%23")
881 );
882 read(
883 &uri,
884 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
885 )
886 }
887 history => history,
888 }
889 .map(Some)
890}
891
892fn sizing_of(db: &Connection, since: u64) -> Result<sizing::History, StoreError> {
894 let found: u32 = db.pragma_query_value(None, "user_version", |row| row.get(0))?;
895 let supported = u32::try_from(MIGRATIONS.len()).unwrap_or(u32::MAX);
896 if found > supported {
897 return Err(StoreError::Newer { found, supported });
898 }
899 if found == 0 {
900 return Ok(sizing::History::default());
901 }
902 let added = match found {
904 1 => {
905 "NULL AS memory_cap, NULL AS cpu_cap, NULL AS admission, NULL AS oom_kills, NULL AS ooms, \
906 NULL AS memory_measure"
907 }
908 2..=6 => "NULL AS admission, NULL AS oom_kills, NULL AS ooms, NULL AS memory_measure",
909 7 => "NULL AS ooms, NULL AS memory_measure",
910 _ => "",
911 };
912 if !added.is_empty() {
913 db.execute_batch(&format!(
914 "CREATE TEMP VIEW runners AS SELECT *, {added} FROM main.runners"
915 ))?;
916 }
917 Ok(read::sizing(db, since)?)
918}
919
920fn configure(db: &Connection) -> rusqlite::Result<()> {
922 db.pragma_update(None, "journal_mode", "wal")?;
923 db.pragma_update(None, "synchronous", "normal")?;
924 db.pragma_update(None, "foreign_keys", "on")?;
925 db.busy_timeout(Duration::from_secs(5))
926}
927
928fn migrate(db: &mut Connection) -> Result<(), StoreError> {
930 let found: u32 = db.pragma_query_value(None, "user_version", |row| row.get(0))?;
931 let supported = u32::try_from(MIGRATIONS.len()).unwrap_or(u32::MAX);
932 if found > supported {
933 return Err(StoreError::Newer { found, supported });
934 }
935 for (version, migration) in (1..).zip(MIGRATIONS).skip(found as usize) {
936 let transaction = db.transaction()?;
937 transaction.execute_batch(migration)?;
938 transaction.pragma_update(None, "user_version", version)?;
939 transaction.commit()?;
940 }
941 Ok(())
942}
943
944fn logged<T>(what: &str, result: rusqlite::Result<T>) -> Option<T> {
946 result
947 .map_err(|error| warn!("job history: cannot {what}: {error}"))
948 .ok()
949}
950
951#[cfg(test)]
952mod tests;