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::sizing;
23use crate::sync::lock;
24
25mod prune;
26mod read;
27mod write;
28
29const 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
42pub(crate) const LOG_BACKLOG: u64 = 8 << 20;
45
46const HOST_SAMPLE_BATCH: usize = 30;
49
50const EVENT_BUFFER: usize = 4096;
52
53#[derive(Debug, thiserror::Error)]
55pub enum StoreError {
56 #[error("{0}")]
58 Sqlite(#[from] rusqlite::Error),
59 #[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 found: u32,
67 supported: u32,
69 },
70 #[error("{0}")]
72 Io(#[from] std::io::Error),
73 #[error("a database read stopped: {0}")]
75 Join(#[from] tokio::task::JoinError),
76 #[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#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct RunnerRecord {
89 pub name: String,
91 pub repository: String,
93 pub profile: String,
95 pub slot: usize,
97 pub container: String,
99 pub started_at: u64,
101 pub memory_cap: Option<u64>,
103 pub cpu_cap: Option<u64>,
105 pub admission: Option<api::Admission>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct SampleRecord {
112 pub runner: String,
114 pub at: u64,
116 pub cpu: u64,
118 pub cpu_usage: u64,
120 pub memory: u64,
122 pub network_received: u64,
124 pub network_sent: u64,
126 pub events: Events,
128}
129
130#[derive(Debug, Clone, Default, PartialEq, Eq)]
132pub struct Measured {
133 pub at: Option<u64>,
135 pub bytes: BTreeMap<String, u64>,
137}
138
139#[derive(Debug, Clone, Default, PartialEq, Eq)]
142pub struct Announced {
143 pub profiles: Vec<api::ProfileStatus>,
145 pub repositories: Vec<api::RepositoryStatus>,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct OpenJob {
152 pub id: u64,
154 pub repository: String,
156 pub state: api::JobState,
158 pub runner: Option<String>,
160 pub runner_removed_at: Option<u64>,
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub struct LogPosition {
167 pub page: u64,
169 pub offset: u64,
171}
172
173#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
177pub struct MirrorPosition {
178 pub at: u64,
180 pub seen: u64,
182}
183
184#[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 position: Option<LogPosition>,
226 max_bytes: u64,
227 _backlog: OwnedSemaphorePermit,
229 },
230 Samples(Vec<SampleRecord>),
231 HostSample(api::HostSample),
232 MirrorBytes {
233 bytes: Vec<(String, u64)>,
234 until: MirrorPosition,
235 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 done: oneshot::Sender<bool>,
257 },
258 SizingIntent {
259 change: api::SizingChange,
260 done: oneshot::Sender<Option<i64>>,
262 },
263 SizingResolved {
264 id: i64,
265 resolution: Resolution,
266 done: oneshot::Sender<bool>,
268 },
269 Flush(oneshot::Sender<()>),
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274pub enum Resolution {
275 Done,
277 Failed,
279 Uncertain,
282}
283
284#[derive(Debug, Clone, Default, PartialEq, Eq)]
286pub struct AutoSizing {
287 pub last: BTreeMap<String, u64>,
289 pub pending: Vec<(i64, api::SizingChange)>,
291}
292
293#[derive(Debug, Clone)]
295pub struct Store {
296 writes: mpsc::UnboundedSender<Write>,
297 reader: Arc<Mutex<Connection>>,
298 pruner: Arc<Mutex<Connection>>,
300 closed: Arc<AtomicBool>,
302 held: Arc<Mutex<Vec<api::HostSample>>>,
305 latest_host: Arc<Mutex<Option<api::HostSample>>>,
307 latest_samples: Arc<Mutex<BTreeMap<String, SampleRecord>>>,
309 events: broadcast::Sender<Arc<EventEnvelope>>,
310 backlog: Arc<Semaphore>,
311}
312
313impl Store {
314 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 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 let _ = self.writes.send(write);
365 }
366
367 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 pub fn runner_started(&self, runner: RunnerRecord) {
377 self.send(Write::RunnerStarted(runner));
378 }
379
380 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 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 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 pub fn details(&self, details: JobDetails) {
408 self.send(Write::Details { details });
409 }
410
411 pub fn elsewhere(&self, details: JobDetails) {
413 self.send(Write::Elsewhere { details });
414 }
415
416 pub fn lost(&self, job: u64, at: u64) {
418 self.send(Write::Lost { job, at });
419 }
420
421 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 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 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 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 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 pub fn latest_sample(&self, runner: &str) -> Option<SampleRecord> {
483 lock(&self.latest_samples).get(runner).cloned()
484 }
485
486 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 pub fn runner_oom_killed(&self, runner: &str) {
497 self.send(Write::RunnerOomKilled(runner.to_owned()));
498 }
499
500 pub fn latest_host_sample(&self) -> Option<api::HostSample> {
502 lock(&self.latest_host).clone()
503 }
504
505 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 pub fn cache_sizes(&self, at: u64, sizes: BTreeMap<String, u64>) {
515 self.send(Write::CacheSizes { at, sizes });
516 }
517
518 pub fn event(&self, event: Event) {
520 self.send(Write::Event(event));
521 }
522
523 pub fn record_made(&self, record: api::Record) {
525 self.send(Write::RecordMade(record));
526 }
527
528 pub fn record_skipped(&self, job: u64, why: String) {
530 self.send(Write::RecordSkipped { job, why });
531 }
532
533 pub fn record_removed(&self, job: u64, at: u64, by: api::RecordRemoval) {
535 self.send(Write::RecordRemoved { job, at, by });
536 }
537
538 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 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 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 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 pub fn close(&self) {
599 self.closed.store(true, Ordering::Relaxed);
600 }
601
602 pub async fn flush(&self) {
604 let (done, wait) = oneshot::channel();
605 self.send(Write::Flush(done));
606 let _ = wait.await;
607 }
608
609 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 Ok(tokio::task::spawn_blocking(move || query(&lock(&reader))).await??)
622 }
623
624 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 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 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 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 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 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 pub async fn auto_sizing(&self) -> Result<AutoSizing, StoreError> {
672 self.read(read::auto_sizing).await
673 }
674
675 pub fn announced(&self) -> Result<Announced, StoreError> {
678 Ok(read::announced(&lock(&self.reader))?)
679 }
680
681 pub fn paused_repositories(&self) -> Result<Vec<String>, StoreError> {
683 Ok(read::paused(&lock(&self.reader))?)
684 }
685
686 pub async fn host_samples(&self, since: u64) -> Result<Vec<api::HostSample>, StoreError> {
688 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 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 pub async fn sizing(&self, since: u64) -> Result<sizing::History, StoreError> {
707 self.read(move |db| read::sizing(db, since)).await
708 }
709
710 pub async fn cache_sizes_now(&self) -> Result<Measured, StoreError> {
712 self.read(read::latest_cache_sizes).await
713 }
714
715 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 pub async fn open_jobs(&self) -> Result<Vec<OpenJob>, StoreError> {
722 self.read(read::open_jobs).await
723 }
724
725 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 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 pub async fn mirror_log_until(&self) -> Result<MirrorPosition, StoreError> {
738 self.read(read::mirror_log_until).await
739 }
740}
741
742pub 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 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
786fn 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 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
814fn 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
822fn 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
838fn 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;