use parking_lot::Mutex;
use serde::Serialize;
use std::collections::HashMap;
use std::sync::LazyLock;
use utoipa::ToSchema;
use crate::core::repo_locks;
use crate::error::OxenError;
use crate::util::fs::AtomicFile;
use crate::{model::LocalRepository, util};
use std::path::PathBuf;
#[derive(Serialize, Debug, Clone, PartialEq, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum SizeStatus {
Pending,
Done,
Error,
}
#[derive(Serialize, Debug, Clone, ToSchema)]
pub struct RepoSizeFile {
pub status: SizeStatus,
pub size: u64,
}
#[derive(Default)]
struct PassState {
walking: usize,
completed: u64,
last_failed: bool,
}
static PASSES: LazyLock<Mutex<HashMap<PathBuf, PassState>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
struct PassMarker {
repo_path: PathBuf,
failed: bool,
completed_at_start: u64,
}
impl PassMarker {
fn new(repo_path: PathBuf) -> Self {
let completed_at_start = {
let mut passes = PASSES.lock();
let state = passes.entry(repo_path.clone()).or_default();
state.walking += 1;
state.completed
};
Self {
repo_path,
failed: true,
completed_at_start,
}
}
fn finish(mut self, failed: bool) {
self.failed = failed;
}
}
impl Drop for PassMarker {
fn drop(&mut self) {
let mut passes = PASSES.lock();
let Some(state) = passes.get_mut(&self.repo_path) else {
return;
};
state.walking = state.walking.saturating_sub(1);
if self.failed {
if state.completed == self.completed_at_start {
state.last_failed = true;
}
} else {
state.completed += 1;
state.last_failed = false;
}
if state.walking == 0 && !state.last_failed {
passes.remove(&self.repo_path);
}
}
}
pub fn update_size(repo: &LocalRepository) -> Result<(), OxenError> {
let write = repo_locks::begin_write(repo)?;
let marker = PassMarker::new(repo.path.clone());
let repo = repo.clone();
std::thread::spawn(move || {
let _write = write;
let failed = match repo.version_bytes() {
Ok(total) => {
let recorded =
AtomicFile::new(repo_size_path(&repo)).write(total.to_string().as_bytes());
match recorded {
Ok(()) => {
remove_legacy_size_file(&repo);
false
}
Err(e) => {
tracing::error!(
repo = ?repo.path,
cause = ?e,
"Could not record a repository's recalculated size"
);
true
}
}
}
Err(e) => {
tracing::error!(
repo = ?repo.path,
cause = ?e,
"Could not calculate a repository's size"
);
true
}
};
drop(repo);
marker.finish(failed);
});
Ok(())
}
pub fn get_size(repo: &LocalRepository) -> RepoSizeFile {
let (walking, last_failed) = PASSES
.lock()
.get(&repo.path)
.map_or((0, false), |state| (state.walking, state.last_failed));
let recorded = util::fs::read_from_path(repo_size_path(repo))
.ok()
.map(|content| content.trim().parse::<u64>());
let figure = recorded
.as_ref()
.and_then(|parsed| parsed.as_ref().ok())
.copied();
let status = if walking > 0 {
SizeStatus::Pending
} else if last_failed {
SizeStatus::Error
} else if figure.is_some() {
SizeStatus::Done
} else {
match &recorded {
Some(Err(cause)) => tracing::error!(
repo = ?repo.path,
?cause,
"Replacing a recorded repository size that is not a figure"
),
_ => log::info!(
"No size recorded for {:?}, starting a recalculation",
repo.path
),
}
match update_size(repo) {
Ok(()) => SizeStatus::Pending,
Err(cause) => {
tracing::error!(
repo = ?repo.path,
?cause,
"Could not start the recalculation a repository with no figure needs"
);
SizeStatus::Error
}
}
};
RepoSizeFile {
status,
size: figure.unwrap_or(0),
}
}
pub fn repo_size_path(repo: &LocalRepository) -> PathBuf {
util::fs::oxen_hidden_dir(&repo.path).join("repo_size.json")
}
fn remove_legacy_size_file(repo: &LocalRepository) {
let legacy = util::fs::oxen_hidden_dir(&repo.path).join("repo_size.toml");
if legacy.exists()
&& let Err(err) = util::fs::remove_file(&legacy)
{
log::warn!("Failed to remove {legacy:?}: {err}");
}
}
#[cfg(test)]
pub(crate) fn wait_for_recorded_size(repo: &LocalRepository) -> Result<u64, OxenError> {
use std::time::{Duration, Instant};
let deadline = Instant::now() + Duration::from_secs(30);
loop {
let recorded = get_size(repo);
match recorded.status {
SizeStatus::Done => return Ok(recorded.size),
SizeStatus::Error => {
return Err(OxenError::internal_error(
"the size recalculation recorded an error, which the log details",
));
}
SizeStatus::Pending => {}
}
assert!(
Instant::now() < deadline,
"the size stayed pending past the deadline"
);
std::thread::sleep(Duration::from_millis(2));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_a_failed_pass_reports_a_failure_only_when_no_pass_completed() {
let landed = PathBuf::from("/test/size/a-pass-landed-alongside-a-failure");
let completed = PassMarker::new(landed.clone());
let failed = PassMarker::new(landed.clone());
completed.finish(false);
failed.finish(true);
assert!(
!PASSES.lock().contains_key(&landed),
"a pass completing alongside a failed one leaves no failure to report"
);
let none_landed = PathBuf::from("/test/size/every-pass-failed");
let first = PassMarker::new(none_landed.clone());
let second = PassMarker::new(none_landed.clone());
first.finish(true);
second.finish(true);
assert!(
PASSES
.lock()
.get(&none_landed)
.is_some_and(|state| state.last_failed),
"a repository whose every pass failed has a failure left to report"
);
PASSES.lock().remove(&none_landed);
}
}