use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use aion_core::ClusterEvent;
use aion_store::{WorkerDeployment, WorkerDeploymentListing};
use crate::ServerState;
use crate::worker::supervisor::{Convergence, converge_and_report};
use super::documents;
use super::listener::worker_dial_address;
use super::outcome::{AutoWorkerDecision, AutoWorkerOutcome};
use super::provision::{OPERATION, log_outcome};
use super::queues::HarnessQueue;
use super::record;
pub(super) async fn withdraw_undeclared(
state: &ServerState,
workflow_type: &str,
declared: &[HarnessQueue],
listing: &WorkerDeploymentListing,
) -> Vec<AutoWorkerOutcome> {
let declared: BTreeSet<&str> = declared
.iter()
.map(|queue| queue.task_queue.as_str())
.collect();
let mut outcomes = Vec::new();
for stale in listing.deployments.iter().filter(|deployment| {
record::is_auto_name(&deployment.name)
&& record::staged_workflow_type(&deployment.artifact).as_deref() == Some(workflow_type)
&& !declared.contains(deployment.task_queue.as_str())
}) {
outcomes.push(withdraw(state, workflow_type, stale, WithdrawCause::UndeclaredQueue).await);
}
outcomes
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum WithdrawCause {
UndeclaredQueue,
NoPackage,
}
impl WithdrawCause {
fn sentence(self, workflow_type: &str, task_queue: &str) -> String {
match self {
Self::UndeclaredQueue => format!(
"`{workflow_type}` no longer declares a `harness` section for task queue \
`{task_queue}`"
),
Self::NoPackage => format!(
"no deployed package carries workflow type `{workflow_type}`, which task queue \
`{task_queue}` was auto-provisioned to serve"
),
}
}
}
pub(super) async fn withdraw(
state: &ServerState,
workflow_type: &str,
doomed: &WorkerDeployment,
cause: WithdrawCause,
) -> AutoWorkerOutcome {
let name = doomed.name.as_str();
let task_queue = doomed.task_queue.as_str();
let because = cause.sentence(workflow_type, task_queue);
if let Err(error) = state.worker_supervisor().stop(name).await {
return AutoWorkerOutcome::new(
task_queue,
workflow_type,
AutoWorkerDecision::Failed,
Some(name.to_owned()),
format!(
"{because}, but worker deployment `{name}` could not be stopped, so it was NOT \
withdrawn: {error}"
),
);
}
match state
.worker_deployment_store()
.delete_worker_deployment(name)
.await
{
Ok(_) => {
let event_name = name.to_owned();
drop(
state
.cluster_publisher()
.emit(|meta| ClusterEvent::WorkerDeploymentDeleted {
meta,
name: event_name,
}),
);
if let Err(error) = state.worker_supervisor().forget(name).await {
tracing::error!(
operation = OPERATION,
worker = name,
%error,
"a withdrawn auto worker deployment could not be dropped out of supervision"
);
}
AutoWorkerOutcome::new(
task_queue,
workflow_type,
AutoWorkerDecision::Retired,
Some(name.to_owned()),
format!(
"{because}, so worker deployment `{name}` was stopped and withdrawn. Nothing \
serves that queue now"
),
)
}
Err(error) => AutoWorkerOutcome::new(
task_queue,
workflow_type,
AutoWorkerDecision::Failed,
Some(name.to_owned()),
format!(
"{because}; worker deployment `{name}` was stopped but its record could not be \
removed: {error}"
),
),
}
}
pub(super) async fn prune_snapshots(state: &ServerState, root: &Path) {
let listing = match state
.worker_deployment_store()
.list_worker_deployments()
.await
{
Ok(listing) => listing,
Err(error) => {
tracing::warn!(
operation = OPERATION,
%error,
"the worker deployments could not be listed, so superseded staged documents were \
kept rather than risk removing one a record still replays"
);
return;
}
};
let Some(live) = live_documents(&listing) else {
for poisoned in &listing.undecodable {
tracing::warn!(
operation = OPERATION,
record = %poisoned.name,
error = %poisoned.error,
"a worker deployment record could not be decoded, so superseded staged documents \
were kept rather than risk removing the one that record still replays"
);
}
return;
};
match documents::prune(root, &live) {
Ok(removed) if removed.is_empty() => {}
Ok(removed) => tracing::info!(
operation = OPERATION,
count = removed.len(),
"superseded staged documents reclaimed"
),
Err(error) => tracing::warn!(
operation = OPERATION,
%error,
"superseded staged documents could not be reclaimed"
),
}
}
pub(super) fn live_documents(listing: &WorkerDeploymentListing) -> Option<BTreeSet<PathBuf>> {
if !listing.undecodable.is_empty() {
return None;
}
Some(
listing
.deployments
.iter()
.filter_map(|deployment| record::document_path(&deployment.artifact))
.map(Path::to_path_buf)
.collect(),
)
}
pub async fn refresh_dial_addresses(state: &ServerState) -> Vec<AutoWorkerOutcome> {
let listing = match state
.worker_deployment_store()
.list_worker_deployments()
.await
{
Ok(listing) => listing,
Err(error) => {
tracing::error!(
operation = OPERATION,
%error,
"the worker deployments could not be listed, so this boot cannot tell whether its \
built-in agent workers still dial an address it binds"
);
return Vec::new();
}
};
let auto: Vec<&WorkerDeployment> = listing
.deployments
.iter()
.filter(|deployment| record::is_auto_name(&deployment.name))
.collect();
if auto.is_empty() {
return Vec::new();
}
let namespace = state.runtime_config().default_namespace.clone();
let dial = match worker_dial_address(&state.runtime_config().outbox) {
Ok(dial) => dial,
Err(dark) => {
let outcomes: Vec<AutoWorkerOutcome> = auto
.iter()
.map(|deployment| {
AutoWorkerOutcome::new(
deployment.task_queue.clone(),
record::staged_workflow_type(&deployment.artifact).unwrap_or_default(),
AutoWorkerDecision::DarkOutbox,
Some(deployment.name.clone()),
format!(
"worker deployment `{name}` exists to serve task queue `{queue}`, but \
{dark}. It will keep failing to connect until that is fixed",
name = deployment.name,
queue = deployment.task_queue
),
)
})
.collect();
for outcome in &outcomes {
log_outcome(outcome);
}
state.worker_supervisor().record_auto_provision(&outcomes);
return outcomes;
}
};
let mut outcomes = Vec::new();
for deployment in auto {
if let Some(outcome) = refresh_one(state, deployment, &dial, &namespace).await {
log_outcome(&outcome);
outcomes.push(outcome);
}
}
state.worker_supervisor().record_auto_provision(&outcomes);
outcomes
}
async fn refresh_one(
state: &ServerState,
deployment: &WorkerDeployment,
dial: &str,
namespace: &str,
) -> Option<AutoWorkerOutcome> {
let document = record::document_path(&deployment.artifact)?.to_path_buf();
let wanted = record::verb(&deployment.task_queue, &document, dial, namespace);
let aion_store::WorkerArtifactRef::Builtin { verb } = &deployment.artifact;
if verb == &wanted {
return None;
}
let binary = match crate::worker::capture_binary_identity() {
Ok(binary) => binary,
Err(error) => {
return Some(AutoWorkerOutcome::new(
deployment.task_queue.clone(),
record::staged_workflow_type(&deployment.artifact).unwrap_or_default(),
AutoWorkerDecision::Failed,
Some(deployment.name.clone()),
format!(
"worker deployment `{}` dials an address this boot does not bind, and could \
not be rewritten because this executable could not be identified: {error}",
deployment.name
),
));
}
};
let mut requested =
record::new_deployment(&deployment.task_queue, &document, dial, namespace, binary);
requested.desired = deployment.desired;
let record = WorkerDeployment::new(requested, chrono::Utc::now()).ok()?;
let put = state
.worker_deployment_store()
.put_worker_deployment(record)
.await;
let workflow_type = record::staged_workflow_type(&deployment.artifact).unwrap_or_default();
match put {
Ok(_) => {
let converged = converge_and_report(
state.worker_supervisor(),
&deployment.name,
Convergence::Replacing,
OPERATION,
)
.await
.is_ok();
Some(AutoWorkerOutcome::new(
deployment.task_queue.clone(),
workflow_type,
if converged {
AutoWorkerDecision::Reminted
} else {
AutoWorkerDecision::RecordedNotRunning
},
Some(deployment.name.clone()),
format!(
"worker deployment `{name}` dialled an address this boot does not bind, so \
its launch was rewritten onto `{dial}`",
name = deployment.name
),
))
}
Err(error) => Some(AutoWorkerOutcome::new(
deployment.task_queue.clone(),
workflow_type,
AutoWorkerDecision::Failed,
Some(deployment.name.clone()),
format!(
"worker deployment `{}` dials an address this boot does not bind, and the \
corrected record could not be persisted: {error}",
deployment.name
),
)),
}
}
#[cfg(test)]
#[path = "retire_tests.rs"]
mod tests;