1use std::collections::BTreeMap;
7use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
8use std::path::Path;
9use std::sync::{Arc, Mutex};
10use std::time::Duration;
11
12use rusqlite::{Connection, OpenFlags};
13use tokio::sync::{OwnedSemaphorePermit, Semaphore, broadcast, mpsc, oneshot};
14use tracing::warn;
15
16use crate::api::{self, Event, EventEnvelope};
17use crate::config::History;
18use crate::github::{JobDetails, QueuedJob};
19use crate::sync::lock;
20
21mod read;
22mod write;
23
24const MIGRATIONS: &[&str] = &[
26 include_str!("store/migrations/0001_history.sql"),
27 include_str!("store/migrations/0002_runner_caps.sql"),
28];
29
30pub(crate) const LOG_BACKLOG: u64 = 8 << 20;
33
34const EVENT_BUFFER: usize = 4096;
36
37#[derive(Debug, thiserror::Error)]
39pub enum StoreError {
40 #[error("{0}")]
42 Sqlite(#[from] rusqlite::Error),
43 #[error(
45 "the job history database is version {found}, newer than the {supported} this runnerd reads; \
46 run a newer runnerd or move the database aside"
47 )]
48 Newer {
49 found: u32,
51 supported: u32,
53 },
54 #[error("{0}")]
56 Io(#[from] std::io::Error),
57 #[error("a database read stopped: {0}")]
59 Join(#[from] tokio::task::JoinError),
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct RunnerRecord {
65 pub name: String,
67 pub repository: String,
69 pub profile: String,
71 pub slot: usize,
73 pub container: String,
75 pub started_at: u64,
77 pub memory_cap: Option<u64>,
79 pub cpu_cap: Option<u64>,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct SampleRecord {
86 pub runner: String,
88 pub at: u64,
90 pub cpu: u64,
92 pub cpu_usage: u64,
94 pub memory: u64,
96 pub network_received: u64,
98 pub network_sent: u64,
100}
101
102#[derive(Debug, Clone, Default, PartialEq, Eq)]
104pub struct Measured {
105 pub at: Option<u64>,
107 pub bytes: BTreeMap<String, u64>,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct OpenJob {
114 pub id: u64,
116 pub repository: String,
118 pub state: api::JobState,
120 pub runner: Option<String>,
122 pub runner_removed_at: Option<u64>,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub struct LogPosition {
129 pub page: u64,
131 pub offset: u64,
133}
134
135#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
139pub struct MirrorPosition {
140 pub at: u64,
142 pub seen: u64,
144}
145
146#[derive(Debug)]
148enum Write {
149 Queued {
150 repository: String,
151 job: QueuedJob,
152 },
153 RunnerStarted(RunnerRecord),
154 RunnerRemoved {
155 runner: String,
156 at: u64,
157 },
158 Claimed {
159 job: u64,
160 run_id: u64,
161 runner: String,
162 at: u64,
163 },
164 Details {
165 details: JobDetails,
166 },
167 Elsewhere {
168 details: JobDetails,
169 },
170 Lost {
171 job: u64,
172 at: u64,
173 },
174 Log {
175 job: u64,
176 lines: Vec<String>,
177 position: Option<LogPosition>,
179 max_bytes: u64,
180 _backlog: OwnedSemaphorePermit,
182 },
183 Samples(Vec<SampleRecord>),
184 HostSample(api::HostSample),
185 MirrorBytes {
186 bytes: Vec<(String, u64)>,
187 until: MirrorPosition,
188 done: oneshot::Sender<bool>,
190 },
191 CacheSizes {
192 at: u64,
193 sizes: BTreeMap<String, u64>,
194 },
195 Event(Event),
196 Prune {
197 now: u64,
198 history: History,
199 },
200 Flush(oneshot::Sender<()>),
201}
202
203#[derive(Debug, Clone)]
205pub struct Store {
206 writes: mpsc::UnboundedSender<Write>,
207 reader: Arc<Mutex<Connection>>,
208 events: broadcast::Sender<Arc<EventEnvelope>>,
209 backlog: Arc<Semaphore>,
210}
211
212impl Store {
213 pub fn open(path: &Path) -> Result<Store, StoreError> {
216 if let Some(dir) = path.parent().filter(|dir| !dir.as_os_str().is_empty()) {
217 std::fs::DirBuilder::new()
218 .recursive(true)
219 .mode(0o700)
220 .create(dir)?;
221 }
222 std::fs::OpenOptions::new()
224 .create(true)
225 .append(true)
226 .mode(0o600)
227 .open(path)?;
228 let mut db = Connection::open(path)?;
229 configure(&db)?;
230 migrate(&mut db)?;
231 let reader = Connection::open_with_flags(
232 path,
233 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
234 )?;
235 configure(&reader)?;
236 let (events, _) = broadcast::channel(EVENT_BUFFER);
237 let (writes, receiver) = mpsc::unbounded_channel();
238 let thread_events = events.clone();
239 std::thread::Builder::new()
240 .name("runnerd-store".to_owned())
241 .spawn(move || write::run(db, receiver, &thread_events))?;
242 Ok(Store {
243 writes,
244 reader: Arc::new(Mutex::new(reader)),
245 events,
246 backlog: Arc::new(Semaphore::new(
247 usize::try_from(LOG_BACKLOG).unwrap_or(usize::MAX),
248 )),
249 })
250 }
251
252 fn send(&self, write: Write) {
253 let _ = self.writes.send(write);
255 }
256
257 pub fn queued(&self, repository: &str, job: &QueuedJob) {
259 self.send(Write::Queued {
260 repository: repository.to_owned(),
261 job: job.clone(),
262 });
263 }
264
265 pub fn runner_started(&self, runner: RunnerRecord) {
267 self.send(Write::RunnerStarted(runner));
268 }
269
270 pub fn runner_removed(&self, runner: &str, at: u64) {
272 self.send(Write::RunnerRemoved {
273 runner: runner.to_owned(),
274 at,
275 });
276 }
277
278 pub fn claimed(&self, job: u64, run_id: u64, runner: &str, at: u64) {
280 self.send(Write::Claimed {
281 job,
282 run_id,
283 runner: runner.to_owned(),
284 at,
285 });
286 }
287
288 pub fn details(&self, details: JobDetails) {
290 self.send(Write::Details { details });
291 }
292
293 pub fn elsewhere(&self, details: JobDetails) {
295 self.send(Write::Elsewhere { details });
296 }
297
298 pub fn lost(&self, job: u64, at: u64) {
300 self.send(Write::Lost { job, at });
301 }
302
303 pub async fn log(&self, job: u64, lines: Vec<String>, position: LogPosition, max_bytes: u64) {
306 let size = |line: &String| line.len() as u64 + 1;
307 let mut lines = lines.into_iter().map(|mut line| {
308 if size(&line) > LOG_BACKLOG {
310 let mut end = usize::try_from(LOG_BACKLOG / 2).unwrap_or(usize::MAX);
311 while !line.is_char_boundary(end) {
312 end -= 1;
313 }
314 line.truncate(end);
315 }
316 line
317 });
318 let mut next = lines.next();
319 loop {
320 let (mut part, mut bytes) = (Vec::new(), 0);
321 while let Some(line) = next.take_if(|line| bytes + size(line) <= LOG_BACKLOG) {
322 bytes += size(&line);
323 part.push(line);
324 next = lines.next();
325 }
326 let last = next.is_none();
327 let share = u32::try_from(bytes.max(1)).unwrap_or(u32::MAX);
328 let Ok(backlog) = self.backlog.clone().acquire_many_owned(share).await else {
330 return;
331 };
332 self.send(Write::Log {
333 job,
334 lines: part,
335 position: last.then_some(position),
336 max_bytes,
337 _backlog: backlog,
338 });
339 if last {
340 return;
341 }
342 }
343 }
344
345 pub fn samples(&self, samples: Vec<SampleRecord>) {
347 self.send(Write::Samples(samples));
348 }
349
350 pub fn host_sample(&self, sample: api::HostSample) {
352 self.send(Write::HostSample(sample));
353 }
354
355 pub async fn mirror_bytes(&self, bytes: Vec<(String, u64)>, until: MirrorPosition) -> bool {
358 let (done, committed) = oneshot::channel();
359 self.send(Write::MirrorBytes { bytes, until, done });
360 committed.await.unwrap_or(false)
361 }
362
363 pub fn cache_sizes(&self, at: u64, sizes: BTreeMap<String, u64>) {
365 self.send(Write::CacheSizes { at, sizes });
366 }
367
368 pub fn event(&self, event: Event) {
370 self.send(Write::Event(event));
371 }
372
373 pub fn prune(&self, now: u64, history: &History) {
375 self.send(Write::Prune {
376 now,
377 history: history.clone(),
378 });
379 }
380
381 pub async fn flush(&self) {
383 let (done, wait) = oneshot::channel();
384 self.send(Write::Flush(done));
385 let _ = wait.await;
386 }
387
388 pub fn subscribe(&self) -> broadcast::Receiver<Arc<EventEnvelope>> {
391 self.events.subscribe()
392 }
393
394 async fn read<T: Send + 'static>(
395 &self,
396 query: impl FnOnce(&Connection) -> rusqlite::Result<T> + Send + 'static,
397 ) -> Result<T, StoreError> {
398 let reader = self.reader.clone();
399 Ok(tokio::task::spawn_blocking(move || query(&lock(&reader))).await??)
401 }
402
403 pub async fn jobs(&self, query: api::JobQuery) -> Result<api::JobPage, StoreError> {
405 self.read(move |db| read::jobs(db, &query)).await
406 }
407
408 pub async fn job(&self, id: u64) -> Result<Option<api::Job>, StoreError> {
410 self.read(move |db| read::job(db, id)).await
411 }
412
413 pub async fn job_samples(
415 &self,
416 id: u64,
417 interval: Duration,
418 ) -> Result<Option<api::SampleSeries>, StoreError> {
419 self.read(move |db| read::job_samples(db, id, interval))
420 .await
421 }
422
423 pub async fn log_lines(
425 &self,
426 job: u64,
427 from: u64,
428 limit: u64,
429 ) -> Result<Vec<(u64, String)>, StoreError> {
430 self.read(move |db| read::log_lines(db, job, from, limit))
431 .await
432 }
433
434 pub async fn events_after(
436 &self,
437 after: u64,
438 limit: u64,
439 ) -> Result<Vec<EventEnvelope>, StoreError> {
440 self.read(move |db| read::events_after(db, after, limit))
441 .await
442 }
443
444 pub fn announced_repositories(&self) -> Result<Vec<api::RepositoryStatus>, StoreError> {
447 Ok(read::announced_repositories(&lock(&self.reader))?)
448 }
449
450 pub async fn host_samples(&self, since: u64) -> Result<Vec<api::HostSample>, StoreError> {
452 self.read(move |db| read::host_samples(db, since)).await
453 }
454
455 pub async fn stats(&self, query: api::StatsQuery, now: u64) -> Result<api::Stats, StoreError> {
457 self.read(move |db| read::stats(db, &query, now)).await
458 }
459
460 pub async fn cache_sizes_now(&self) -> Result<Measured, StoreError> {
462 self.read(read::latest_cache_sizes).await
463 }
464
465 pub async fn cache_history(&self, since: u64) -> Result<Vec<api::CacheSize>, StoreError> {
467 self.read(move |db| read::cache_history(db, since)).await
468 }
469
470 pub async fn open_jobs(&self) -> Result<Vec<OpenJob>, StoreError> {
472 self.read(read::open_jobs).await
473 }
474
475 pub async fn runner_job(&self, runner: &str) -> Result<Option<u64>, StoreError> {
477 let runner = runner.to_owned();
478 self.read(move |db| read::runner_job(db, &runner)).await
479 }
480
481 pub async fn log_position(&self, job: u64) -> Result<LogPosition, StoreError> {
483 self.read(move |db| read::log_position(db, job)).await
484 }
485
486 pub async fn mirror_log_until(&self) -> Result<MirrorPosition, StoreError> {
488 self.read(read::mirror_log_until).await
489 }
490}
491
492fn configure(db: &Connection) -> rusqlite::Result<()> {
494 db.pragma_update(None, "journal_mode", "wal")?;
495 db.pragma_update(None, "synchronous", "normal")?;
496 db.pragma_update(None, "foreign_keys", "on")?;
497 db.busy_timeout(Duration::from_secs(5))
498}
499
500fn migrate(db: &mut Connection) -> Result<(), StoreError> {
502 let found: u32 = db.pragma_query_value(None, "user_version", |row| row.get(0))?;
503 let supported = u32::try_from(MIGRATIONS.len()).unwrap_or(u32::MAX);
504 if found > supported {
505 return Err(StoreError::Newer { found, supported });
506 }
507 for (version, migration) in (1..).zip(MIGRATIONS).skip(found as usize) {
508 let transaction = db.transaction()?;
509 transaction.execute_batch(migration)?;
510 transaction.pragma_update(None, "user_version", version)?;
511 transaction.commit()?;
512 }
513 Ok(())
514}
515
516fn logged<T>(what: &str, result: rusqlite::Result<T>) -> Option<T> {
518 result
519 .map_err(|error| warn!("job history: cannot {what}: {error}"))
520 .ok()
521}
522
523#[cfg(test)]
524mod tests;