Fix disk/migration defects and add a real per-project lock
The compaction panel's headline action had never worked, three reclaim paths could delete data with no confirmation and no grace period, and the app's only mutual-exclusion primitive was one-way. **A per-project lock (`project_lock.rs`).** `ACTIVE_MIGRATIONS` was the app's only exclusion and everything but migration merely *polled* it once at entry. Compaction, start/stop/recreate, Reset and destroy now **acquire** a `ProjectGuard` and hold it for the whole operation; `is_migrating` is a view onto the same registry. Closes the three verified interleavings where a compaction commits `flat(A)` over a `:latest` that a migration, a recreate or a Reset had already moved. In-process only — the two-instance case is documented in the module, not solved, and the daemon-wide reapers gained age gates to bound it. **H1: compaction never ran.** `fold_shell_script` joined the scrub script's lines with a space, so every build died on `syntax error: unexpected "do"`. Replaced with the JSON exec form, which carries any script verbatim; `sh -n` and a real end-to-end build now cover it (159.5 MB / 9 layers -> 33.7 MB / 1 layer, setuid and multi-line env preserved). **H2/H4:** `reclaim_migration_pins` and `survey_rollback_pins` apply `parse_rollback_tag` and `pin_is_reapable` like every other path, and stop double-counting an image with two pin tags. The 14-day grace period is re-anchored from the tag's timestamp (when the migration *started*) to a tombstone recording when the record went missing, with clock skew handled in both directions. **H3:** `migration_store::load` no longer renames a corrupt record aside — that destroyed the `has_record` signal both pin reapers depend on. `save` fsyncs the file and the directory, and corruption backups are timestamped. **H2b/M2:** a crashed compaction's `:compacting` tag and `triple-c-compact-*` container are reaped at startup; the stale-container sweep moved from the end of a compaction to the start, where its doc always claimed it was. **M5/M6:** orphan-volume deletion moved from a `Safety::Safe` tick to the destructive path with a typed volume name; `project_store_trust` reads the real `projects.json` so a second instance's project is not offered as an orphan. Numbers: `images_total_bytes` uses `df()`'s deduplicated `layers_size`; the Total column is derived from the same figure the Snapshot column shows; partial container/staging reclaims report their failure count; `human()` no longer prints "1000.0 KB"; `docker_cli` has a timeout; blocking `fs` calls moved to `spawn_blocking`. Also fixes `ProbeContainerGuard::remove_now`, which disarmed before the await and so did nothing on the cancellation path it exists for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
@@ -754,9 +754,16 @@ impl ProbeContainerGuard {
|
||||
&self.id
|
||||
}
|
||||
|
||||
/// **Disarm after the await, never before it.** Clearing `armed` first
|
||||
/// looked equivalent and was the exact inverse of this guard's purpose: on
|
||||
/// the one path it exists for — this future being dropped part-way through
|
||||
/// the removal — `Drop` then saw a disarmed guard and did nothing, so the
|
||||
/// container survived with no background removal queued behind it. Setting
|
||||
/// it afterwards means a cancelled `remove_now` falls back to `Drop`'s
|
||||
/// detached removal, and only a removal that actually completed disarms.
|
||||
async fn remove_now(mut self) {
|
||||
self.armed = false;
|
||||
remove_probe_container(&self.id).await;
|
||||
self.armed = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -810,6 +817,17 @@ async fn remove_probe_container(id: &str) {
|
||||
/// and no volumes, owned entirely by a `run_throwaway` call. If one is running
|
||||
/// right now it belongs to this process — and this runs before any migration
|
||||
/// can be started, so there is none to interrupt.
|
||||
///
|
||||
/// **Except that "belongs to this process" is not something this can know.**
|
||||
/// The filter is a label, and labels are daemon-wide: a second copy of the app
|
||||
/// migrating a project on the same daemon has probe containers carrying exactly
|
||||
/// this label, and force-removing one mid-manifest-capture fails that
|
||||
/// migration. In-process state cannot see the other instance, so the only
|
||||
/// available brake is age — [`PROBE_REAP_MIN_AGE_SECS`]. A probe runs a `df`, an
|
||||
/// `apt-get update` or a `find` over a root filesystem; none of those is a
|
||||
/// multi-minute job, so anything younger than the gate is far more likely to be
|
||||
/// someone's live probe than a leftover, and a leftover simply waits for the
|
||||
/// next start.
|
||||
pub async fn reap_probe_containers() {
|
||||
let Ok(docker) = get_docker() else {
|
||||
return;
|
||||
@@ -835,7 +853,23 @@ pub async fn reap_probe_containers() {
|
||||
}
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
for c in containers {
|
||||
// `created` is a unix timestamp; a summary without one is treated as
|
||||
// too young to touch, because unknown is never permission.
|
||||
let age = c.created.map(|created| now - created);
|
||||
match age {
|
||||
Some(age) if age >= PROBE_REAP_MIN_AGE_SECS => {}
|
||||
_ => {
|
||||
log::info!(
|
||||
"Leaving migration probe container {} alone — it is younger than {} minutes, \
|
||||
so it may belong to another Triple-C instance's live migration",
|
||||
c.id.as_deref().unwrap_or("<unknown>"),
|
||||
PROBE_REAP_MIN_AGE_SECS / 60
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(id) = c.id {
|
||||
log::info!("Removing leftover migration probe container {}", id);
|
||||
remove_probe_container(&id).await;
|
||||
@@ -843,6 +877,16 @@ pub async fn reap_probe_containers() {
|
||||
}
|
||||
}
|
||||
|
||||
/// How old a `triple-c.probe=migration` container must be before
|
||||
/// [`reap_probe_containers`] will force-remove it, in seconds.
|
||||
///
|
||||
/// The label is daemon-wide and this process cannot tell its own leftovers from
|
||||
/// another instance's live probe, so this is the whole guard. Generous against
|
||||
/// the longest probe there is (an `apt-get update` inside a throwaway container
|
||||
/// on a slow link) and still short enough that a crashed run's probe stops
|
||||
/// pinning a multi-gigabyte base image within the hour.
|
||||
pub const PROBE_REAP_MIN_AGE_SECS: i64 = 30 * 60;
|
||||
|
||||
async fn run_throwaway_inner(id: &str) -> Result<ThrowawayResult, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
@@ -1025,6 +1069,10 @@ pub fn rollback_tag(now: &chrono::DateTime<chrono::Utc>) -> String {
|
||||
///
|
||||
/// Two weeks, chosen to be far longer than anyone deliberates over a base
|
||||
/// update and far shorter than "forever", which is what it was.
|
||||
///
|
||||
/// **Measured from when the record went missing, not from the tag.** See
|
||||
/// [`pin_is_reapable`] and
|
||||
/// [`crate::storage::migration_store::note_ownerless_since`].
|
||||
pub const STALE_PIN_MAX_AGE_DAYS: i64 = 14;
|
||||
|
||||
/// Recover the timestamp encoded in a tag produced by [`rollback_tag`].
|
||||
@@ -1051,26 +1099,59 @@ pub fn parse_snapshot_reference(reference: &str) -> Option<(String, String)> {
|
||||
Some((project_id, tag))
|
||||
}
|
||||
|
||||
/// Whether a rollback pin is safe to drop, given how old it is and whether the
|
||||
/// project it belongs to still has a migration record.
|
||||
/// Whether a rollback pin is safe to drop, given whether the project it belongs
|
||||
/// to still has a migration record and how long it has been without one.
|
||||
///
|
||||
/// Pure so the decision can be tested without a daemon. The order of the two
|
||||
/// Pure so the decision can be tested without a daemon. The order of the
|
||||
/// conditions is the point: **a pin whose migration is still awaiting
|
||||
/// confirmation is never reaped at any age**, because it is the only copy of
|
||||
/// the rollback target and the user has not yet said they are happy with the
|
||||
/// new base.
|
||||
///
|
||||
/// ## `ownerless_since`, and why it is not the tag's timestamp
|
||||
///
|
||||
/// This used to compute the age from `parse_rollback_tag(tag)` — the instant
|
||||
/// the migration *started*. A migration is allowed to sit at
|
||||
/// `awaiting-confirmation` for as long as the user likes; that is what
|
||||
/// `keep_rollback` is for. A project parked there for a month whose record is
|
||||
/// then lost had a tag a month old, so the pin was reapable on the very next
|
||||
/// check and the startup sweep deleted the image immediately after. The
|
||||
/// fourteen days were nominal: the real grace period for the case the constant
|
||||
/// was written for was zero.
|
||||
///
|
||||
/// So the clock starts when the claim was lost, which is recorded by
|
||||
/// [`crate::storage::migration_store::note_ownerless_since`] the first time a
|
||||
/// reaper notices. `None` means no reaper has recorded a sighting yet, and that
|
||||
/// is **not** "sighted now": returning false there is what gives a pin its
|
||||
/// first full fourteen days instead of none.
|
||||
///
|
||||
/// ## Clock skew
|
||||
///
|
||||
/// A `now` earlier than `ownerless_since` — a host clock that ran fast and was
|
||||
/// corrected, or a data directory carried between machines — yields a negative
|
||||
/// elapsed time. That is treated as not reapable, and the marker writer
|
||||
/// re-anchors it, rather than letting a negative `num_days()` mean "never" or
|
||||
/// an inflated one mean "immediately".
|
||||
pub fn pin_is_reapable(
|
||||
tag: &str,
|
||||
has_migration_record: bool,
|
||||
ownerless_since: Option<chrono::DateTime<chrono::Utc>>,
|
||||
now: &chrono::DateTime<chrono::Utc>,
|
||||
) -> bool {
|
||||
if has_migration_record {
|
||||
return false;
|
||||
}
|
||||
let Some(created) = parse_rollback_tag(tag) else {
|
||||
// Still required: the tag has to be one of ours. A hand-made
|
||||
// `pre-migration-keepme` is somebody's deliberate pin and is never guessed
|
||||
// at, whatever a marker beside it says.
|
||||
if parse_rollback_tag(tag).is_none() {
|
||||
return false;
|
||||
}
|
||||
let Some(since) = ownerless_since else {
|
||||
return false;
|
||||
};
|
||||
(*now - created).num_days() >= STALE_PIN_MAX_AGE_DAYS
|
||||
let elapsed = *now - since;
|
||||
elapsed >= chrono::Duration::days(STALE_PIN_MAX_AGE_DAYS)
|
||||
}
|
||||
|
||||
/// Drop `triple-c-snapshot-*:pre-migration-*` tags that no migration record
|
||||
@@ -1138,15 +1219,35 @@ pub async fn reap_stale_migration_pins() -> usize {
|
||||
// still count as "somebody may want this back".
|
||||
let has_record =
|
||||
crate::storage::migration_store::has_record(&project_id).unwrap_or(true);
|
||||
if !pin_is_reapable(&tag, has_record, &now) {
|
||||
if has_record {
|
||||
// Owned again (or still owned): throw away any grace clock a
|
||||
// previous pass started, so a pin that loses its record twice
|
||||
// gets a fresh fourteen days rather than inheriting a stale one.
|
||||
crate::storage::migration_store::clear_ownerless(&project_id, &tag);
|
||||
continue;
|
||||
}
|
||||
// Only a *well-formed* pin gets a marker written for it — a tag
|
||||
// that is not one of ours is left entirely alone, files included.
|
||||
if parse_rollback_tag(&tag).is_none() {
|
||||
continue;
|
||||
}
|
||||
// Records the first sighting when there is none, which is why this
|
||||
// returns `None` on that pass and the pin survives it.
|
||||
let ownerless_since =
|
||||
crate::storage::migration_store::note_ownerless_since(&project_id, &tag, &now);
|
||||
if !pin_is_reapable(&tag, has_record, ownerless_since, &now) {
|
||||
continue;
|
||||
}
|
||||
match untag_image(reference).await {
|
||||
Ok(()) => {
|
||||
crate::storage::migration_store::clear_ownerless(&project_id, &tag);
|
||||
log::info!(
|
||||
"Dropped stale rollback pin {} ({:.2} GB) — no migration record has claimed it for {} days",
|
||||
"Dropped stale rollback pin {} ({:.2} GB) — no migration record has claimed it since {}, more than {} days",
|
||||
reference,
|
||||
summary.size as f64 / 1_073_741_824.0,
|
||||
ownerless_since
|
||||
.map(|t| t.to_rfc3339())
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
STALE_PIN_MAX_AGE_DAYS,
|
||||
);
|
||||
reaped += 1;
|
||||
@@ -1915,29 +2016,93 @@ mod tests {
|
||||
// image is the only copy of the rollback target and the user has not
|
||||
// yet said they are happy on the new base.
|
||||
let ancient = rollback_tag(&at(2020, 1, 1));
|
||||
assert!(!pin_is_reapable(&ancient, true, &at(2026, 8, 23)));
|
||||
assert!(!pin_is_reapable(
|
||||
&ancient,
|
||||
true,
|
||||
Some(at(2020, 1, 1)),
|
||||
&at(2026, 8, 23)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unclaimed_pin_is_reaped_only_once_it_is_old() {
|
||||
let made = at(2026, 8, 1);
|
||||
let tag = rollback_tag(&made);
|
||||
assert!(!pin_is_reapable(&tag, false, &at(2026, 8, 2)));
|
||||
let tag = rollback_tag(&at(2026, 8, 1));
|
||||
// The clock runs from when the record went missing, which here is well
|
||||
// after the migration started.
|
||||
let lost = at(2026, 8, 10);
|
||||
assert!(!pin_is_reapable(&tag, false, Some(lost), &at(2026, 8, 11)));
|
||||
assert!(!pin_is_reapable(
|
||||
&tag,
|
||||
false,
|
||||
&(made + chrono::Duration::days(STALE_PIN_MAX_AGE_DAYS - 1))
|
||||
Some(lost),
|
||||
&(lost + chrono::Duration::days(STALE_PIN_MAX_AGE_DAYS) - chrono::Duration::seconds(1))
|
||||
));
|
||||
assert!(pin_is_reapable(
|
||||
&tag,
|
||||
false,
|
||||
&(made + chrono::Duration::days(STALE_PIN_MAX_AGE_DAYS))
|
||||
Some(lost),
|
||||
&(lost + chrono::Duration::days(STALE_PIN_MAX_AGE_DAYS))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_grace_period_runs_from_the_lost_record_not_from_the_tag() {
|
||||
// The bug this replaced, stated as a test. A migration parked at
|
||||
// `awaiting-confirmation` for a month — supported, that is what
|
||||
// `keep_rollback` is for — whose record is then lost had a
|
||||
// month-old tag, so the old rule made its pin reapable on the very
|
||||
// next app start with the startup sweep deleting the image two lines
|
||||
// later. Zero grace, on the one case the fourteen days exist for.
|
||||
let started = at(2026, 6, 1);
|
||||
let tag = rollback_tag(&started);
|
||||
let record_lost = at(2026, 7, 1);
|
||||
let noticed_immediately_after = record_lost + chrono::Duration::minutes(5);
|
||||
assert!(
|
||||
!pin_is_reapable(&tag, false, Some(record_lost), ¬iced_immediately_after),
|
||||
"a tag a month old must still get its full grace period once orphaned"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unsighted_pin_is_never_reaped_on_the_pass_that_first_sees_it() {
|
||||
// `None` means no reaper has recorded a sighting. Treating that as
|
||||
// "sighted now" would be harmless; treating it as "sighted long ago"
|
||||
// would not, and neither is what it means — the marker is written on
|
||||
// this pass and the pin becomes reapable fourteen days later.
|
||||
let tag = rollback_tag(&at(2020, 1, 1));
|
||||
assert!(!pin_is_reapable(&tag, false, None, &at(2026, 8, 23)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clock_that_ran_backwards_neither_reaps_nor_strands() {
|
||||
// A marker dated after `now`: the host clock was fast and got
|
||||
// corrected, or the data directory came from another machine. A
|
||||
// negative elapsed time must read as "not yet", not as a huge age.
|
||||
let tag = rollback_tag(&at(2026, 1, 1));
|
||||
let marker = at(2026, 9, 1);
|
||||
assert!(!pin_is_reapable(&tag, false, Some(marker), &at(2026, 8, 1)));
|
||||
// The other direction is bounded by the marker rather than by the tag:
|
||||
// a wildly future `now` can only expire a clock that was actually
|
||||
// started, and a pin with no marker (the case above) still cannot be
|
||||
// reaped at all — which is what stops a fast host clock from making
|
||||
// *every* pin on the daemon instantly collectable.
|
||||
assert!(pin_is_reapable(
|
||||
&tag,
|
||||
false,
|
||||
Some(at(2026, 8, 20)),
|
||||
&at(2030, 1, 1)
|
||||
));
|
||||
assert!(!pin_is_reapable(&tag, false, None, &at(2030, 1, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tag_we_cannot_date_is_left_alone() {
|
||||
assert!(!pin_is_reapable("pre-migration-handmade", false, &at(2026, 8, 23)));
|
||||
assert!(!pin_is_reapable("latest", false, &at(2026, 8, 23)));
|
||||
// Even with an ancient ownerless marker sitting beside it: a tag that
|
||||
// merely *starts* `pre-migration-` is somebody's deliberate pin, and
|
||||
// the reaper never writes a marker for one in the first place.
|
||||
let ancient = Some(at(2020, 1, 1));
|
||||
let now = at(2026, 8, 23);
|
||||
assert!(!pin_is_reapable("pre-migration-handmade", false, ancient, &now));
|
||||
assert!(!pin_is_reapable("latest", false, ancient, &now));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user