use std::collections::BTreeMap;
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, TryLockError};
use std::time::Duration;
use rusqlite::{Connection, OpenFlags};
use tokio::sync::{OwnedSemaphorePermit, Semaphore, broadcast, mpsc, oneshot};
use tracing::warn;
use crate::api::{self, Event, EventEnvelope};
use crate::config::History;
use crate::github::{JobDetails, QueuedJob};
use crate::observe::metrics::Events;
use crate::scheduler::warm;
use crate::sizing;
use crate::sync::lock;
mod prune;
mod read;
mod write;
const MIGRATIONS: &[&str] = &[
include_str!("store/migrations/0001_history.sql"),
include_str!("store/migrations/0002_runner_caps.sql"),
include_str!("store/migrations/0003_paused.sql"),
include_str!("store/migrations/0004_prune.sql"),
include_str!("store/migrations/0005_announced_profiles.sql"),
include_str!("store/migrations/0006_records.sql"),
include_str!("store/migrations/0007_admission.sql"),
include_str!("store/migrations/0008_memory_without_page_cache.sql"),
include_str!("store/migrations/0009_auto_sizing.sql"),
include_str!("store/migrations/0010_auto_sizing_cpus.sql"),
include_str!("store/migrations/0011_notify.sql"),
include_str!("store/migrations/0012_build_cache.sql"),
include_str!("store/migrations/0013_host_memory.sql"),
include_str!("store/migrations/0014_local_ooms.sql"),
include_str!("store/migrations/0015_repositories.sql"),
];
pub(crate) const LOG_BACKLOG: u64 = 8 << 20;
const HOST_SAMPLE_BATCH: usize = 30;
const EVENT_BUFFER: usize = 4096;
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
#[error("{0}")]
Sqlite(#[from] rusqlite::Error),
#[error(
"the job history database is version {found}, newer than the {supported} this runnerd reads; \
run a newer runnerd or move the database aside"
)]
Newer {
found: u32,
supported: u32,
},
#[error("{0}")]
Io(#[from] std::io::Error),
#[error("a database read stopped: {0}")]
Join(#[from] tokio::task::JoinError),
#[error(
"{} holds changes not yet in the job history, which this user cannot read; run runnerd doctor as the user \
the daemon runs as",
.0.display()
)]
Unread(PathBuf),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunnerRecord {
pub name: String,
pub repository: String,
pub profile: String,
pub slot: usize,
pub container: String,
pub started_at: u64,
pub memory_cap: Option<u64>,
pub cpu_cap: Option<u64>,
pub admission: Option<api::Admission>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SampleRecord {
pub runner: String,
pub at: u64,
pub cpu: u64,
pub cpu_usage: u64,
pub memory: u64,
pub network_received: u64,
pub network_sent: u64,
pub events: Events,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Measured {
pub at: Option<u64>,
pub bytes: BTreeMap<String, u64>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Announced {
pub profiles: Vec<api::ProfileStatus>,
pub repositories: Vec<api::RepositoryStatus>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpenJob {
pub id: u64,
pub repository: String,
pub state: api::JobState,
pub runner: Option<String>,
pub runner_removed_at: Option<u64>,
pub run_id: u64,
pub run_attempt: u32,
pub workflow: Option<String>,
pub queued_at: u64,
pub labels: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LogPosition {
pub page: u64,
pub offset: u64,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct MirrorPosition {
pub at: u64,
pub seen: u64,
}
#[derive(Debug)]
enum Write {
Queued {
repository: String,
job: QueuedJob,
},
RunnerStarted(RunnerRecord),
RunnerAdmission {
runner: String,
admission: api::Admission,
},
RunnerOomKilled(String),
HostWaited {
jobs: Vec<u64>,
ms: u64,
},
BuildCache {
runner: String,
counts: api::BuildCacheCounts,
},
RunnerRemoved {
runner: String,
at: u64,
},
RunnersGone {
running: Vec<String>,
at: u64,
},
Claimed {
job: u64,
run_id: u64,
runner: String,
at: u64,
},
Details {
details: JobDetails,
},
Elsewhere {
details: JobDetails,
},
Lost {
job: u64,
at: u64,
},
Log {
job: u64,
lines: Vec<String>,
position: Option<LogPosition>,
max_bytes: u64,
_backlog: OwnedSemaphorePermit,
},
Samples(Vec<SampleRecord>),
HostSample(api::HostSample),
MirrorBytes {
bytes: Vec<(String, u64)>,
until: MirrorPosition,
done: oneshot::Sender<bool>,
},
CacheSizes {
at: u64,
sizes: BTreeMap<String, u64>,
},
Event(Box<Event>),
RecordMade(api::Record),
RecordSkipped {
job: u64,
why: String,
},
RecordRemoved {
job: u64,
at: u64,
by: api::RecordRemoval,
},
Paused {
repositories: Vec<String>,
done: oneshot::Sender<bool>,
},
Visibility {
repository: String,
visibility: api::Visibility,
at: u64,
},
SizingIntent {
change: api::SizingChange,
done: oneshot::Sender<Option<i64>>,
},
SizingResolved {
id: i64,
resolution: Resolution,
done: oneshot::Sender<bool>,
},
Notify {
change: NotifyChange,
done: oneshot::Sender<bool>,
},
Flush(oneshot::Sender<()>),
}
#[derive(Debug)]
enum NotifyChange {
Record {
target: String,
set: Vec<(String, u64)>,
remove: Vec<String>,
},
Keep(Vec<String>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Resolution {
Done,
Failed,
Uncertain,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AutoSizing {
pub last: BTreeMap<String, u64>,
pub pending: Vec<(i64, api::SizingChange)>,
pub cpus: Vec<sizing::CpusSet>,
pub memory: Vec<sizing::MemorySet>,
}
#[derive(Debug, Clone)]
pub struct Store {
writes: mpsc::UnboundedSender<Write>,
reader: Arc<Mutex<Connection>>,
pruner: Arc<Mutex<Connection>>,
closed: Arc<AtomicBool>,
held: Arc<Mutex<Vec<api::HostSample>>>,
latest_host: Arc<Mutex<Option<api::HostSample>>>,
latest_host_memory: Arc<Mutex<Option<api::HostMemory>>>,
latest_samples: Arc<Mutex<BTreeMap<String, SampleRecord>>>,
events: broadcast::Sender<Arc<EventEnvelope>>,
backlog: Arc<Semaphore>,
}
impl Store {
pub fn open(path: &Path) -> Result<Store, StoreError> {
if let Some(dir) = path.parent().filter(|dir| !dir.as_os_str().is_empty()) {
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir)?;
}
std::fs::OpenOptions::new()
.create(true)
.append(true)
.mode(0o600)
.open(path)?;
let mut db = Connection::open(path)?;
configure(&db)?;
migrate(&mut db)?;
let reader = Connection::open_with_flags(
path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?;
configure(&reader)?;
let pruner = Connection::open(path)?;
configure(&pruner)?;
let (events, _) = broadcast::channel(EVENT_BUFFER);
let (writes, receiver) = mpsc::unbounded_channel();
let thread_events = events.clone();
let held = Arc::new(Mutex::new(Vec::new()));
let thread_held = held.clone();
std::thread::Builder::new()
.name("runnerd-store".to_owned())
.spawn(move || write::run(db, receiver, &thread_events, &thread_held))?;
Ok(Store {
writes,
reader: Arc::new(Mutex::new(reader)),
pruner: Arc::new(Mutex::new(pruner)),
closed: Arc::new(AtomicBool::new(false)),
held,
latest_host: Arc::new(Mutex::new(None)),
latest_host_memory: Arc::new(Mutex::new(None)),
latest_samples: Arc::new(Mutex::new(BTreeMap::new())),
events,
backlog: Arc::new(Semaphore::new(
usize::try_from(LOG_BACKLOG).unwrap_or(usize::MAX),
)),
})
}
fn send(&self, write: Write) {
let _ = self.writes.send(write);
}
pub fn queued(&self, repository: &str, job: &QueuedJob) {
self.send(Write::Queued {
repository: repository.to_owned(),
job: job.clone(),
});
}
pub fn runner_started(&self, runner: RunnerRecord) {
self.send(Write::RunnerStarted(runner));
}
pub fn runner_removed(&self, runner: &str, at: u64) {
lock(&self.latest_samples).remove(runner);
self.send(Write::RunnerRemoved {
runner: runner.to_owned(),
at,
});
}
pub fn runners_gone(&self, running: Vec<String>, at: u64) {
lock(&self.latest_samples).retain(|runner, _| running.contains(runner));
self.send(Write::RunnersGone { running, at });
}
pub fn claimed(&self, job: u64, run_id: u64, runner: &str, at: u64) {
self.send(Write::Claimed {
job,
run_id,
runner: runner.to_owned(),
at,
});
}
pub fn details(&self, details: JobDetails) {
self.send(Write::Details { details });
}
pub fn elsewhere(&self, details: JobDetails) {
self.send(Write::Elsewhere { details });
}
pub fn lost(&self, job: u64, at: u64) {
self.send(Write::Lost { job, at });
}
pub async fn log(&self, job: u64, lines: Vec<String>, position: LogPosition, max_bytes: u64) {
let size = |line: &String| line.len() as u64 + 1;
let mut lines = lines.into_iter().map(|mut line| {
if size(&line) > LOG_BACKLOG {
let mut end = usize::try_from(LOG_BACKLOG / 2).unwrap_or(usize::MAX);
while !line.is_char_boundary(end) {
end -= 1;
}
line.truncate(end);
}
line
});
let mut next = lines.next();
loop {
let (mut part, mut bytes) = (Vec::new(), 0);
while let Some(line) = next.take_if(|line| bytes + size(line) <= LOG_BACKLOG) {
bytes += size(&line);
part.push(line);
next = lines.next();
}
let last = next.is_none();
let share = u32::try_from(bytes.max(1)).unwrap_or(u32::MAX);
let Ok(backlog) = self.backlog.clone().acquire_many_owned(share).await else {
return;
};
self.send(Write::Log {
job,
lines: part,
position: last.then_some(position),
max_bytes,
_backlog: backlog,
});
if last {
return;
}
}
}
pub fn samples(&self, samples: Vec<SampleRecord>) {
let mut latest = lock(&self.latest_samples);
for sample in &samples {
latest.insert(sample.runner.clone(), sample.clone());
}
drop(latest);
self.send(Write::Samples(samples));
}
pub fn host_sample(&self, sample: api::HostSample) {
*lock(&self.latest_host) = Some(sample.clone());
lock(&self.held).push(sample.clone());
self.send(Write::HostSample(sample));
}
pub fn latest_sample(&self, runner: &str) -> Option<SampleRecord> {
lock(&self.latest_samples).get(runner).cloned()
}
pub fn runner_admission(&self, runner: &str, admission: api::Admission) {
self.send(Write::RunnerAdmission {
runner: runner.to_owned(),
admission,
});
}
pub fn runner_oom_killed(&self, runner: &str) {
self.send(Write::RunnerOomKilled(runner.to_owned()));
}
pub fn build_cache(&self, runner: &str, counts: api::BuildCacheCounts) {
self.send(Write::BuildCache {
runner: runner.to_owned(),
counts,
});
}
pub fn host_memory(&self, memory: api::HostMemory) {
*lock(&self.latest_host_memory) = Some(memory);
}
pub fn latest_host_memory(&self) -> Option<api::HostMemory> {
lock(&self.latest_host_memory).clone()
}
pub fn host_waited(&self, jobs: Vec<u64>, ms: u64) {
self.send(Write::HostWaited { jobs, ms });
}
pub fn latest_host_sample(&self) -> Option<api::HostSample> {
lock(&self.latest_host).clone()
}
pub async fn mirror_bytes(&self, bytes: Vec<(String, u64)>, until: MirrorPosition) -> bool {
let (done, committed) = oneshot::channel();
self.send(Write::MirrorBytes { bytes, until, done });
committed.await.unwrap_or(false)
}
pub fn cache_sizes(&self, at: u64, sizes: BTreeMap<String, u64>) {
self.send(Write::CacheSizes { at, sizes });
}
pub fn event(&self, event: Event) {
self.send(Write::Event(Box::new(event)));
}
pub fn record_made(&self, record: api::Record) {
self.send(Write::RecordMade(record));
}
pub fn record_skipped(&self, job: u64, why: String) {
self.send(Write::RecordSkipped { job, why });
}
pub fn record_removed(&self, job: u64, at: u64, by: api::RecordRemoval) {
self.send(Write::RecordRemoved { job, at, by });
}
pub fn paused(&self, repositories: Vec<String>) -> impl Future<Output = bool> + use<> {
let (done, committed) = oneshot::channel();
self.send(Write::Paused { repositories, done });
async move { committed.await.unwrap_or(false) }
}
pub fn visibility(&self, repository: String, visibility: api::Visibility, at: u64) {
self.send(Write::Visibility {
repository,
visibility,
at,
});
}
pub fn sizing_intent(
&self,
change: api::SizingChange,
) -> impl Future<Output = Option<i64>> + use<> {
let (done, committed) = oneshot::channel();
self.send(Write::SizingIntent { change, done });
async move { committed.await.ok().flatten() }
}
pub fn sizing_resolved(
&self,
id: i64,
resolution: Resolution,
) -> impl Future<Output = bool> + use<> {
let (done, committed) = oneshot::channel();
self.send(Write::SizingResolved {
id,
resolution,
done,
});
async move { committed.await.unwrap_or(false) }
}
pub fn notify_record(
&self,
target: &str,
set: Vec<(String, u64)>,
remove: Vec<String>,
) -> impl Future<Output = bool> + use<> {
let (done, committed) = oneshot::channel();
self.send(Write::Notify {
change: NotifyChange::Record {
target: target.to_owned(),
set,
remove,
},
done,
});
async move { committed.await.unwrap_or(false) }
}
pub fn notify_keep(&self, targets: Vec<String>) -> impl Future<Output = bool> + use<> {
let (done, committed) = oneshot::channel();
self.send(Write::Notify {
change: NotifyChange::Keep(targets),
done,
});
async move { committed.await.unwrap_or(false) }
}
pub fn prune(&self, now: u64, history: &History) -> tokio::task::JoinHandle<()> {
let pruner = self.pruner.clone();
let closed = self.closed.clone();
let history = history.clone();
tokio::task::spawn_blocking(move || {
let db = match pruner.try_lock() {
Ok(db) => db,
Err(TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
Err(TryLockError::WouldBlock) => return,
};
let db = prune::Db {
connection: &db,
stop: &closed,
};
if !db.stop.load(Ordering::Relaxed) {
prune::run(&db, now, &history);
}
})
}
pub fn close(&self) {
self.closed.store(true, Ordering::Relaxed);
}
pub async fn flush(&self) {
let (done, wait) = oneshot::channel();
self.send(Write::Flush(done));
let _ = wait.await;
}
pub fn subscribe(&self) -> broadcast::Receiver<Arc<EventEnvelope>> {
self.events.subscribe()
}
async fn read<T: Send + 'static>(
&self,
query: impl FnOnce(&Connection) -> rusqlite::Result<T> + Send + 'static,
) -> Result<T, StoreError> {
let reader = self.reader.clone();
Ok(tokio::task::spawn_blocking(move || query(&lock(&reader))).await??)
}
pub async fn jobs(&self, query: api::JobQuery) -> Result<api::JobPage, StoreError> {
self.read(move |db| read::jobs(db, &query)).await
}
pub async fn job(&self, id: u64) -> Result<Option<api::Job>, StoreError> {
self.read(move |db| read::job(db, id)).await
}
pub async fn job_samples(
&self,
id: u64,
interval: Duration,
) -> Result<Option<api::SampleSeries>, StoreError> {
self.read(move |db| read::job_samples(db, id, interval))
.await
}
pub async fn log_lines(
&self,
job: u64,
from: u64,
limit: u64,
) -> Result<Vec<(u64, String)>, StoreError> {
self.read(move |db| read::log_lines(db, job, from, limit))
.await
}
pub async fn events_after(
&self,
after: u64,
limit: u64,
) -> Result<Vec<EventEnvelope>, StoreError> {
self.read(move |db| read::events_after(db, after, limit))
.await
}
pub async fn sizing_changes(&self, since: u64) -> Result<Vec<api::SizingChange>, StoreError> {
self.read(move |db| read::sizing_changes(db, since)).await
}
pub async fn auto_sizing(&self) -> Result<AutoSizing, StoreError> {
self.read(read::auto_sizing).await
}
pub fn announced(&self) -> Result<Announced, StoreError> {
Ok(read::announced(&lock(&self.reader))?)
}
pub fn paused_repositories(&self) -> Result<Vec<String>, StoreError> {
Ok(read::paused(&lock(&self.reader))?)
}
pub async fn host_samples(&self, since: u64) -> Result<Vec<api::HostSample>, StoreError> {
let held = lock(&self.held).clone();
let mut samples = self.read(move |db| read::host_samples(db, since)).await?;
let stored = samples.last().map_or(0, |sample| sample.at);
samples.extend(
held.into_iter()
.filter(|sample| sample.at >= since && sample.at > stored),
);
Ok(samples)
}
pub async fn activity(&self, since: u64, now: u64) -> Result<api::Activity, StoreError> {
self.read(move |db| read::activity(db, since, now)).await
}
pub async fn stats(&self, query: api::StatsQuery, now: u64) -> Result<api::Stats, StoreError> {
self.read(move |db| read::stats(db, &query, now)).await
}
pub async fn sizing(&self, since: u64) -> Result<sizing::History, StoreError> {
self.read(move |db| read::sizing(db, since)).await
}
pub async fn pricing(&self, since: u64) -> Result<api::PricingHistory, StoreError> {
self.read(move |db| read::pricing(db, since)).await
}
pub async fn insights(&self, since: u64) -> Result<Vec<crate::insights::Run>, StoreError> {
self.read(move |db| read::insights(db, since)).await
}
pub async fn last_event(&self) -> Result<u64, StoreError> {
self.read(read::last_event).await
}
pub async fn notify_state(
&self,
) -> Result<BTreeMap<String, BTreeMap<String, u64>>, StoreError> {
self.read(read::notify_state).await
}
pub async fn warm(&self, since: u64) -> Result<Vec<warm::Seen>, StoreError> {
self.read(move |db| read::warm(db, since)).await
}
pub async fn cache_sizes_now(&self) -> Result<Measured, StoreError> {
self.read(read::latest_cache_sizes).await
}
pub async fn build_cache_since(&self, since: u64) -> Result<api::BuildCacheCounts, StoreError> {
self.read(move |db| read::build_cache_since(db, since))
.await
}
pub async fn cache_history(&self, since: u64) -> Result<Vec<api::CacheSize>, StoreError> {
self.read(move |db| read::cache_history(db, since)).await
}
pub async fn open_jobs(&self) -> Result<Vec<OpenJob>, StoreError> {
self.read(read::open_jobs).await
}
pub async fn runner_job(&self, runner: &str) -> Result<Option<u64>, StoreError> {
let runner = runner.to_owned();
self.read(move |db| read::runner_job(db, &runner)).await
}
pub async fn runner_admitted(
&self,
runner: &str,
) -> Result<Option<api::Admission>, StoreError> {
let runner = runner.to_owned();
self.read(move |db| read::runner_admission(db, &runner))
.await
}
pub async fn log_position(&self, job: u64) -> Result<LogPosition, StoreError> {
self.read(move |db| read::log_position(db, job)).await
}
pub async fn mirror_log_until(&self) -> Result<MirrorPosition, StoreError> {
self.read(read::mirror_log_until).await
}
}
pub fn sizing_at(path: &Path, since: u64) -> Result<Option<sizing::History>, StoreError> {
if !path.exists() {
return Ok(None);
}
let read = |path: &str, flags| -> Result<sizing::History, StoreError> {
let db = Connection::open_with_flags(path, flags)?;
db.busy_timeout(Duration::from_secs(5))?;
sizing_of(&db, since)
};
let name = path.to_string_lossy();
match read(&name, OpenFlags::SQLITE_OPEN_READ_ONLY) {
Err(StoreError::Sqlite(error))
if matches!(
error.sqlite_error_code(),
Some(rusqlite::ErrorCode::ReadOnly | rusqlite::ErrorCode::CannotOpen)
) =>
{
let mut wal = path.as_os_str().to_owned();
wal.push("-wal");
let wal = PathBuf::from(wal);
if std::fs::metadata(&wal).is_ok_and(|wal| wal.len() > 0) {
return Err(StoreError::Unread(wal));
}
let uri = format!(
"file:{}?mode=ro&immutable=1",
name.replace('%', "%25")
.replace('?', "%3f")
.replace('#', "%23")
);
read(
&uri,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
)
}
history => history,
}
.map(Some)
}
fn sizing_of(db: &Connection, since: u64) -> Result<sizing::History, StoreError> {
let found: u32 = db.pragma_query_value(None, "user_version", |row| row.get(0))?;
let supported = u32::try_from(MIGRATIONS.len()).unwrap_or(u32::MAX);
if found > supported {
return Err(StoreError::Newer { found, supported });
}
if found == 0 {
return Ok(sizing::History::default());
}
let mut added = match found {
1 => {
"NULL AS memory_cap, NULL AS cpu_cap, NULL AS admission, NULL AS oom_kills, NULL AS ooms, \
NULL AS memory_measure, "
}
2..=6 => "NULL AS admission, NULL AS oom_kills, NULL AS ooms, NULL AS memory_measure, ",
7 => "NULL AS ooms, NULL AS memory_measure, ",
_ => "",
}
.to_owned();
if found < 14 {
added.push_str("NULL AS local_ooms");
}
let added = added.trim_end_matches(", ");
if found < 13 {
db.execute_batch("CREATE TEMP VIEW jobs AS SELECT *, 0 AS host_waited FROM main.jobs")?;
}
if !added.is_empty() {
db.execute_batch(&format!(
"CREATE TEMP VIEW runners AS SELECT *, {added} FROM main.runners"
))?;
}
let mut history = read::sizing(db, since)?;
if found >= 14 {
history.memory_set = read::auto_sizing(db)?.memory;
history.changes = read::sizing_changes(db, since.saturating_sub(sizing::memory::HISTORY))?;
}
Ok(history)
}
fn configure(db: &Connection) -> rusqlite::Result<()> {
db.pragma_update(None, "journal_mode", "wal")?;
db.pragma_update(None, "synchronous", "normal")?;
db.pragma_update(None, "foreign_keys", "on")?;
db.busy_timeout(Duration::from_secs(5))
}
fn migrate(db: &mut Connection) -> Result<(), StoreError> {
let found: u32 = db.pragma_query_value(None, "user_version", |row| row.get(0))?;
let supported = u32::try_from(MIGRATIONS.len()).unwrap_or(u32::MAX);
if found > supported {
return Err(StoreError::Newer { found, supported });
}
for (version, migration) in (1..).zip(MIGRATIONS).skip(found as usize) {
let transaction = db.transaction()?;
transaction.execute_batch(migration)?;
transaction.pragma_update(None, "user_version", version)?;
transaction.commit()?;
}
Ok(())
}
fn logged<T>(what: &str, result: rusqlite::Result<T>) -> Option<T> {
result
.map_err(|error| warn!("job history: cannot {what}: {error}"))
.ok()
}
#[cfg(test)]
mod tests;