mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Share one SQLite snapshot-staging helper between fabro-db and activation
create_backup re-implemented the staging half of fabro-db's pre-migration snapshot (remove stale staging file, UTF-8 check, VACUUM INTO, private permissions), and remove_file_if_exists and set_private_permissions had been made pub precisely to hand-copy that sequence. Any future hardening of snapshot staging would have had to land in two crates and could drift. fabro-db now exposes write_snapshot_to_staging with a typed SnapshotStagingError; both the pre-migration snapshot and the pre-activation backup stage through it, and the hand-copied helpers are private again. The publish halves stay separate on purpose: migrations overwrite their snapshot, activation publishes with persist_noclobber plus integrity validation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
31c7a670d5
commit
e067de9382
4 changed files with 81 additions and 74 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -2593,6 +2593,7 @@ dependencies = [
|
|||
"chrono",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -67,26 +67,8 @@ pub(crate) enum BlobActivationError {
|
|||
required_bytes: u64,
|
||||
available_bytes: u64,
|
||||
},
|
||||
#[error("removing stale activation backup staging file {path}")]
|
||||
RemoveStaging {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("activation backup staging path is not valid UTF-8 at {path}")]
|
||||
NonUtf8StagingPath { path: PathBuf },
|
||||
#[error("writing the pre-activation SQLite backup at {path}")]
|
||||
WriteBackup {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: sqlx::Error,
|
||||
},
|
||||
#[error("setting private permissions on activation backup staging file {path}")]
|
||||
SetBackupPermissions {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("staging the pre-activation SQLite backup")]
|
||||
StageBackup(#[source] fabro_db::SnapshotStagingError),
|
||||
#[error("joining the activation backup publication task")]
|
||||
JoinBackupPublication(#[source] JoinError),
|
||||
#[error("publishing the activation backup at {path} without overwriting")]
|
||||
|
|
@ -300,32 +282,9 @@ async fn create_backup(
|
|||
backup_path: &Path,
|
||||
) -> Result<(), BlobActivationError> {
|
||||
let staging_path = fabro_db::append_to_path(backup_path, STAGING_SUFFIX);
|
||||
fabro_db::remove_file_if_exists(&staging_path)
|
||||
fabro_db::write_snapshot_to_staging(pool, &staging_path)
|
||||
.await
|
||||
.map_err(|source| BlobActivationError::RemoveStaging {
|
||||
path: staging_path.clone(),
|
||||
source,
|
||||
})?;
|
||||
let staging_target =
|
||||
staging_path
|
||||
.to_str()
|
||||
.ok_or_else(|| BlobActivationError::NonUtf8StagingPath {
|
||||
path: staging_path.clone(),
|
||||
})?;
|
||||
sqlx::query("VACUUM INTO ?")
|
||||
.bind(staging_target)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|source| BlobActivationError::WriteBackup {
|
||||
path: staging_path.clone(),
|
||||
source,
|
||||
})?;
|
||||
fabro_db::set_private_permissions(&staging_path)
|
||||
.await
|
||||
.map_err(|source| BlobActivationError::SetBackupPermissions {
|
||||
path: staging_path.clone(),
|
||||
source,
|
||||
})?;
|
||||
.map_err(BlobActivationError::StageBackup)?;
|
||||
validate_backup(&staging_path).await?;
|
||||
|
||||
let publish_staging = staging_path.clone();
|
||||
|
|
@ -581,7 +540,10 @@ mod tests {
|
|||
.await
|
||||
.expect_err("a closed pool must fail backup creation");
|
||||
|
||||
assert!(matches!(error, BlobActivationError::WriteBackup { .. }));
|
||||
assert!(matches!(
|
||||
error,
|
||||
BlobActivationError::StageBackup(fabro_db::SnapshotStagingError::Write { .. })
|
||||
));
|
||||
assert!(!backup_path.exists());
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ workspace = true
|
|||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
sqlx.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
|
|
|
|||
|
|
@ -92,33 +92,12 @@ impl Database {
|
|||
let database_path = connect_options.get_filename();
|
||||
let snapshot_path = pre_migration_snapshot_path(database_path);
|
||||
|
||||
// VACUUM INTO produces a consistent single-file copy from the live
|
||||
// pool, so the snapshot needs no -wal/-shm siblings to restore. It
|
||||
// writes to a staging file that is renamed into place afterwards, so
|
||||
// a failure mid-copy never leaves a partial file at the snapshot
|
||||
// path.
|
||||
// The snapshot is staged and then renamed into place, so a failure
|
||||
// mid-copy never leaves a partial file at the snapshot path.
|
||||
let staging_path = append_to_path(&snapshot_path, ".tmp");
|
||||
remove_file_if_exists(&staging_path)
|
||||
write_snapshot_to_staging(&self.pool, &staging_path)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"removing stale snapshot staging file {}",
|
||||
staging_path.display()
|
||||
)
|
||||
})?;
|
||||
let staging_target = staging_path
|
||||
.to_str()
|
||||
.context("snapshot staging path is not valid UTF-8")?;
|
||||
sqlx::query("VACUUM INTO ?")
|
||||
.bind(staging_target)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("writing pre-migration snapshot {}", staging_path.display())
|
||||
})?;
|
||||
set_private_permissions(&staging_path)
|
||||
.await
|
||||
.with_context(|| format!("setting permissions on {}", staging_path.display()))?;
|
||||
.context("staging the pre-migration snapshot")?;
|
||||
remove_file_if_exists(&snapshot_path)
|
||||
.await
|
||||
.with_context(|| {
|
||||
|
|
@ -226,8 +205,72 @@ async fn applied_migration_versions(pool: &DbPool) -> anyhow::Result<HashSet<i64
|
|||
.collect())
|
||||
}
|
||||
|
||||
/// Error writing a consistent single-file SQLite snapshot to a staging path.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SnapshotStagingError {
|
||||
#[error("removing stale snapshot staging file {path}")]
|
||||
RemoveStale {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("snapshot staging path is not valid UTF-8 at {path}")]
|
||||
NonUtf8Path { path: PathBuf },
|
||||
#[error("writing SQLite snapshot {path}")]
|
||||
Write {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: sqlx::Error,
|
||||
},
|
||||
#[error("setting private permissions on snapshot staging file {path}")]
|
||||
SetPermissions {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
/// Writes a consistent single-file copy of the live pool to `staging_path`.
|
||||
///
|
||||
/// `VACUUM INTO` produces a snapshot that needs no `-wal`/`-shm` siblings to
|
||||
/// restore. Any stale staging file is removed first and the copy is
|
||||
/// restricted to private permissions. The caller publishes the staging file
|
||||
/// into its final path and owns the durability of that rename.
|
||||
pub async fn write_snapshot_to_staging(
|
||||
pool: &DbPool,
|
||||
staging_path: &Path,
|
||||
) -> Result<(), SnapshotStagingError> {
|
||||
remove_file_if_exists(staging_path)
|
||||
.await
|
||||
.map_err(|source| SnapshotStagingError::RemoveStale {
|
||||
path: staging_path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
let staging_target =
|
||||
staging_path
|
||||
.to_str()
|
||||
.ok_or_else(|| SnapshotStagingError::NonUtf8Path {
|
||||
path: staging_path.to_path_buf(),
|
||||
})?;
|
||||
sqlx::query("VACUUM INTO ?")
|
||||
.bind(staging_target)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|source| SnapshotStagingError::Write {
|
||||
path: staging_path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
set_private_permissions(staging_path)
|
||||
.await
|
||||
.map_err(|source| SnapshotStagingError::SetPermissions {
|
||||
path: staging_path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes `path`, treating an already-missing file as success.
|
||||
pub async fn remove_file_if_exists(path: &Path) -> std::io::Result<()> {
|
||||
async fn remove_file_if_exists(path: &Path) -> std::io::Result<()> {
|
||||
match fs::remove_file(path).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
|
|
@ -237,7 +280,7 @@ pub async fn remove_file_if_exists(path: &Path) -> std::io::Result<()> {
|
|||
|
||||
/// Restricts `path` to owner-only access (0o600). No-op off Unix.
|
||||
#[cfg(unix)]
|
||||
pub async fn set_private_permissions(path: &Path) -> std::io::Result<()> {
|
||||
async fn set_private_permissions(path: &Path) -> std::io::Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await
|
||||
|
|
@ -245,7 +288,7 @@ pub async fn set_private_permissions(path: &Path) -> std::io::Result<()> {
|
|||
|
||||
/// Restricts `path` to owner-only access (0o600). No-op off Unix.
|
||||
#[cfg(not(unix))]
|
||||
pub async fn set_private_permissions(_path: &Path) -> std::io::Result<()> {
|
||||
async fn set_private_permissions(_path: &Path) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue