use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, LazyLock, Mutex, PoisonError};
use git2::{Oid, Repository};
use tokio::sync::{OnceCell, Semaphore};
const MAX_CONCURRENT_COMPUTATIONS: usize = 4;
const WALK_MEMO_CAPACITY: usize = 4096;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(super) struct Divergence {
pub(super) ahead_behind: Option<(usize, usize)>,
pub(super) main_behind: Option<usize>,
}
type WalkKey = (PathBuf, Oid, Oid);
struct WalkMemo {
capacity: usize,
entries: Mutex<HashMap<WalkKey, (usize, usize)>>,
}
impl WalkMemo {
fn with_capacity(capacity: usize) -> Self {
Self {
capacity,
entries: Mutex::new(HashMap::new()),
}
}
fn get_or_walk(
&self,
key: WalkKey,
walk: impl FnOnce() -> Option<(usize, usize)>,
) -> Option<(usize, usize)> {
if let Some(hit) = self.lock().get(&key) {
return Some(*hit);
}
let counts = walk()?;
let mut entries = self.lock();
if entries.len() >= self.capacity {
entries.clear();
}
entries.insert(key, counts);
Some(counts)
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<WalkKey, (usize, usize)>> {
self.entries.lock().unwrap_or_else(PoisonError::into_inner)
}
#[cfg(test)]
fn len(&self) -> usize {
self.lock().len()
}
}
static WALKS: LazyLock<WalkMemo> = LazyLock::new(|| WalkMemo::with_capacity(WALK_MEMO_CAPACITY));
pub(super) fn graph_ahead_behind(
repo: &Repository,
local: Oid,
upstream: Oid,
) -> Option<(usize, usize)> {
graph_ahead_behind_in(&WALKS, repo, local, upstream)
}
fn graph_ahead_behind_in(
memo: &WalkMemo,
repo: &Repository,
local: Oid,
upstream: Oid,
) -> Option<(usize, usize)> {
let walk = || repo.graph_ahead_behind(local, upstream).ok();
if repo.is_shallow() {
return walk();
}
let key = (repo.commondir().to_path_buf(), local, upstream);
memo.get_or_walk(key, walk)
}
#[derive(Default)]
struct Flight {
result: OnceCell<Divergence>,
started: AtomicBool,
}
pub(super) struct AheadBehindCoordinator {
in_flight: Mutex<HashMap<PathBuf, Arc<Flight>>>,
permits: Arc<Semaphore>,
}
impl AheadBehindCoordinator {
pub(super) fn new() -> Self {
Self::with_concurrency(MAX_CONCURRENT_COMPUTATIONS)
}
fn with_concurrency(limit: usize) -> Self {
Self {
in_flight: Mutex::new(HashMap::new()),
permits: Arc::new(Semaphore::new(limit)),
}
}
pub(super) async fn get_or_compute<F>(&self, path: PathBuf, compute: F) -> Divergence
where
F: FnOnce(PathBuf) -> Divergence + Send + 'static,
{
let flight = self.join_or_start(&path);
let _forget = Forget {
in_flight: &self.in_flight,
path: &path,
flight: &flight,
};
*flight
.result
.get_or_init(|| self.compute_bounded(Arc::clone(&flight), path.clone(), compute))
.await
}
fn join_or_start(&self, path: &PathBuf) -> Arc<Flight> {
let mut in_flight = self.lock();
if let Some(queued) = in_flight.get(path) {
if !queued.started.load(Ordering::SeqCst) {
return Arc::clone(queued);
}
}
let fresh = Arc::new(Flight::default());
in_flight.insert(path.clone(), Arc::clone(&fresh));
fresh
}
async fn compute_bounded<F>(&self, flight: Arc<Flight>, path: PathBuf, compute: F) -> Divergence
where
F: FnOnce(PathBuf) -> Divergence + Send + 'static,
{
let Ok(permit) = Arc::clone(&self.permits).acquire_owned().await else {
return Divergence::default();
};
let joined = tokio::task::spawn_blocking(move || {
let _permit = permit;
flight.started.store(true, Ordering::SeqCst);
compute(path)
})
.await;
joined.unwrap_or_else(|e| {
tracing::warn!("ahead/behind computation failed: {e}");
Divergence::default()
})
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<PathBuf, Arc<Flight>>> {
self.in_flight
.lock()
.unwrap_or_else(PoisonError::into_inner)
}
#[cfg(test)]
fn in_flight_len(&self) -> usize {
self.lock().len()
}
}
struct Forget<'a> {
in_flight: &'a Mutex<HashMap<PathBuf, Arc<Flight>>>,
path: &'a PathBuf,
flight: &'a Arc<Flight>,
}
impl Drop for Forget<'_> {
fn drop(&mut self) {
let mut in_flight = self
.in_flight
.lock()
.unwrap_or_else(PoisonError::into_inner);
if in_flight
.get(self.path)
.is_some_and(|current| Arc::ptr_eq(current, self.flight))
{
in_flight.remove(self.path);
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use std::sync::atomic::AtomicUsize;
use std::time::{Duration, Instant};
use super::*;
fn divergence(ahead: usize) -> Divergence {
Divergence {
ahead_behind: Some((ahead, 0)),
main_behind: None,
}
}
async fn wait_until(flag: &AtomicBool) {
for _ in 0..400 {
if flag.load(Ordering::SeqCst) {
return;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
panic!("timed out waiting for a computation to start"); }
async fn hold_a_permit(
coordinator: &Arc<AheadBehindCoordinator>,
path: &str,
hold: Duration,
) -> tokio::task::JoinHandle<Divergence> {
let running = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&running);
let coordinator = Arc::clone(coordinator);
let path = PathBuf::from(path);
let task = tokio::spawn(async move {
coordinator
.get_or_compute(path, move |_| {
flag.store(true, Ordering::SeqCst);
std::thread::sleep(hold);
divergence(0)
})
.await
});
wait_until(&running).await;
task
}
#[tokio::test]
async fn requests_queued_for_one_path_share_one_computation() {
let coordinator = Arc::new(AheadBehindCoordinator::with_concurrency(1));
let blocker = hold_a_permit(&coordinator, "/blocker", Duration::from_millis(150)).await;
let calls = Arc::new(AtomicUsize::new(0));
let results = futures::future::join_all((0..8).map(|_| {
let calls = Arc::clone(&calls);
coordinator.get_or_compute(PathBuf::from("/repo/a"), move |_| {
calls.fetch_add(1, Ordering::SeqCst);
divergence(7)
})
}))
.await;
blocker.await.unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 1, "work was duplicated");
assert!(results.iter().all(|r| *r == divergence(7)));
assert_eq!(coordinator.in_flight_len(), 0, "an entry leaked");
}
#[tokio::test]
async fn a_request_never_joins_a_computation_that_has_already_started() {
let coordinator = Arc::new(AheadBehindCoordinator::with_concurrency(4));
let leader = hold_a_permit(&coordinator, "/repo/a", Duration::from_millis(150)).await;
let later = coordinator
.get_or_compute(PathBuf::from("/repo/a"), |_| divergence(2))
.await;
assert_eq!(later, divergence(2), "the later request got a stale answer");
assert_eq!(leader.await.unwrap(), divergence(0));
assert_eq!(coordinator.in_flight_len(), 0, "an entry leaked");
}
#[tokio::test]
async fn distinct_paths_are_computed_independently() {
let coordinator = AheadBehindCoordinator::new();
let calls = Arc::new(AtomicUsize::new(0));
futures::future::join_all(["/a", "/b", "/c"].map(|p| {
let calls = Arc::clone(&calls);
coordinator.get_or_compute(PathBuf::from(p), move |_| {
calls.fetch_add(1, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(30));
divergence(7)
})
}))
.await;
assert_eq!(calls.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn a_later_request_recomputes_instead_of_reusing_a_finished_result() {
let coordinator = AheadBehindCoordinator::new();
let path = PathBuf::from("/repo/a");
let first = coordinator
.get_or_compute(path.clone(), |_| divergence(1))
.await;
let second = coordinator.get_or_compute(path, |_| divergence(2)).await;
assert_eq!(first, divergence(1));
assert_eq!(second, divergence(2));
}
#[tokio::test]
async fn no_more_than_the_cap_run_at_once() {
let coordinator = AheadBehindCoordinator::with_concurrency(2);
let running = Arc::new(AtomicUsize::new(0));
let peak = Arc::new(AtomicUsize::new(0));
futures::future::join_all((0..8).map(|i| {
let running = Arc::clone(&running);
let peak = Arc::clone(&peak);
coordinator.get_or_compute(PathBuf::from(format!("/repo/{i}")), move |_| {
let now = running.fetch_add(1, Ordering::SeqCst) + 1;
peak.fetch_max(now, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(30));
running.fetch_sub(1, Ordering::SeqCst);
divergence(0)
})
}))
.await;
let peak = peak.load(Ordering::SeqCst);
assert!(peak <= 2, "{peak} computations ran at once, cap is 2");
assert_eq!(running.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn a_waiter_takes_over_when_the_queued_caller_is_dropped() {
let coordinator = Arc::new(AheadBehindCoordinator::with_concurrency(1));
let started = Instant::now();
let blocker = hold_a_permit(&coordinator, "/blocker", Duration::from_millis(150)).await;
let waiter = {
let coordinator = Arc::clone(&coordinator);
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(30)).await;
coordinator
.get_or_compute(PathBuf::from("/repo/a"), |_| divergence(2))
.await
})
};
let abandoned = tokio::time::timeout(
Duration::from_millis(60),
coordinator.get_or_compute(PathBuf::from("/repo/a"), |_| divergence(1)),
)
.await;
assert!(abandoned.is_err(), "the leader should still be queued");
let result = tokio::time::timeout(Duration::from_secs(5), waiter)
.await
.expect("the waiter must not hang")
.unwrap();
assert_eq!(result, divergence(2), "the waiter ran its own computation");
assert!(
started.elapsed() >= Duration::from_millis(150),
"the waiter ran before the blocker released the only permit"
);
blocker.await.unwrap();
assert_eq!(coordinator.in_flight_len(), 0, "an entry leaked");
}
#[tokio::test]
async fn a_dropped_caller_does_not_free_the_permit_its_thread_still_holds() {
let coordinator = Arc::new(AheadBehindCoordinator::with_concurrency(1));
let started = Instant::now();
let abandoned = hold_a_permit(&coordinator, "/repo/a", Duration::from_millis(150)).await;
abandoned.abort();
let next = coordinator
.get_or_compute(PathBuf::from("/repo/b"), |_| divergence(2))
.await;
assert_eq!(next, divergence(2));
assert!(
started.elapsed() >= Duration::from_millis(150),
"a second computation ran while the abandoned one held the only permit"
);
}
#[tokio::test]
async fn a_closed_semaphore_yields_an_empty_answer_and_leaks_nothing() {
let coordinator = AheadBehindCoordinator::with_concurrency(1);
coordinator.permits.close();
let result = coordinator
.get_or_compute(PathBuf::from("/repo/a"), |_| divergence(1))
.await;
assert_eq!(result, Divergence::default());
assert_eq!(coordinator.in_flight_len(), 0, "an entry leaked");
}
#[tokio::test]
async fn a_panicking_computation_yields_an_empty_answer_and_frees_its_permit() {
let coordinator = AheadBehindCoordinator::with_concurrency(1);
let failed = coordinator
.get_or_compute(PathBuf::from("/repo/a"), |_| panic!("boom"))
.await;
assert_eq!(failed, Divergence::default());
assert_eq!(coordinator.in_flight_len(), 0, "an entry leaked");
let next = tokio::time::timeout(
Duration::from_secs(5),
coordinator.get_or_compute(PathBuf::from("/repo/b"), |_| divergence(2)),
)
.await
.expect("the only permit must have been released");
assert_eq!(next, divergence(2));
}
#[test]
fn walk_memo_runs_the_walk_once_per_key() {
let memo = WalkMemo::with_capacity(8);
let walks = AtomicUsize::new(0);
let key = (PathBuf::from("/r/.git"), Oid::ZERO_SHA1, Oid::ZERO_SHA1);
for _ in 0..3 {
let counts = memo.get_or_walk(key.clone(), || {
walks.fetch_add(1, Ordering::SeqCst);
Some((2, 3))
});
assert_eq!(counts, Some((2, 3)));
}
assert_eq!(walks.load(Ordering::SeqCst), 1);
}
#[test]
fn walk_memo_does_not_remember_a_failed_walk() {
let memo = WalkMemo::with_capacity(8);
let key = (PathBuf::from("/r/.git"), Oid::ZERO_SHA1, Oid::ZERO_SHA1);
assert_eq!(memo.get_or_walk(key.clone(), || None), None);
assert_eq!(memo.len(), 0);
assert_eq!(memo.get_or_walk(key, || Some((1, 1))), Some((1, 1)));
}
#[test]
fn walk_memo_starts_over_at_capacity_instead_of_growing() {
let memo = WalkMemo::with_capacity(2);
let key = |n: u8| {
(
PathBuf::from(format!("/r{n}/.git")),
Oid::ZERO_SHA1,
Oid::ZERO_SHA1,
)
};
for n in 0..5 {
memo.get_or_walk(key(n), || Some((usize::from(n), 0)));
assert!(memo.len() <= 2, "grew to {}", memo.len());
}
assert_eq!(memo.get_or_walk(key(4), || None), Some((4, 0)));
}
#[test]
fn graph_ahead_behind_tracks_new_commits_and_matches_libgit2() {
let memo = WalkMemo::with_capacity(8);
let dir = tempfile::tempdir().unwrap();
let (repo, base, first, second) = three_commits(dir.path());
assert_eq!(
graph_ahead_behind_in(&memo, &repo, first, base),
Some((1, 0))
);
assert_eq!(
graph_ahead_behind_in(&memo, &repo, first, base),
Some((1, 0))
);
assert_eq!(memo.len(), 1, "the repeat is a hit, not a second entry");
assert_eq!(
graph_ahead_behind_in(&memo, &repo, second, base),
Some((2, 0))
);
assert_eq!(
graph_ahead_behind_in(&memo, &repo, second, base),
repo.graph_ahead_behind(second, base).ok()
);
}
#[test]
fn a_shallow_repository_bypasses_the_memo() {
let memo = WalkMemo::with_capacity(8);
let dir = tempfile::tempdir().unwrap();
let (repo, base, first, _second) = three_commits(dir.path());
std::fs::write(repo.path().join("shallow"), format!("{base}\n")).unwrap();
assert!(repo.is_shallow());
assert!(graph_ahead_behind_in(&memo, &repo, first, base).is_some());
assert_eq!(memo.len(), 0, "a shallow repository was memoized");
}
fn three_commits(dir: &std::path::Path) -> (Repository, Oid, Oid, Oid) {
let repo = Repository::init(dir).unwrap();
let sig = git2::Signature::now("t", "t@example.invalid").unwrap();
let tree = repo
.find_tree(repo.treebuilder(None).unwrap().write().unwrap())
.unwrap();
let base = repo.commit(None, &sig, &sig, "base", &tree, &[]).unwrap();
let first = {
let parent = repo.find_commit(base).unwrap();
repo.commit(None, &sig, &sig, "one", &tree, &[&parent])
.unwrap()
};
let second = {
let parent = repo.find_commit(first).unwrap();
repo.commit(None, &sig, &sig, "two", &tree, &[&parent])
.unwrap()
};
drop(tree);
(repo, base, first, second)
}
}