use crate::{
LineageError, RunStoreError, StoreError, StoredRecord,
error::database_error,
schema::Layout,
sqlite::{
recovery::files,
snapshot::{column, decode},
},
};
use dpm_model::{LineageId, WorkspaceId};
use rusqlite::{Connection, OpenFlags, Transaction, TransactionBehavior};
use serde::{Deserialize, Serialize};
use std::{
path::{Path, PathBuf},
time::Duration,
};
mod activity;
mod codec;
pub(super) mod pairing;
mod read;
pub(super) mod recovery;
mod schema;
mod write;
pub use recovery::RunStoreReport;
pub use schema::RUN_STORE_VERSION;
const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
pub const RUN_ACTIVITY_LIMIT: u64 = 10_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunBinding {
pub workspace_id: WorkspaceId,
pub lineage_id: LineageId,
pub archived: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Written<T> {
pub value: T,
pub replayed: bool,
}
pub struct RunStore {
connection: Connection,
path: PathBuf,
activity_limit: u64,
}
impl RunStore {
#[must_use]
pub fn sidecar_path(project: &Path) -> PathBuf {
files::side_file(project, ".runs")
}
pub fn open_existing_blocking(
path: &Path,
workspace: WorkspaceId,
) -> Result<Option<Self>, RunStoreError> {
let exists = path.try_exists().map_err(|source| StoreError::Io {
action: "inspect",
path: path.to_path_buf(),
source,
})?;
if !exists {
return Ok(None);
}
let probe = files::open_read_only_blocking(path, files::read_mode_blocking(path)?)?;
match schema::check_blocking(&probe, path)? {
Layout::Empty => return Ok(None),
Layout::Current => check_workspace(&read_binding(&probe, path)?, workspace, path)?,
}
drop(probe);
let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE)
.map_err(database_error(path, "open run store"))?;
Ok(Some(Self::prepare(connection, path.to_path_buf())?))
}
pub fn open_or_create_blocking(
path: &Path,
workspace: WorkspaceId,
lineage: LineageId,
) -> Result<Self, RunStoreError> {
if let Some(store) = Self::open_existing_blocking(path, workspace)? {
return Ok(store);
}
let connection =
Connection::open(path).map_err(database_error(path, "create run store"))?;
let mut store = Self::prepare(connection, path.to_path_buf())?;
store.initialize_blocking(workspace, lineage)?;
Ok(store)
}
pub fn in_memory_blocking(
workspace: WorkspaceId,
lineage: LineageId,
) -> Result<Self, RunStoreError> {
let path = PathBuf::from(":memory:");
let connection =
Connection::open_in_memory().map_err(database_error(&path, "open run store"))?;
let mut store = Self::prepare(connection, path)?;
store.initialize_blocking(workspace, lineage)?;
Ok(store)
}
fn prepare(connection: Connection, path: PathBuf) -> Result<Self, StoreError> {
connection
.busy_timeout(BUSY_TIMEOUT)
.map_err(database_error(&path, "set busy timeout"))?;
connection
.execute_batch("PRAGMA foreign_keys = ON;")
.map_err(database_error(&path, "configure run store"))?;
Ok(Self {
connection,
path,
activity_limit: RUN_ACTIVITY_LIMIT,
})
}
fn initialize_blocking(
&mut self,
workspace: WorkspaceId,
lineage: LineageId,
) -> Result<(), RunStoreError> {
self.connection
.execute_batch("PRAGMA journal_mode = WAL;")
.map_err(database_error(&self.path, "configure run store"))?;
let transaction = self
.connection
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(database_error(&self.path, "begin run store creation"))?;
if schema::check_blocking(&transaction, &self.path)? == Layout::Empty {
schema::create_blocking(&transaction, &self.path)?;
transaction
.execute(
"INSERT INTO run_binding(singleton, workspace_id, lineage_id, archived) \
VALUES(1, ?1, ?2, 0)",
rusqlite::params![workspace.to_string(), lineage.to_string()],
)
.map_err(database_error(&self.path, "bind run store"))?;
transaction
.execute(
"INSERT INTO run_activity_retention(singleton, pruned_through) VALUES(1, 0)",
[],
)
.map_err(database_error(&self.path, "record retention"))?;
}
check_workspace(
&read_binding(&transaction, &self.path)?,
workspace,
&self.path,
)?;
transaction
.commit()
.map_err(database_error(&self.path, "commit run store creation"))?;
Ok(())
}
pub fn set_activity_limit(&mut self, limit: u64) {
self.activity_limit = limit;
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
pub fn binding_blocking(&self) -> Result<RunBinding, RunStoreError> {
Ok(read_binding(&self.connection, &self.path)?)
}
fn read_transaction(&self) -> Result<Transaction<'_>, StoreError> {
self.connection
.unchecked_transaction()
.map_err(database_error(&self.path, "begin run query"))
}
}
fn check_workspace(
binding: &RunBinding,
expected: WorkspaceId,
path: &Path,
) -> Result<(), RunStoreError> {
if binding.workspace_id == expected {
return Ok(());
}
Err(RunStoreError::WorkspaceMismatch {
path: path.to_path_buf(),
expected,
actual: binding.workspace_id,
})
}
pub(super) fn read_binding(connection: &Connection, path: &Path) -> Result<RunBinding, StoreError> {
let mut statement = connection
.prepare("SELECT workspace_id, lineage_id, archived FROM run_binding WHERE singleton = 1")
.map_err(database_error(path, "prepare run binding query"))?;
let mut rows = statement
.query([])
.map_err(database_error(path, "load run binding"))?;
let Some(row) = rows
.next()
.map_err(database_error(path, "load run binding"))?
else {
return Err(StoreError::Lineage(LineageError::Missing {
path: path.to_path_buf(),
}));
};
let scalar = |index, field| -> Result<String, StoreError> {
column::<String>(row, index, path, StoredRecord::Run, field)
.map(|text| serde_json::Value::String(text).to_string())
};
Ok(RunBinding {
workspace_id: decode(
&scalar(0, "workspace_id")?,
path,
StoredRecord::Run,
"workspace_id",
)?,
lineage_id: decode(
&scalar(1, "lineage_id")?,
path,
StoredRecord::Run,
"lineage_id",
)?,
archived: column(row, 2, path, StoredRecord::Run, "archived")?,
})
}
#[cfg(test)]
mod tests;