feat(job): fix key rotation on local storage (replace blob)

This commit is contained in:
Edouard Vanbelle
2026-08-02 00:45:52 +02:00
parent e12d8bcb90
commit bc481bdd99
4 changed files with 160 additions and 6 deletions
@@ -556,6 +556,26 @@ impl BlobStorageBackend for EncryptedBlobBackend {
})
}
/// Frame the plaintext with the head pair's format (encrypted-v1
/// or plaintext-v1), then delegate the atomic replace to the
/// inner backend. Used by `storage_rotate` to actually change the
/// on-disk bytes — `put_blob_from_bytes` would silently no-op on
/// `LocalBlobBackend` when the object key already exists.
fn put_blob_from_bytes_replace(
&self,
hash: &str,
data: Bytes,
) -> Pin<Box<dyn std::future::Future<Output = Result<u64, DomainError>> + Send + '_>> {
let inner = self.inner.clone();
let hash = hash.to_string();
let head_cipher = self.head_cipher.clone();
let head_key_fp = self.head_key_fp;
Box::pin(async move {
let out = frame_write(head_cipher, head_key_fp, data.to_vec()).await?;
inner.put_blob_from_bytes_replace(&hash, out).await
})
}
fn sync_blobs(
&self,
hashes: &[String],
@@ -422,6 +422,96 @@ impl BlobStorageBackend for LocalBlobBackend {
})
}
/// **Atomic replace**: write to a same-directory tempfile, fsync,
/// then `rename(2)` over the target. `write_blob_bytes`'s
/// `O_CREAT|O_EXCL` idempotent-skip (the right choice for uploads)
/// silently no-ops when the target already exists — wrong for
/// callers like `storage_rotate` that need the bytes to change.
/// See the trait doc for the full picture.
///
/// Tempfile lives beside the target under the same shard directory
/// so `rename` is a cheap same-filesystem operation (never an
/// EXDEV cross-device copy fallback). The tempfile name embeds
/// the process pid + a monotonic counter so parallel replaces on
/// the same hash from different tasks don't clobber each other.
fn put_blob_from_bytes_replace(
&self,
hash: &str,
data: Bytes,
) -> Pin<Box<dyn std::future::Future<Output = Result<u64, DomainError>> + Send + '_>> {
let hash = hash.to_owned();
Box::pin(async move {
let blob_path = self.blob_path(&hash);
let size = data.len() as u64;
// Tempfile in the SAME directory as the target → rename is
// cheap same-filesystem, never EXDEV. Counter ensures
// uniqueness under parallel replaces (rare — rotate is
// sequential per-blob today, but future concurrency won't
// corrupt).
static REPLACE_COUNTER: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
let counter = REPLACE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let tmp_path = blob_path.with_file_name(format!(
"{}.replace.{}.{}.tmp",
hash,
std::process::id(),
counter
));
// Create + write + fsync the tempfile. `create_new(true)`
// stays here to catch the astronomically-unlikely case of
// two tasks colliding on the same counter value (belt-and-
// braces; the pid+counter naming already prevents it).
{
let mut tmp = fs::File::options()
.write(true)
.create_new(true)
.open(&tmp_path)
.await
.map_err(|e| {
DomainError::internal_error(
"Blob",
format!("Failed to create replace-tmp: {}", e),
)
})?;
if let Err(e) = tmp.write_all(&data).await {
let _ = fs::remove_file(&tmp_path).await;
return Err(DomainError::internal_error(
"Blob",
format!("Failed to write replace-tmp: {}", e),
));
}
if let Err(e) = tmp.sync_all().await {
let _ = fs::remove_file(&tmp_path).await;
return Err(DomainError::internal_error(
"Blob",
format!("Failed to fsync replace-tmp: {}", e),
));
}
}
// Atomic replace. On POSIX `rename(2)` is atomic within a
// filesystem — a concurrent reader sees either the old or
// new bytes, never a truncated view. Older bytes drop out
// as soon as no reader holds an open fd.
if let Err(e) = fs::rename(&tmp_path, &blob_path).await {
let _ = fs::remove_file(&tmp_path).await;
return Err(DomainError::internal_error(
"Blob",
format!("Failed to atomically replace blob: {}", e),
));
}
// fsync the parent directory so the dirent change (i.e. the
// rename result) survives a power loss, same discipline as
// the create path in `put_blob_from_bytes`.
fsync_parent_dir(&blob_path).await;
Ok(size)
})
}
fn sync_blobs(
&self,
hashes: &[String],
@@ -376,13 +376,21 @@ impl RecoverableJobHandler for StorageRotateService {
continue;
}
// Rewrite via the standard write path — atomic
// replace at the same object key. `put_blob_from_bytes`
// frames the plaintext with the head pair's format
// (encrypted-v1 or plaintext-v1) and hands the
// resulting bytes to the inner backend.
// Rewrite via the atomic-replace write path.
//
// **NOT** `put_blob_from_bytes`: that variant is
// idempotent-skip (`O_CREAT|O_EXCL` on
// `LocalBlobBackend`) — correct for uploads (same
// plaintext ↔ any ciphertext at hash decrypts back)
// but a silent no-op for us. Rotate NEEDS the on-disk
// bytes to change (legacy → v1 header, old key → new
// key, plaintext ↔ encrypted). Ed hit this on
// 2026-08-02: rotation reported success in 9s but
// every blob on disk still had the legacy shape.
// `put_blob_from_bytes_replace` writes to a tempfile
// + atomic `rename(2)`s over the existing object key.
if let Err(e) = wrapper
.put_blob_from_bytes(hash, Bytes::from(plaintext.to_vec()))
.put_blob_from_bytes_replace(hash, Bytes::from(plaintext.to_vec()))
.await
{
failed_count += 1;