use super::*;
pub(crate) const SECS_PER_DAY: f64 = 86_400.0;
pub(crate) const TIME_STALE_DAYS: f64 = 90.0;
pub(super) const TIME_WEIGHT: f32 = 0.20;
pub(super) const GIT_WEIGHT: f32 = 0.35;
#[allow(dead_code)]
pub(super) const SEMANTIC_WEIGHT: f32 = 0.25;
pub(super) const DEP_WEIGHT: f32 = 0.10;
pub(super) const CASCADE_WEIGHT_FACTOR: f32 = 0.10;
pub(super) const GIT_REVWALK_LIMIT: usize = 2000;
pub(crate) const GIT_CAP_HIT_COMMITS: u32 = 3;
pub(super) const MAX_RECOMPUTE_SIGNALS: usize = 10;
pub const ANALYZE_TIME_BUDGET_MS: u64 = 2000;
pub(super) const MAX_TOMBSTONE_RATIO: f32 = 0.5;
pub(super) const MIN_TOMBSTONE_SAMPLE: u32 = 20;
pub(super) const STALENESS_PREFIXES: &[&str] =
&["file:", "gotcha:", "decision:", "dep:", "dev_note:"];
pub(super) const REPARSE_WINDOW_SECS: u64 = 86_400;
#[derive(Debug, Clone)]
pub struct StalenessReport {
pub scanned: u32,
pub updated: u32,
pub tombstoned: u32,
pub liability: u32,
pub stale: u32,
}
pub struct StalenessAnalyzer {
pub(super) repo: Option<Mutex<git2::Repository>>,
pub(super) root: PathBuf,
pub(super) root_from_git: bool,
pub(super) now: u64,
pub(super) head_commit: Option<String>,
}
impl StalenessAnalyzer {
pub fn new(repo_path: &Path) -> Self {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Self::open_at(repo_path, now)
}
fn open_at(repo_path: &Path, now: u64) -> Self {
let (ident, discovered) = RepoIdent::discover_with_repo(repo_path);
let root = ident.slug_root(repo_path);
let grounded = ident.workdir.is_some();
let repo = grounded.then_some(discovered).flatten();
let head_commit = repo.as_ref().and_then(head_commit_sha);
Self {
repo: repo.map(Mutex::new),
root_from_git: grounded,
root,
now,
head_commit,
}
}
#[cfg(test)]
pub(super) fn new_with_now(repo_path: &Path, now: u64) -> Self {
Self::open_at(repo_path, now)
}
fn path_exists(&self, path: &str) -> bool {
self.root.join(path).exists()
}
fn is_dead_stub(&self, record: &Record) -> bool {
if !crate::store::gotcha_ops::is_auto_gotcha(&record.key) {
return false;
}
let Some(gotcha) = record.payload_as::<GotchaRecord>() else {
return false;
};
!gotcha.confirmed
&& !gotcha.affected_files.is_empty()
&& gotcha.affected_files.iter().all(|p| !self.path_exists(p))
}
pub async fn analyze_all(&self, store: &Store) -> Result<StalenessReport> {
let deadline = Instant::now() + std::time::Duration::from_millis(ANALYZE_TIME_BUDGET_MS);
self.analyze_until(store, deadline).await
}
pub(super) async fn analyze_until(
&self,
store: &Store,
deadline: Instant,
) -> Result<StalenessReport> {
let mut report = StalenessReport {
scanned: 0,
updated: 0,
tombstoned: 0,
liability: 0,
stale: 0,
};
let dep_records = store.scan_prefix("dep:").await.unwrap_or_default();
let dep_cache: HashMap<String, Record> = dep_records
.into_iter()
.map(|r| (r.key.clone(), r))
.collect();
let stored_cursor = read_cursor(store).await;
let resume = stored_cursor.as_deref().and_then(|key| {
STALENESS_PREFIXES
.iter()
.position(|p| key.starts_with(p))
.map(|idx| (idx, key))
});
let (resume_prefix, resume_after) = match resume {
Some((idx, key)) => (idx, Some(key)),
None => (0, None),
};
let mut updates: Vec<(String, Record)> = Vec::new();
let mut last_visited: Option<String> = None;
let mut truncated = false;
'prefixes: for (idx, prefix) in STALENESS_PREFIXES.iter().enumerate().skip(resume_prefix) {
if Instant::now() >= deadline {
truncated = true;
tracing::warn!(
"staleness analyze_all: time budget exceeded after {} records",
report.scanned
);
break;
}
let mut records = match store.scan_prefix(prefix).await {
Ok(r) => r,
Err(e) => {
tracing::warn!("staleness scan_prefix({prefix}) failed: {e}");
continue;
}
};
records.sort_by(|a, b| a.key.cmp(&b.key));
for record in records {
if idx == resume_prefix {
if let Some(after) = resume_after {
if record.key.as_str() <= after {
continue;
}
}
}
if Instant::now() >= deadline {
truncated = true;
tracing::warn!(
"staleness analyze_all: time budget exceeded mid-prefix at {} records",
report.scanned
);
break 'prefixes;
}
report.scanned += 1;
last_visited = Some(record.key.clone());
if !matches!(record.lifecycle, RecordLifecycle::Active) {
continue;
}
let mut updated = record.clone();
match self
.compute_staleness(&mut updated, store, &dep_cache, deadline)
.await
{
Ok(()) => {}
Err(e) => {
tracing::warn!("staleness compute for {} failed: {e}", record.key);
continue;
}
}
if staleness_changed(&record, &updated) {
match updated.staleness.tier {
StalenessTier::Tombstone => report.tombstoned += 1,
StalenessTier::Liability => report.liability += 1,
StalenessTier::Stale => report.stale += 1,
_ => {}
}
updated.updated_at = self.now;
updated.version.logical_clock += 1;
updated.version.wall_clock = self.now;
updates.push((updated.key.clone(), updated));
report.updated += 1;
}
}
}
if report.scanned >= MIN_TOMBSTONE_SAMPLE
&& report.tombstoned as f32 > report.scanned as f32 * MAX_TOMBSTONE_RATIO
{
tracing::error!(
scanned = report.scanned,
tombstoned = report.tombstoned,
root = %self.root.display(),
root_from_git = self.root_from_git,
"staleness analyze_all: tombstone ratio above {MAX_TOMBSTONE_RATIO}, pass discarded"
);
return Ok(StalenessReport {
scanned: report.scanned,
updated: 0,
tombstoned: 0,
liability: 0,
stale: 0,
});
}
if !updates.is_empty() {
let batch: Vec<(&str, &Record)> =
updates.iter().map(|(k, r)| (k.as_str(), r)).collect();
store.put_batch(&batch).await.with_context(|| {
format!("staleness batch write failed for {} records", batch.len())
})?;
}
match (truncated, last_visited) {
(true, Some(key)) => write_cursor(store, &key, self.now).await,
(true, None) => {}
(false, _) if stored_cursor.is_some() => clear_cursor(store).await,
(false, _) => {}
}
Ok(report)
}
pub(super) async fn compute_staleness(
&self,
record: &mut Record,
store: &Store,
dep_cache: &HashMap<String, Record>,
deadline: Instant,
) -> Result<()> {
let file_record: Option<FileRecord> = if record.key.starts_with("file:") {
record.payload_as::<FileRecord>()
} else {
None
};
if record
.staleness
.signals
.iter()
.any(|s| matches!(s, StalenessSignal::FileDeleted))
{
let path = record.key.strip_prefix("file:").unwrap_or(&record.key);
if self.path_exists(path) {
record
.staleness
.signals
.retain(|s| !matches!(s, StalenessSignal::FileDeleted));
} else {
if self.root_from_git {
record.staleness.value = 1.0;
record.staleness.tier = StalenessTier::Tombstone;
record.staleness.computed_at = self.now;
}
return Ok(());
}
}
if record.key.starts_with("file:") && self.root_from_git {
let path = record.key.strip_prefix("file:").unwrap_or(&record.key);
if !path.is_empty() && !self.path_exists(path) {
record.staleness.signals.push(StalenessSignal::FileDeleted);
record.staleness.value = 1.0;
record.staleness.tier = StalenessTier::Tombstone;
record.staleness.computed_at = self.now;
return Ok(());
}
}
if self.root_from_git && self.is_dead_stub(record) {
record.staleness.value = 1.0;
record.staleness.tier = StalenessTier::Tombstone;
record.staleness.computed_at = self.now;
return Ok(());
}
let has_rename = record
.staleness
.signals
.iter()
.any(|s| matches!(s, StalenessSignal::FileRenamed { .. }));
if has_rename {
let new_path_exists = record.staleness.signals.iter().any(|s| {
if let StalenessSignal::FileRenamed { new_path } = s {
self.path_exists(new_path)
} else {
false
}
});
if new_path_exists {
record.staleness.value = 0.85;
record.staleness.tier = StalenessTier::Liability;
record.staleness.computed_at = self.now;
return Ok(());
}
}
let reparse_signals: Vec<StalenessSignal> = record
.staleness
.signals
.iter()
.filter(|s| is_reparse_signal(s))
.cloned()
.collect();
let had_recent_reparse = record.staleness.computed_at > 0
&& self.now.saturating_sub(record.staleness.computed_at) < REPARSE_WINDOW_SECS
&& !reparse_signals.is_empty();
let old_value = record.staleness.value;
let time_f = time_factor(record, self.now);
let (git_f, new_sha) =
self.git_factor_for(&record.key, &record.staleness.last_record_sha, deadline);
let semantic_f = semantic_factor();
let dep_f = dep_factor(file_record.as_ref(), dep_cache);
let cascade_f = cascade_factor(record, file_record.as_ref(), store).await;
let raw_value = time_f * TIME_WEIGHT
+ git_f * GIT_WEIGHT
+ semantic_f * SEMANTIC_WEIGHT
+ dep_f * DEP_WEIGHT
+ cascade_f * CASCADE_WEIGHT_FACTOR;
let clamped = raw_value.clamp(0.0, 1.0);
let final_value = if had_recent_reparse {
clamped.max(old_value.min(MAX_REPARSE_STALENESS))
} else {
clamped
};
let mut new_signals = Vec::new();
if had_recent_reparse {
for sig in reparse_signals.iter().take(MAX_RECOMPUTE_SIGNALS) {
new_signals.push(sig.clone());
}
}
if git_f > 0.0 {
new_signals.push(StalenessSignal::LinesChangedPct(git_f));
}
const MAX_SIGNALS: usize = 20;
if new_signals.len() > MAX_SIGNALS {
let drain_count = new_signals.len() - MAX_SIGNALS;
new_signals.drain(..drain_count);
}
record.staleness.value = final_value;
record.staleness.tier = StalenessScore::tier_from_value(final_value);
record.staleness.computed_at = self.now;
record.staleness.signals = new_signals;
if let Some(sha) = new_sha {
record.staleness.last_record_sha = sha;
}
Ok(())
}
pub(super) fn git_factor_for(
&self,
key: &str,
last_record_sha: &str,
deadline: Instant,
) -> (f32, Option<String>) {
let Some(mutex) = self.repo.as_ref() else {
return (0.0, None);
};
let Ok(repo) = mutex.lock() else {
return (0.0, None);
};
let path = key.strip_prefix("file:").unwrap_or(key);
self.git_factor(&repo, path, last_record_sha, deadline)
}
pub(super) fn git_factor(
&self,
repo: &git2::Repository,
path: &str,
last_record_sha: &str,
deadline: Instant,
) -> (f32, Option<String>) {
let head_sha = match &self.head_commit {
Some(sha) => sha.clone(),
None => return (0.0, None),
};
if last_record_sha.is_empty() {
return (0.0, Some(head_sha));
}
if last_record_sha == head_sha {
return (0.0, None);
}
let blob_at_head = blob_sha_at_head(repo, path);
let blob_at_record = blob_sha_at_commit(repo, path, last_record_sha);
match (blob_at_head, blob_at_record) {
(Some(ref h), Some(ref r)) if h == r => {
return (0.0, Some(head_sha));
}
(None, _) => {
return (0.0, Some(head_sha));
}
_ => {
}
}
let count = self.count_commits_since(repo, path, last_record_sha, deadline);
let factor = commits_to_factor(count);
(factor, Some(head_sha))
}
pub(super) fn count_commits_since(
&self,
repo: &git2::Repository,
path: &str,
since_sha: &str,
deadline: Instant,
) -> u32 {
let head_oid = match repo.head().ok().and_then(|h| h.target()) {
Some(oid) => oid,
None => return 0,
};
let mut revwalk = match repo.revwalk() {
Ok(rw) => rw,
Err(_) => return 0,
};
if revwalk.push(head_oid).is_err() {
return 0;
}
revwalk.set_sorting(git2::Sort::TOPOLOGICAL).ok();
let mut count: u32 = 0;
let mut total_iterations: usize = 0;
let mut found_since = false;
let mut stopped_early = false;
for oid_result in revwalk {
total_iterations += 1;
if total_iterations > GIT_REVWALK_LIMIT {
stopped_early = true;
break;
}
if Instant::now() >= deadline {
stopped_early = true;
break;
}
let oid = match oid_result {
Ok(o) => o,
Err(_) => continue,
};
let oid_str = oid.to_string();
if oid_str == since_sha {
found_since = true;
break;
}
if commit_touches_file(repo, oid, path) {
count += 1;
}
}
if !found_since && count == 0 && stopped_early {
return GIT_CAP_HIT_COMMITS;
}
count
}
}
const _: fn() = || {
fn assert_send<T: Send>(_: T) {}
fn probe(analyzer: &StalenessAnalyzer, store: &Store) {
assert_send(analyzer.analyze_all(store));
}
let _ = probe;
};