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];
44
45pub(crate) const LOG_BACKLOG: u64 = 8 << 20;
48
49const HOST_SAMPLE_BATCH: usize = 30;
52
53const EVENT_BUFFER: usize = 4096;
55
56#[derive(Debug, thiserror::Error)]
58pub enum StoreError {
59 #[error("{0}")]
61 Sqlite(#[from] rusqlite::Error),
62 #[error(
64 "the job history database is version {found}, newer than the {supported} this runnerd reads; \
65 run a newer runnerd or move the database aside"
66 )]
67 Newer {
68 found: u32,
70 supported: u32,
72 },
73 #[error("{0}")]
75 Io(#[from] std::io::Error),
76 #[error("a database read stopped: {0}")]
78 Join(#[from] tokio::task::JoinError),
79 #[error(
82 "{} holds changes not yet in the job history, which this user cannot read; run runnerd doctor as the user \
83 the daemon runs as",
84 .0.display()
85 )]
86 Unread(PathBuf),
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct RunnerRecord {
92 pub name: String,
94 pub repository: String,
96 pub profile: String,
98 pub slot: usize,
100 pub container: String,
102 pub started_at: u64,
104 pub memory_cap: Option<u64>,
106 pub cpu_cap: Option<u64>,
108 pub admission: Option<api::Admission>,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct SampleRecord {
115 pub runner: String,
117 pub at: u64,
119 pub cpu: u64,
121 pub cpu_usage: u64,
123 pub memory: u64,
125 pub network_received: u64,
127 pub network_sent: u64,
129 pub events: Events,
131}
132
133#[derive(Debug, Clone, Default, PartialEq, Eq)]
135pub struct Measured {
136 pub at: Option<u64>,
138 pub bytes: BTreeMap<String, u64>,
140}
141
142#[derive(Debug, Clone, Default, PartialEq, Eq)]
145pub struct Announced {
146 pub profiles: Vec<api::ProfileStatus>,
148 pub repositories: Vec<api::RepositoryStatus>,
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct OpenJob {
155 pub id: u64,
157 pub repository: String,
159 pub state: api::JobState,
161 pub runner: Option<String>,
163 pub runner_removed_at: Option<u64>,
165 pub run_id: u64,
167 pub run_attempt: u32,
169 pub workflow: Option<String>,
171 pub queued_at: u64,
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub struct LogPosition {
178 pub page: u64,
180 pub offset: u64,
182}
183
184#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
188pub struct MirrorPosition {
189 pub at: u64,
191 pub seen: u64,
193}
194
195#[derive(Debug)]
197enum Write {
198 Queued {
199 repository: String,
200 job: QueuedJob,
201 },
202 RunnerStarted(RunnerRecord),
203 RunnerAdmission {
204 runner: String,
205 admission: api::Admission,
206 },
207 RunnerOomKilled(String),
208 RunnerRemoved {
209 runner: String,
210 at: u64,
211 },
212 RunnersGone {
213 running: Vec<String>,
214 at: u64,
215 },
216 Claimed {
217 job: u64,
218 run_id: u64,
219 runner: String,
220 at: u64,
221 },
222 Details {
223 details: JobDetails,
224 },
225 Elsewhere {
226 details: JobDetails,
227 },
228 Lost {
229 job: u64,
230 at: u64,
231 },
232 Log {
233 job: u64,
234 lines: Vec<String>,
235 position: Option<LogPosition>,
237 max_bytes: u64,
238 _backlog: OwnedSemaphorePermit,
240 },
241 Samples(Vec<SampleRecord>),
242 HostSample(api::HostSample),
243 MirrorBytes {
244 bytes: Vec<(String, u64)>,
245 until: MirrorPosition,
246 done: oneshot::Sender<bool>,
248 },
249 CacheSizes {
250 at: u64,
251 sizes: BTreeMap<String, u64>,
252 },
253 Event(Event),
254 RecordMade(api::Record),
255 RecordSkipped {
256 job: u64,
257 why: String,
258 },
259 RecordRemoved {
260 job: u64,
261 at: u64,
262 by: api::RecordRemoval,
263 },
264 Paused {
265 repositories: Vec<String>,
266 done: oneshot::Sender<bool>,
268 },
269 SizingIntent {
270 change: api::SizingChange,
271 done: oneshot::Sender<Option<i64>>,
273 },
274 SizingResolved {
275 id: i64,
276 resolution: Resolution,
277 done: oneshot::Sender<bool>,
279 },
280 Notify {
281 change: NotifyChange,
282 done: oneshot::Sender<bool>,
284 },
285 Flush(oneshot::Sender<()>),
286}
287
288#[derive(Debug)]
290enum NotifyChange {
291 Record {
293 target: String,
294 set: Vec<(String, u64)>,
295 remove: Vec<String>,
296 },
297 Keep(Vec<String>),
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303pub enum Resolution {
304 Done,
306 Failed,
308 Uncertain,
311}
312
313#[derive(Debug, Clone, Default, PartialEq, Eq)]
315pub struct AutoSizing {
316 pub last: BTreeMap<String, u64>,
318 pub pending: Vec<(i64, api::SizingChange)>,
320}
321
322#[derive(Debug, Clone)]
324pub struct Store {
325 writes: mpsc::UnboundedSender<Write>,
326 reader: Arc<Mutex<Connection>>,
327 pruner: Arc<Mutex<Connection>>,
329 closed: Arc<AtomicBool>,
331 held: Arc<Mutex<Vec<api::HostSample>>>,
334 latest_host: Arc<Mutex<Option<api::HostSample>>>,
336 latest_samples: Arc<Mutex<BTreeMap<String, SampleRecord>>>,
338 events: broadcast::Sender<Arc<EventEnvelope>>,
339 backlog: Arc<Semaphore>,
340}
341
342impl Store {
343 pub fn open(path: &Path) -> Result<Store, StoreError> {
346 if let Some(dir) = path.parent().filter(|dir| !dir.as_os_str().is_empty()) {
347 std::fs::DirBuilder::new()
348 .recursive(true)
349 .mode(0o700)
350 .create(dir)?;
351 }
352 std::fs::OpenOptions::new()
354 .create(true)
355 .append(true)
356 .mode(0o600)
357 .open(path)?;
358 let mut db = Connection::open(path)?;
359 configure(&db)?;
360 migrate(&mut db)?;
361 let reader = Connection::open_with_flags(
362 path,
363 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
364 )?;
365 configure(&reader)?;
366 let pruner = Connection::open(path)?;
367 configure(&pruner)?;
368 let (events, _) = broadcast::channel(EVENT_BUFFER);
369 let (writes, receiver) = mpsc::unbounded_channel();
370 let thread_events = events.clone();
371 let held = Arc::new(Mutex::new(Vec::new()));
372 let thread_held = held.clone();
373 std::thread::Builder::new()
374 .name("runnerd-store".to_owned())
375 .spawn(move || write::run(db, receiver, &thread_events, &thread_held))?;
376 Ok(Store {
377 writes,
378 reader: Arc::new(Mutex::new(reader)),
379 pruner: Arc::new(Mutex::new(pruner)),
380 closed: Arc::new(AtomicBool::new(false)),
381 held,
382 latest_host: Arc::new(Mutex::new(None)),
383 latest_samples: Arc::new(Mutex::new(BTreeMap::new())),
384 events,
385 backlog: Arc::new(Semaphore::new(
386 usize::try_from(LOG_BACKLOG).unwrap_or(usize::MAX),
387 )),
388 })
389 }
390
391 fn send(&self, write: Write) {
392 let _ = self.writes.send(write);
394 }
395
396 pub fn queued(&self, repository: &str, job: &QueuedJob) {
398 self.send(Write::Queued {
399 repository: repository.to_owned(),
400 job: job.clone(),
401 });
402 }
403
404 pub fn runner_started(&self, runner: RunnerRecord) {
406 self.send(Write::RunnerStarted(runner));
407 }
408
409 pub fn runner_removed(&self, runner: &str, at: u64) {
411 lock(&self.latest_samples).remove(runner);
412 self.send(Write::RunnerRemoved {
413 runner: runner.to_owned(),
414 at,
415 });
416 }
417
418 pub fn runners_gone(&self, running: Vec<String>, at: u64) {
421 lock(&self.latest_samples).retain(|runner, _| running.contains(runner));
422 self.send(Write::RunnersGone { running, at });
423 }
424
425 pub fn claimed(&self, job: u64, run_id: u64, runner: &str, at: u64) {
427 self.send(Write::Claimed {
428 job,
429 run_id,
430 runner: runner.to_owned(),
431 at,
432 });
433 }
434
435 pub fn details(&self, details: JobDetails) {
437 self.send(Write::Details { details });
438 }
439
440 pub fn elsewhere(&self, details: JobDetails) {
442 self.send(Write::Elsewhere { details });
443 }
444
445 pub fn lost(&self, job: u64, at: u64) {
447 self.send(Write::Lost { job, at });
448 }
449
450 pub async fn log(&self, job: u64, lines: Vec<String>, position: LogPosition, max_bytes: u64) {
453 let size = |line: &String| line.len() as u64 + 1;
454 let mut lines = lines.into_iter().map(|mut line| {
455 if size(&line) > LOG_BACKLOG {
457 let mut end = usize::try_from(LOG_BACKLOG / 2).unwrap_or(usize::MAX);
458 while !line.is_char_boundary(end) {
459 end -= 1;
460 }
461 line.truncate(end);
462 }
463 line
464 });
465 let mut next = lines.next();
466 loop {
467 let (mut part, mut bytes) = (Vec::new(), 0);
468 while let Some(line) = next.take_if(|line| bytes + size(line) <= LOG_BACKLOG) {
469 bytes += size(&line);
470 part.push(line);
471 next = lines.next();
472 }
473 let last = next.is_none();
474 let share = u32::try_from(bytes.max(1)).unwrap_or(u32::MAX);
475 let Ok(backlog) = self.backlog.clone().acquire_many_owned(share).await else {
477 return;
478 };
479 self.send(Write::Log {
480 job,
481 lines: part,
482 position: last.then_some(position),
483 max_bytes,
484 _backlog: backlog,
485 });
486 if last {
487 return;
488 }
489 }
490 }
491
492 pub fn samples(&self, samples: Vec<SampleRecord>) {
494 let mut latest = lock(&self.latest_samples);
495 for sample in &samples {
496 latest.insert(sample.runner.clone(), sample.clone());
497 }
498 drop(latest);
499 self.send(Write::Samples(samples));
500 }
501
502 pub fn host_sample(&self, sample: api::HostSample) {
505 *lock(&self.latest_host) = Some(sample.clone());
506 lock(&self.held).push(sample.clone());
507 self.send(Write::HostSample(sample));
508 }
509
510 pub fn latest_sample(&self, runner: &str) -> Option<SampleRecord> {
512 lock(&self.latest_samples).get(runner).cloned()
513 }
514
515 pub fn runner_admission(&self, runner: &str, admission: api::Admission) {
517 self.send(Write::RunnerAdmission {
518 runner: runner.to_owned(),
519 admission,
520 });
521 }
522
523 pub fn runner_oom_killed(&self, runner: &str) {
526 self.send(Write::RunnerOomKilled(runner.to_owned()));
527 }
528
529 pub fn latest_host_sample(&self) -> Option<api::HostSample> {
531 lock(&self.latest_host).clone()
532 }
533
534 pub async fn mirror_bytes(&self, bytes: Vec<(String, u64)>, until: MirrorPosition) -> bool {
537 let (done, committed) = oneshot::channel();
538 self.send(Write::MirrorBytes { bytes, until, done });
539 committed.await.unwrap_or(false)
540 }
541
542 pub fn cache_sizes(&self, at: u64, sizes: BTreeMap<String, u64>) {
544 self.send(Write::CacheSizes { at, sizes });
545 }
546
547 pub fn event(&self, event: Event) {
549 self.send(Write::Event(event));
550 }
551
552 pub fn record_made(&self, record: api::Record) {
554 self.send(Write::RecordMade(record));
555 }
556
557 pub fn record_skipped(&self, job: u64, why: String) {
559 self.send(Write::RecordSkipped { job, why });
560 }
561
562 pub fn record_removed(&self, job: u64, at: u64, by: api::RecordRemoval) {
564 self.send(Write::RecordRemoved { job, at, by });
565 }
566
567 pub fn paused(&self, repositories: Vec<String>) -> impl Future<Output = bool> + use<> {
570 let (done, committed) = oneshot::channel();
571 self.send(Write::Paused { repositories, done });
572 async move { committed.await.unwrap_or(false) }
573 }
574
575 pub fn sizing_intent(
578 &self,
579 change: api::SizingChange,
580 ) -> impl Future<Output = Option<i64>> + use<> {
581 let (done, committed) = oneshot::channel();
582 self.send(Write::SizingIntent { change, done });
583 async move { committed.await.ok().flatten() }
584 }
585
586 pub fn sizing_resolved(
589 &self,
590 id: i64,
591 resolution: Resolution,
592 ) -> impl Future<Output = bool> + use<> {
593 let (done, committed) = oneshot::channel();
594 self.send(Write::SizingResolved {
595 id,
596 resolution,
597 done,
598 });
599 async move { committed.await.unwrap_or(false) }
600 }
601
602 pub fn notify_record(
605 &self,
606 target: &str,
607 set: Vec<(String, u64)>,
608 remove: Vec<String>,
609 ) -> impl Future<Output = bool> + use<> {
610 let (done, committed) = oneshot::channel();
611 self.send(Write::Notify {
612 change: NotifyChange::Record {
613 target: target.to_owned(),
614 set,
615 remove,
616 },
617 done,
618 });
619 async move { committed.await.unwrap_or(false) }
620 }
621
622 pub fn notify_keep(&self, targets: Vec<String>) -> impl Future<Output = bool> + use<> {
624 let (done, committed) = oneshot::channel();
625 self.send(Write::Notify {
626 change: NotifyChange::Keep(targets),
627 done,
628 });
629 async move { committed.await.unwrap_or(false) }
630 }
631
632 pub fn prune(&self, now: u64, history: &History) -> tokio::task::JoinHandle<()> {
636 let pruner = self.pruner.clone();
637 let closed = self.closed.clone();
638 let history = history.clone();
639 tokio::task::spawn_blocking(move || {
640 let db = match pruner.try_lock() {
641 Ok(db) => db,
642 Err(TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
643 Err(TryLockError::WouldBlock) => return,
644 };
645 let db = prune::Db {
646 connection: &db,
647 stop: &closed,
648 };
649 if !db.stop.load(Ordering::Relaxed) {
650 prune::run(&db, now, &history);
651 }
652 })
653 }
654
655 pub fn close(&self) {
658 self.closed.store(true, Ordering::Relaxed);
659 }
660
661 pub async fn flush(&self) {
663 let (done, wait) = oneshot::channel();
664 self.send(Write::Flush(done));
665 let _ = wait.await;
666 }
667
668 pub fn subscribe(&self) -> broadcast::Receiver<Arc<EventEnvelope>> {
671 self.events.subscribe()
672 }
673
674 async fn read<T: Send + 'static>(
675 &self,
676 query: impl FnOnce(&Connection) -> rusqlite::Result<T> + Send + 'static,
677 ) -> Result<T, StoreError> {
678 let reader = self.reader.clone();
679 Ok(tokio::task::spawn_blocking(move || query(&lock(&reader))).await??)
681 }
682
683 pub async fn jobs(&self, query: api::JobQuery) -> Result<api::JobPage, StoreError> {
685 self.read(move |db| read::jobs(db, &query)).await
686 }
687
688 pub async fn job(&self, id: u64) -> Result<Option<api::Job>, StoreError> {
690 self.read(move |db| read::job(db, id)).await
691 }
692
693 pub async fn job_samples(
695 &self,
696 id: u64,
697 interval: Duration,
698 ) -> Result<Option<api::SampleSeries>, StoreError> {
699 self.read(move |db| read::job_samples(db, id, interval))
700 .await
701 }
702
703 pub async fn log_lines(
705 &self,
706 job: u64,
707 from: u64,
708 limit: u64,
709 ) -> Result<Vec<(u64, String)>, StoreError> {
710 self.read(move |db| read::log_lines(db, job, from, limit))
711 .await
712 }
713
714 pub async fn events_after(
716 &self,
717 after: u64,
718 limit: u64,
719 ) -> Result<Vec<EventEnvelope>, StoreError> {
720 self.read(move |db| read::events_after(db, after, limit))
721 .await
722 }
723
724 pub async fn sizing_changes(&self, since: u64) -> Result<Vec<api::SizingChange>, StoreError> {
726 self.read(move |db| read::sizing_changes(db, since)).await
727 }
728
729 pub async fn auto_sizing(&self) -> Result<AutoSizing, StoreError> {
731 self.read(read::auto_sizing).await
732 }
733
734 pub fn announced(&self) -> Result<Announced, StoreError> {
737 Ok(read::announced(&lock(&self.reader))?)
738 }
739
740 pub fn paused_repositories(&self) -> Result<Vec<String>, StoreError> {
742 Ok(read::paused(&lock(&self.reader))?)
743 }
744
745 pub async fn host_samples(&self, since: u64) -> Result<Vec<api::HostSample>, StoreError> {
747 let held = lock(&self.held).clone();
750 let mut samples = self.read(move |db| read::host_samples(db, since)).await?;
751 let stored = samples.last().map_or(0, |sample| sample.at);
752 samples.extend(
753 held.into_iter()
754 .filter(|sample| sample.at >= since && sample.at > stored),
755 );
756 Ok(samples)
757 }
758
759 pub async fn activity(&self, since: u64, now: u64) -> Result<api::Activity, StoreError> {
761 self.read(move |db| read::activity(db, since, now)).await
762 }
763
764 pub async fn stats(&self, query: api::StatsQuery, now: u64) -> Result<api::Stats, StoreError> {
766 self.read(move |db| read::stats(db, &query, now)).await
767 }
768
769 pub async fn sizing(&self, since: u64) -> Result<sizing::History, StoreError> {
771 self.read(move |db| read::sizing(db, since)).await
772 }
773
774 pub async fn insights(&self, since: u64) -> Result<Vec<crate::insights::Run>, StoreError> {
776 self.read(move |db| read::insights(db, since)).await
777 }
778
779 pub async fn last_event(&self) -> Result<u64, StoreError> {
781 self.read(read::last_event).await
782 }
783
784 pub async fn notify_state(
786 &self,
787 ) -> Result<BTreeMap<String, BTreeMap<String, u64>>, StoreError> {
788 self.read(read::notify_state).await
789 }
790
791 pub async fn warm(&self, since: u64) -> Result<Vec<warm::Seen>, StoreError> {
793 self.read(move |db| read::warm(db, since)).await
794 }
795
796 pub async fn cache_sizes_now(&self) -> Result<Measured, StoreError> {
798 self.read(read::latest_cache_sizes).await
799 }
800
801 pub async fn cache_history(&self, since: u64) -> Result<Vec<api::CacheSize>, StoreError> {
803 self.read(move |db| read::cache_history(db, since)).await
804 }
805
806 pub async fn open_jobs(&self) -> Result<Vec<OpenJob>, StoreError> {
808 self.read(read::open_jobs).await
809 }
810
811 pub async fn runner_job(&self, runner: &str) -> Result<Option<u64>, StoreError> {
813 let runner = runner.to_owned();
814 self.read(move |db| read::runner_job(db, &runner)).await
815 }
816
817 pub async fn log_position(&self, job: u64) -> Result<LogPosition, StoreError> {
819 self.read(move |db| read::log_position(db, job)).await
820 }
821
822 pub async fn mirror_log_until(&self) -> Result<MirrorPosition, StoreError> {
824 self.read(read::mirror_log_until).await
825 }
826}
827
828pub fn sizing_at(path: &Path, since: u64) -> Result<Option<sizing::History>, StoreError> {
833 if !path.exists() {
834 return Ok(None);
835 }
836 let read = |path: &str, flags| -> Result<sizing::History, StoreError> {
837 let db = Connection::open_with_flags(path, flags)?;
838 db.busy_timeout(Duration::from_secs(5))?;
839 sizing_of(&db, since)
840 };
841 let name = path.to_string_lossy();
842 match read(&name, OpenFlags::SQLITE_OPEN_READ_ONLY) {
843 Err(StoreError::Sqlite(error))
844 if matches!(
845 error.sqlite_error_code(),
846 Some(rusqlite::ErrorCode::ReadOnly | rusqlite::ErrorCode::CannotOpen)
847 ) =>
848 {
849 let mut wal = path.as_os_str().to_owned();
851 wal.push("-wal");
852 let wal = PathBuf::from(wal);
853 if std::fs::metadata(&wal).is_ok_and(|wal| wal.len() > 0) {
854 return Err(StoreError::Unread(wal));
855 }
856 let uri = format!(
857 "file:{}?mode=ro&immutable=1",
858 name.replace('%', "%25")
859 .replace('?', "%3f")
860 .replace('#', "%23")
861 );
862 read(
863 &uri,
864 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
865 )
866 }
867 history => history,
868 }
869 .map(Some)
870}
871
872fn sizing_of(db: &Connection, since: u64) -> Result<sizing::History, StoreError> {
874 let found: u32 = db.pragma_query_value(None, "user_version", |row| row.get(0))?;
875 let supported = u32::try_from(MIGRATIONS.len()).unwrap_or(u32::MAX);
876 if found > supported {
877 return Err(StoreError::Newer { found, supported });
878 }
879 if found == 0 {
880 return Ok(sizing::History::default());
881 }
882 let added = match found {
884 1 => {
885 "NULL AS memory_cap, NULL AS cpu_cap, NULL AS admission, NULL AS oom_kills, NULL AS ooms, \
886 NULL AS memory_measure"
887 }
888 2..=6 => "NULL AS admission, NULL AS oom_kills, NULL AS ooms, NULL AS memory_measure",
889 7 => "NULL AS ooms, NULL AS memory_measure",
890 _ => "",
891 };
892 if !added.is_empty() {
893 db.execute_batch(&format!(
894 "CREATE TEMP VIEW runners AS SELECT *, {added} FROM main.runners"
895 ))?;
896 }
897 Ok(read::sizing(db, since)?)
898}
899
900fn configure(db: &Connection) -> rusqlite::Result<()> {
902 db.pragma_update(None, "journal_mode", "wal")?;
903 db.pragma_update(None, "synchronous", "normal")?;
904 db.pragma_update(None, "foreign_keys", "on")?;
905 db.busy_timeout(Duration::from_secs(5))
906}
907
908fn migrate(db: &mut Connection) -> Result<(), StoreError> {
910 let found: u32 = db.pragma_query_value(None, "user_version", |row| row.get(0))?;
911 let supported = u32::try_from(MIGRATIONS.len()).unwrap_or(u32::MAX);
912 if found > supported {
913 return Err(StoreError::Newer { found, supported });
914 }
915 for (version, migration) in (1..).zip(MIGRATIONS).skip(found as usize) {
916 let transaction = db.transaction()?;
917 transaction.execute_batch(migration)?;
918 transaction.pragma_update(None, "user_version", version)?;
919 transaction.commit()?;
920 }
921 Ok(())
922}
923
924fn logged<T>(what: &str, result: rusqlite::Result<T>) -> Option<T> {
926 result
927 .map_err(|error| warn!("job history: cannot {what}: {error}"))
928 .ok()
929}
930
931#[cfg(test)]
932mod tests;