281 lines
10 KiB
Rust
281 lines
10 KiB
Rust
use std::{
|
|
os::fd::AsRawFd,
|
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
|
};
|
|
|
|
use super::{is_lower_hex, nofollow_stat};
|
|
use crate::{
|
|
ArtifactError, ArtifactStore,
|
|
store::{RootLock, ensure_root_unchanged, fsync_fd, open_existing_dir, stat_fd, unlinkat},
|
|
temp_scan::{list_names_after, valid_temp_name},
|
|
};
|
|
|
|
/// Opaque single-use evidence for a stale temporary inode under the pinned root.
|
|
#[derive(Debug)]
|
|
pub struct StaleTemp {
|
|
name: String,
|
|
shard: String,
|
|
root_dev: u64,
|
|
root_ino: u64,
|
|
dev: u64,
|
|
ino: u64,
|
|
modified_seconds: i64,
|
|
modified_nanoseconds: i64,
|
|
grace: Duration,
|
|
}
|
|
|
|
/// Opaque, inode-bound progress marker for the bounded stale-temp sweeper.
|
|
///
|
|
/// It deliberately advances by shard instead of retaining a directory offset:
|
|
/// directory offsets are invalidated by a concurrent writer, while round-robin
|
|
/// shard progress prevents a busy low-numbered shard from starving all others.
|
|
#[derive(Clone, Debug)]
|
|
pub struct TempScanCursor {
|
|
root_dev: u64,
|
|
root_ino: u64,
|
|
next_shard: u8,
|
|
shard_continuation: Option<TempShardContinuation>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct TempShardContinuation {
|
|
shard: u8,
|
|
dev: u64,
|
|
ino: u64,
|
|
cookie: i64,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
pub struct TempScan {
|
|
pub scanned: usize,
|
|
pub omitted: usize,
|
|
/// True when this page completed a full round-robin traversal.
|
|
pub complete: bool,
|
|
}
|
|
|
|
impl ArtifactStore {
|
|
/// Scans at most `scan_budget` entries and returns `result_limit` stale capabilities.
|
|
pub fn scan_stale_temps(
|
|
&self,
|
|
grace: Duration,
|
|
scan_budget: usize,
|
|
result_limit: usize,
|
|
) -> Result<(TempScan, Vec<StaleTemp>), ArtifactError> {
|
|
self.scan_stale_temps_after(grace, None, scan_budget, result_limit)
|
|
.map(|(report, candidates, _)| (report, candidates))
|
|
}
|
|
|
|
/// Resumes stale-temp cleanup from an opaque round-robin marker. A
|
|
/// continuation is valid only for this exact pinned root.
|
|
pub fn scan_stale_temps_after(
|
|
&self,
|
|
grace: Duration,
|
|
continuation: Option<TempScanCursor>,
|
|
scan_budget: usize,
|
|
result_limit: usize,
|
|
) -> Result<(TempScan, Vec<StaleTemp>, TempScanCursor), ArtifactError> {
|
|
let root = self.root()?;
|
|
let _lock = RootLock::shared(root)?;
|
|
ensure_root_unchanged(root)?;
|
|
let sha = match open_existing_dir(root.fd.as_raw_fd(), b"sha256") {
|
|
Ok(fd) => fd,
|
|
Err(ArtifactError::NotFound) => {
|
|
return Ok((
|
|
TempScan {
|
|
complete: true,
|
|
..TempScan::default()
|
|
},
|
|
Vec::new(),
|
|
TempScanCursor {
|
|
root_dev: root.dev,
|
|
root_ino: root.ino,
|
|
next_shard: 0,
|
|
shard_continuation: None,
|
|
},
|
|
));
|
|
}
|
|
Err(error) => return Err(error),
|
|
};
|
|
let (start_shard, mut shard_continuation) = match continuation {
|
|
Some(cursor) if cursor.root_dev == root.dev && cursor.root_ino == root.ino => {
|
|
(cursor.next_shard, cursor.shard_continuation)
|
|
}
|
|
Some(_) => return Err(ArtifactError::UnsafeRoot),
|
|
None => (0, None),
|
|
};
|
|
let mut report = TempScan::default();
|
|
let mut remaining = scan_budget;
|
|
let mut result = Vec::new();
|
|
if remaining == 0 {
|
|
return Ok((
|
|
report,
|
|
result,
|
|
TempScanCursor {
|
|
root_dev: root.dev,
|
|
root_ino: root.ino,
|
|
next_shard: start_shard,
|
|
shard_continuation,
|
|
},
|
|
));
|
|
}
|
|
let mut next_shard = start_shard;
|
|
for offset in 0_u16..=255 {
|
|
if remaining == 0 {
|
|
break;
|
|
}
|
|
let shard_number = start_shard.wrapping_add(offset as u8);
|
|
next_shard = shard_number.wrapping_add(1);
|
|
let shard = format!("{shard_number:02x}");
|
|
let shard_fd = match open_existing_dir(sha.as_raw_fd(), shard.as_bytes()) {
|
|
Ok(fd) => fd,
|
|
Err(ArtifactError::NotFound) => {
|
|
shard_continuation = None;
|
|
continue;
|
|
}
|
|
Err(error) => return Err(error),
|
|
};
|
|
let stat = stat_fd(shard_fd.as_raw_fd())?;
|
|
let resume = shard_continuation.take().and_then(|continuation| {
|
|
if continuation.shard == shard_number
|
|
&& continuation.dev == stat.st_dev
|
|
&& continuation.ino == stat.st_ino
|
|
{
|
|
Some(continuation.cookie)
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
let page = list_names_after(shard_fd.as_raw_fd(), &mut remaining, &mut report, resume)?;
|
|
for entry in page.names {
|
|
let name = entry.name;
|
|
if !valid_temp_name(&name) {
|
|
continue;
|
|
}
|
|
#[cfg(debug_assertions)]
|
|
let _ = crate::test_support::checkpoint("housekeeping_before_stat");
|
|
let stat = match nofollow_stat(shard_fd.as_raw_fd(), &name) {
|
|
Ok(stat) => stat,
|
|
Err(ArtifactError::NotFound) => continue,
|
|
Err(error) => return Err(error),
|
|
};
|
|
if (stat.st_mode & libc::S_IFMT) != libc::S_IFREG || stat.st_nlink != 1 {
|
|
continue;
|
|
}
|
|
let modified = UNIX_EPOCH
|
|
.checked_add(Duration::new(
|
|
stat.st_mtime.max(0) as u64,
|
|
stat.st_mtime_nsec.max(0) as u32,
|
|
))
|
|
.unwrap_or(SystemTime::UNIX_EPOCH);
|
|
if SystemTime::now()
|
|
.duration_since(modified)
|
|
.is_ok_and(|age| age >= grace)
|
|
{
|
|
if result.len() == result_limit {
|
|
report.omitted += 1;
|
|
if result_limit > 0 {
|
|
return Ok((
|
|
report,
|
|
result,
|
|
TempScanCursor {
|
|
root_dev: root.dev,
|
|
root_ino: root.ino,
|
|
next_shard: shard_number,
|
|
shard_continuation: Some(TempShardContinuation {
|
|
shard: shard_number,
|
|
dev: stat.st_dev,
|
|
ino: stat.st_ino,
|
|
cookie: entry.cookie_before,
|
|
}),
|
|
},
|
|
));
|
|
}
|
|
continue;
|
|
}
|
|
result.push(StaleTemp {
|
|
name,
|
|
shard: shard.clone(),
|
|
root_dev: root.dev,
|
|
root_ino: root.ino,
|
|
dev: stat.st_dev,
|
|
ino: stat.st_ino,
|
|
modified_seconds: stat.st_mtime,
|
|
modified_nanoseconds: stat.st_mtime_nsec,
|
|
grace,
|
|
});
|
|
}
|
|
}
|
|
if let Some(cookie) = page.continuation {
|
|
return Ok((
|
|
report,
|
|
result,
|
|
TempScanCursor {
|
|
root_dev: root.dev,
|
|
root_ino: root.ino,
|
|
next_shard: shard_number,
|
|
shard_continuation: Some(TempShardContinuation {
|
|
shard: shard_number,
|
|
dev: stat.st_dev,
|
|
ino: stat.st_ino,
|
|
cookie,
|
|
}),
|
|
},
|
|
));
|
|
}
|
|
}
|
|
report.complete = true;
|
|
Ok((
|
|
report,
|
|
result,
|
|
TempScanCursor {
|
|
root_dev: root.dev,
|
|
root_ino: root.ino,
|
|
next_shard,
|
|
shard_continuation: None,
|
|
},
|
|
))
|
|
}
|
|
|
|
/// Deletes a revalidated stale inode under an exclusive lock, then fsyncs.
|
|
pub fn delete_stale_temp(&self, candidate: StaleTemp) -> Result<(), ArtifactError> {
|
|
let root = self.root()?;
|
|
let _lock = RootLock::exclusive(root)?;
|
|
ensure_root_unchanged(root)?;
|
|
if candidate.root_dev != root.dev || candidate.root_ino != root.ino {
|
|
return Err(ArtifactError::UnsafeRoot);
|
|
}
|
|
if !valid_temp_name(&candidate.name)
|
|
|| candidate.shard.len() != 2
|
|
|| !candidate.shard.bytes().all(is_lower_hex)
|
|
{
|
|
return Err(ArtifactError::UnsafeRoot);
|
|
}
|
|
let sha = open_existing_dir(root.fd.as_raw_fd(), b"sha256")?;
|
|
let shard = open_existing_dir(sha.as_raw_fd(), candidate.shard.as_bytes())?;
|
|
let stat = nofollow_stat(shard.as_raw_fd(), &candidate.name)?;
|
|
if stat.st_dev != candidate.dev
|
|
|| stat.st_ino != candidate.ino
|
|
|| stat.st_mtime != candidate.modified_seconds
|
|
|| stat.st_mtime_nsec != candidate.modified_nanoseconds
|
|
|| (stat.st_mode & libc::S_IFMT) != libc::S_IFREG
|
|
|| stat.st_nlink != 1
|
|
{
|
|
return Err(ArtifactError::UnsafeRoot);
|
|
}
|
|
let modified = UNIX_EPOCH
|
|
.checked_add(Duration::new(
|
|
stat.st_mtime.max(0) as u64,
|
|
stat.st_mtime_nsec.max(0) as u32,
|
|
))
|
|
.unwrap_or(SystemTime::UNIX_EPOCH);
|
|
if !SystemTime::now()
|
|
.duration_since(modified)
|
|
.is_ok_and(|age| age >= candidate.grace)
|
|
{
|
|
return Err(ArtifactError::UnsafeRoot);
|
|
}
|
|
unlinkat(shard.as_raw_fd(), candidate.name.as_bytes())?;
|
|
fsync_fd(shard.as_raw_fd())
|
|
}
|
|
}
|