Address review findings: durability, stale container ids, honest toasts
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m38s
Build App (Preview) / build-windows (pull_request) Successful in 6m18s
Build App (Preview) / build-linux (pull_request) Successful in 7m48s
Build App (Preview) / prune-previews (pull_request) Successful in 1s

An Opus review of the previous commit found several real gaps:

- pending_cleanup::save used plain write-temp-then-rename, unlike
  migration_store's fsync'd write it claimed to mirror — a crash in that
  window left a truncated record that list() would skip forever, silently
  reproducing the exact bug this module exists to fix. Now matches
  migration_store's File::create/write_all/sync_all/rename/sync_dir shape,
  and the tests exercise the real save/list/clear functions against a temp
  dir instead of re-implementing their bodies inline.
- remove_project and rebuild_project_container only ever looked at
  project.container_id, unlike every other container-destroying path in the
  codebase, which falls back to find_existing_container for exactly this
  race (a crash between creating a container and persisting its id). A miss
  here left a container that then blocked every subsequent volume removal
  with a 409, forever. Both now resolve the same way the rest of the
  codebase does, and record the container by its deterministic name rather
  than its id so a retry still has something that resolves.
- remove_project's toast promised an automatic retry unconditionally, even
  when writing the pending-cleanup record itself failed (the one case
  where nothing will actually retry). ProjectRemovalReport now carries
  retry_scheduled, and the UI is honest about which case it's in.
- remove_volumes_by_name now retries once after a short delay on a 409,
  since Docker releasing a volume's mount reference right after its
  container is removed is not always instantaneous, and this is exactly
  the sequence remove_project runs.
- rebuild_project_container (Reset) returns ProjectResetOutcome so the UI
  can warn when Reset could not fully clear a project's volumes, instead
  of only logging it — the new container silently reuses old data
  otherwise, which is what Reset promises not to do.
- retry_pending_cleanup_logged escalates a record's log level after it has
  failed for a week, since recorded_at was otherwise write-only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
This commit is contained in:
2026-08-27 08:36:27 -07:00
co-authored by Claude Sonnet 5
parent 4827170715
commit d8bb5ab262
9 changed files with 351 additions and 100 deletions
+156 -61
View File
@@ -10,13 +10,21 @@
//! `commands::project_commands::retry_pending_cleanup_logged`) and deletes
//! the ones that fully succeed.
//!
//! Same write-temp-then-rename shape as `projects.json` and the migration
//! store, and the same per-project-file layout as
//! `storage::migration_store` — a stuck cleanup record for one project must
//! never block the retry of another's.
//! **This record is written in the same instant its record in `projects.json`
//! is destroyed, and it is the only remaining handle on the leftover
//! resource** — which is a stronger claim on durability than an ordinary
//! write-temp-then-rename gives. `storage::migration_store::save` carries the
//! same reasoning for the migration state file: `fs::write` returns once the
//! bytes are in the page cache, and a rename over them is atomic with respect
//! to other readers, not to power loss. A crash in that window leaves the
//! rename applied and the data half-written, which [`list`] then treats as
//! unparseable and skips — reproducing the exact bug this module exists to
//! close, silently, with only a startup log line as evidence. So `save` here
//! takes the same `File::create` → `write_all` → `sync_all` → `rename` →
//! directory-sync shape `migration_store` does.
use std::fs;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
@@ -27,6 +35,11 @@ pub struct PendingCleanup {
/// second lookup — the project record itself is already gone by the time
/// this is read back.
pub project_name: String,
/// The project's container, if it could not be removed. Named by its
/// deterministic `triple-c-{id}` name rather than the (possibly stale)
/// container id Docker handed out — Docker's remove-container API
/// accepts either, and the name is the one identifier guaranteed to still
/// resolve to the same container by the time a retry runs.
pub container_id: Option<String>,
pub image: Option<String>,
pub volumes: Vec<String>,
@@ -63,29 +76,15 @@ fn sanitize(project_id: &str) -> String {
.collect()
}
fn path_for(project_id: &str) -> Result<PathBuf, String> {
Ok(dir()?.join(format!("{}.json", sanitize(project_id))))
}
/// Write (or overwrite) a project's pending-cleanup record.
pub fn save(record: &PendingCleanup) -> Result<(), String> {
let path = path_for(&record.project_id)?;
let data = serde_json::to_string_pretty(record)
.map_err(|e| format!("Failed to serialize pending cleanup record: {}", e))?;
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, data).map_err(|e| format!("Failed to write pending cleanup record: {}", e))?;
fs::rename(&tmp, &path).map_err(|e| format!("Failed to commit pending cleanup record: {}", e))
save_in(&dir()?, record)
}
/// Remove a project's pending-cleanup record. Missing is success — this is
/// how a fully-succeeded retry (or a record that never existed) is expressed.
pub fn clear(project_id: &str) -> Result<(), String> {
let path = path_for(project_id)?;
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(format!("Failed to remove pending cleanup record: {}", e)),
}
clear_in(&dir()?, project_id)
}
/// Every pending-cleanup record on disk. An unparseable file is logged and
@@ -93,7 +92,50 @@ pub fn clear(project_id: &str) -> Result<(), String> {
/// "one bad record can't wedge the rest" reasoning as the migration store.
pub fn list() -> Vec<PendingCleanup> {
let Ok(dir) = dir() else { return Vec::new() };
let Ok(entries) = fs::read_dir(&dir) else { return Vec::new() };
list_in(&dir)
}
fn path_in(dir: &Path, project_id: &str) -> PathBuf {
dir.join(format!("{}.json", sanitize(project_id)))
}
/// Durable write: fsync the file before the rename, and fsync the directory
/// after it — see the module doc comment for why a plain
/// write-temp-then-rename is not enough here. Mirrors
/// `storage::migration_store::save`/`sync_dir`.
fn save_in(dir: &Path, record: &PendingCleanup) -> Result<(), String> {
let path = path_in(dir, &record.project_id);
let data = serde_json::to_string_pretty(record)
.map_err(|e| format!("Failed to serialize pending cleanup record: {}", e))?;
let tmp = path.with_extension("json.tmp");
{
use std::io::Write;
let mut file = fs::File::create(&tmp)
.map_err(|e| format!("Failed to write pending cleanup record: {}", e))?;
file.write_all(data.as_bytes())
.map_err(|e| format!("Failed to write pending cleanup record: {}", e))?;
file.sync_all()
.map_err(|e| format!("Failed to flush pending cleanup record to disk: {}", e))?;
}
fs::rename(&tmp, &path)
.map_err(|e| format!("Failed to commit pending cleanup record: {}", e))?;
sync_dir(&path);
Ok(())
}
fn clear_in(dir: &Path, project_id: &str) -> Result<(), String> {
let path = path_in(dir, project_id);
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(format!("Failed to remove pending cleanup record: {}", e)),
}
}
fn list_in(dir: &Path) -> Vec<PendingCleanup> {
let Ok(entries) = fs::read_dir(dir) else { return Vec::new() };
entries
.flatten()
@@ -116,28 +158,50 @@ pub fn list() -> Vec<PendingCleanup> {
.collect()
}
/// fsync the directory holding `path`, so a rename into it survives power
/// loss. Best effort only on the platforms where it is meaningless: Windows
/// has no directory handle to sync and errors on the attempt, so failure is
/// logged rather than propagated — the file's own `sync_all` above is what
/// carries the data. Mirrors `storage::migration_store::sync_dir`, which is
/// private to that module, so this is a small deliberate duplicate rather
/// than a shared dependency between two otherwise-independent stores.
fn sync_dir(path: &Path) {
let Some(dir) = path.parent() else { return };
match fs::File::open(dir).and_then(|d| d.sync_all()) {
Ok(()) => {}
Err(e) => log::debug!(
"Could not fsync the pending-cleanup directory {}: {} — the record itself was flushed",
dir.display(),
e
),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_data_dir(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("triple-c-pending-cleanup-{}-{}", name, uuid::Uuid::new_v4().simple()))
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"triple-c-pending-cleanup-{}-{}",
name,
uuid::Uuid::new_v4().simple()
));
fs::create_dir_all(&dir).unwrap();
dir
}
fn record(project_id: &str) -> PendingCleanup {
PendingCleanup {
project_id: project_id.to_string(),
project_name: "Some Project".to_string(),
container_id: Some("abc123".to_string()),
container_id: Some("triple-c-abc".to_string()),
image: Some("triple-c-snapshot-abc:latest".to_string()),
volumes: vec!["triple-c-home-abc".to_string()],
recorded_at: "2026-08-25T00:00:00Z".to_string(),
}
}
/// `list`/`save`/`clear` go through `dirs::data_dir()`, so these exercise
/// the pure parts directly against a temp directory rather than the real
/// one — same approach `migration_store`'s tests take for `sanitize`.
#[test]
fn project_ids_cannot_escape_the_pending_cleanup_directory() {
assert_eq!(sanitize("../../etc/passwd"), "______etc_passwd");
@@ -161,26 +225,43 @@ mod tests {
assert!(r.is_empty());
}
/// Save-then-list-then-clear against a real (temp) directory, bypassing
/// `dir()`'s hardcoded `dirs::data_dir()` join by writing/reading the
/// files directly the way `save`/`list` do internally.
/// Exercises the real `save_in`/`list_in`/`clear_in` — not a
/// re-implementation of their bodies — against a temp directory standing
/// in for `dir()`.
#[test]
fn a_saved_record_round_trips_and_clearing_removes_it() {
let dir = temp_data_dir("roundtrip");
fs::create_dir_all(&dir).unwrap();
let dir = temp_dir("roundtrip");
let rec = record("proj-1");
let path = dir.join(format!("{}.json", sanitize(&rec.project_id)));
let data = serde_json::to_string_pretty(&rec).unwrap();
fs::write(&path, data).unwrap();
save_in(&dir, &rec).expect("save");
let found = list_in(&dir);
assert_eq!(found.len(), 1);
assert_eq!(found[0].project_id, "proj-1");
assert_eq!(found[0].volumes, vec!["triple-c-home-abc".to_string()]);
let loaded: PendingCleanup =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(loaded.project_id, "proj-1");
assert_eq!(loaded.volumes, vec!["triple-c-home-abc".to_string()]);
clear_in(&dir, "proj-1").expect("clear");
assert!(list_in(&dir).is_empty());
fs::remove_file(&path).unwrap();
assert!(!path.exists());
fs::remove_dir_all(&dir).ok();
}
/// A second `save` for the same project overwrites rather than appending
/// — a retry that narrows the leftovers must not leave the old, wider
/// record behind it.
#[test]
fn saving_the_same_project_twice_overwrites_not_appends() {
let dir = temp_dir("overwrite");
let mut rec = record("proj-1");
save_in(&dir, &rec).expect("save");
rec.container_id = None;
rec.image = None;
save_in(&dir, &rec).expect("save again");
let found = list_in(&dir);
assert_eq!(found.len(), 1, "one file per project, not one per save");
assert!(found[0].container_id.is_none());
assert_eq!(found[0].volumes, vec!["triple-c-home-abc".to_string()]);
fs::remove_dir_all(&dir).ok();
}
@@ -188,30 +269,44 @@ mod tests {
/// A record that fails to parse must not poison the rest of the listing.
#[test]
fn an_unparseable_record_is_skipped_not_fatal() {
let dir = temp_data_dir("corrupt");
fs::create_dir_all(&dir).unwrap();
let dir = temp_dir("corrupt");
fs::write(dir.join("bad.json"), "{ not json").unwrap();
let good = record("proj-2");
fs::write(
dir.join("proj-2.json"),
serde_json::to_string_pretty(&good).unwrap(),
)
.unwrap();
save_in(&dir, &record("proj-2")).expect("save");
let mut found = Vec::new();
for entry in fs::read_dir(&dir).unwrap().flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "json") {
if let Ok(data) = fs::read_to_string(&path) {
if let Ok(r) = serde_json::from_str::<PendingCleanup>(&data) {
found.push(r);
}
}
}
}
let found = list_in(&dir);
assert_eq!(found.len(), 1);
assert_eq!(found[0].project_id, "proj-2");
fs::remove_dir_all(&dir).ok();
}
/// `list_in` must not pick up the `.json.tmp` staging file `save_in`
/// leaves behind if a crash lands between the write and the rename — the
/// whole point of the temp-then-rename dance is that only the renamed
/// file is ever a complete record.
#[test]
fn a_leftover_tmp_file_is_not_listed() {
let dir = temp_dir("tmp-leftover");
fs::write(dir.join("proj-3.json.tmp"), "not a complete record").unwrap();
assert!(list_in(&dir).is_empty());
fs::remove_dir_all(&dir).ok();
}
/// Clearing by project id must remove exactly the file that id maps to
/// under `sanitize`, and nothing else.
#[test]
fn clearing_one_project_does_not_touch_another() {
let dir = temp_dir("clear-scoped");
save_in(&dir, &record("proj-a")).unwrap();
save_in(&dir, &record("proj-b")).unwrap();
clear_in(&dir, "proj-a").unwrap();
let found = list_in(&dir);
assert_eq!(found.len(), 1);
assert_eq!(found[0].project_id, "proj-b");
fs::remove_dir_all(&dir).ok();
}
}