use anyhow::{Context, Result};
use indicatif::ProgressBar;
use parking_lot::{Condvar, Mutex};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
use std::time::Instant;
use crate::color;
use crate::errors;
use crate::graph::{BuildGraph, Product};
use crate::json_output;
use crate::object_store::ObjectStore;
use crate::progress;
use crate::stats::{BuildStats, ProductTiming};
use super::{
Classification, Executor, HandlerContext, LevelWork, PreCheckResult, RestoreOutcome,
SharedState, WorkItem,
};
fn effective_max_jobs(name: &str, proc: &dyn crate::processors::Processor) -> Option<usize> {
let plugin = crate::registries::processor::find_plugin(name);
let user_set = proc.scan_config().max_jobs;
let static_cap = plugin.and_then(|p| p.max_jobs_cap);
match (user_set, static_cap) {
(Some(c), Some(m)) => Some(c.min(m)),
(Some(c), None) => Some(c),
(None, Some(m)) => Some(m),
(None, None) => None,
}
}
fn effective_supports_batch(name: &str, proc: &dyn crate::processors::Processor) -> bool {
let plugin_ok =
crate::registries::processor::find_plugin(name).is_some_and(|p| p.supports_batch);
plugin_ok && proc.scan_config().batch
}
fn batch_chunk_size(batch_size: usize, n_items: usize) -> usize {
if batch_size == 0 {
n_items.max(1)
} else {
batch_size
}
}
const fn should_batch(batching_enabled: bool, supports_batch: bool, rebuild_count: usize) -> bool {
batching_enabled && supports_batch && rebuild_count > 1
}
struct Semaphore {
state: Mutex<usize>,
condvar: Condvar,
max_permits: usize,
}
impl Semaphore {
const fn new(max_permits: usize) -> Self {
Self {
state: Mutex::new(0),
condvar: Condvar::new(),
max_permits,
}
}
fn acquire(&self) {
let mut active = self.state.lock();
while *active >= self.max_permits {
self.condvar.wait(&mut active);
}
*active += 1;
}
fn release(&self) {
let mut active = self.state.lock();
*active -= 1;
drop(active);
self.condvar.notify_one();
}
}
pub(super) fn remove_stale_outputs(
product: &Product,
object_store: &ObjectStore,
input_checksum: &str,
) -> anyhow::Result<()> {
if !product.output_dirs.is_empty() {
let cache_key = product.descriptor_key(input_checksum);
let mut stale: BTreeSet<PathBuf> = object_store
.previous_tree_paths(&cache_key)
.into_iter()
.collect();
match object_store.last_tree_key(&product.owner_key()) {
Some(last_key) => stale.extend(object_store.previous_tree_paths(&last_key)),
None => stale.extend(restored_hardlinks_under(&product.output_dirs)),
}
for file in stale {
if file.exists() {
fs::remove_file(&file).with_context(|| {
format!("Failed to remove stale output: {}", file.display())
})?;
}
}
for output_dir in &product.output_dirs {
fs::create_dir_all(output_dir.as_ref()).with_context(|| {
format!(
"Failed to create output directory: {}",
output_dir.display()
)
})?;
}
}
for output in &product.outputs {
if output.exists() {
fs::remove_file(output)
.with_context(|| format!("Failed to remove stale output: {}", output.display()))?;
}
}
Ok(())
}
fn restored_hardlinks_under(dirs: &[Arc<PathBuf>]) -> Vec<PathBuf> {
use std::os::unix::fs::MetadataExt;
dirs.iter()
.flat_map(|dir| crate::object_store::walk_files(dir))
.filter(|file| {
fs::metadata(file).is_ok_and(|m| m.permissions().readonly() && m.nlink() > 1)
})
.collect()
}
struct ProgressCounters {
total_per_processor: Arc<HashMap<String, usize>>,
current_per_processor: Arc<Mutex<HashMap<String, usize>>>,
}
struct LevelContext<'b> {
graph: &'b BuildGraph,
object_store: &'b ObjectStore,
force: bool,
keep_going: bool,
timings: bool,
shared: &'b SharedState,
pb: &'b ProgressBar,
build_start: Instant,
}
impl Executor<'_> {
pub fn execute(
&self,
graph: &BuildGraph,
object_store: &ObjectStore,
force: bool,
timings: bool,
keep_going: bool,
classification: &Classification,
) -> Result<BuildStats> {
let build_start = Instant::now();
let order = graph.topological_sort()?;
json_output::emit_build_start(order.len());
let result = self.execute_parallel(
graph,
&order,
object_store,
force,
timings,
keep_going,
classification,
);
match result {
Ok(mut stats) => {
stats.total_duration = build_start.elapsed();
json_output::emit_build_summary(
stats.total_processed()
+ stats.total_skipped()
+ stats.total_restored()
+ stats.failed_count,
stats.total_processed(),
stats.failed_count,
stats.total_skipped(),
stats.total_restored(),
stats.total_duration,
&stats.failed_messages,
);
Ok(stats)
}
Err(e) => {
let duration = build_start.elapsed();
json_output::emit_build_summary(
order.len(),
0,
1,
0,
0,
duration,
&[e.to_string()],
);
Err(e)
}
}
}
#[allow(clippy::too_many_arguments)]
fn execute_parallel(
&self,
graph: &BuildGraph,
order: &[usize],
object_store: &ObjectStore,
force: bool,
timings: bool,
keep_going: bool,
classification: &Classification,
) -> Result<BuildStats> {
let build_start = Instant::now();
let levels = super::compute_parallel_levels(graph, order);
let mut total_per_processor: HashMap<String, usize> = HashMap::new();
for &product_id in order {
let product = graph
.get_product(product_id)
.expect(errors::INVALID_PRODUCT_ID);
*total_per_processor
.entry(product.processor.clone())
.or_insert(0) += 1;
}
let counters = ProgressCounters {
total_per_processor: Arc::new(total_per_processor),
current_per_processor: Arc::new(Mutex::new(HashMap::new())),
};
let global_total = order.len();
let work_count = classification.restore_count + classification.build_count;
let pb = progress::create_bar(
work_count as u64,
self.verbose || json_output::is_json_mode() || crate::runtime_flags::quiet(),
);
let shared = SharedState {
stats: Arc::new(Mutex::new(HashMap::new())),
errors: Arc::new(Mutex::new(Vec::new())),
failed_products: Arc::new(Mutex::new(HashSet::new())),
failed_messages: Arc::new(Mutex::new(Vec::new())),
failed_processors: Arc::new(Mutex::new(HashSet::new())),
global_current: Arc::new(AtomicUsize::new(0)),
global_total,
};
let semaphores: HashMap<String, Arc<Semaphore>> = self
.processors
.iter()
.filter_map(|(name, proc)| {
effective_max_jobs(name, proc.as_ref())
.map(|max| (name.clone(), Arc::new(Semaphore::new(max))))
})
.collect();
for level in levels {
if self.is_interrupted() {
break;
}
let LevelWork {
batch_groups,
non_batch_items,
} = self.prepare_level_work(graph, &level, object_store, force, keep_going, &shared);
let lctx = LevelContext {
graph,
object_store,
force,
keep_going,
timings,
shared: &shared,
pb: &pb,
build_start,
};
thread::scope(|s| {
let lctx_ref = &lctx;
let semaphores_ref = &semaphores;
for (proc_name, items) in &batch_groups {
s.spawn(move || {
self.process_batch_group(proc_name, items, lctx_ref, semaphores_ref);
});
}
if !non_batch_items.is_empty() {
let chunk_size = non_batch_items.len().div_ceil(self.parallel.max(1));
for chunk in non_batch_items.chunks(chunk_size.max(1)) {
let total_ref = Arc::clone(&counters.total_per_processor);
let current_ref = Arc::clone(&counters.current_per_processor);
s.spawn(move || {
self.process_non_batch_chunk(
chunk,
lctx_ref,
&total_ref,
¤t_ref,
semaphores_ref,
);
});
}
}
});
if self.is_interrupted() {
crate::output::info(&color::yellow("Interrupted, saving progress..."));
break;
}
if !keep_going && !shared.errors.lock().is_empty() {
break;
}
}
pb.finish_and_clear();
Self::collect_build_stats(shared, keep_going, self.is_interrupted())
}
fn try_skip_or_restore(
&self,
item: &WorkItem,
proc_name: &str,
lctx: &LevelContext,
emit_fail_event: bool,
) -> PreCheckResult {
let product = lctx
.graph
.get_product(item.product_id)
.expect(errors::INVALID_PRODUCT_ID);
if self.explain {
let action = self.policy.explain(
self.build_ctx,
product,
lctx.object_store,
&item.input_checksum,
lctx.force,
);
self.print_explain(product, &action);
}
if !item.needs_rebuild {
self.handle_skip(product, lctx.shared);
return PreCheckResult::Handled;
}
let ctx = HandlerContext {
product,
id: item.product_id,
input_checksum: &item.input_checksum,
proc_name,
keep_going: lctx.keep_going,
shared: lctx.shared,
pb: lctx.pb,
};
match self.handle_restore(&ctx, lctx.object_store, lctx.force, emit_fail_event) {
RestoreOutcome::Restored | RestoreOutcome::Failed => PreCheckResult::Handled,
RestoreOutcome::NotRestorable => PreCheckResult::NeedsExecution,
}
}
fn process_batch_group(
&self,
proc_name: &str,
items: &[WorkItem],
lctx: &LevelContext,
semaphores: &HashMap<String, Arc<Semaphore>>,
) {
if self.is_interrupted() {
return;
}
let Some(processor) = self.processors.get(proc_name) else {
return;
};
let mut to_execute: Vec<&WorkItem> = Vec::new();
for item in items {
match self.try_skip_or_restore(item, proc_name, lctx, true) {
PreCheckResult::Handled => {}
PreCheckResult::NeedsExecution => to_execute.push(item),
}
}
if to_execute.is_empty() || self.is_interrupted() {
return;
}
let proc_total = items.len();
let mut proc_current = items.len() - to_execute.len();
let chunk_size = batch_chunk_size(
self.batch_size
.expect("batch groups only form when batching is enabled"),
to_execute.len(),
);
for chunk in to_execute.chunks(chunk_size) {
if self.is_interrupted() || (!lctx.keep_going && !lctx.shared.errors.lock().is_empty())
{
break;
}
let product_refs: Vec<&crate::graph::Product> = chunk
.iter()
.map(|item| {
lctx.graph
.get_product(item.product_id)
.expect(errors::INVALID_PRODUCT_ID)
})
.collect();
proc_current += chunk.len();
if self.verbose {
let display = product_refs
.iter()
.map(|p| self.product_display(p))
.collect::<Vec<_>>()
.join(", ");
let gc = lctx.shared.global_current.load(Ordering::SeqCst);
crate::output::info(&format!(
"[{}] ({}/{}) ({}/{}) {} {} files: {}",
proc_name,
gc + 1,
lctx.shared.global_total,
proc_current,
proc_total,
color::green("Processing batch:"),
product_refs.len(),
display
));
} else {
lctx.pb.set_message(format!(
"[{}] batch {} files",
proc_name,
product_refs.len()
));
}
for p in &product_refs {
json_output::emit_product_start(&self.product_display(p), &p.processor);
}
let semaphore = semaphores.get(proc_name);
if let Some(sem) = semaphore {
sem.acquire();
}
let batch_start = Instant::now();
let max_attempts = 1 + self.retry;
let mut final_results: Vec<Option<anyhow::Result<()>>> =
(0..chunk.len()).map(|_| None).collect();
let mut pending: Vec<usize> = (0..chunk.len()).collect();
crate::processors::set_declared_tools(Some(processor.required_tools()));
for attempt in 1..=max_attempts {
let refs: Vec<&crate::graph::Product> =
pending.iter().map(|&i| product_refs[i]).collect();
let results = processor.execute_batch(self.build_ctx, &refs);
assert_eq!(
results.len(),
refs.len(),
"execute_batch returned {} results for {} products (processor: {})",
results.len(),
refs.len(),
proc_name
);
let mut still_failing: Vec<usize> = Vec::new();
for (&idx, result) in pending.iter().zip(results) {
if result.is_err() && attempt < max_attempts {
crate::output::info(&format!(
"[{}] {} {} (attempt {}/{}, retrying...)",
proc_name,
color::yellow("Retry:"),
self.product_display(product_refs[idx]),
attempt,
max_attempts
));
still_failing.push(idx);
} else {
if result.is_ok() && attempt > 1 {
let mut stats = lctx.shared.stats.lock();
stats.entry(proc_name.to_string()).or_default().flaky += 1;
crate::output::info(&format!(
"[{}] {} {} (passed on attempt {})",
proc_name,
color::yellow("FLAKY:"),
self.product_display(product_refs[idx]),
attempt
));
}
final_results[idx] = Some(result);
}
}
pending = still_failing;
if pending.is_empty() {
break;
}
}
crate::processors::set_declared_tools(None);
let batch_duration = batch_start.elapsed();
if let Some(sem) = semaphore {
sem.release();
}
for (item, result) in chunk.iter().zip(final_results) {
let result =
result.expect("the retry loop records a final result for every product");
let product = lctx
.graph
.get_product(item.product_id)
.expect(errors::INVALID_PRODUCT_ID);
let ctx = HandlerContext {
product,
id: item.product_id,
input_checksum: &item.input_checksum,
proc_name,
keep_going: lctx.keep_going,
shared: lctx.shared,
pb: lctx.pb,
};
match result {
Ok(()) => {
self.handle_success(&ctx, lctx.object_store, lctx.graph, None);
}
Err(e) => {
self.handle_error(&ctx, e, None);
}
}
Self::inc_progress(lctx.pb, lctx.shared);
}
if lctx.timings {
let timing = ProductTiming {
display: format!("batch ({} files)", product_refs.len()),
processor: proc_name.to_string(),
duration: batch_duration,
start_offset: Some(batch_start.duration_since(lctx.build_start)),
};
let mut stats = lctx.shared.stats.lock();
let proc_stats = stats.entry(proc_name.to_string()).or_default();
proc_stats.duration += batch_duration;
proc_stats.product_timings.push(timing);
}
}
}
fn process_non_batch_chunk(
&self,
chunk: &[WorkItem],
lctx: &LevelContext,
total_per_processor: &HashMap<String, usize>,
current_per_processor: &Mutex<HashMap<String, usize>>,
semaphores: &HashMap<String, Arc<Semaphore>>,
) {
for item in chunk {
if self.is_interrupted() || (!lctx.keep_going && !lctx.shared.errors.lock().is_empty())
{
break;
}
let product = lctx
.graph
.get_product(item.product_id)
.expect(errors::INVALID_PRODUCT_ID);
if matches!(
self.try_skip_or_restore(item, &product.processor, lctx, true),
PreCheckResult::Handled
) {
continue;
}
let semaphore = semaphores.get(&product.processor);
if let Some(sem) = semaphore {
sem.acquire();
}
if let Some(processor) = self.processors.get(&product.processor) {
let ctx = HandlerContext {
product,
id: item.product_id,
input_checksum: &item.input_checksum,
proc_name: &product.processor,
keep_going: lctx.keep_going,
shared: lctx.shared,
pb: lctx.pb,
};
let current = {
let mut current_guard = current_per_processor.lock();
let c = current_guard.entry(product.processor.clone()).or_insert(0);
*c += 1;
*c
};
let total = total_per_processor
.get(&product.processor)
.copied()
.expect(errors::PROCESSOR_NOT_IN_TOTALS);
if self.verbose {
let variant_tag = product
.variant
.as_ref()
.map(|v| format!(":{v}"))
.unwrap_or_default();
let gc = lctx.shared.global_current.load(Ordering::SeqCst) + 1;
crate::output::info(&format!(
"[{}{}] ({}/{}) ({}/{}) {} {}",
product.processor,
variant_tag,
gc,
lctx.shared.global_total,
current,
total,
color::green("Processing:"),
self.product_display(product)
));
} else {
let variant_tag = product
.variant
.as_ref()
.map(|v| format!(":{v}"))
.unwrap_or_default();
lctx.pb.set_message(format!(
"[{}{}] {}",
product.processor,
variant_tag,
self.product_display(product)
));
}
json_output::emit_product_start(&self.product_display(product), &product.processor);
let product_start = Instant::now();
let mut last_error = None;
let max_attempts = 1 + self.retry;
crate::processors::set_declared_tools(Some(processor.required_tools()));
for attempt in 1..=max_attempts {
match processor.execute(self.build_ctx, product) {
Ok(()) => {
let duration = product_start.elapsed();
last_error = None;
if !self.handle_success(
&ctx,
lctx.object_store,
lctx.graph,
Some(duration),
) {
break;
}
if attempt > 1 {
let mut stats = lctx.shared.stats.lock();
let proc_stats =
stats.entry(product.processor.clone()).or_default();
proc_stats.flaky += 1;
crate::output::info(&format!(
"[{}] {} {} (passed on attempt {})",
product.processor,
color::yellow("FLAKY:"),
self.product_display(product),
attempt
));
}
{
let mut stats = lctx.shared.stats.lock();
let proc_stats =
stats.entry(product.processor.clone()).or_default();
proc_stats.duration += duration;
if lctx.timings {
proc_stats.product_timings.push(ProductTiming {
display: self.product_display(product),
processor: product.processor.clone(),
duration,
start_offset: Some(
product_start.duration_since(lctx.build_start),
),
});
}
}
break;
}
Err(e) => {
if attempt < max_attempts {
crate::output::info(&format!(
"[{}] {} {} (attempt {}/{}, retrying...)",
product.processor,
color::yellow("Retry:"),
self.product_display(product),
attempt,
max_attempts
));
last_error = Some(e);
} else {
let duration = product_start.elapsed();
self.handle_error(&ctx, e, Some(duration));
last_error = None; }
}
}
}
if let Some(e) = last_error {
let duration = product_start.elapsed();
self.handle_error(&ctx, e, Some(duration));
}
crate::processors::set_declared_tools(None);
Self::inc_progress(lctx.pb, lctx.shared);
}
if let Some(sem) = semaphore {
sem.release();
}
}
}
fn collect_build_stats(
shared: SharedState,
keep_going: bool,
interrupted: bool,
) -> Result<BuildStats> {
let final_stats = Arc::try_unwrap(shared.stats)
.map_err(|_| anyhow::anyhow!("internal error: outstanding Arc reference to stats"))?
.into_inner();
let mut stats = BuildStats::default();
for (_, proc_stats) in final_stats {
stats.add(proc_stats);
}
let final_failed = Arc::try_unwrap(shared.failed_products)
.map_err(|_| {
anyhow::anyhow!("internal error: outstanding Arc reference to failed products")
})?
.into_inner();
let final_msgs = Arc::try_unwrap(shared.failed_messages)
.map_err(|_| {
anyhow::anyhow!("internal error: outstanding Arc reference to failed messages")
})?
.into_inner();
stats.failed_count = final_failed.len();
stats.failed_messages = final_msgs;
if !keep_going && !interrupted {
let errs = Arc::try_unwrap(shared.errors)
.map_err(|_| {
anyhow::anyhow!("internal error: outstanding Arc reference to errors")
})?
.into_inner();
if let Some(first_err) = errs.into_iter().next() {
return Err(first_err);
}
}
Ok(stats)
}
pub(super) fn prepare_level_work(
&self,
graph: &BuildGraph,
level: &[usize],
object_store: &ObjectStore,
force: bool,
keep_going: bool,
shared: &SharedState,
) -> LevelWork {
let mut work_items: Vec<WorkItem> = Vec::new();
let mut skipped_ids: HashSet<usize> = HashSet::new();
{
let failed_guard = shared.failed_products.lock();
for &id in level {
if super::has_failed_dependency(graph, id, &failed_guard) {
let product = graph.get_product(id).expect(errors::INVALID_PRODUCT_ID);
crate::output::detail(
self.verbose,
&format!(
"[{}] {} {}",
product.processor,
color::yellow("Skipping (dependency failed):"),
self.product_display(product)
),
);
skipped_ids.insert(id);
}
}
}
if !skipped_ids.is_empty() {
let mut failed_guard = shared.failed_products.lock();
for id in &skipped_ids {
failed_guard.insert(*id);
}
}
{
let fp_guard = shared.failed_processors.lock();
for &id in level {
if skipped_ids.contains(&id) {
continue;
}
let product = graph.get_product(id).expect(errors::INVALID_PRODUCT_ID);
if !keep_going && fp_guard.contains(&product.processor) {
shared.failed_products.lock().insert(id);
continue;
}
let input_checksum =
match crate::checksum::combined_input_checksum(self.build_ctx, &product.inputs)
{
Ok(cs) => cs,
Err(e) => {
if keep_going {
let msg = format!(
"[{}] {}: {}",
product.processor,
self.product_display(product),
e
);
crate::output::error(&msg);
shared.failed_products.lock().insert(id);
shared.failed_messages.lock().push(msg);
} else {
shared.failed_products.lock().insert(id);
shared.errors.lock().push(e);
}
continue;
}
};
let desc_key = product.descriptor_key(&input_checksum);
let needs_rebuild = force
|| object_store.needs_rebuild_descriptor(
self.build_ctx,
&desc_key,
&product.outputs,
);
work_items.push(WorkItem {
product_id: id,
input_checksum,
needs_rebuild,
});
}
}
let mut batch_groups: HashMap<String, Vec<WorkItem>> = HashMap::new();
let mut non_batch_items: Vec<WorkItem> = Vec::new();
let mut by_processor: HashMap<String, Vec<WorkItem>> = HashMap::new();
for item in work_items {
let product = graph
.get_product(item.product_id)
.expect(errors::INVALID_PRODUCT_ID);
by_processor
.entry(product.processor.clone())
.or_default()
.push(item);
}
let batching_enabled = self.batch_size.is_some();
for (proc_name, items) in by_processor {
let processor = self.processors.get(&proc_name);
let supports_batch =
processor.is_some_and(|p| effective_supports_batch(&proc_name, p.as_ref()));
let rebuild_count = items.iter().filter(|item| item.needs_rebuild).count();
if should_batch(batching_enabled, supports_batch, rebuild_count) {
batch_groups.insert(proc_name, items);
} else {
non_batch_items.extend(items);
}
}
LevelWork {
batch_groups,
non_batch_items,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn batch_chunk_size_decision_table() {
assert_eq!(batch_chunk_size(0, 7), 7, "0 means no limit");
assert_eq!(batch_chunk_size(3, 7), 3, "explicit size wins");
assert_eq!(
batch_chunk_size(3, 2),
3,
"size larger than group is harmless"
);
}
#[test]
fn batch_chunk_size_never_zero() {
assert_eq!(batch_chunk_size(0, 0), 1);
}
#[test]
fn should_batch_requires_all_conditions() {
assert!(should_batch(true, true, 2));
assert!(
!should_batch(true, true, 1),
"one rebuilding item is not a batch"
);
assert!(!should_batch(true, true, 0));
assert!(
!should_batch(true, false, 5),
"processor must support batching"
);
assert!(
!should_batch(false, true, 5),
"batch_size None disables batching entirely"
);
}
#[test]
fn restored_hardlinks_are_read_only_and_multiply_linked() {
let tmp = tempfile::TempDir::new().unwrap();
let out = tmp.path().join("out");
fs::create_dir_all(&out).unwrap();
let object = tmp.path().join("object");
fs::write(&object, b"cached").unwrap();
let mut perms = fs::metadata(&object).unwrap().permissions();
perms.set_readonly(true);
fs::set_permissions(&object, perms).unwrap();
let restored = out.join("restored.txt");
fs::hard_link(&object, &restored).unwrap();
let written = out.join("written.txt");
fs::write(&written, b"tool output").unwrap();
let read_only_copy = out.join("readonly.txt");
fs::write(&read_only_copy, b"copy").unwrap();
let mut perms = fs::metadata(&read_only_copy).unwrap().permissions();
perms.set_readonly(true);
fs::set_permissions(&read_only_copy, perms).unwrap();
let found = restored_hardlinks_under(&[Arc::new(out)]);
assert_eq!(found, vec![restored]);
}
}