mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
Add managed enforcement of DELETE journaling on NFS (#57225)
## Why Existing databases retain their journal mode, so selecting `DELETE` for new NFS databases leaves existing WAL databases unchanged. ## What changed - Add the managed `unsafe_enforce_sqlite_journal_mode` requirement to migrate existing NFS databases before opening writable pools and require `DELETE` on each connection. - Share migration code between startup and `codex migrate-nfs-db`, preserving WAL backups and resuming interrupted replacements, including lazily opened databases. - Hold the migration lock through pool initialization and keep migration failures out of automatic corruption recovery. - Preserve local logs behavior and skip legacy NFS logs cleanup when enforcement is enabled. ## Testing Add coverage for requirement precedence, database settings, data and backup preservation across restarts, interrupted replacement recovery, refusal of active WAL databases, migration lock waiting, and local logs remaining in WAL mode. GitOrigin-RevId: da41ec1b58b1ee8092591cdf8b358280928ff9d4
This commit is contained in:
committed by
copyberry
parent
964aeda627
commit
480ab2d006
@@ -134,8 +134,9 @@ async fn resolve_sqlite_config(args: &Args) -> anyhow::Result<SqliteConfig> {
|
||||
.parent()
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
return Ok(SqliteConfig::from_sqlite_home(
|
||||
return Ok(SqliteConfig::from_sqlite_home_with_nfs_enforcement(
|
||||
AbsolutePathBuf::relative_to_current_dir(sqlite_home)?,
|
||||
/*enforce_delete_on_nfs*/ false,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,37 +1,17 @@
|
||||
//! Convert configured databases to DELETE mode and keep their WAL originals in backup directories.
|
||||
//! Require confirmation before opening database files. All clients must remain stopped.
|
||||
//! Keep source descriptors open until SQLite closes. Do not checkpoint the WAL source.
|
||||
//! NFS must enforce POSIX locks across clients. Local descriptor checks cannot prove this.
|
||||
//! Confirms explicit database migration and reports the shared migrator's results.
|
||||
//! Other SQLite clients must remain stopped during replacement, and NFS must enforce
|
||||
//! cross-client POSIX locks.
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::ensure;
|
||||
use clap::Parser;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_state::NfsDbMigration;
|
||||
use codex_state::NfsDbMigrationOutcome;
|
||||
use codex_utils_cli::CliConfigOverrides;
|
||||
use owo_colors::OwoColorize;
|
||||
use sqlx::ConnectOptions;
|
||||
use sqlx::Connection;
|
||||
use sqlx::SqliteConnection;
|
||||
use sqlx::sqlite::SqliteConnectOptions;
|
||||
use sqlx::sqlite::SqliteJournalMode;
|
||||
use sqlx::sqlite::SqliteLockingMode;
|
||||
use sqlx::sqlite::SqliteSynchronous;
|
||||
use std::fs::File;
|
||||
use std::io::ErrorKind;
|
||||
use std::io::Read;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(unix)]
|
||||
mod access;
|
||||
mod replacement;
|
||||
|
||||
use replacement::Recovery;
|
||||
use replacement::Replacement;
|
||||
use replacement::regular_file_exists;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
@@ -83,226 +63,28 @@ pub(crate) async fn run(
|
||||
println!("No databases found.");
|
||||
return Ok(());
|
||||
}
|
||||
// Keep this file after release. Removing it lets two migrations lock different inodes.
|
||||
let migration_lock = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(sqlite.home().join(".codex-nfs-migration.lock"))?;
|
||||
migration_lock
|
||||
.try_lock()
|
||||
.context("another database migration is running")?;
|
||||
let mut migration = NfsDbMigration::lock(sqlite.home())?;
|
||||
for database in sqlite.runtime_db_paths() {
|
||||
if skip_logs && database.path == sqlite.logs_db_path() {
|
||||
println!("Skipped logs database: log_dir is configured.");
|
||||
continue;
|
||||
}
|
||||
migrate(&database.path, &command.staging_dir).await.with_context(|| format!(
|
||||
let outcome = migration.migrate(&database.path, &command.staging_dir).await.with_context(|| format!(
|
||||
"migration failed for {}. Keep all Codex instances stopped. Correct the error and rerun codex migrate-nfs-db",
|
||||
database.path.display()
|
||||
))?;
|
||||
match outcome {
|
||||
NfsDbMigrationOutcome::Missing => {}
|
||||
NfsDbMigrationOutcome::AlreadyDelete => {
|
||||
println!("Already in DELETE mode: {}", database.path.display())
|
||||
}
|
||||
NfsDbMigrationOutcome::Migrated { backup_directory } => println!(
|
||||
"Migrated database: {}\nWAL backup: {}",
|
||||
database.path.display(),
|
||||
backup_directory.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
println!("Migration completed successfully. You can restart Codex.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "Migration needs dedicated exclusive connections, not the runtime's WAL pool"
|
||||
)]
|
||||
async fn migrate(source: &Path, staging_dir: &Path) -> anyhow::Result<()> {
|
||||
let replacement = Replacement::new(source)?;
|
||||
if let Recovery::Installed = replacement.resume().await? {
|
||||
println!(
|
||||
"Migrated database: {}\nWAL backup: {}",
|
||||
source.display(),
|
||||
replacement.backup_directory.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let exists = regular_file_exists(source)?;
|
||||
for suffix in ["-wal", "-shm", "-journal"] {
|
||||
let mut path = source.as_os_str().to_owned();
|
||||
path.push(suffix);
|
||||
let sidecar_exists = regular_file_exists(Path::new(&path))?;
|
||||
ensure!(
|
||||
exists || !sidecar_exists,
|
||||
"database is missing but a SQLite sidecar exists"
|
||||
);
|
||||
}
|
||||
if !exists {
|
||||
return Ok(());
|
||||
}
|
||||
let source_file = File::open(source)?;
|
||||
#[cfg(unix)]
|
||||
access::require_private_database(&source_file)?;
|
||||
// The private directory protects WAL contents even on a shared /tmp.
|
||||
let staging = tempfile::Builder::new()
|
||||
.prefix("codex-nfs-migration-")
|
||||
.tempdir_in(staging_dir)
|
||||
.context("cannot create local staging directory")?;
|
||||
let staged_database = staging.path().join("database.sqlite");
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(source)
|
||||
// Set this mode before SQLite first accesses the WAL.
|
||||
// SQLite then locks the database file without shared memory across NFS client kernels.
|
||||
.locking_mode(SqliteLockingMode::Exclusive)
|
||||
.synchronous(SqliteSynchronous::Full)
|
||||
.busy_timeout(Duration::ZERO)
|
||||
.disable_statement_logging();
|
||||
let mut connection = SqliteConnection::connect_with(&options).await.context(
|
||||
"cannot open source database exclusively; close its SQLite clients on all hosts",
|
||||
)?;
|
||||
|
||||
// Closing any descriptor for an inode can release this process's POSIX record locks
|
||||
// for that inode.
|
||||
// Keep both source files open until SQLite closes its connection, including after an error.
|
||||
let mut source_files = vec![source_file];
|
||||
let result: anyhow::Result<bool> = async {
|
||||
{
|
||||
let mut handle = connection.lock_handle().await?;
|
||||
// SAFETY: SQLx's guard prevents its worker from accessing this open connection.
|
||||
// This db_config option accepts an int and an optional output pointer to an int.
|
||||
let status = unsafe {
|
||||
libsqlite3_sys::sqlite3_db_config(
|
||||
handle.as_raw_handle().as_ptr(),
|
||||
libsqlite3_sys::SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE,
|
||||
/*onoff*/ 1,
|
||||
std::ptr::null_mut::<i32>(),
|
||||
)
|
||||
};
|
||||
ensure!(
|
||||
status == libsqlite3_sys::SQLITE_OK,
|
||||
"cannot disable source checkpoint on close: {status}"
|
||||
);
|
||||
}
|
||||
let mode: String = sqlx::query_scalar("PRAGMA journal_mode")
|
||||
.fetch_one(&mut connection)
|
||||
.await
|
||||
.context(
|
||||
"cannot access source database exclusively; close its SQLite clients on all hosts",
|
||||
)?;
|
||||
if mode == "delete" {
|
||||
ensure!(
|
||||
!regular_file_exists(&replacement.replacement_path())?,
|
||||
"unfinished backup exists"
|
||||
);
|
||||
println!("Already in DELETE mode: {}", source.display());
|
||||
return Ok(false);
|
||||
}
|
||||
ensure!(
|
||||
mode == "wal",
|
||||
"source database must be in WAL mode (found {mode})"
|
||||
);
|
||||
// BEGIN EXCLUSIVE alone blocks only WAL writers. EXCLUSIVE locking_mode also blocks readers.
|
||||
// The mode keeps the database lock after ROLLBACK.
|
||||
sqlx::raw_sql("BEGIN EXCLUSIVE; ROLLBACK;")
|
||||
.execute(&mut connection)
|
||||
.await
|
||||
.context(
|
||||
"cannot lock source database exclusively; close its SQLite clients on all hosts",
|
||||
)?;
|
||||
|
||||
let mut wal_path = source.as_os_str().to_owned();
|
||||
wal_path.push("-wal");
|
||||
match File::open(wal_path) {
|
||||
Ok(file) => source_files.push(file),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error).context("cannot open source WAL"),
|
||||
}
|
||||
for (file, name) in source_files
|
||||
.iter_mut()
|
||||
.zip(["database.sqlite", "database.sqlite-wal"])
|
||||
{
|
||||
let mut target = File::create(staging.path().join(name))?;
|
||||
copy_buffered(file, &mut target).context("cannot stage source database and WAL")?;
|
||||
}
|
||||
let mut copy = SqliteConnection::connect_with(
|
||||
&SqliteConnectOptions::new()
|
||||
.filename(&staged_database)
|
||||
.locking_mode(SqliteLockingMode::Exclusive)
|
||||
.journal_mode(SqliteJournalMode::Delete)
|
||||
.synchronous(SqliteSynchronous::Full)
|
||||
.disable_statement_logging(),
|
||||
)
|
||||
.await
|
||||
.context("cannot convert staged database to DELETE")?;
|
||||
let validated = validate_delete_database(&mut copy)
|
||||
.await
|
||||
.context("staged database failed validation");
|
||||
let closed = copy.close().await;
|
||||
validated?;
|
||||
closed?;
|
||||
|
||||
replacement.prepare_backup_directory()?;
|
||||
let mut temporary = tempfile::NamedTempFile::new_in(&replacement.backup_directory)?;
|
||||
copy_buffered(&mut File::open(&staged_database)?, temporary.as_file_mut())
|
||||
.context("cannot write replacement copy")?;
|
||||
// Use the open handle. Closing another source descriptor can release SQLite's locks.
|
||||
temporary
|
||||
.as_file()
|
||||
.set_permissions(source_files[0].metadata()?.permissions())?;
|
||||
#[cfg(unix)]
|
||||
access::require_private_database(temporary.as_file())?;
|
||||
temporary.as_file().sync_all()?;
|
||||
temporary
|
||||
.into_temp_path()
|
||||
.persist_noclobber(replacement.replacement_path())
|
||||
.context("cannot save replacement copy")?;
|
||||
File::open(&replacement.backup_directory)?.sync_all()?;
|
||||
for file in &source_files {
|
||||
file.sync_all()?;
|
||||
}
|
||||
replacement.move_original_to_backup()?;
|
||||
Ok(true)
|
||||
}
|
||||
.await;
|
||||
let closed = connection.close().await;
|
||||
drop(source_files);
|
||||
let changed = result?;
|
||||
closed.context("cannot close source database. Keep clients stopped")?;
|
||||
if changed {
|
||||
replacement.install()?;
|
||||
println!(
|
||||
"Migrated database: {}\nWAL backup: {}",
|
||||
source.display(),
|
||||
replacement.backup_directory.display()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn validate_delete_database(connection: &mut SqliteConnection) -> anyhow::Result<()> {
|
||||
let mode: String = sqlx::query_scalar("PRAGMA journal_mode")
|
||||
.fetch_one(&mut *connection)
|
||||
.await?;
|
||||
ensure!(
|
||||
mode == "delete",
|
||||
"database did not enter DELETE journal mode"
|
||||
);
|
||||
let integrity: String = sqlx::query_scalar("PRAGMA integrity_check(1)")
|
||||
.fetch_one(connection)
|
||||
.await
|
||||
.context("database failed integrity check")?;
|
||||
ensure!(
|
||||
integrity == "ok",
|
||||
"database failed integrity check: {integrity}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_buffered(source: &mut File, destination: &mut File) -> std::io::Result<()> {
|
||||
// Use large reads to avoid the fallback to small reads for copy_file_range on NFSv4.1.
|
||||
let mut buffer = vec![0; 1024 * 1024];
|
||||
loop {
|
||||
let bytes = match source.read(&mut buffer) {
|
||||
Err(error) if error.kind() == ErrorKind::Interrupted => continue,
|
||||
result => result?,
|
||||
};
|
||||
if bytes == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
destination.write_all(&buffer[..bytes])?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
//! Reject databases with access permissions that replacement cannot preserve.
|
||||
//! Accept only files owned by the current user with no group or other access.
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::ensure;
|
||||
use std::fs::File;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
pub(super) fn require_private_database(file: &File) -> anyhow::Result<()> {
|
||||
let metadata = file.metadata()?;
|
||||
// SAFETY: geteuid has no preconditions.
|
||||
let user = unsafe { libc::geteuid() };
|
||||
ensure!(
|
||||
metadata.uid() == user,
|
||||
"database must be owned by the current user"
|
||||
);
|
||||
ensure!(
|
||||
metadata.mode() & 0o077 == 0,
|
||||
"shared database access is not supported: group and other permissions must be absent"
|
||||
);
|
||||
check_acl(file).context("cannot verify private database access")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn check_acl(file: &File) -> anyhow::Result<()> {
|
||||
use std::os::fd::AsRawFd;
|
||||
|
||||
// POSIX ACL grants are limited by the group mode bits checked above.
|
||||
// NFSv4 named entries can grant access independently of those bits.
|
||||
let mut buffer = vec![0u8; 65536];
|
||||
// SAFETY: The descriptor is open. The name and writable buffer are valid for this call.
|
||||
let size = unsafe {
|
||||
libc::fgetxattr(
|
||||
file.as_raw_fd(),
|
||||
c"system.nfs4_acl".as_ptr(),
|
||||
buffer.as_mut_ptr().cast(),
|
||||
buffer.len(),
|
||||
)
|
||||
};
|
||||
if size < 0 {
|
||||
let error = std::io::Error::last_os_error();
|
||||
return match error.raw_os_error() {
|
||||
Some(libc::ENODATA | libc::EOPNOTSUPP) => Ok(()),
|
||||
_ => Err(error.into()),
|
||||
};
|
||||
}
|
||||
let mut bytes = &buffer[..size as usize];
|
||||
let read_word = |bytes: &mut &[u8]| -> anyhow::Result<u32> {
|
||||
let (word, rest) = bytes
|
||||
.split_at_checked(/*mid*/ 4)
|
||||
.context("invalid NFSv4 ACL")?;
|
||||
*bytes = rest;
|
||||
Ok(u32::from_be_bytes(word.try_into()?))
|
||||
};
|
||||
let entries = read_word(&mut bytes)?;
|
||||
for _ in 0..entries {
|
||||
let kind = read_word(&mut bytes)?;
|
||||
let _flags = read_word(&mut bytes)?;
|
||||
let permissions = read_word(&mut bytes)?;
|
||||
let length = read_word(&mut bytes)? as usize;
|
||||
let (name, rest) = bytes
|
||||
.split_at_checked(length)
|
||||
.context("invalid NFSv4 ACL")?;
|
||||
ensure!(
|
||||
matches!(name, b"OWNER@" | b"GROUP@" | b"EVERYONE@"),
|
||||
"databases with named NFSv4 ACL entries are not supported"
|
||||
);
|
||||
ensure!(kind <= 1, "unsupported NFSv4 ACL entry type");
|
||||
// Other users can read attributes and ACLs, and synchronize. Reject all other grants.
|
||||
const READ_ATTRIBUTES: u32 = 0x80;
|
||||
const READ_ACL: u32 = 0x20000;
|
||||
const SYNCHRONIZE: u32 = 0x100000;
|
||||
ensure!(
|
||||
kind != 0
|
||||
|| name == b"OWNER@"
|
||||
|| permissions & !(READ_ATTRIBUTES | READ_ACL | SYNCHRONIZE) == 0,
|
||||
"shared database access through an NFSv4 ACL is not supported"
|
||||
);
|
||||
bytes = rest
|
||||
.get((4 - length % 4) % 4..)
|
||||
.context("invalid NFSv4 ACL padding")?;
|
||||
}
|
||||
ensure!(bytes.is_empty(), "invalid NFSv4 ACL trailing data");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn check_acl(file: &File) -> anyhow::Result<()> {
|
||||
use std::ffi::c_void;
|
||||
use std::os::fd::AsRawFd;
|
||||
|
||||
unsafe extern "C" {
|
||||
fn acl_get_fd(fd: libc::c_int) -> *mut c_void;
|
||||
fn acl_get_entry(
|
||||
acl: *mut c_void,
|
||||
entry_id: libc::c_int,
|
||||
entry: *mut *mut c_void,
|
||||
) -> libc::c_int;
|
||||
fn acl_free(acl: *mut c_void) -> libc::c_int;
|
||||
}
|
||||
// SAFETY: The descriptor is open. The returned ACL remains valid until acl_free.
|
||||
let acl = unsafe { acl_get_fd(file.as_raw_fd()) };
|
||||
if acl.is_null() {
|
||||
let error = std::io::Error::last_os_error();
|
||||
return match error.raw_os_error() {
|
||||
// ENOENT means the open file has no extended ACL.
|
||||
Some(libc::ENOENT | libc::ENOTSUP) => Ok(()),
|
||||
_ => Err(error.into()),
|
||||
};
|
||||
}
|
||||
let mut entry = std::ptr::null_mut();
|
||||
// SAFETY: The ACL and output pointer are valid. Zero selects ACL_FIRST_ENTRY on macOS.
|
||||
let status = unsafe {
|
||||
acl_get_entry(acl, /*entry_id*/ 0, &mut entry)
|
||||
};
|
||||
let error = std::io::Error::last_os_error();
|
||||
// SAFETY: This call releases the ACL returned by acl_get_fd exactly once.
|
||||
unsafe { acl_free(acl) };
|
||||
ensure!(
|
||||
status != 0,
|
||||
"databases with extended macOS ACLs are not supported"
|
||||
);
|
||||
ensure!(
|
||||
error.raw_os_error() == Some(libc::EINVAL),
|
||||
"cannot read database ACL: {error}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
fn check_acl(_file: &File) -> anyhow::Result<()> {
|
||||
anyhow::bail!("database ACL checks are not supported on this operating system")
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
//! Move the WAL files into a backup directory, then install the validated DELETE copy.
|
||||
//! Resume an interrupted replacement from the files that remain on disk.
|
||||
//! All clients must remain stopped. The caller must hold the migration lock.
|
||||
//! Sync both directories after each step before changing more files.
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::ensure;
|
||||
use sqlx::ConnectOptions;
|
||||
use sqlx::Connection;
|
||||
use sqlx::SqliteConnection;
|
||||
use sqlx::sqlite::SqliteConnectOptions;
|
||||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::io::ErrorKind;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const MARKER: &[u8] = b"codex-nfs-migration-v1\n";
|
||||
const SIDECARS: [&str; 3] = ["-wal", "-shm", "-journal"];
|
||||
|
||||
pub(super) enum Recovery {
|
||||
// The caller must check and lock the source before starting a new replacement.
|
||||
InspectSource,
|
||||
// Recovery installed the saved copy. No new replacement is necessary.
|
||||
Installed,
|
||||
}
|
||||
|
||||
pub(super) struct Replacement {
|
||||
source: PathBuf,
|
||||
pub(super) backup_directory: PathBuf,
|
||||
backup_database: PathBuf,
|
||||
}
|
||||
|
||||
impl Replacement {
|
||||
pub(super) fn new(source: &Path) -> anyhow::Result<Self> {
|
||||
let name = source.file_name().context("database needs a file name")?;
|
||||
let mut directory = source.as_os_str().to_owned();
|
||||
directory.push("_wal_migration_backup");
|
||||
let directory = PathBuf::from(directory);
|
||||
Ok(Self {
|
||||
source: source.to_path_buf(),
|
||||
backup_database: directory.join(name),
|
||||
backup_directory: directory,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn replacement_path(&self) -> PathBuf {
|
||||
self.backup_directory.join("delete.sqlite")
|
||||
}
|
||||
|
||||
// Call only while the original database is locked. Rebuild an unused copy on every attempt.
|
||||
pub(super) fn prepare_backup_directory(&self) -> anyhow::Result<()> {
|
||||
if self.backup_directory.try_exists()? {
|
||||
ensure!(
|
||||
!regular_file_exists(&self.backup_database)?,
|
||||
"WAL backup already exists"
|
||||
);
|
||||
if regular_file_exists(&self.replacement_path())? {
|
||||
fs::remove_file(self.replacement_path())?;
|
||||
}
|
||||
} else {
|
||||
let builder = fs::DirBuilder::new();
|
||||
#[cfg(unix)]
|
||||
let builder = {
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
let mut builder = builder;
|
||||
builder.mode(0o700);
|
||||
builder
|
||||
};
|
||||
builder.create(&self.backup_directory)?;
|
||||
let mut marker = File::create_new(self.backup_directory.join("migration-v1"))?;
|
||||
marker.write_all(MARKER)?;
|
||||
marker.sync_all()?;
|
||||
}
|
||||
self.sync_directories()
|
||||
}
|
||||
|
||||
pub(super) fn move_original_to_backup(&self) -> anyhow::Result<()> {
|
||||
ensure!(
|
||||
!regular_file_exists(&self.backup_database)?,
|
||||
"WAL backup already exists"
|
||||
);
|
||||
// Move the main file first. An incomplete switch must not look like an intact source.
|
||||
fs::rename(&self.source, &self.backup_database)?;
|
||||
self.sync_directories()?;
|
||||
self.move_sidecars_to_backup()
|
||||
}
|
||||
|
||||
fn move_sidecars_to_backup(&self) -> anyhow::Result<()> {
|
||||
for suffix in SIDECARS {
|
||||
let source = with_suffix(&self.source, suffix);
|
||||
let destination = with_suffix(&self.backup_database, suffix);
|
||||
if regular_file_exists(&source)? {
|
||||
ensure!(
|
||||
!regular_file_exists(&destination)?,
|
||||
"backup sidecar already exists: {}",
|
||||
destination.display()
|
||||
);
|
||||
fs::rename(source, destination)?;
|
||||
}
|
||||
}
|
||||
self.sync_directories()
|
||||
}
|
||||
|
||||
pub(super) fn install(&self) -> anyhow::Result<()> {
|
||||
for suffix in ["", "-wal", "-shm", "-journal"] {
|
||||
ensure!(
|
||||
!regular_file_exists(&with_suffix(&self.source, suffix))?,
|
||||
"database or sidecar already exists at the installation path"
|
||||
);
|
||||
}
|
||||
// A hard link installs the file atomically without replacing an unexpected database.
|
||||
fs::hard_link(self.replacement_path(), &self.source)?;
|
||||
self.finish_installation()
|
||||
}
|
||||
|
||||
fn finish_installation(&self) -> anyhow::Result<()> {
|
||||
self.sync_directories()?;
|
||||
fs::remove_file(self.replacement_path())?;
|
||||
self.sync_directories()
|
||||
}
|
||||
|
||||
fn sync_directories(&self) -> anyhow::Result<()> {
|
||||
File::open(&self.backup_directory)?.sync_all()?;
|
||||
File::open(
|
||||
self.source
|
||||
.parent()
|
||||
.context("database needs a parent directory")?,
|
||||
)?
|
||||
.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "Inspect the prepared copy without runtime initialization"
|
||||
)]
|
||||
pub(super) async fn resume(&self) -> anyhow::Result<Recovery> {
|
||||
match fs::symlink_metadata(&self.backup_directory) {
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||
return Ok(Recovery::InspectSource);
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
Ok(metadata) => ensure!(
|
||||
metadata.is_dir(),
|
||||
"backup path already exists and is not a directory"
|
||||
),
|
||||
}
|
||||
let marker = self.backup_directory.join("migration-v1");
|
||||
ensure!(
|
||||
regular_file_exists(&marker)? && fs::read(&marker)? == MARKER,
|
||||
"backup directory already exists without a valid migration marker: {}",
|
||||
self.backup_directory.display()
|
||||
);
|
||||
let backup_exists = regular_file_exists(&self.backup_database)?;
|
||||
let replacement_exists = regular_file_exists(&self.replacement_path())?;
|
||||
let source_exists = regular_file_exists(&self.source)?;
|
||||
#[cfg(unix)]
|
||||
if backup_exists {
|
||||
super::access::require_private_database(&File::open(&self.backup_database)?)?;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
if replacement_exists {
|
||||
super::access::require_private_database(&File::open(self.replacement_path())?)?;
|
||||
}
|
||||
// No original files moved yet. The caller must lock the source and rebuild the copy.
|
||||
if !backup_exists {
|
||||
for suffix in SIDECARS {
|
||||
ensure!(
|
||||
!regular_file_exists(&with_suffix(&self.backup_database, suffix))?,
|
||||
"backup has a SQLite sidecar without its original database"
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
source_exists,
|
||||
"original database is missing from both source and backup"
|
||||
);
|
||||
return Ok(Recovery::InspectSource);
|
||||
}
|
||||
// Installation finished, or the replacement is missing. The caller must check the source.
|
||||
if !replacement_exists {
|
||||
ensure!(
|
||||
source_exists,
|
||||
"replacement is missing. Preserve the WAL backup and restore it before retrying"
|
||||
);
|
||||
return Ok(Recovery::InspectSource);
|
||||
}
|
||||
// An existing source must be the link installed before the interruption.
|
||||
if source_exists {
|
||||
let same_file = {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let source = fs::metadata(&self.source)?;
|
||||
let prepared = fs::metadata(self.replacement_path())?;
|
||||
(source.dev(), source.ino()) == (prepared.dev(), prepared.ino())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
false
|
||||
}
|
||||
};
|
||||
ensure!(
|
||||
same_file,
|
||||
"another database already exists at the installation path"
|
||||
);
|
||||
self.finish_installation()?;
|
||||
return Ok(Recovery::Installed);
|
||||
}
|
||||
|
||||
// The original is in the backup. Check the replacement before installing it.
|
||||
let mut copy = SqliteConnection::connect_with(
|
||||
&SqliteConnectOptions::new()
|
||||
.filename(self.replacement_path())
|
||||
.read_only(true)
|
||||
.immutable(true)
|
||||
.disable_statement_logging(),
|
||||
)
|
||||
.await?;
|
||||
let checked = super::validate_delete_database(&mut copy)
|
||||
.await
|
||||
.context("saved replacement failed validation");
|
||||
let closed = copy.close().await;
|
||||
checked?;
|
||||
closed?;
|
||||
self.move_sidecars_to_backup()?;
|
||||
self.install()?;
|
||||
Ok(Recovery::Installed)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn regular_file_exists(path: &Path) -> anyhow::Result<bool> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(false),
|
||||
Err(error) => Err(error.into()),
|
||||
Ok(metadata) => {
|
||||
ensure!(
|
||||
metadata.is_file(),
|
||||
"path already exists and is not a regular file: {}",
|
||||
path.display()
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
|
||||
let mut value = path.as_os_str().to_owned();
|
||||
value.push(suffix);
|
||||
value.into()
|
||||
}
|
||||
Reference in New Issue
Block a user