1use std::collections::BTreeMap;
8use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
9use std::path::Path;
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::sync::lock;
22
23mod prune;
24mod read;
25mod write;
26
27const MIGRATIONS: &[&str] = &[
29 include_str!("store/migrations/0001_history.sql"),
30 include_str!("store/migrations/0002_runner_caps.sql"),
31 include_str!("store/migrations/0003_paused.sql"),
32 include_str!("store/migrations/0004_prune.sql"),
33 include_str!("store/migrations/0005_announced_profiles.sql"),
34];
35
36pub(crate) const LOG_BACKLOG: u64 = 8 << 20;
39
40const HOST_SAMPLE_BATCH: usize = 30;
43
44const EVENT_BUFFER: usize = 4096;
46
47#[derive(Debug, thiserror::Error)]
49pub enum StoreError {
50 #[error("{0}")]
52 Sqlite(#[from] rusqlite::Error),
53 #[error(
55 "the job history database is version {found}, newer than the {supported} this runnerd reads; \
56 run a newer runnerd or move the database aside"
57 )]
58 Newer {
59 found: u32,
61 supported: u32,
63 },
64 #[error("{0}")]
66 Io(#[from] std::io::Error),
67 #[error("a database read stopped: {0}")]
69 Join(#[from] tokio::task::JoinError),
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct RunnerRecord {
75 pub name: String,
77 pub repository: String,
79 pub profile: String,
81 pub slot: usize,
83 pub container: String,
85 pub started_at: u64,
87 pub memory_cap: Option<u64>,
89 pub cpu_cap: Option<u64>,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct SampleRecord {
96 pub runner: String,
98 pub at: u64,
100 pub cpu: u64,
102 pub cpu_usage: u64,
104 pub memory: u64,
106 pub network_received: u64,
108 pub network_sent: u64,
110}
111
112#[derive(Debug, Clone, Default, PartialEq, Eq)]
114pub struct Measured {
115 pub at: Option<u64>,
117 pub bytes: BTreeMap<String, u64>,
119}
120
121#[derive(Debug, Clone, Default, PartialEq, Eq)]
124pub struct Announced {
125 pub profiles: Vec<api::ProfileStatus>,
127 pub repositories: Vec<api::RepositoryStatus>,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct OpenJob {
134 pub id: u64,
136 pub repository: String,
138 pub state: api::JobState,
140 pub runner: Option<String>,
142 pub runner_removed_at: Option<u64>,
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub struct LogPosition {
149 pub page: u64,
151 pub offset: u64,
153}
154
155#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
159pub struct MirrorPosition {
160 pub at: u64,
162 pub seen: u64,
164}
165
166#[derive(Debug)]
168enum Write {
169 Queued {
170 repository: String,
171 job: QueuedJob,
172 },
173 RunnerStarted(RunnerRecord),
174 RunnerRemoved {
175 runner: String,
176 at: u64,
177 },
178 Claimed {
179 job: u64,
180 run_id: u64,
181 runner: String,
182 at: u64,
183 },
184 Details {
185 details: JobDetails,
186 },
187 Elsewhere {
188 details: JobDetails,
189 },
190 Lost {
191 job: u64,
192 at: u64,
193 },
194 Log {
195 job: u64,
196 lines: Vec<String>,
197 position: Option<LogPosition>,
199 max_bytes: u64,
200 _backlog: OwnedSemaphorePermit,
202 },
203 Samples(Vec<SampleRecord>),
204 HostSample(api::HostSample),
205 MirrorBytes {
206 bytes: Vec<(String, u64)>,
207 until: MirrorPosition,
208 done: oneshot::Sender<bool>,
210 },
211 CacheSizes {
212 at: u64,
213 sizes: BTreeMap<String, u64>,
214 },
215 Event(Event),
216 Paused {
217 repositories: Vec<String>,
218 done: oneshot::Sender<bool>,
220 },
221 Flush(oneshot::Sender<()>),
222}
223
224#[derive(Debug, Clone)]
226pub struct Store {
227 writes: mpsc::UnboundedSender<Write>,
228 reader: Arc<Mutex<Connection>>,
229 pruner: Arc<Mutex<Connection>>,
231 closed: Arc<AtomicBool>,
233 held: Arc<Mutex<Vec<api::HostSample>>>,
236 events: broadcast::Sender<Arc<EventEnvelope>>,
237 backlog: Arc<Semaphore>,
238}
239
240impl Store {
241 pub fn open(path: &Path) -> Result<Store, StoreError> {
244 if let Some(dir) = path.parent().filter(|dir| !dir.as_os_str().is_empty()) {
245 std::fs::DirBuilder::new()
246 .recursive(true)
247 .mode(0o700)
248 .create(dir)?;
249 }
250 std::fs::OpenOptions::new()
252 .create(true)
253 .append(true)
254 .mode(0o600)
255 .open(path)?;
256 let mut db = Connection::open(path)?;
257 configure(&db)?;
258 migrate(&mut db)?;
259 let reader = Connection::open_with_flags(
260 path,
261 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
262 )?;
263 configure(&reader)?;
264 let pruner = Connection::open(path)?;
265 configure(&pruner)?;
266 let (events, _) = broadcast::channel(EVENT_BUFFER);
267 let (writes, receiver) = mpsc::unbounded_channel();
268 let thread_events = events.clone();
269 let held = Arc::new(Mutex::new(Vec::new()));
270 let thread_held = held.clone();
271 std::thread::Builder::new()
272 .name("runnerd-store".to_owned())
273 .spawn(move || write::run(db, receiver, &thread_events, &thread_held))?;
274 Ok(Store {
275 writes,
276 reader: Arc::new(Mutex::new(reader)),
277 pruner: Arc::new(Mutex::new(pruner)),
278 closed: Arc::new(AtomicBool::new(false)),
279 held,
280 events,
281 backlog: Arc::new(Semaphore::new(
282 usize::try_from(LOG_BACKLOG).unwrap_or(usize::MAX),
283 )),
284 })
285 }
286
287 fn send(&self, write: Write) {
288 let _ = self.writes.send(write);
290 }
291
292 pub fn queued(&self, repository: &str, job: &QueuedJob) {
294 self.send(Write::Queued {
295 repository: repository.to_owned(),
296 job: job.clone(),
297 });
298 }
299
300 pub fn runner_started(&self, runner: RunnerRecord) {
302 self.send(Write::RunnerStarted(runner));
303 }
304
305 pub fn runner_removed(&self, runner: &str, at: u64) {
307 self.send(Write::RunnerRemoved {
308 runner: runner.to_owned(),
309 at,
310 });
311 }
312
313 pub fn claimed(&self, job: u64, run_id: u64, runner: &str, at: u64) {
315 self.send(Write::Claimed {
316 job,
317 run_id,
318 runner: runner.to_owned(),
319 at,
320 });
321 }
322
323 pub fn details(&self, details: JobDetails) {
325 self.send(Write::Details { details });
326 }
327
328 pub fn elsewhere(&self, details: JobDetails) {
330 self.send(Write::Elsewhere { details });
331 }
332
333 pub fn lost(&self, job: u64, at: u64) {
335 self.send(Write::Lost { job, at });
336 }
337
338 pub async fn log(&self, job: u64, lines: Vec<String>, position: LogPosition, max_bytes: u64) {
341 let size = |line: &String| line.len() as u64 + 1;
342 let mut lines = lines.into_iter().map(|mut line| {
343 if size(&line) > LOG_BACKLOG {
345 let mut end = usize::try_from(LOG_BACKLOG / 2).unwrap_or(usize::MAX);
346 while !line.is_char_boundary(end) {
347 end -= 1;
348 }
349 line.truncate(end);
350 }
351 line
352 });
353 let mut next = lines.next();
354 loop {
355 let (mut part, mut bytes) = (Vec::new(), 0);
356 while let Some(line) = next.take_if(|line| bytes + size(line) <= LOG_BACKLOG) {
357 bytes += size(&line);
358 part.push(line);
359 next = lines.next();
360 }
361 let last = next.is_none();
362 let share = u32::try_from(bytes.max(1)).unwrap_or(u32::MAX);
363 let Ok(backlog) = self.backlog.clone().acquire_many_owned(share).await else {
365 return;
366 };
367 self.send(Write::Log {
368 job,
369 lines: part,
370 position: last.then_some(position),
371 max_bytes,
372 _backlog: backlog,
373 });
374 if last {
375 return;
376 }
377 }
378 }
379
380 pub fn samples(&self, samples: Vec<SampleRecord>) {
382 self.send(Write::Samples(samples));
383 }
384
385 pub fn host_sample(&self, sample: api::HostSample) {
388 lock(&self.held).push(sample.clone());
389 self.send(Write::HostSample(sample));
390 }
391
392 pub async fn mirror_bytes(&self, bytes: Vec<(String, u64)>, until: MirrorPosition) -> bool {
395 let (done, committed) = oneshot::channel();
396 self.send(Write::MirrorBytes { bytes, until, done });
397 committed.await.unwrap_or(false)
398 }
399
400 pub fn cache_sizes(&self, at: u64, sizes: BTreeMap<String, u64>) {
402 self.send(Write::CacheSizes { at, sizes });
403 }
404
405 pub fn event(&self, event: Event) {
407 self.send(Write::Event(event));
408 }
409
410 pub fn paused(&self, repositories: Vec<String>) -> impl Future<Output = bool> + use<> {
413 let (done, committed) = oneshot::channel();
414 self.send(Write::Paused { repositories, done });
415 async move { committed.await.unwrap_or(false) }
416 }
417
418 pub fn prune(&self, now: u64, history: &History) -> tokio::task::JoinHandle<()> {
422 let pruner = self.pruner.clone();
423 let closed = self.closed.clone();
424 let history = history.clone();
425 tokio::task::spawn_blocking(move || {
426 let db = match pruner.try_lock() {
427 Ok(db) => db,
428 Err(TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
429 Err(TryLockError::WouldBlock) => return,
430 };
431 let db = prune::Db {
432 connection: &db,
433 stop: &closed,
434 };
435 if !db.stop.load(Ordering::Relaxed) {
436 prune::run(&db, now, &history);
437 }
438 })
439 }
440
441 pub fn close(&self) {
444 self.closed.store(true, Ordering::Relaxed);
445 }
446
447 pub async fn flush(&self) {
449 let (done, wait) = oneshot::channel();
450 self.send(Write::Flush(done));
451 let _ = wait.await;
452 }
453
454 pub fn subscribe(&self) -> broadcast::Receiver<Arc<EventEnvelope>> {
457 self.events.subscribe()
458 }
459
460 async fn read<T: Send + 'static>(
461 &self,
462 query: impl FnOnce(&Connection) -> rusqlite::Result<T> + Send + 'static,
463 ) -> Result<T, StoreError> {
464 let reader = self.reader.clone();
465 Ok(tokio::task::spawn_blocking(move || query(&lock(&reader))).await??)
467 }
468
469 pub async fn jobs(&self, query: api::JobQuery) -> Result<api::JobPage, StoreError> {
471 self.read(move |db| read::jobs(db, &query)).await
472 }
473
474 pub async fn job(&self, id: u64) -> Result<Option<api::Job>, StoreError> {
476 self.read(move |db| read::job(db, id)).await
477 }
478
479 pub async fn job_samples(
481 &self,
482 id: u64,
483 interval: Duration,
484 ) -> Result<Option<api::SampleSeries>, StoreError> {
485 self.read(move |db| read::job_samples(db, id, interval))
486 .await
487 }
488
489 pub async fn log_lines(
491 &self,
492 job: u64,
493 from: u64,
494 limit: u64,
495 ) -> Result<Vec<(u64, String)>, StoreError> {
496 self.read(move |db| read::log_lines(db, job, from, limit))
497 .await
498 }
499
500 pub async fn events_after(
502 &self,
503 after: u64,
504 limit: u64,
505 ) -> Result<Vec<EventEnvelope>, StoreError> {
506 self.read(move |db| read::events_after(db, after, limit))
507 .await
508 }
509
510 pub fn announced(&self) -> Result<Announced, StoreError> {
513 Ok(read::announced(&lock(&self.reader))?)
514 }
515
516 pub fn paused_repositories(&self) -> Result<Vec<String>, StoreError> {
518 Ok(read::paused(&lock(&self.reader))?)
519 }
520
521 pub async fn host_samples(&self, since: u64) -> Result<Vec<api::HostSample>, StoreError> {
523 let held = lock(&self.held).clone();
526 let mut samples = self.read(move |db| read::host_samples(db, since)).await?;
527 let stored = samples.last().map_or(0, |sample| sample.at);
528 samples.extend(
529 held.into_iter()
530 .filter(|sample| sample.at >= since && sample.at > stored),
531 );
532 Ok(samples)
533 }
534
535 pub async fn stats(&self, query: api::StatsQuery, now: u64) -> Result<api::Stats, StoreError> {
537 self.read(move |db| read::stats(db, &query, now)).await
538 }
539
540 pub async fn cache_sizes_now(&self) -> Result<Measured, StoreError> {
542 self.read(read::latest_cache_sizes).await
543 }
544
545 pub async fn cache_history(&self, since: u64) -> Result<Vec<api::CacheSize>, StoreError> {
547 self.read(move |db| read::cache_history(db, since)).await
548 }
549
550 pub async fn open_jobs(&self) -> Result<Vec<OpenJob>, StoreError> {
552 self.read(read::open_jobs).await
553 }
554
555 pub async fn runner_job(&self, runner: &str) -> Result<Option<u64>, StoreError> {
557 let runner = runner.to_owned();
558 self.read(move |db| read::runner_job(db, &runner)).await
559 }
560
561 pub async fn log_position(&self, job: u64) -> Result<LogPosition, StoreError> {
563 self.read(move |db| read::log_position(db, job)).await
564 }
565
566 pub async fn mirror_log_until(&self) -> Result<MirrorPosition, StoreError> {
568 self.read(read::mirror_log_until).await
569 }
570}
571
572fn configure(db: &Connection) -> rusqlite::Result<()> {
574 db.pragma_update(None, "journal_mode", "wal")?;
575 db.pragma_update(None, "synchronous", "normal")?;
576 db.pragma_update(None, "foreign_keys", "on")?;
577 db.busy_timeout(Duration::from_secs(5))
578}
579
580fn migrate(db: &mut Connection) -> Result<(), StoreError> {
582 let found: u32 = db.pragma_query_value(None, "user_version", |row| row.get(0))?;
583 let supported = u32::try_from(MIGRATIONS.len()).unwrap_or(u32::MAX);
584 if found > supported {
585 return Err(StoreError::Newer { found, supported });
586 }
587 for (version, migration) in (1..).zip(MIGRATIONS).skip(found as usize) {
588 let transaction = db.transaction()?;
589 transaction.execute_batch(migration)?;
590 transaction.pragma_update(None, "user_version", version)?;
591 transaction.commit()?;
592 }
593 Ok(())
594}
595
596fn logged<T>(what: &str, result: rusqlite::Result<T>) -> Option<T> {
598 result
599 .map_err(|error| warn!("job history: cannot {what}: {error}"))
600 .ok()
601}
602
603#[cfg(test)]
604mod tests;