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::sizing;
22use crate::sync::lock;
23
24mod prune;
25mod read;
26mod write;
27
28const MIGRATIONS: &[&str] = &[
30 include_str!("store/migrations/0001_history.sql"),
31 include_str!("store/migrations/0002_runner_caps.sql"),
32 include_str!("store/migrations/0003_paused.sql"),
33 include_str!("store/migrations/0004_prune.sql"),
34 include_str!("store/migrations/0005_announced_profiles.sql"),
35 include_str!("store/migrations/0006_records.sql"),
36 include_str!("store/migrations/0007_admission.sql"),
37];
38
39pub(crate) const LOG_BACKLOG: u64 = 8 << 20;
42
43const HOST_SAMPLE_BATCH: usize = 30;
46
47const EVENT_BUFFER: usize = 4096;
49
50#[derive(Debug, thiserror::Error)]
52pub enum StoreError {
53 #[error("{0}")]
55 Sqlite(#[from] rusqlite::Error),
56 #[error(
58 "the job history database is version {found}, newer than the {supported} this runnerd reads; \
59 run a newer runnerd or move the database aside"
60 )]
61 Newer {
62 found: u32,
64 supported: u32,
66 },
67 #[error("{0}")]
69 Io(#[from] std::io::Error),
70 #[error("a database read stopped: {0}")]
72 Join(#[from] tokio::task::JoinError),
73 #[error(
76 "{} holds changes not yet in the job history, which this user cannot read; run runnerd doctor as the user \
77 the daemon runs as",
78 .0.display()
79 )]
80 Unread(PathBuf),
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct RunnerRecord {
86 pub name: String,
88 pub repository: String,
90 pub profile: String,
92 pub slot: usize,
94 pub container: String,
96 pub started_at: u64,
98 pub memory_cap: Option<u64>,
100 pub cpu_cap: Option<u64>,
102 pub admission: Option<api::Admission>,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct SampleRecord {
109 pub runner: String,
111 pub at: u64,
113 pub cpu: u64,
115 pub cpu_usage: u64,
117 pub memory: u64,
119 pub network_received: u64,
121 pub network_sent: u64,
123 pub oom_kills: u64,
125}
126
127#[derive(Debug, Clone, Default, PartialEq, Eq)]
129pub struct Measured {
130 pub at: Option<u64>,
132 pub bytes: BTreeMap<String, u64>,
134}
135
136#[derive(Debug, Clone, Default, PartialEq, Eq)]
139pub struct Announced {
140 pub profiles: Vec<api::ProfileStatus>,
142 pub repositories: Vec<api::RepositoryStatus>,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct OpenJob {
149 pub id: u64,
151 pub repository: String,
153 pub state: api::JobState,
155 pub runner: Option<String>,
157 pub runner_removed_at: Option<u64>,
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub struct LogPosition {
164 pub page: u64,
166 pub offset: u64,
168}
169
170#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
174pub struct MirrorPosition {
175 pub at: u64,
177 pub seen: u64,
179}
180
181#[derive(Debug)]
183enum Write {
184 Queued {
185 repository: String,
186 job: QueuedJob,
187 },
188 RunnerStarted(RunnerRecord),
189 RunnerAdmission {
190 runner: String,
191 admission: api::Admission,
192 },
193 RunnerOomKilled(String),
194 RunnerRemoved {
195 runner: String,
196 at: u64,
197 },
198 RunnersGone {
199 running: Vec<String>,
200 at: u64,
201 },
202 Claimed {
203 job: u64,
204 run_id: u64,
205 runner: String,
206 at: u64,
207 },
208 Details {
209 details: JobDetails,
210 },
211 Elsewhere {
212 details: JobDetails,
213 },
214 Lost {
215 job: u64,
216 at: u64,
217 },
218 Log {
219 job: u64,
220 lines: Vec<String>,
221 position: Option<LogPosition>,
223 max_bytes: u64,
224 _backlog: OwnedSemaphorePermit,
226 },
227 Samples(Vec<SampleRecord>),
228 HostSample(api::HostSample),
229 MirrorBytes {
230 bytes: Vec<(String, u64)>,
231 until: MirrorPosition,
232 done: oneshot::Sender<bool>,
234 },
235 CacheSizes {
236 at: u64,
237 sizes: BTreeMap<String, u64>,
238 },
239 Event(Event),
240 RecordMade(api::Record),
241 RecordSkipped {
242 job: u64,
243 why: String,
244 },
245 RecordRemoved {
246 job: u64,
247 at: u64,
248 by: api::RecordRemoval,
249 },
250 Paused {
251 repositories: Vec<String>,
252 done: oneshot::Sender<bool>,
254 },
255 Flush(oneshot::Sender<()>),
256}
257
258#[derive(Debug, Clone)]
260pub struct Store {
261 writes: mpsc::UnboundedSender<Write>,
262 reader: Arc<Mutex<Connection>>,
263 pruner: Arc<Mutex<Connection>>,
265 closed: Arc<AtomicBool>,
267 held: Arc<Mutex<Vec<api::HostSample>>>,
270 latest_host: Arc<Mutex<Option<api::HostSample>>>,
272 latest_samples: Arc<Mutex<BTreeMap<String, SampleRecord>>>,
274 events: broadcast::Sender<Arc<EventEnvelope>>,
275 backlog: Arc<Semaphore>,
276}
277
278impl Store {
279 pub fn open(path: &Path) -> Result<Store, StoreError> {
282 if let Some(dir) = path.parent().filter(|dir| !dir.as_os_str().is_empty()) {
283 std::fs::DirBuilder::new()
284 .recursive(true)
285 .mode(0o700)
286 .create(dir)?;
287 }
288 std::fs::OpenOptions::new()
290 .create(true)
291 .append(true)
292 .mode(0o600)
293 .open(path)?;
294 let mut db = Connection::open(path)?;
295 configure(&db)?;
296 migrate(&mut db)?;
297 let reader = Connection::open_with_flags(
298 path,
299 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
300 )?;
301 configure(&reader)?;
302 let pruner = Connection::open(path)?;
303 configure(&pruner)?;
304 let (events, _) = broadcast::channel(EVENT_BUFFER);
305 let (writes, receiver) = mpsc::unbounded_channel();
306 let thread_events = events.clone();
307 let held = Arc::new(Mutex::new(Vec::new()));
308 let thread_held = held.clone();
309 std::thread::Builder::new()
310 .name("runnerd-store".to_owned())
311 .spawn(move || write::run(db, receiver, &thread_events, &thread_held))?;
312 Ok(Store {
313 writes,
314 reader: Arc::new(Mutex::new(reader)),
315 pruner: Arc::new(Mutex::new(pruner)),
316 closed: Arc::new(AtomicBool::new(false)),
317 held,
318 latest_host: Arc::new(Mutex::new(None)),
319 latest_samples: Arc::new(Mutex::new(BTreeMap::new())),
320 events,
321 backlog: Arc::new(Semaphore::new(
322 usize::try_from(LOG_BACKLOG).unwrap_or(usize::MAX),
323 )),
324 })
325 }
326
327 fn send(&self, write: Write) {
328 let _ = self.writes.send(write);
330 }
331
332 pub fn queued(&self, repository: &str, job: &QueuedJob) {
334 self.send(Write::Queued {
335 repository: repository.to_owned(),
336 job: job.clone(),
337 });
338 }
339
340 pub fn runner_started(&self, runner: RunnerRecord) {
342 self.send(Write::RunnerStarted(runner));
343 }
344
345 pub fn runner_removed(&self, runner: &str, at: u64) {
347 lock(&self.latest_samples).remove(runner);
348 self.send(Write::RunnerRemoved {
349 runner: runner.to_owned(),
350 at,
351 });
352 }
353
354 pub fn runners_gone(&self, running: Vec<String>, at: u64) {
357 lock(&self.latest_samples).retain(|runner, _| running.contains(runner));
358 self.send(Write::RunnersGone { running, at });
359 }
360
361 pub fn claimed(&self, job: u64, run_id: u64, runner: &str, at: u64) {
363 self.send(Write::Claimed {
364 job,
365 run_id,
366 runner: runner.to_owned(),
367 at,
368 });
369 }
370
371 pub fn details(&self, details: JobDetails) {
373 self.send(Write::Details { details });
374 }
375
376 pub fn elsewhere(&self, details: JobDetails) {
378 self.send(Write::Elsewhere { details });
379 }
380
381 pub fn lost(&self, job: u64, at: u64) {
383 self.send(Write::Lost { job, at });
384 }
385
386 pub async fn log(&self, job: u64, lines: Vec<String>, position: LogPosition, max_bytes: u64) {
389 let size = |line: &String| line.len() as u64 + 1;
390 let mut lines = lines.into_iter().map(|mut line| {
391 if size(&line) > LOG_BACKLOG {
393 let mut end = usize::try_from(LOG_BACKLOG / 2).unwrap_or(usize::MAX);
394 while !line.is_char_boundary(end) {
395 end -= 1;
396 }
397 line.truncate(end);
398 }
399 line
400 });
401 let mut next = lines.next();
402 loop {
403 let (mut part, mut bytes) = (Vec::new(), 0);
404 while let Some(line) = next.take_if(|line| bytes + size(line) <= LOG_BACKLOG) {
405 bytes += size(&line);
406 part.push(line);
407 next = lines.next();
408 }
409 let last = next.is_none();
410 let share = u32::try_from(bytes.max(1)).unwrap_or(u32::MAX);
411 let Ok(backlog) = self.backlog.clone().acquire_many_owned(share).await else {
413 return;
414 };
415 self.send(Write::Log {
416 job,
417 lines: part,
418 position: last.then_some(position),
419 max_bytes,
420 _backlog: backlog,
421 });
422 if last {
423 return;
424 }
425 }
426 }
427
428 pub fn samples(&self, samples: Vec<SampleRecord>) {
430 let mut latest = lock(&self.latest_samples);
431 for sample in &samples {
432 latest.insert(sample.runner.clone(), sample.clone());
433 }
434 drop(latest);
435 self.send(Write::Samples(samples));
436 }
437
438 pub fn host_sample(&self, sample: api::HostSample) {
441 *lock(&self.latest_host) = Some(sample.clone());
442 lock(&self.held).push(sample.clone());
443 self.send(Write::HostSample(sample));
444 }
445
446 pub fn latest_sample(&self, runner: &str) -> Option<SampleRecord> {
448 lock(&self.latest_samples).get(runner).cloned()
449 }
450
451 pub fn runner_admission(&self, runner: &str, admission: api::Admission) {
453 self.send(Write::RunnerAdmission {
454 runner: runner.to_owned(),
455 admission,
456 });
457 }
458
459 pub fn runner_oom_killed(&self, runner: &str) {
462 self.send(Write::RunnerOomKilled(runner.to_owned()));
463 }
464
465 pub fn latest_host_sample(&self) -> Option<api::HostSample> {
467 lock(&self.latest_host).clone()
468 }
469
470 pub async fn mirror_bytes(&self, bytes: Vec<(String, u64)>, until: MirrorPosition) -> bool {
473 let (done, committed) = oneshot::channel();
474 self.send(Write::MirrorBytes { bytes, until, done });
475 committed.await.unwrap_or(false)
476 }
477
478 pub fn cache_sizes(&self, at: u64, sizes: BTreeMap<String, u64>) {
480 self.send(Write::CacheSizes { at, sizes });
481 }
482
483 pub fn event(&self, event: Event) {
485 self.send(Write::Event(event));
486 }
487
488 pub fn record_made(&self, record: api::Record) {
490 self.send(Write::RecordMade(record));
491 }
492
493 pub fn record_skipped(&self, job: u64, why: String) {
495 self.send(Write::RecordSkipped { job, why });
496 }
497
498 pub fn record_removed(&self, job: u64, at: u64, by: api::RecordRemoval) {
500 self.send(Write::RecordRemoved { job, at, by });
501 }
502
503 pub fn paused(&self, repositories: Vec<String>) -> impl Future<Output = bool> + use<> {
506 let (done, committed) = oneshot::channel();
507 self.send(Write::Paused { repositories, done });
508 async move { committed.await.unwrap_or(false) }
509 }
510
511 pub fn prune(&self, now: u64, history: &History) -> tokio::task::JoinHandle<()> {
515 let pruner = self.pruner.clone();
516 let closed = self.closed.clone();
517 let history = history.clone();
518 tokio::task::spawn_blocking(move || {
519 let db = match pruner.try_lock() {
520 Ok(db) => db,
521 Err(TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
522 Err(TryLockError::WouldBlock) => return,
523 };
524 let db = prune::Db {
525 connection: &db,
526 stop: &closed,
527 };
528 if !db.stop.load(Ordering::Relaxed) {
529 prune::run(&db, now, &history);
530 }
531 })
532 }
533
534 pub fn close(&self) {
537 self.closed.store(true, Ordering::Relaxed);
538 }
539
540 pub async fn flush(&self) {
542 let (done, wait) = oneshot::channel();
543 self.send(Write::Flush(done));
544 let _ = wait.await;
545 }
546
547 pub fn subscribe(&self) -> broadcast::Receiver<Arc<EventEnvelope>> {
550 self.events.subscribe()
551 }
552
553 async fn read<T: Send + 'static>(
554 &self,
555 query: impl FnOnce(&Connection) -> rusqlite::Result<T> + Send + 'static,
556 ) -> Result<T, StoreError> {
557 let reader = self.reader.clone();
558 Ok(tokio::task::spawn_blocking(move || query(&lock(&reader))).await??)
560 }
561
562 pub async fn jobs(&self, query: api::JobQuery) -> Result<api::JobPage, StoreError> {
564 self.read(move |db| read::jobs(db, &query)).await
565 }
566
567 pub async fn job(&self, id: u64) -> Result<Option<api::Job>, StoreError> {
569 self.read(move |db| read::job(db, id)).await
570 }
571
572 pub async fn job_samples(
574 &self,
575 id: u64,
576 interval: Duration,
577 ) -> Result<Option<api::SampleSeries>, StoreError> {
578 self.read(move |db| read::job_samples(db, id, interval))
579 .await
580 }
581
582 pub async fn log_lines(
584 &self,
585 job: u64,
586 from: u64,
587 limit: u64,
588 ) -> Result<Vec<(u64, String)>, StoreError> {
589 self.read(move |db| read::log_lines(db, job, from, limit))
590 .await
591 }
592
593 pub async fn events_after(
595 &self,
596 after: u64,
597 limit: u64,
598 ) -> Result<Vec<EventEnvelope>, StoreError> {
599 self.read(move |db| read::events_after(db, after, limit))
600 .await
601 }
602
603 pub fn announced(&self) -> Result<Announced, StoreError> {
606 Ok(read::announced(&lock(&self.reader))?)
607 }
608
609 pub fn paused_repositories(&self) -> Result<Vec<String>, StoreError> {
611 Ok(read::paused(&lock(&self.reader))?)
612 }
613
614 pub async fn host_samples(&self, since: u64) -> Result<Vec<api::HostSample>, StoreError> {
616 let held = lock(&self.held).clone();
619 let mut samples = self.read(move |db| read::host_samples(db, since)).await?;
620 let stored = samples.last().map_or(0, |sample| sample.at);
621 samples.extend(
622 held.into_iter()
623 .filter(|sample| sample.at >= since && sample.at > stored),
624 );
625 Ok(samples)
626 }
627
628 pub async fn stats(&self, query: api::StatsQuery, now: u64) -> Result<api::Stats, StoreError> {
630 self.read(move |db| read::stats(db, &query, now)).await
631 }
632
633 pub async fn sizing(&self, since: u64) -> Result<sizing::History, StoreError> {
635 self.read(move |db| read::sizing(db, since)).await
636 }
637
638 pub async fn cache_sizes_now(&self) -> Result<Measured, StoreError> {
640 self.read(read::latest_cache_sizes).await
641 }
642
643 pub async fn cache_history(&self, since: u64) -> Result<Vec<api::CacheSize>, StoreError> {
645 self.read(move |db| read::cache_history(db, since)).await
646 }
647
648 pub async fn open_jobs(&self) -> Result<Vec<OpenJob>, StoreError> {
650 self.read(read::open_jobs).await
651 }
652
653 pub async fn runner_job(&self, runner: &str) -> Result<Option<u64>, StoreError> {
655 let runner = runner.to_owned();
656 self.read(move |db| read::runner_job(db, &runner)).await
657 }
658
659 pub async fn log_position(&self, job: u64) -> Result<LogPosition, StoreError> {
661 self.read(move |db| read::log_position(db, job)).await
662 }
663
664 pub async fn mirror_log_until(&self) -> Result<MirrorPosition, StoreError> {
666 self.read(read::mirror_log_until).await
667 }
668}
669
670pub fn sizing_at(path: &Path, since: u64) -> Result<Option<sizing::History>, StoreError> {
675 if !path.exists() {
676 return Ok(None);
677 }
678 let read = |path: &str, flags| -> Result<sizing::History, StoreError> {
679 let db = Connection::open_with_flags(path, flags)?;
680 db.busy_timeout(Duration::from_secs(5))?;
681 sizing_of(&db, since)
682 };
683 let name = path.to_string_lossy();
684 match read(&name, OpenFlags::SQLITE_OPEN_READ_ONLY) {
685 Err(StoreError::Sqlite(error))
686 if matches!(
687 error.sqlite_error_code(),
688 Some(rusqlite::ErrorCode::ReadOnly | rusqlite::ErrorCode::CannotOpen)
689 ) =>
690 {
691 let mut wal = path.as_os_str().to_owned();
693 wal.push("-wal");
694 let wal = PathBuf::from(wal);
695 if std::fs::metadata(&wal).is_ok_and(|wal| wal.len() > 0) {
696 return Err(StoreError::Unread(wal));
697 }
698 let uri = format!(
699 "file:{}?mode=ro&immutable=1",
700 name.replace('%', "%25")
701 .replace('?', "%3f")
702 .replace('#', "%23")
703 );
704 read(
705 &uri,
706 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
707 )
708 }
709 history => history,
710 }
711 .map(Some)
712}
713
714fn sizing_of(db: &Connection, since: u64) -> Result<sizing::History, StoreError> {
716 let found: u32 = db.pragma_query_value(None, "user_version", |row| row.get(0))?;
717 let supported = u32::try_from(MIGRATIONS.len()).unwrap_or(u32::MAX);
718 if found > supported {
719 return Err(StoreError::Newer { found, supported });
720 }
721 if found == 0 {
722 return Ok(sizing::History::default());
723 }
724 let added = match found {
726 1 => "NULL AS memory_cap, NULL AS cpu_cap, NULL AS admission, NULL AS oom_kills",
727 2..=6 => "NULL AS admission, NULL AS oom_kills",
728 _ => "",
729 };
730 if !added.is_empty() {
731 db.execute_batch(&format!(
732 "CREATE TEMP VIEW runners AS SELECT *, {added} FROM main.runners"
733 ))?;
734 }
735 Ok(read::sizing(db, since)?)
736}
737
738fn configure(db: &Connection) -> rusqlite::Result<()> {
740 db.pragma_update(None, "journal_mode", "wal")?;
741 db.pragma_update(None, "synchronous", "normal")?;
742 db.pragma_update(None, "foreign_keys", "on")?;
743 db.busy_timeout(Duration::from_secs(5))
744}
745
746fn migrate(db: &mut Connection) -> Result<(), StoreError> {
748 let found: u32 = db.pragma_query_value(None, "user_version", |row| row.get(0))?;
749 let supported = u32::try_from(MIGRATIONS.len()).unwrap_or(u32::MAX);
750 if found > supported {
751 return Err(StoreError::Newer { found, supported });
752 }
753 for (version, migration) in (1..).zip(MIGRATIONS).skip(found as usize) {
754 let transaction = db.transaction()?;
755 transaction.execute_batch(migration)?;
756 transaction.pragma_update(None, "user_version", version)?;
757 transaction.commit()?;
758 }
759 Ok(())
760}
761
762fn logged<T>(what: &str, result: rusqlite::Result<T>) -> Option<T> {
764 result
765 .map_err(|error| warn!("job history: cannot {what}: {error}"))
766 .ok()
767}
768
769#[cfg(test)]
770mod tests;