use std::{
io::ErrorKind,
path::{Path, PathBuf},
thread,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use rusqlite::{
params, Connection, ErrorCode, OpenFlags, OptionalExtension, Transaction, TransactionBehavior,
};
use super::super::create_private_file;
use super::paths::prepare_private_directory;
pub(super) const INDEX_FILE_NAME: &str = "index.sqlite3";
const INDEX_SCHEMA_VERSION: i64 = 3;
const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
pub(super) const PARENT_LOCK_TTL_SECS: i64 = 5 * 60;
pub(super) fn initialize_index(path: &Path) -> anyhow::Result<Connection> {
if let Some(parent) = path.parent() {
prepare_private_directory(parent)?;
}
prepare_index_file(path)?;
let deadline = Instant::now() + BUSY_TIMEOUT;
loop {
match initialize_index_once(path) {
Err(error) if is_lock_contention(&error) && Instant::now() < deadline => {
thread::sleep(Duration::from_millis(10));
}
result => return result,
}
}
}
fn initialize_index_once(path: &Path) -> anyhow::Result<Connection> {
let mut connection = open_index(path)?;
connection.pragma_update(None, "journal_mode", "WAL")?;
connection.pragma_update(None, "synchronous", "NORMAL")?;
let current: i64 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?;
anyhow::ensure!(
current <= INDEX_SCHEMA_VERSION,
"delegated run index schema {current} is newer than supported schema {INDEX_SCHEMA_VERSION}"
);
if current < INDEX_SCHEMA_VERSION {
let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
let mut current: i64 =
transaction.pragma_query_value(None, "user_version", |row| row.get(0))?;
anyhow::ensure!(
current <= INDEX_SCHEMA_VERSION,
"delegated run index schema {current} is newer than supported schema {INDEX_SCHEMA_VERSION}"
);
if current < 1 {
transaction.execute_batch(
"CREATE TABLE IF NOT EXISTS runs (
run_id TEXT PRIMARY KEY NOT NULL,
path TEXT NOT NULL UNIQUE,
parent_session_id TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS runs_parent_session_idx ON runs(parent_session_id);",
)?;
transaction.pragma_update(None, "user_version", 1)?;
current = 1;
}
if current < 2 {
transaction.pragma_update(None, "user_version", 2)?;
current = 2;
}
if current < 3 {
transaction.execute_batch(
"DROP TABLE IF EXISTS parent_locks;
CREATE TABLE parent_locks (
parent_session_id TEXT PRIMARY KEY NOT NULL,
locked_at INTEGER NOT NULL
);",
)?;
transaction.pragma_update(None, "user_version", 3)?;
}
transaction.commit()?;
}
set_index_permissions(path)?;
Ok(connection)
}
fn open_index(path: &Path) -> anyhow::Result<Connection> {
let connection = Connection::open_with_flags(
path,
OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?;
connection.busy_timeout(BUSY_TIMEOUT)?;
Ok(connection)
}
fn prepare_index_file(path: &Path) -> std::io::Result<()> {
loop {
match std::fs::symlink_metadata(path) {
Ok(metadata) => {
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(std::io::Error::new(
ErrorKind::InvalidInput,
format!("{} is not a trusted database file", path.display()),
));
}
return set_index_permissions(path);
}
Err(error) if error.kind() == ErrorKind::NotFound => match create_private_file(path) {
Ok(file) => {
drop(file);
return set_index_permissions(path);
}
Err(error) if error.kind() == ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error),
},
Err(error) => return Err(error),
}
}
}
pub(super) fn set_index_permissions(path: &Path) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
for candidate in [
path.to_path_buf(),
PathBuf::from(format!("{}-wal", path.display())),
PathBuf::from(format!("{}-shm", path.display())),
] {
if candidate.exists() {
std::fs::set_permissions(candidate, std::fs::Permissions::from_mode(0o600))?;
}
}
}
let _ = path;
Ok(())
}
fn is_lock_contention(error: &anyhow::Error) -> bool {
error.chain().any(|cause| {
matches!(
cause.downcast_ref::<rusqlite::Error>(),
Some(rusqlite::Error::SqliteFailure(details, _))
if matches!(details.code, ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
)
})
}
pub(super) fn unix_timestamp_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs().min(i64::MAX as u64) as i64)
.unwrap_or_default()
}
fn lock_is_fresh(locked_at: i64, now: i64) -> bool {
now.saturating_sub(locked_at) < PARENT_LOCK_TTL_SECS
}
pub(super) fn acquire_parent_lock(
transaction: &Transaction<'_>,
parent_session_id: &str,
now: i64,
) -> anyhow::Result<()> {
let existing = transaction
.query_row(
"SELECT locked_at FROM parent_locks WHERE parent_session_id = ?1",
params![parent_session_id],
|row| row.get::<_, i64>(0),
)
.optional()?;
match existing {
Some(locked_at) if lock_is_fresh(locked_at, now) => {
anyhow::bail!("parent session '{parent_session_id}' is already being deleted");
}
Some(_) => {
transaction.execute(
"UPDATE parent_locks SET locked_at = ?1 WHERE parent_session_id = ?2",
params![now, parent_session_id],
)?;
}
None => {
transaction.execute(
"INSERT INTO parent_locks (parent_session_id, locked_at) VALUES (?1, ?2)",
params![parent_session_id, now],
)?;
}
}
Ok(())
}
pub(super) fn ensure_parent_not_locked(
transaction: &Transaction<'_>,
parent_session_id: &str,
now: i64,
) -> anyhow::Result<()> {
let existing = transaction
.query_row(
"SELECT locked_at FROM parent_locks WHERE parent_session_id = ?1",
params![parent_session_id],
|row| row.get::<_, i64>(0),
)
.optional()?;
match existing {
Some(locked_at) if lock_is_fresh(locked_at, now) => {
anyhow::bail!("parent session '{parent_session_id}' is being deleted");
}
Some(_) => {
transaction.execute(
"DELETE FROM parent_locks WHERE parent_session_id = ?1",
params![parent_session_id],
)?;
Ok(())
}
None => Ok(()),
}
}
pub(super) fn clear_parent_index_rows(
subagents_root: &Path,
parent_session_id: &str,
) -> anyhow::Result<()> {
let index_path = subagents_root.join(INDEX_FILE_NAME);
let mut connection = initialize_index(&index_path)?;
let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
transaction.execute(
"DELETE FROM runs WHERE parent_session_id = ?1",
params![parent_session_id],
)?;
transaction.execute(
"DELETE FROM parent_locks WHERE parent_session_id = ?1",
params![parent_session_id],
)?;
transaction.commit()?;
Ok(())
}
pub(super) fn unlock_parent(subagents_root: &Path, parent_session_id: &str) -> anyhow::Result<()> {
let index_path = subagents_root.join(INDEX_FILE_NAME);
if !index_path.is_file() {
return Ok(());
}
let mut connection = initialize_index(&index_path)?;
let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
transaction.execute(
"DELETE FROM parent_locks WHERE parent_session_id = ?1",
params![parent_session_id],
)?;
transaction.commit()?;
Ok(())
}
#[cfg(test)]
pub(super) fn insert_parent_lock_for_test(
subagents_root: &Path,
parent_session_id: &str,
locked_at: i64,
) -> anyhow::Result<()> {
prepare_private_directory(subagents_root)?;
let index_path = subagents_root.join(INDEX_FILE_NAME);
let mut connection = initialize_index(&index_path)?;
let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
transaction.execute(
"INSERT OR REPLACE INTO parent_locks (parent_session_id, locked_at) VALUES (?1, ?2)",
params![parent_session_id, locked_at],
)?;
transaction.commit()?;
Ok(())
}