use anyhow::{Context, Result};
use indicatif::ProgressBar;
use parking_lot::{Condvar, Mutex, RwLock};
use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, mpsc};
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, GraphRefresh, HandlerContext, LevelWork, PreCheckResult,
ProductAction, ReadyTracker, RestoreOutcome, SharedState, WorkItem,
};
fn effective_max_jobs(name: &str, proc: &dyn crate::processor::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::processor::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
}
enum Unit {
Single(WorkItem),
Batch {
processor: String,
items: Vec<WorkItem>,
},
}
impl Unit {
fn processor(&self) -> &str {
match self {
Self::Single(item) => &item.product.processor,
Self::Batch { processor, .. } => processor,
}
}
fn product_ids(&self) -> Vec<usize> {
match self {
Self::Single(item) => vec![item.product_id],
Self::Batch { items, .. } => items.iter().map(|i| i.product_id).collect(),
}
}
}
struct QueueState {
units: VecDeque<Unit>,
running: HashMap<String, usize>,
closed: bool,
}
struct WorkQueue {
state: Mutex<QueueState>,
changed: Condvar,
}
impl WorkQueue {
fn new() -> Self {
Self {
state: Mutex::new(QueueState {
units: VecDeque::new(),
running: HashMap::new(),
closed: false,
}),
changed: Condvar::new(),
}
}
fn push(&self, units: Vec<Unit>) {
if units.is_empty() {
return;
}
self.state.lock().units.extend(units);
self.changed.notify_all();
}
fn pop(&self, caps: &HashMap<String, usize>) -> Option<Unit> {
let mut state = self.state.lock();
loop {
if state.closed {
return None;
}
let runnable = state.units.iter().position(|unit| {
caps.get(unit.processor()).is_none_or(|&cap| {
state.running.get(unit.processor()).copied().unwrap_or(0) < cap
})
});
if let Some(index) = runnable {
let unit = state.units.remove(index).expect("index from position");
*state
.running
.entry(unit.processor().to_string())
.or_insert(0) += 1;
return Some(unit);
}
self.changed.wait(&mut state);
}
}
fn done(&self, processor: &str) {
let mut state = self.state.lock();
if let Some(n) = state.running.get_mut(processor) {
*n -= 1;
}
drop(state);
self.changed.notify_all();
}
fn close(&self) {
self.state.lock().closed = true;
self.changed.notify_all();
}
}
struct FinishOnDrop<'q> {
ids: Vec<usize>,
processor: String,
queue: &'q WorkQueue,
tx: mpsc::Sender<Vec<usize>>,
}
impl Drop for FinishOnDrop<'_> {
fn drop(&mut self) {
self.queue.done(&self.processor);
let _ = self.tx.send(std::mem::take(&mut self.ids));
}
}
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 RwLock<BuildGraph>,
object_store: &'b ObjectStore,
keep_going: bool,
timings: bool,
shared: &'b SharedState,
pb: &'b ProgressBar,
build_start: Instant,
predicted: &'b HashMap<usize, ProductAction>,
}
impl Executor<'_> {
pub fn execute(
&self,
graph: &mut BuildGraph,
object_store: &ObjectStore,
classification: &Classification,
refresh: Option<&mut GraphRefresh<'_>>,
) -> Result<BuildStats> {
let build_start = Instant::now();
let order = graph.topological_sort()?;
self.build_ctx.set_declared_outputs(
graph
.products()
.iter()
.flat_map(|p| p.outputs.iter().cloned())
.collect(),
);
json_output::emit_build_start(order.len());
let result = self.execute_parallel(graph, &order, object_store, classification, refresh);
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)
}
}
}
fn execute_parallel(
&self,
graph: &mut BuildGraph,
order: &[usize],
object_store: &ObjectStore,
classification: &Classification,
refresh: Option<&mut GraphRefresh<'_>>,
) -> Result<BuildStats> {
let build_start = Instant::now();
let keep_going = self.keep_going;
let predicted: HashMap<usize, ProductAction> = classification
.products
.iter()
.map(|c| (c.id, c.action))
.collect();
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())),
changed: Arc::new(Mutex::new(Vec::new())),
attempted: Arc::new(Mutex::new(HashSet::new())),
global_current: Arc::new(AtomicUsize::new(0)),
global_total,
};
let caps: HashMap<String, usize> = self
.processors
.iter()
.filter_map(|(name, proc)| {
effective_max_jobs(name, proc.as_ref()).map(|max| (name.clone(), max))
})
.collect();
let graph_lock = RwLock::new(std::mem::take(graph));
let queue = WorkQueue::new();
let (tx, rx) = mpsc::channel::<Vec<usize>>();
let lctx = LevelContext {
graph: &graph_lock,
object_store,
keep_going,
timings: self.timings,
shared: &shared,
pb: &pb,
build_start,
predicted: &predicted,
};
let result = thread::scope(|s| -> Result<()> {
let tx = tx;
for _ in 0..self.parallel.max(1) {
let tx = tx.clone();
let (queue, caps, lctx, counters) = (&queue, &caps, &lctx, &counters);
s.spawn(move || {
while let Some(unit) = queue.pop(caps) {
let _finished = FinishOnDrop {
ids: unit.product_ids(),
processor: unit.processor().to_string(),
queue,
tx: tx.clone(),
};
self.run_unit(unit, lctx, counters);
}
});
}
drop(tx);
let outcome = self.coordinate(&graph_lock, &queue, &rx, object_store, &shared, refresh);
queue.close();
outcome
});
*graph = graph_lock.into_inner();
pb.finish_and_clear();
if self.is_interrupted() {
crate::output::info(&color::yellow("Interrupted, saving progress..."));
}
result?;
Self::remove_outputs_never_rebuilt(graph, object_store, classification, &shared)?;
Self::collect_build_stats(shared, keep_going, self.is_interrupted())
}
fn remove_outputs_never_rebuilt(
graph: &BuildGraph,
object_store: &ObjectStore,
classification: &Classification,
shared: &SharedState,
) -> Result<()> {
let attempted = shared.attempted.lock();
for c in &classification.products {
if c.action == ProductAction::Skip || attempted.contains(&c.id) {
continue;
}
let product = graph.get_product(c.id).expect(errors::INVALID_PRODUCT_ID);
remove_stale_outputs(product, object_store, &c.input_checksum)?;
}
Ok(())
}
fn coordinate(
&self,
graph: &RwLock<BuildGraph>,
queue: &WorkQueue,
finished: &mpsc::Receiver<Vec<usize>>,
object_store: &ObjectStore,
shared: &SharedState,
mut refresh: Option<&mut GraphRefresh<'_>>,
) -> Result<()> {
let held: HashSet<String> = if self.batch_size.is_some() {
self.processors
.iter()
.filter(|(name, processor)| {
crate::registries::processor::processor_type_of(name)
== crate::processor::ProcessorType::Checker
&& effective_supports_batch(name, processor.as_ref())
})
.map(|(name, _)| name.clone())
.collect()
} else {
HashSet::new()
};
let mut tracker = ReadyTracker::new(&graph.read(), &held);
loop {
let stopping =
self.is_interrupted() || (!self.keep_going && !shared.errors.lock().is_empty());
if !stopping {
let wave = tracker.take_ready();
if !wave.is_empty() {
let work = self.prepare_level_work(&graph.read(), &wave, object_store, shared);
let units = self.units_of(work);
let queued: HashSet<usize> = units.iter().flat_map(Unit::product_ids).collect();
let g = graph.read();
for &id in wave.iter().filter(|id| !queued.contains(id)) {
tracker.finish(&g, id);
}
drop(g);
queue.push(units);
continue;
}
}
if tracker.in_flight == 0 {
return Ok(());
}
let mut done = finished
.recv()
.context("internal error: every worker exited with products in flight")?;
while let Ok(more) = finished.try_recv() {
done.extend(more);
}
let g = graph.read();
for id in done {
tracker.finish(&g, id);
}
drop(g);
let changed = std::mem::take(&mut *shared.changed.lock());
if let Some(refresh) = refresh.as_deref_mut()
&& !changed.is_empty()
{
let mut g = graph.write();
if refresh(&mut g, &changed).context("Failed to re-analyze regenerated sources")? {
g.resolve_dependencies();
g.topological_sort()?;
tracker.recount(&g);
}
}
}
}
fn units_of(&self, work: LevelWork) -> Vec<Unit> {
let LevelWork {
batch_groups,
non_batch_items,
} = work;
let mut units: Vec<Unit> = non_batch_items.into_iter().map(Unit::Single).collect();
let mut groups: Vec<(String, Vec<WorkItem>)> = batch_groups.into_iter().collect();
groups.sort_by(|a, b| a.0.cmp(&b.0));
for (processor, items) in groups {
let (to_run, settled): (Vec<WorkItem>, Vec<WorkItem>) = items
.into_iter()
.partition(|item| item.action == ProductAction::Build);
units.extend(settled.into_iter().map(Unit::Single));
let chunk_size = batch_chunk_size(
self.batch_size
.expect("batch groups only form when batching is enabled"),
to_run.len(),
);
let mut to_run = to_run.into_iter().peekable();
while to_run.peek().is_some() {
let chunk: Vec<WorkItem> = to_run.by_ref().take(chunk_size).collect();
units.push(Unit::Batch {
processor: processor.clone(),
items: chunk,
});
}
}
units
}
fn run_unit(&self, unit: Unit, lctx: &LevelContext, counters: &ProgressCounters) {
match unit {
Unit::Single(item) => self.process_non_batch_chunk(
std::slice::from_ref(&item),
lctx,
&counters.total_per_processor,
&counters.current_per_processor,
),
Unit::Batch { processor, items } => {
self.process_batch_group(&processor, &items, lctx);
}
}
}
fn try_skip_or_restore(
&self,
item: &WorkItem,
proc_name: &str,
lctx: &LevelContext,
emit_fail_event: bool,
) -> PreCheckResult {
let product = &item.product;
lctx.shared.attempted.lock().insert(item.product_id);
if self.explain {
let action = self.policy.explain(
self.build_ctx,
product,
lctx.object_store,
&item.input_checksum,
self.force,
);
self.print_explain(product, &action);
}
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,
};
if item.action == ProductAction::Skip {
self.handle_skip(product, lctx.shared);
if lctx.predicted.get(&item.product_id) != Some(&ProductAction::Skip) {
lctx.pb.inc(1);
}
return PreCheckResult::Handled;
}
let replaces_own = self
.processors
.get(proc_name)
.is_some_and(|p| p.replaces_own_outputs());
if !replaces_own
&& let Err(e) = remove_stale_outputs(product, lctx.object_store, &item.input_checksum)
{
self.handle_error(&ctx, e, None);
Self::inc_progress(lctx.pb, lctx.shared);
return PreCheckResult::Handled;
}
match item.action {
ProductAction::Restore => {
match self.handle_restore(&ctx, lctx.object_store, emit_fail_event) {
RestoreOutcome::Restored | RestoreOutcome::Failed => PreCheckResult::Handled,
RestoreOutcome::NotRestorable => PreCheckResult::NeedsExecution,
}
}
ProductAction::Skip | ProductAction::Build => PreCheckResult::NeedsExecution,
}
}
fn process_batch_group(&self, proc_name: &str, items: &[WorkItem], lctx: &LevelContext) {
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| &item.product).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 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::processor::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::processor::set_declared_tools(None);
let batch_duration = batch_start.elapsed();
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 = &item.product;
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>>,
) {
for item in chunk {
if self.is_interrupted() || (!lctx.keep_going && !lctx.shared.errors.lock().is_empty())
{
break;
}
let product = &item.product;
if matches!(
self.try_skip_or_restore(item, &product.processor, lctx, true),
PreCheckResult::Handled
) {
continue;
}
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::processor::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::processor::set_declared_tools(None);
Self::inc_progress(lctx.pb, lctx.shared);
}
}
}
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,
shared: &SharedState,
) -> LevelWork {
let keep_going = self.keep_going;
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 action = self.policy.classify(
self.build_ctx,
product,
object_store,
&input_checksum,
self.force,
);
work_items.push(WorkItem {
product_id: id,
product: product.clone(),
input_checksum,
action,
});
}
}
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.action != ProductAction::Skip)
.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]);
}
}