Skip to main content

codex_state/runtime/
recovery.rs

1//! Backup-and-rebuild support for Codex runtime SQLite databases.
2//!
3//! Codex keeps several independent runtime SQLite databases under one SQLite
4//! home. When SQLite reports that one of them is corrupt, automatic recovery
5//! moves only that database file and its sidecars into a backup folder so the
6//! other databases keep their data.
7
8use std::borrow::Cow;
9use std::path::Path;
10use std::path::PathBuf;
11
12const BACKUP_DIR_NAME: &str = "db-backups";
13
14#[derive(Clone, Debug, Eq, PartialEq)]
15pub struct RuntimeDbBackup {
16    /// Path where the runtime database or sidecar lived before it was moved.
17    pub original_path: PathBuf,
18    /// Path where the runtime database or sidecar was backed up.
19    pub backup_path: PathBuf,
20}
21
22#[derive(Debug)]
23pub(crate) struct RuntimeDbInitError {
24    label: &'static str,
25    operation: &'static str,
26    path: PathBuf,
27    source: anyhow::Error,
28}
29
30impl RuntimeDbInitError {
31    pub(crate) fn new(
32        label: &'static str,
33        operation: &'static str,
34        path: &Path,
35        source: anyhow::Error,
36    ) -> Self {
37        Self {
38            label,
39            operation,
40            path: path.to_path_buf(),
41            source,
42        }
43    }
44
45    fn path(&self) -> &Path {
46        self.path.as_path()
47    }
48}
49
50impl std::fmt::Display for RuntimeDbInitError {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        write!(
53            f,
54            "failed to {} {} at {}: {}",
55            self.operation,
56            self.label,
57            self.path.display(),
58            self.source
59        )
60    }
61}
62
63impl std::error::Error for RuntimeDbInitError {
64    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
65        Some(self.source.as_ref())
66    }
67}
68
69/// Move one Codex runtime SQLite database out of the way so that database can
70/// be recreated without discarding unrelated runtime databases.
71pub async fn backup_runtime_db_for_fresh_start(
72    db_path: &Path,
73) -> std::io::Result<Vec<RuntimeDbBackup>> {
74    let sqlite_home = db_path.parent().ok_or_else(|| {
75        std::io::Error::other(format!(
76            "database path does not have a parent directory: {}",
77            db_path.display()
78        ))
79    })?;
80    match tokio::fs::metadata(sqlite_home).await {
81        Ok(metadata) if metadata.is_dir() => backup_runtime_db_files(db_path).await,
82        Ok(_) => backup_blocking_sqlite_home(sqlite_home).await,
83        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
84            tokio::fs::create_dir_all(sqlite_home).await?;
85            Err(std::io::Error::other(format!(
86                "no Codex runtime database files were found to back up for {}",
87                db_path.display()
88            )))
89        }
90        Err(err) => Err(err),
91    }
92}
93
94pub fn runtime_db_path_for_corruption_error(err: &anyhow::Error) -> Option<PathBuf> {
95    if !is_sqlite_corruption_error(err) {
96        return None;
97    }
98    err.chain()
99        .find_map(|source| source.downcast_ref::<RuntimeDbInitError>())
100        .map(|err| err.path().to_path_buf())
101}
102
103pub fn is_sqlite_corruption_error(err: &anyhow::Error) -> bool {
104    err.chain().any(sqlite_error_source_is_corruption)
105}
106
107fn sqlite_error_source_is_corruption(source: &(dyn std::error::Error + 'static)) -> bool {
108    let Some(err) = source.downcast_ref::<sqlx::Error>() else {
109        return false;
110    };
111    let sqlx::Error::Database(database_error) = err else {
112        return false;
113    };
114    sqlite_error_detail_is_corruption(database_error.message())
115        || database_error
116            .code()
117            .is_some_and(sqlite_database_code_is_corruption)
118}
119
120fn sqlite_database_code_is_corruption(code: Cow<'_, str>) -> bool {
121    matches!(
122        code.as_ref().to_ascii_lowercase().as_str(),
123        "11" | "26" | "sqlite_corrupt" | "sqlite_notadb"
124    )
125}
126
127pub fn sqlite_error_detail_is_corruption(detail: &str) -> bool {
128    let detail = detail.to_ascii_lowercase();
129    detail.contains("database disk image is malformed")
130        || detail.contains("database schema is malformed")
131        || detail.contains("database is corrupt")
132        || detail.contains("file is not a database")
133        || detail.contains("sqlite_corrupt")
134        || detail.contains("sqlite_notadb")
135        || detail.contains("(code: 11)")
136        || detail.contains("(code: 26)")
137}
138
139pub fn sqlite_error_detail_is_lock(detail: &str) -> bool {
140    let detail = detail.to_ascii_lowercase();
141    detail.contains("database is locked") || detail.contains("database is busy")
142}
143
144async fn backup_runtime_db_files(db_path: &Path) -> std::io::Result<Vec<RuntimeDbBackup>> {
145    let sqlite_home = db_path.parent().ok_or_else(|| {
146        std::io::Error::other(format!(
147            "database path does not have a parent directory: {}",
148            db_path.display()
149        ))
150    })?;
151    backup_sqlite_paths(sqlite_home, sqlite_paths(db_path)).await
152}
153
154async fn backup_sqlite_paths(
155    sqlite_home: &Path,
156    paths: impl IntoIterator<Item = PathBuf>,
157) -> std::io::Result<Vec<RuntimeDbBackup>> {
158    let backup_dir = create_unique_backup_dir(sqlite_home.join(BACKUP_DIR_NAME).as_path()).await?;
159    let mut backups = Vec::new();
160
161    for path in paths {
162        if tokio::fs::try_exists(path.as_path()).await? {
163            let backup_path = backup_dir.join(file_name(path.as_path())?);
164            tokio::fs::rename(path.as_path(), backup_path.as_path()).await?;
165            backups.push(RuntimeDbBackup {
166                original_path: path,
167                backup_path,
168            });
169        }
170    }
171
172    if backups.is_empty() {
173        let _ = tokio::fs::remove_dir(backup_dir).await;
174        return Err(std::io::Error::other(
175            "no Codex runtime database files were found to back up",
176        ));
177    }
178
179    Ok(backups)
180}
181
182async fn backup_blocking_sqlite_home(sqlite_home: &Path) -> std::io::Result<Vec<RuntimeDbBackup>> {
183    let parent = sqlite_home.parent().ok_or_else(|| {
184        std::io::Error::other(format!(
185            "cannot create a backup folder for {}",
186            sqlite_home.display()
187        ))
188    })?;
189    let mut backup_dir_name = file_name(sqlite_home)?.to_os_string();
190    backup_dir_name.push(format!(".{BACKUP_DIR_NAME}"));
191    let backup_parent = parent.join(backup_dir_name);
192    let backup_dir = create_unique_backup_dir(backup_parent.as_path()).await?;
193    let backup_path = backup_dir.join(file_name(sqlite_home)?);
194    tokio::fs::rename(sqlite_home, backup_path.as_path()).await?;
195    tokio::fs::create_dir_all(sqlite_home).await?;
196    Ok(vec![RuntimeDbBackup {
197        original_path: sqlite_home.to_path_buf(),
198        backup_path,
199    }])
200}
201
202fn sqlite_paths(db_path: &Path) -> Vec<PathBuf> {
203    let mut wal_path = db_path.as_os_str().to_os_string();
204    wal_path.push("-wal");
205    let mut shm_path = db_path.as_os_str().to_os_string();
206    shm_path.push("-shm");
207    vec![
208        db_path.to_path_buf(),
209        PathBuf::from(wal_path),
210        PathBuf::from(shm_path),
211    ]
212}
213
214async fn create_unique_backup_dir(backup_parent: &Path) -> std::io::Result<PathBuf> {
215    tokio::fs::create_dir_all(backup_parent).await?;
216    let timestamp = std::time::SystemTime::now()
217        .duration_since(std::time::UNIX_EPOCH)
218        .map_or(0, |duration| duration.as_secs());
219    let mut sequence = 0_u32;
220    loop {
221        let backup_dir = backup_parent.join(format!("sqlite-{timestamp}-{sequence}"));
222        match tokio::fs::create_dir(backup_dir.as_path()).await {
223            Ok(()) => return Ok(backup_dir),
224            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
225                sequence += 1;
226            }
227            Err(err) => return Err(err),
228        }
229    }
230}
231
232fn file_name(path: &Path) -> std::io::Result<&std::ffi::OsStr> {
233    path.file_name().ok_or_else(|| {
234        std::io::Error::other(format!(
235            "cannot create a backup name for {}",
236            path.display()
237        ))
238    })
239}
240
241#[cfg(test)]
242#[path = "recovery_tests.rs"]
243mod tests;