use parking_lot::Mutex;
use serde::Serialize;
use std::collections::HashMap;
use std::panic::{self, AssertUnwindSafe};
use std::sync::LazyLock;
use std::thread;
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 {
running: bool,
rerun: Option<LocalRepository>,
last_failed: bool,
}
static PASSES: LazyLock<Mutex<HashMap<PathBuf, PassState>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
pub fn update_size(repo: &LocalRepository) -> Result<(), OxenError> {
let write = repo_locks::begin_write(repo)?;
{
let mut passes = PASSES.lock();
let state = passes.entry(repo.path.clone()).or_default();
if state.running {
state.rerun.get_or_insert_with(|| repo.clone());
return Ok(());
}
state.running = true;
}
let mut next = Some(repo.clone());
let spawned = thread::Builder::new().spawn(move || {
let _write = write;
while let Some(repo) = next.take() {
let failed =
panic::catch_unwind(AssertUnwindSafe(|| walk_and_record(&repo))).unwrap_or(true);
let path = repo.path.clone();
drop(repo);
let mut passes = PASSES.lock();
let Some(state) = passes.get_mut(&path) else {
break;
};
next = state.rerun.take();
if next.is_none() {
state.running = false;
state.last_failed = failed;
if !failed {
passes.remove(&path);
}
}
}
});
if let Err(cause) = spawned {
let mut passes = PASSES.lock();
if let Some(state) = passes.get_mut(&repo.path) {
state.running = false;
state.rerun = None;
if !state.last_failed {
passes.remove(&repo.path);
}
}
return Err(cause.into());
}
Ok(())
}
fn walk_and_record(repo: &LocalRepository) -> bool {
match repo.version_bytes() {
Ok(total) => {
#[cfg(test)]
tests::after_walk(&repo.path);
match AtomicFile::new(repo_size_path(repo)).write(total.to_string().as_bytes()) {
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
}
}
}
pub fn get_size(repo: &LocalRepository) -> RepoSizeFile {
let (running, last_failed) = PASSES
.lock()
.get(&repo.path)
.map_or((false, false), |state| (state.running, state.last_failed));
let figure = util::fs::read_from_path(repo_size_path(repo))
.ok()
.and_then(|content| content.trim().parse::<u64>().ok());
let status = if running {
SizeStatus::Pending
} else if last_failed {
SizeStatus::Error
} else if figure.is_some() {
SizeStatus::Done
} else {
tracing::info!(repo = ?repo.path, "No size figure recorded, starting a recalculation");
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::*;
use crate::repositories;
use crate::test;
use std::collections::HashSet;
use std::path::Path;
use std::sync::mpsc::{self, Receiver, Sender};
use std::time::Duration;
static WALKS: LazyLock<Mutex<HashMap<PathBuf, usize>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
type Hold = (Sender<()>, Receiver<()>);
static HOLDS: LazyLock<Mutex<HashMap<PathBuf, Hold>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
static PANICS: LazyLock<Mutex<HashSet<PathBuf>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
pub(super) fn after_walk(repo_path: &Path) {
*WALKS.lock().entry(repo_path.to_path_buf()).or_default() += 1;
let hold = HOLDS.lock().remove(repo_path);
if let Some((reached, release)) = hold {
reached
.send(())
.expect("the test waiting on the hold should still be listening");
release
.recv()
.expect("the test should release the held pass");
}
if PANICS.lock().remove(repo_path) {
panic!("panic_next_walk asked this walk to panic");
}
}
fn panic_next_walk(repo_path: &Path) {
PANICS.lock().insert(repo_path.to_path_buf());
}
fn hold_next_walk(repo_path: &Path) -> (Receiver<()>, Sender<()>) {
let (reached_tx, reached_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel();
HOLDS
.lock()
.insert(repo_path.to_path_buf(), (reached_tx, release_rx));
(reached_rx, release_tx)
}
#[tokio::test]
async fn test_calls_during_a_pass_coalesce_into_one_follow_up() -> Result<(), OxenError> {
test::run_one_commit_local_repo_test_async(|repo| async move {
let (reached, release) = hold_next_walk(&repo.path);
update_size(&repo)?;
reached
.recv_timeout(Duration::from_secs(30))
.expect("the pass should hold once it has walked");
let added = repo.path.join("added.txt");
util::fs::write_to_path(&added, "added while a pass was walking")?;
repositories::add(&repo, &added).await?;
repositories::commit(&repo, "Add a file while a pass walks")?;
let current = repo.version_bytes()?;
for _ in 0..3 {
update_size(&repo)?;
}
release
.send(())
.expect("the held pass should be waiting for its release");
assert_eq!(
wait_for_recorded_size(&repo)?,
current,
"the follow-up pass should record the size after the commit"
);
assert_eq!(
WALKS.lock().get(&repo.path).copied(),
Some(2),
"three calls during the held pass should run exactly one follow-up pass"
);
let (reached, release) = hold_next_walk(&repo.path);
panic_next_walk(&repo.path);
update_size(&repo)?;
reached
.recv_timeout(Duration::from_secs(30))
.expect("the pass should hold once it has walked");
update_size(&repo)?;
release
.send(())
.expect("the held pass should be waiting for its release");
assert_eq!(
wait_for_recorded_size(&repo)?,
repo.version_bytes()?,
"the walk asked for during the panicking one should run and record the size"
);
assert_eq!(
WALKS.lock().get(&repo.path).copied(),
Some(4),
"the pass should walk once more after its walk panicked"
);
panic_next_walk(&repo.path);
update_size(&repo)?;
assert!(
wait_for_recorded_size(&repo).is_err(),
"a pass whose last walk panicked should report a failure"
);
PASSES.lock().remove(&repo.path);
Ok(())
})
.await
}
}