use futures::stream::{FuturesUnordered, StreamExt};
use repolith_core::action::Action;
use repolith_core::cache::{Cache, CacheError};
use repolith_core::manifest::Manifest;
use repolith_core::plan::{Plan, PlanError};
use repolith_core::types::{ActionId, BuildEvent, Ctx, ExecMode};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
use thiserror::Error;
use tokio::sync::Semaphore;
use tokio_util::sync::CancellationToken;
pub struct Orchestrator {
actions: Vec<Box<dyn Action>>,
cache: Box<dyn Cache>,
pub manifest: Option<Manifest>,
max_parallelism: usize,
shared_semaphore: Option<Arc<Semaphore>>,
base_ctx: Ctx,
}
#[derive(Debug, Error)]
pub enum ExecError {
#[error("layer failed: {} event(s) recorded", events.len())]
LayerFailed {
events: Vec<BuildEvent>,
},
#[error(transparent)]
Plan(#[from] PlanError),
#[error(transparent)]
Cache(#[from] CacheError),
}
#[derive(Debug, Error)]
pub enum BuilderError {
#[error("orchestrator builder requires a cache")]
MissingCache,
}
pub struct Builder {
actions: Vec<Box<dyn Action>>,
cache: Option<Box<dyn Cache>>,
manifest: Option<Manifest>,
max_parallelism: usize,
shared_semaphore: Option<Arc<Semaphore>>,
base_ctx: Ctx,
}
impl Orchestrator {
#[must_use]
pub fn builder() -> Builder {
Builder {
actions: vec![],
cache: None,
manifest: None,
max_parallelism: num_cpus::get().max(1),
shared_semaphore: None,
base_ctx: Ctx {
cancel: CancellationToken::new(),
workdir: std::env::current_dir().unwrap_or_default(),
env: std::collections::HashMap::new(),
},
}
}
pub async fn compute_plan(&self) -> Result<Plan, PlanError> {
Plan::compute(&self.actions, self.cache.as_ref(), &self.base_ctx).await
}
pub async fn execute_plan(
&mut self,
plan: &Plan,
mode: ExecMode,
) -> Result<Vec<BuildEvent>, ExecError> {
let sem = self
.shared_semaphore
.clone()
.unwrap_or_else(|| Arc::new(Semaphore::new(self.max_parallelism)));
let mut all_events: Vec<BuildEvent> = Vec::new();
for layer in plan.layers() {
if self.base_ctx.cancel.is_cancelled() {
return Err(ExecError::LayerFailed { events: all_events });
}
let stale: Vec<ActionId> = layer
.iter()
.filter(|id| plan.reasons().contains_key(*id))
.cloned()
.collect();
if stale.is_empty() {
continue;
}
let layer_events = self.execute_layer(&stale, plan, mode, &sem).await;
self.cache.record_batch(layer_events.clone()).await?;
all_events.extend(layer_events.iter().cloned());
if layer_events
.iter()
.any(|e| matches!(e, BuildEvent::Failed { .. }))
{
return Err(ExecError::LayerFailed { events: all_events });
}
}
Ok(all_events)
}
async fn execute_layer(
&self,
stale_ids: &[ActionId],
plan: &Plan,
mode: ExecMode,
sem: &Arc<Semaphore>,
) -> Vec<BuildEvent> {
let cancel = self.base_ctx.cancel.child_token();
let layer_ctx = Ctx {
cancel: cancel.clone(),
workdir: self.base_ctx.workdir.clone(),
env: self.base_ctx.env.clone(),
};
let by_id: HashMap<ActionId, &Box<dyn Action>> =
self.actions.iter().map(|a| (a.id(), a)).collect();
let mut pending: FuturesUnordered<_> = stale_ids
.iter()
.filter_map(|id| by_id.get(id).map(|a| (id.clone(), *a)))
.map(|(id, action)| {
let sem = Arc::clone(sem);
let layer_ctx = layer_ctx.clone();
let input_hash = plan
.input_hash(&id)
.expect("stale id must have been planned with an input_hash");
let coordinator = action.is_coordinator();
async move {
let _permit = if coordinator {
None
} else {
Some(sem.acquire().await.expect("semaphore never closed"))
};
let started = Instant::now();
let result = action.execute(&layer_ctx).await;
(id, started.elapsed(), input_hash, result)
}
})
.collect();
let mut events: Vec<BuildEvent> = Vec::new();
while let Some((id, dur, input_hash, result)) = pending.next().await {
let ms = u64::try_from(dur.as_millis()).unwrap_or(u64::MAX);
match result {
Ok(out) => events.push(BuildEvent::Success {
id,
input: input_hash,
output: out.output_hash,
ms,
}),
Err(e) => {
events.push(BuildEvent::Failed {
id,
input: input_hash,
error: e,
ms,
});
if mode == ExecMode::FailFast {
cancel.cancel();
}
}
}
}
events
}
}
impl Builder {
#[must_use]
pub fn cache<C: Cache + 'static>(mut self, c: C) -> Self {
self.cache = Some(Box::new(c));
self
}
#[must_use]
pub fn cache_boxed(mut self, c: Box<dyn Cache>) -> Self {
self.cache = Some(c);
self
}
#[must_use]
pub fn manifest(mut self, m: Manifest) -> Self {
self.manifest = Some(m);
self
}
#[must_use]
pub fn max_parallelism(mut self, n: usize) -> Self {
self.max_parallelism = n.max(1);
self
}
#[must_use]
pub fn shared_semaphore(mut self, sem: Arc<Semaphore>) -> Self {
self.shared_semaphore = Some(sem);
self
}
#[must_use]
pub fn base_ctx(mut self, ctx: Ctx) -> Self {
self.base_ctx = ctx;
self
}
#[must_use]
pub fn register<A: Action + 'static>(mut self, a: A) -> Self {
self.actions.push(Box::new(a));
self
}
#[must_use]
pub fn register_boxed(mut self, a: Box<dyn Action>) -> Self {
self.actions.push(a);
self
}
pub fn build(self) -> Result<Orchestrator, BuilderError> {
let cache = self.cache.ok_or(BuilderError::MissingCache)?;
Ok(Orchestrator {
actions: self.actions,
cache,
manifest: self.manifest,
max_parallelism: self.max_parallelism,
shared_semaphore: self.shared_semaphore,
base_ctx: self.base_ctx,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use repolith_core::cache::Result as CacheResult;
struct StubCache;
#[async_trait::async_trait]
impl Cache for StubCache {
async fn last_build(&self, _id: &ActionId) -> Option<BuildEvent> {
None
}
async fn record(&mut self, _event: BuildEvent) -> CacheResult<()> {
Ok(())
}
}
#[test]
fn builder_default_env_is_empty() {
let orch = Orchestrator::builder()
.cache(StubCache)
.build()
.expect("build");
assert!(
orch.base_ctx.env.is_empty(),
"default builder env must be empty, got keys: {:?}",
orch.base_ctx.env.keys().collect::<Vec<_>>()
);
}
}