use serde::Serialize;
use std::sync::RwLock;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
const PARSE_BAND_END: f32 = 10.0;
const INGEST_BAND_END: f32 = 90.0;
const RESOLVING_PERCENT: f32 = 95.0;
struct PercentInputs {
stage: IndexingStage,
total_files: u64,
parsed_files: u64,
total_entities: Option<u64>,
entities_ingested: u64,
}
fn compute_percent(inputs: &PercentInputs) -> f32 {
if inputs.stage == IndexingStage::Completed {
return 100.0;
}
if inputs.stage == IndexingStage::ResolvingReferences {
return RESOLVING_PERCENT;
}
if let Some(total_entities) = inputs.total_entities {
let ratio = if total_entities == 0 {
1.0
} else {
(inputs.entities_ingested as f32 / total_entities as f32).clamp(0.0, 1.0)
};
return PARSE_BAND_END + ratio * (INGEST_BAND_END - PARSE_BAND_END);
}
if inputs.total_files > 0 {
let raw = (inputs.parsed_files as f32 / inputs.total_files as f32) * PARSE_BAND_END;
return raw.clamp(0.0, PARSE_BAND_END);
}
0.0
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum IndexingStage {
Idle,
Discovering,
Classifying,
CleaningStaleData,
Parsing,
Ingesting,
ResolvingReferences,
Completed,
Failed,
}
#[derive(Debug, Clone, Serialize)]
pub struct IndexingProgress {
pub repo_name: String,
pub stage: IndexingStage,
pub total_files: u64,
pub parsed_files: u64,
pub percent_complete: f32,
pub entities_ingested: u64,
pub batches_ingested: u64,
pub total_entities: u64,
pub error: Option<String>,
}
pub struct ProgressTracker {
repo_name: RwLock<String>,
stage: RwLock<IndexingStage>,
error: RwLock<Option<String>>,
total_files: AtomicU64,
parsed_files: AtomicU64,
entities_ingested: AtomicU64,
batches_ingested: AtomicU64,
total_entities: AtomicU64,
total_entities_known: AtomicBool,
}
impl ProgressTracker {
pub fn new() -> Self {
Self {
repo_name: RwLock::new(String::new()),
stage: RwLock::new(IndexingStage::Idle),
error: RwLock::new(None),
total_files: AtomicU64::new(0),
parsed_files: AtomicU64::new(0),
entities_ingested: AtomicU64::new(0),
batches_ingested: AtomicU64::new(0),
total_entities: AtomicU64::new(0),
total_entities_known: AtomicBool::new(false),
}
}
pub fn snapshot(&self) -> IndexingProgress {
let stage = *self.stage.read().unwrap();
let total = self.total_files.load(Ordering::Relaxed);
let parsed = self.parsed_files.load(Ordering::Relaxed);
let entities = self.entities_ingested.load(Ordering::Relaxed);
let batches = self.batches_ingested.load(Ordering::Relaxed);
let total_entities_value = self.total_entities.load(Ordering::Acquire);
let total_entities_known = self.total_entities_known.load(Ordering::Acquire);
let total_entities = if total_entities_known {
Some(total_entities_value)
} else {
None
};
let pct = compute_percent(&PercentInputs {
stage,
total_files: total,
parsed_files: parsed,
total_entities,
entities_ingested: entities,
});
IndexingProgress {
repo_name: self.repo_name.read().unwrap().clone(),
stage,
total_files: total,
parsed_files: parsed,
percent_complete: pct,
entities_ingested: entities,
batches_ingested: batches,
total_entities: total_entities_value,
error: self.error.read().unwrap().clone(),
}
}
pub(crate) fn begin_run(&self, repo_name: &str) {
*self.repo_name.write().unwrap() = repo_name.to_string();
*self.stage.write().unwrap() = IndexingStage::Discovering;
*self.error.write().unwrap() = None;
self.total_files.store(0, Ordering::Relaxed);
self.parsed_files.store(0, Ordering::Relaxed);
self.entities_ingested.store(0, Ordering::Relaxed);
self.batches_ingested.store(0, Ordering::Relaxed);
self.total_entities.store(0, Ordering::Release);
self.total_entities_known.store(false, Ordering::Release);
}
pub(crate) fn set_stage(&self, stage: IndexingStage) {
*self.stage.write().unwrap() = stage;
}
pub(crate) fn set_total_files(&self, total: u64) {
self.total_files.store(total, Ordering::Relaxed);
}
pub(crate) fn incr_parsed_files(&self) {
self.parsed_files.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn record_batch_ingested(&self, entity_count: u64) {
self.batches_ingested.fetch_add(1, Ordering::Relaxed);
self.entities_ingested
.fetch_add(entity_count, Ordering::Relaxed);
}
pub(crate) fn set_total_entities(&self, n: u64) {
self.total_entities.store(n, Ordering::Release);
self.total_entities_known.store(true, Ordering::Release);
}
pub(crate) fn complete(&self) {
*self.stage.write().unwrap() = IndexingStage::Completed;
}
pub(crate) fn fail(&self, msg: &str) {
*self.stage.write().unwrap() = IndexingStage::Failed;
*self.error.write().unwrap() = Some(msg.to_string());
}
}
impl Default for ProgressTracker {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_tracker_is_idle_zeroed() {
let t = ProgressTracker::new();
let snap = t.snapshot();
assert_eq!(snap.stage, IndexingStage::Idle);
assert_eq!(snap.total_files, 0);
assert_eq!(snap.parsed_files, 0);
assert_eq!(snap.percent_complete, 0.0);
assert_eq!(snap.entities_ingested, 0);
assert_eq!(snap.batches_ingested, 0);
assert!(snap.error.is_none());
}
#[test]
fn test_percent_basic() {
let t = ProgressTracker::new();
t.set_total_files(5000);
t.set_stage(IndexingStage::Parsing);
for _ in 0..1000 {
t.incr_parsed_files();
}
let snap = t.snapshot();
assert_eq!(snap.percent_complete, 2.0);
}
#[test]
fn test_percent_zero_total() {
let t = ProgressTracker::new();
t.set_stage(IndexingStage::Parsing);
let snap = t.snapshot();
assert_eq!(snap.percent_complete, 0.0);
}
#[test]
fn test_completed_forces_100() {
let t = ProgressTracker::new();
t.complete();
let snap = t.snapshot();
assert_eq!(snap.stage, IndexingStage::Completed);
assert_eq!(snap.percent_complete, 100.0);
}
#[test]
fn test_fail_records_error() {
let t = ProgressTracker::new();
t.set_total_files(100);
t.incr_parsed_files();
t.fail("boom");
let snap = t.snapshot();
assert_eq!(snap.stage, IndexingStage::Failed);
assert_eq!(snap.error, Some("boom".to_string()));
assert_eq!(snap.parsed_files, 1);
}
#[test]
fn test_begin_run_resets() {
let t = ProgressTracker::new();
t.begin_run("repo1");
t.set_total_files(100);
t.incr_parsed_files();
t.record_batch_ingested(10);
t.set_total_entities(500);
t.fail("oops");
t.begin_run("repo2");
let snap = t.snapshot();
assert_eq!(snap.repo_name, "repo2");
assert_eq!(snap.stage, IndexingStage::Discovering);
assert_eq!(snap.total_files, 0);
assert_eq!(snap.parsed_files, 0);
assert_eq!(snap.entities_ingested, 0);
assert_eq!(snap.batches_ingested, 0);
assert_eq!(snap.total_entities, 0);
assert!(snap.error.is_none());
}
#[test]
fn test_concurrent_increments() {
let t = std::sync::Arc::new(ProgressTracker::new());
let threads: Vec<_> = (0..8)
.map(|_| {
let t = std::sync::Arc::clone(&t);
std::thread::spawn(move || {
for _ in 0..1000 {
t.incr_parsed_files();
}
})
})
.collect();
for th in threads {
th.join().unwrap();
}
assert_eq!(t.snapshot().parsed_files, 8000);
}
#[test]
fn test_record_batch_accumulates() {
let t = ProgressTracker::new();
t.record_batch_ingested(64);
t.record_batch_ingested(64);
t.record_batch_ingested(10);
let snap = t.snapshot();
assert_eq!(snap.batches_ingested, 3);
assert_eq!(snap.entities_ingested, 138);
}
#[test]
fn test_serialize_snapshot_json() {
let t = ProgressTracker::new();
t.begin_run("my-repo");
t.set_stage(IndexingStage::Ingesting);
let snap = t.snapshot();
let val = serde_json::to_value(snap).unwrap();
assert_eq!(val["stage"], "ingesting");
assert!(val["repo_name"].is_string());
assert!(val["total_files"].is_number());
assert!(val["parsed_files"].is_number());
assert!(val["percent_complete"].is_number());
assert!(val["total_entities"].is_number());
assert_eq!(val["error"], serde_json::Value::Null);
}
#[test]
fn test_percent_clamped() {
let t = ProgressTracker::new();
t.set_total_files(100);
t.set_stage(IndexingStage::Parsing);
for _ in 0..200 {
t.incr_parsed_files();
}
let snap = t.snapshot();
assert_eq!(snap.percent_complete, 10.0);
}
#[test]
fn given_new_tracker_when_snapshot_then_total_entities_is_zero_and_unknown() {
let t = ProgressTracker::new();
let snap = t.snapshot();
assert_eq!(snap.total_entities, 0);
}
#[test]
fn given_parse_finished_when_total_entities_recorded_then_snapshot_exposes_it() {
let t = ProgressTracker::new();
t.set_total_entities(83_456);
let snap = t.snapshot();
assert_eq!(snap.total_entities, 83_456);
}
#[test]
fn given_a_populated_tracker_when_begin_run_then_total_entities_is_reset() {
let t = ProgressTracker::new();
t.begin_run("repo1");
t.set_total_entities(500);
t.begin_run("repo2");
let snap = t.snapshot();
assert_eq!(snap.total_entities, 0);
}
#[test]
fn given_zero_value_when_set_total_entities_then_zero_is_recorded_as_a_real_total() {
let t = ProgressTracker::new();
t.set_total_entities(0);
let snap = t.snapshot();
assert_eq!(snap.total_entities, 0);
}
#[test]
fn given_half_the_files_parsed_when_snapshot_then_percent_is_half_the_parse_band() {
let t = ProgressTracker::new();
t.set_total_files(5000);
t.set_stage(IndexingStage::Parsing);
for _ in 0..2500 {
t.incr_parsed_files();
}
let snap = t.snapshot();
assert_eq!(snap.percent_complete, 5.0);
}
#[test]
fn given_all_files_parsed_but_nothing_ingested_when_snapshot_then_percent_stays_in_parse_band()
{
let t = ProgressTracker::new();
t.set_total_files(3713);
t.set_stage(IndexingStage::Parsing);
for _ in 0..3713 {
t.incr_parsed_files();
}
let snap = t.snapshot();
assert_eq!(snap.percent_complete, 10.0);
}
#[test]
fn given_half_the_entities_ingested_when_snapshot_then_percent_is_mid_ingest_band() {
let t = ProgressTracker::new();
t.set_total_files(3713);
for _ in 0..3713 {
t.incr_parsed_files();
}
t.set_total_entities(1000);
t.set_stage(IndexingStage::Ingesting);
t.record_batch_ingested(500);
let snap = t.snapshot();
assert_eq!(snap.percent_complete, 50.0);
}
#[test]
fn given_all_entities_ingested_when_snapshot_then_percent_is_ingest_band_ceiling() {
let t = ProgressTracker::new();
t.set_total_files(3713);
for _ in 0..3713 {
t.incr_parsed_files();
}
t.set_total_entities(1000);
t.set_stage(IndexingStage::Ingesting);
t.record_batch_ingested(1000);
let snap = t.snapshot();
assert_eq!(snap.percent_complete, 90.0);
}
#[test]
fn given_zero_entities_parsed_when_snapshot_then_percent_jumps_to_ingest_band_ceiling() {
let t = ProgressTracker::new();
t.set_total_files(10);
for _ in 0..10 {
t.incr_parsed_files();
}
t.set_total_entities(0);
t.set_stage(IndexingStage::Ingesting);
let snap = t.snapshot();
assert_eq!(snap.percent_complete, 90.0);
}
#[test]
fn given_more_entities_ingested_than_expected_when_snapshot_then_percent_is_clamped() {
let t = ProgressTracker::new();
t.set_total_files(100);
for _ in 0..100 {
t.incr_parsed_files();
}
t.set_total_entities(100);
t.set_stage(IndexingStage::Ingesting);
t.record_batch_ingested(150); let snap = t.snapshot();
assert_eq!(snap.percent_complete, 90.0);
}
#[test]
fn given_resolving_references_stage_when_snapshot_then_percent_is_resolving_constant() {
let t = ProgressTracker::new();
t.set_total_files(3713);
for _ in 0..3713 {
t.incr_parsed_files();
}
t.set_total_entities(83_456);
t.set_stage(IndexingStage::ResolvingReferences);
let snap = t.snapshot();
assert_eq!(snap.percent_complete, 95.0);
}
#[test]
fn given_failed_stage_when_snapshot_then_percent_reflects_work_done_so_far() {
let t = ProgressTracker::new();
t.set_total_files(3713);
for _ in 0..1856 {
t.incr_parsed_files();
}
t.set_total_entities(83_456);
t.set_stage(IndexingStage::Ingesting);
t.record_batch_ingested(20_000);
t.fail("boom");
let snap = t.snapshot();
assert_eq!(snap.stage, IndexingStage::Failed);
assert!(snap.percent_complete > 0.0);
assert!(snap.percent_complete < 100.0);
}
#[test]
fn given_a_full_pipeline_sequence_when_progressing_then_percent_never_decreases() {
let t = ProgressTracker::new();
let mut pct: Vec<f32> = Vec::new();
t.begin_run("opencode");
pct.push(t.snapshot().percent_complete);
t.set_total_files(3713);
pct.push(t.snapshot().percent_complete);
t.set_stage(IndexingStage::Parsing);
let mut parsed = 0u64;
while parsed < 3713 {
for _ in 0..500 {
if parsed < 3713 {
t.incr_parsed_files();
parsed += 1;
}
}
pct.push(t.snapshot().percent_complete);
}
let parse_band_peak = *pct.last().unwrap();
assert!(
parse_band_peak < 90.0,
"parse band must not exceed ingest ceiling, got {parse_band_peak}"
);
let total_entities = 83_456u64;
let batch_size = 128u64;
let batches = total_entities / batch_size; t.set_total_entities(total_entities);
t.set_stage(IndexingStage::Ingesting);
let mut ingested = 0u64;
for _ in 0..batches {
let n = batch_size.min(total_entities - ingested);
t.record_batch_ingested(n);
ingested += n;
pct.push(t.snapshot().percent_complete);
}
t.set_stage(IndexingStage::ResolvingReferences);
pct.push(t.snapshot().percent_complete);
t.complete();
pct.push(t.snapshot().percent_complete);
for w in pct.windows(2) {
assert!(
w[1] >= w[0],
"progress decreased: {} -> {} (full series: {:?})",
w[0],
w[1],
pct
);
}
assert_eq!(pct.last().copied(), Some(100.0));
}
}