Codex Handbook
state/src/runtime/recovery.rs 243 lines
//! Backup-and-rebuild support for Codex runtime SQLite databases.//!//! Codex keeps several independent runtime SQLite databases under one SQLite//! home. When SQLite reports that one of them is corrupt, automatic recovery//! moves only that database file and its sidecars into a backup folder so the//! other databases keep their data.use std::borrow::Cow;use std::path::Path;use std::path::PathBuf;const BACKUP_DIR_NAME: &str = "db-backups";#[derive(Clone, Debug, Eq, PartialEq)]pub struct RuntimeDbBackup {    /// Path where the runtime database or sidecar lived before it was moved.    pub original_path: PathBuf,    /// Path where the runtime database or sidecar was backed up.    pub backup_path: PathBuf,}#[derive(Debug)]pub(crate) struct RuntimeDbInitError {    label: &'static str,    operation: &'static str,    path: PathBuf,    source: anyhow::Error,}impl RuntimeDbInitError {    pub(crate) fn new(        label: &'static str,        operation: &'static str,        path: &Path,        source: anyhow::Error,    ) -> Self {        Self {            label,            operation,            path: path.to_path_buf(),            source,        }    }    fn path(&self) -> &Path {        self.path.as_path()    }}impl std::fmt::Display for RuntimeDbInitError {    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {        write!(            f,            "failed to {} {} at {}: {}",            self.operation,            self.label,            self.path.display(),            self.source        )    }}impl std::error::Error for RuntimeDbInitError {    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {        Some(self.source.as_ref())    }}/// Move one Codex runtime SQLite database out of the way so that database can/// be recreated without discarding unrelated runtime databases.pub async fn backup_runtime_db_for_fresh_start(    db_path: &Path,) -> std::io::Result<Vec<RuntimeDbBackup>> {    let sqlite_home = db_path.parent().ok_or_else(|| {        std::io::Error::other(format!(            "database path does not have a parent directory: {}",            db_path.display()        ))    })?;    match tokio::fs::metadata(sqlite_home).await {        Ok(metadata) if metadata.is_dir() => backup_runtime_db_files(db_path).await,        Ok(_) => backup_blocking_sqlite_home(sqlite_home).await,        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {            tokio::fs::create_dir_all(sqlite_home).await?;            Err(std::io::Error::other(format!(                "no Codex runtime database files were found to back up for {}",                db_path.display()            )))        }        Err(err) => Err(err),    }}pub fn runtime_db_path_for_corruption_error(err: &anyhow::Error) -> Option<PathBuf> {    if !is_sqlite_corruption_error(err) {        return None;    }    err.chain()        .find_map(|source| source.downcast_ref::<RuntimeDbInitError>())        .map(|err| err.path().to_path_buf())}pub fn is_sqlite_corruption_error(err: &anyhow::Error) -> bool {    err.chain().any(sqlite_error_source_is_corruption)}fn sqlite_error_source_is_corruption(source: &(dyn std::error::Error + 'static)) -> bool {    let Some(err) = source.downcast_ref::<sqlx::Error>() else {        return false;    };    let sqlx::Error::Database(database_error) = err else {        return false;    };    sqlite_error_detail_is_corruption(database_error.message())        || database_error            .code()            .is_some_and(sqlite_database_code_is_corruption)}fn sqlite_database_code_is_corruption(code: Cow<'_, str>) -> bool {    matches!(        code.as_ref().to_ascii_lowercase().as_str(),        "11" | "26" | "sqlite_corrupt" | "sqlite_notadb"    )}pub fn sqlite_error_detail_is_corruption(detail: &str) -> bool {    let detail = detail.to_ascii_lowercase();    detail.contains("database disk image is malformed")        || detail.contains("database schema is malformed")        || detail.contains("database is corrupt")        || detail.contains("file is not a database")        || detail.contains("sqlite_corrupt")        || detail.contains("sqlite_notadb")        || detail.contains("(code: 11)")        || detail.contains("(code: 26)")}pub fn sqlite_error_detail_is_lock(detail: &str) -> bool {    let detail = detail.to_ascii_lowercase();    detail.contains("database is locked") || detail.contains("database is busy")}async fn backup_runtime_db_files(db_path: &Path) -> std::io::Result<Vec<RuntimeDbBackup>> {    let sqlite_home = db_path.parent().ok_or_else(|| {        std::io::Error::other(format!(            "database path does not have a parent directory: {}",            db_path.display()        ))    })?;    backup_sqlite_paths(sqlite_home, sqlite_paths(db_path)).await}async fn backup_sqlite_paths(    sqlite_home: &Path,    paths: impl IntoIterator<Item = PathBuf>,) -> std::io::Result<Vec<RuntimeDbBackup>> {    let backup_dir = create_unique_backup_dir(sqlite_home.join(BACKUP_DIR_NAME).as_path()).await?;    let mut backups = Vec::new();    for path in paths {        if tokio::fs::try_exists(path.as_path()).await? {            let backup_path = backup_dir.join(file_name(path.as_path())?);            tokio::fs::rename(path.as_path(), backup_path.as_path()).await?;            backups.push(RuntimeDbBackup {                original_path: path,                backup_path,            });        }    }    if backups.is_empty() {        let _ = tokio::fs::remove_dir(backup_dir).await;        return Err(std::io::Error::other(            "no Codex runtime database files were found to back up",        ));    }    Ok(backups)}async fn backup_blocking_sqlite_home(sqlite_home: &Path) -> std::io::Result<Vec<RuntimeDbBackup>> {    let parent = sqlite_home.parent().ok_or_else(|| {        std::io::Error::other(format!(            "cannot create a backup folder for {}",            sqlite_home.display()        ))    })?;    let mut backup_dir_name = file_name(sqlite_home)?.to_os_string();    backup_dir_name.push(format!(".{BACKUP_DIR_NAME}"));    let backup_parent = parent.join(backup_dir_name);    let backup_dir = create_unique_backup_dir(backup_parent.as_path()).await?;    let backup_path = backup_dir.join(file_name(sqlite_home)?);    tokio::fs::rename(sqlite_home, backup_path.as_path()).await?;    tokio::fs::create_dir_all(sqlite_home).await?;    Ok(vec![RuntimeDbBackup {        original_path: sqlite_home.to_path_buf(),        backup_path,    }])}fn sqlite_paths(db_path: &Path) -> Vec<PathBuf> {    let mut wal_path = db_path.as_os_str().to_os_string();    wal_path.push("-wal");    let mut shm_path = db_path.as_os_str().to_os_string();    shm_path.push("-shm");    vec![        db_path.to_path_buf(),        PathBuf::from(wal_path),        PathBuf::from(shm_path),    ]}async fn create_unique_backup_dir(backup_parent: &Path) -> std::io::Result<PathBuf> {    tokio::fs::create_dir_all(backup_parent).await?;    let timestamp = std::time::SystemTime::now()        .duration_since(std::time::UNIX_EPOCH)        .map_or(0, |duration| duration.as_secs());    let mut sequence = 0_u32;    loop {        let backup_dir = backup_parent.join(format!("sqlite-{timestamp}-{sequence}"));        match tokio::fs::create_dir(backup_dir.as_path()).await {            Ok(()) => return Ok(backup_dir),            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {                sequence += 1;            }            Err(err) => return Err(err),        }    }}fn file_name(path: &Path) -> std::io::Result<&std::ffi::OsStr> {    path.file_name().ok_or_else(|| {        std::io::Error::other(format!(            "cannot create a backup name for {}",            path.display()        ))    })}#[cfg(test)]#[path = "recovery_tests.rs"]mod tests;