Skip to content

Commit e7ba476

Browse files
authored
fix: restore GC preservation timestamp stability (#22)
* fix: restore GC preservation timestamp stability - keep stow from overwriting last_gc_mtime_nanos with wall clock time by reusing existing metadata timestamps - clamp GC preservation cutoff to avoid future times, apply five-minute buffer, and skip preservation when age threshold is zero or stale - add unit, integration, and gc tests covering delayed stow and stale timestamp scenarios * fix lints
1 parent 03d0610 commit e7ba476

File tree

6 files changed

+351
-39
lines changed

6 files changed

+351
-39
lines changed

src/commands.rs

Lines changed: 74 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
//! ```
2929
3030
use std::path::{Path, PathBuf};
31+
use std::time::{Duration, SystemTime, UNIX_EPOCH};
3132

3233
use rayon::prelude::*;
3334

@@ -38,7 +39,9 @@ use crate::gc::{self, Gc};
3839
use crate::hashing::{get_file_size, hash_file};
3940
use crate::metadata::{clean_metadata, load_metadata, save_metadata};
4041
use crate::state::{FileState, StateMetadata};
41-
use crate::timestamp::{generate_monotonic_timestamp, restore_timestamps};
42+
use crate::timestamp::{
43+
generate_monotonic_timestamp, restore_timestamps, saturating_duration_from_nanos,
44+
};
4245

4346
/// Executes the anchor command - the main orchestrator.
4447
///
@@ -265,22 +268,51 @@ pub fn stow(metadata_path: &Path, verbose: u8, quiet: bool, working_dir: &Path)
265268
}
266269
}
267270

268-
// Load existing metadata to get the previous max_mtime_nanos
271+
// Load existing metadata so we can capture prior preservation timestamp
269272
let existing_metadata = match load_metadata(metadata_path) {
270273
Ok(metadata) => Some(metadata),
271274
Err(HoldError::DeserializationError { .. }) => None,
272275
Err(err) => return Err(err),
273276
};
274277

275-
if let Some(existing) = existing_metadata {
276-
// Preserve the max mtime from the current build as the last_gc_mtime_nanos
277-
new_metadata.last_gc_mtime_nanos = existing.max_mtime_nanos();
278-
if !quiet
279-
&& verbose > 0
280-
&& let Some(mtime) = new_metadata.last_gc_mtime_nanos
281-
{
282-
eprintln!("Preserving previous build timestamp for GC: {mtime} nanos");
278+
// Establish a preservation timestamp for GC without clobbering historical
279+
// build information. Prefer the previously recorded value, falling back to
280+
// the most recent tracked file mtime. Only use the current clock if we have
281+
// no metadata at all (first run).
282+
let existing_preservation = existing_metadata.as_ref().and_then(|existing| {
283+
existing
284+
.last_gc_mtime_nanos
285+
.or_else(|| existing.max_mtime_nanos())
286+
});
287+
288+
let new_max_mtime = new_metadata.max_mtime_nanos();
289+
290+
let preservation_nanos = match (existing_preservation, new_max_mtime) {
291+
(Some(existing), Some(new_max)) => existing.max(new_max),
292+
(Some(existing), None) => existing,
293+
(None, Some(new_max)) => new_max,
294+
(None, None) => SystemTime::now()
295+
.duration_since(UNIX_EPOCH)
296+
.unwrap_or(Duration::ZERO)
297+
.as_nanos(),
298+
};
299+
300+
new_metadata.last_gc_mtime_nanos = Some(preservation_nanos);
301+
302+
if !quiet && verbose > 0 {
303+
let (preserved_duration, saturated) = saturating_duration_from_nanos(preservation_nanos);
304+
if saturated {
305+
eprintln!(
306+
"Warning: preservation timestamp ({preservation_nanos}) exceeds representable \
307+
range; clamping to ~year 2554.",
308+
);
283309
}
310+
let preserved_time = UNIX_EPOCH + preserved_duration;
311+
let elapsed = SystemTime::now()
312+
.duration_since(preserved_time)
313+
.unwrap_or(Duration::ZERO)
314+
.as_secs();
315+
eprintln!("Preserving build timestamp for GC: {preservation_nanos} nanos ({elapsed}s ago)",);
284316
}
285317

286318
// Metadata path should already be absolute from CLI layer
@@ -919,7 +951,9 @@ pub fn execute_with_dir(cli: &Cli, working_dir: Option<&Path>) -> Result<()> {
919951
#[cfg(test)]
920952
mod tests {
921953
use std::fs;
954+
use std::time::{Duration, SystemTime, UNIX_EPOCH};
922955

956+
use filetime::FileTime;
923957
use tempfile::TempDir;
924958

925959
use super::*;
@@ -1030,4 +1064,34 @@ mod tests {
10301064
let err = stow(&metadata_path, 0, false, temp_dir.path()).unwrap_err();
10311065
assert!(matches!(err, HoldError::ConfigError { .. }));
10321066
}
1067+
1068+
#[test]
1069+
fn test_stow_preserves_last_gc_timestamp_when_time_advances() {
1070+
let temp_dir = setup_git_repo();
1071+
let metadata_path = temp_dir.path().join("test.metadata");
1072+
let tracked_file = temp_dir.path().join("test.txt");
1073+
1074+
// Simulate a build finishing an hour ago by backdating the tracked file.
1075+
let one_hour_ago = SystemTime::now() - Duration::from_secs(3600);
1076+
filetime::set_file_mtime(&tracked_file, FileTime::from_system_time(one_hour_ago)).unwrap();
1077+
1078+
stow(&metadata_path, 0, false, temp_dir.path()).unwrap();
1079+
let first_metadata = load_metadata(&metadata_path).unwrap();
1080+
let first_preservation = first_metadata
1081+
.last_gc_mtime_nanos
1082+
.expect("stow should set last_gc_mtime_nanos");
1083+
let expected_nanos = one_hour_ago.duration_since(UNIX_EPOCH).unwrap().as_nanos();
1084+
assert_eq!(first_preservation, expected_nanos);
1085+
1086+
// Allow the wall clock to move forward before running stow again.
1087+
std::thread::sleep(Duration::from_millis(10));
1088+
1089+
stow(&metadata_path, 0, false, temp_dir.path()).unwrap();
1090+
let second_metadata = load_metadata(&metadata_path).unwrap();
1091+
let second_preservation = second_metadata
1092+
.last_gc_mtime_nanos
1093+
.expect("stow should keep last_gc_mtime_nanos set");
1094+
1095+
assert_eq!(second_preservation, expected_nanos);
1096+
}
10331097
}

src/gc.rs

Lines changed: 146 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ use std::time::SystemTime;
4343
use rayon::prelude::*;
4444

4545
use crate::error::{HoldError, Result};
46+
use crate::timestamp::saturating_duration_from_nanos;
4647

4748
/// Garbage collection
4849
#[derive(Debug)]
@@ -1086,35 +1087,69 @@ pub fn select_artifacts_for_removal(
10861087

10871088
// First, filter out artifacts from the previous build if we have that timestamp
10881089
if let Some(previous_mtime_nanos) = previous_build_mtime_nanos {
1089-
// Convert to SystemTime for comparison
1090-
let previous_mtime =
1091-
SystemTime::UNIX_EPOCH + std::time::Duration::from_nanos(previous_mtime_nanos as u64);
1092-
1093-
// Add a small buffer (1 second) to account for timestamp precision/drift
1094-
let buffer = std::time::Duration::from_secs(1);
1095-
let cutoff_time = previous_mtime
1096-
.checked_sub(buffer)
1097-
.unwrap_or(SystemTime::UNIX_EPOCH);
1098-
1099-
let (preserved, eligible): (Vec<_>, Vec<_>) = remaining_artifacts
1100-
.into_iter()
1101-
.partition(|artifact| artifact.newest_mtime >= cutoff_time);
1102-
1103-
if !quiet && !preserved.is_empty() {
1104-
let preserved_size: u64 = preserved.iter().map(|a| a.total_size).sum();
1090+
let (duration, saturated) = saturating_duration_from_nanos(previous_mtime_nanos);
1091+
if saturated && !quiet {
11051092
eprintln!(
1106-
" Preserving {} artifacts ({}) from previous build",
1107-
preserved.len(),
1108-
format_size(preserved_size)
1093+
"Warning: previous_build_mtime_nanos ({previous_mtime_nanos}) exceeds \
1094+
representable range; clamping to ~year 2554.",
11091095
);
1110-
if verbose > 1 {
1111-
for artifact in &preserved {
1112-
eprintln!(" Preserving: {}-{}", artifact.name, artifact.hash);
1096+
}
1097+
1098+
let mut previous_mtime = SystemTime::UNIX_EPOCH + duration;
1099+
let now = SystemTime::now();
1100+
if previous_mtime > now {
1101+
previous_mtime = now;
1102+
}
1103+
1104+
if age_threshold_days == 0 {
1105+
if !quiet && verbose > 1 {
1106+
eprintln!(" Skipping previous build preservation because age threshold is 0 days");
1107+
}
1108+
} else {
1109+
let age_threshold =
1110+
std::time::Duration::from_secs(age_threshold_days as u64 * 24 * 60 * 60);
1111+
let elapsed_since_previous = now
1112+
.duration_since(previous_mtime)
1113+
.unwrap_or(std::time::Duration::ZERO);
1114+
1115+
let previous_is_stale = elapsed_since_previous > age_threshold;
1116+
1117+
if previous_is_stale {
1118+
if !quiet && verbose > 0 {
1119+
eprintln!(
1120+
" Previous build timestamp is {elapsed_since_previous:?} old; exceeding \
1121+
threshold, skipping preservation",
1122+
);
1123+
}
1124+
} else {
1125+
// Add a generous buffer to account for clock drift and build finishing before
1126+
// GC.
1127+
let buffer = std::time::Duration::from_secs(5 * 60);
1128+
let cutoff_time = previous_mtime
1129+
.checked_sub(buffer)
1130+
.unwrap_or(SystemTime::UNIX_EPOCH);
1131+
1132+
let (preserved, eligible): (Vec<_>, Vec<_>) = remaining_artifacts
1133+
.into_iter()
1134+
.partition(|artifact| artifact.newest_mtime >= cutoff_time);
1135+
1136+
if !quiet && !preserved.is_empty() {
1137+
let preserved_size: u64 = preserved.iter().map(|a| a.total_size).sum();
1138+
eprintln!(
1139+
" Preserving {} artifacts ({}) from previous build",
1140+
preserved.len(),
1141+
format_size(preserved_size)
1142+
);
1143+
if verbose > 1 {
1144+
for artifact in &preserved {
1145+
eprintln!(" Preserving: {}-{}", artifact.name, artifact.hash);
1146+
}
1147+
}
11131148
}
1149+
1150+
remaining_artifacts = eligible;
11141151
}
11151152
}
1116-
1117-
remaining_artifacts = eligible;
11181153
}
11191154

11201155
// Step 1: Apply size-based cleanup if needed
@@ -1295,6 +1330,8 @@ fn calculate_directory_size(path: &Path) -> Result<u64> {
12951330

12961331
#[cfg(test)]
12971332
mod tests {
1333+
use std::time::{Duration, SystemTime};
1334+
12981335
use super::*;
12991336

13001337
#[test]
@@ -1438,4 +1475,89 @@ mod tests {
14381475
.any(|a| a.name == "very-old-crate")
14391476
);
14401477
}
1478+
1479+
#[test]
1480+
fn test_select_artifacts_skips_stale_previous_timestamp() {
1481+
let now = SystemTime::now();
1482+
let ten_days_ago = now - Duration::from_secs(10 * 24 * 60 * 60);
1483+
let two_days_ago = now - Duration::from_secs(2 * 24 * 60 * 60);
1484+
let stale_previous = now - Duration::from_secs(30 * 24 * 60 * 60);
1485+
1486+
let stale_nanos = stale_previous
1487+
.duration_since(SystemTime::UNIX_EPOCH)
1488+
.unwrap()
1489+
.as_nanos();
1490+
1491+
let artifacts = vec![
1492+
CrateArtifact {
1493+
name: "old-crate".to_string(),
1494+
hash: "aaaaaaaaaaaaaaaa".to_string(),
1495+
artifacts: vec![],
1496+
total_size: 2 * 1024 * 1024,
1497+
newest_mtime: ten_days_ago,
1498+
},
1499+
CrateArtifact {
1500+
name: "recent-crate".to_string(),
1501+
hash: "bbbbbbbbbbbbbbbb".to_string(),
1502+
artifacts: vec![],
1503+
total_size: 2 * 1024 * 1024,
1504+
newest_mtime: two_days_ago,
1505+
},
1506+
];
1507+
1508+
let to_remove = select_artifacts_for_removal(
1509+
&artifacts,
1510+
4 * 1024 * 1024,
1511+
None,
1512+
7,
1513+
Some(stale_nanos),
1514+
0,
1515+
false,
1516+
);
1517+
1518+
assert_eq!(to_remove.len(), 1);
1519+
assert_eq!(to_remove[0].name, "old-crate");
1520+
}
1521+
1522+
#[test]
1523+
fn test_select_artifacts_preserves_recent_previous_timestamp_with_buffer() {
1524+
let now = SystemTime::now();
1525+
let two_minutes_ago = now - Duration::from_secs(2 * 60);
1526+
let eight_days_ago = now - Duration::from_secs(8 * 24 * 60 * 60);
1527+
1528+
let previous_build_nanos = now
1529+
.duration_since(SystemTime::UNIX_EPOCH)
1530+
.unwrap()
1531+
.as_nanos();
1532+
1533+
let artifacts = vec![
1534+
CrateArtifact {
1535+
name: "recent-build".to_string(),
1536+
hash: "cccccccccccccccc".to_string(),
1537+
artifacts: vec![],
1538+
total_size: 3 * 1024 * 1024,
1539+
newest_mtime: two_minutes_ago,
1540+
},
1541+
CrateArtifact {
1542+
name: "older-build".to_string(),
1543+
hash: "dddddddddddddddd".to_string(),
1544+
artifacts: vec![],
1545+
total_size: 3 * 1024 * 1024,
1546+
newest_mtime: eight_days_ago,
1547+
},
1548+
];
1549+
1550+
let to_remove = select_artifacts_for_removal(
1551+
&artifacts,
1552+
6 * 1024 * 1024,
1553+
Some(1024 * 1024),
1554+
7,
1555+
Some(previous_build_nanos),
1556+
0,
1557+
false,
1558+
);
1559+
1560+
assert_eq!(to_remove.len(), 1);
1561+
assert_eq!(to_remove[0].name, "older-build");
1562+
}
14411563
}

src/timestamp.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,38 @@ use std::fs::OpenOptions;
33
use std::path::Path;
44
use std::time::{Duration, SystemTime, UNIX_EPOCH};
55

6+
const NANOS_PER_SECOND: u128 = 1_000_000_000;
7+
8+
/// Compute a duration from nanoseconds with saturation at [`Duration::MAX`].
9+
///
10+
/// Returns the saturated duration along with a flag indicating whether the
11+
/// input exceeded the representable range.
12+
pub fn saturating_duration_from_nanos(nanos: u128) -> (Duration, bool) {
13+
let seconds = nanos / NANOS_PER_SECOND;
14+
if seconds > u64::MAX as u128 {
15+
return (Duration::MAX, true);
16+
}
17+
18+
let nanos_remainder = (nanos % NANOS_PER_SECOND) as u32;
19+
(Duration::new(seconds as u64, nanos_remainder), false)
20+
}
21+
22+
/// Compute a [`SystemTime`] from nanoseconds with saturation at
23+
/// `UNIX_EPOCH + Duration::MAX`.
24+
///
25+
/// Returns the saturated timestamp along with a flag indicating whether the
26+
/// input exceeded the representable range.
27+
pub fn saturating_system_time_from_nanos(nanos: u128) -> (SystemTime, bool) {
28+
let (duration, saturated) = saturating_duration_from_nanos(nanos);
29+
(UNIX_EPOCH + duration, saturated)
30+
}
31+
632
use crate::error::{HoldError, Result};
733
use crate::state::{FileState, StateMetadata};
834

935
/// Convert nanoseconds since UNIX_EPOCH to SystemTime
1036
fn nanos_to_system_time(nanos: u128) -> SystemTime {
11-
UNIX_EPOCH + Duration::from_nanos(nanos as u64)
37+
saturating_system_time_from_nanos(nanos).0
1238
}
1339

1440
/// Convert SystemTime to nanoseconds since UNIX_EPOCH

tests/gc_algorithm_tests.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,7 @@ fn test_combined_selection_preserves_previous_build_artifacts() {
337337

338338
#[test]
339339
fn test_combined_selection_timestamp_buffer_edge_case() {
340-
// Test the 1-second buffer for timestamp comparison
340+
// Test the preservation buffer for timestamp comparison
341341

342342
let now = SystemTime::now();
343343
let base_time = now.checked_sub(Duration::from_secs(3600)).unwrap();
@@ -354,7 +354,7 @@ fn test_combined_selection_timestamp_buffer_edge_case() {
354354
artifacts[0].newest_mtime = base_time; // Exactly at cutoff
355355
artifacts[1].newest_mtime = base_time.checked_sub(Duration::from_millis(500)).unwrap(); // 500ms before
356356
artifacts[2].newest_mtime = base_time.checked_add(Duration::from_millis(500)).unwrap(); // 500ms after
357-
artifacts[3].newest_mtime = base_time.checked_sub(Duration::from_secs(2)).unwrap(); // 2s before
357+
artifacts[3].newest_mtime = base_time.checked_sub(Duration::from_secs(6 * 60)).unwrap(); // 6 minutes before
358358

359359
let previous_build_nanos = base_time
360360
.duration_since(SystemTime::UNIX_EPOCH)
@@ -371,8 +371,8 @@ fn test_combined_selection_timestamp_buffer_edge_case() {
371371
false,
372372
);
373373

374-
// With 1-second buffer, artifacts at or near the cutoff should be preserved
375-
// Only well_before_cutoff should be selected
374+
// With the 5-minute buffer, artifacts near the cutoff should be preserved; only
375+
// those older than the buffer should be selected.
376376
assert_eq!(selected.len(), 1);
377377
assert_eq!(selected[0].name, "well_before_cutoff");
378378
}

0 commit comments

Comments
 (0)