use rustvello_core::error::{RustvelloError, RustvelloResult, TaskError};
use rustvello_proto::config::AppConfig;
use rustvello_proto::identifiers::{InvocationId, RunnerId};
use rustvello_proto::status::InvocationStatus;
use crate::task_catalog::TaskCatalog;
use super::Orchestrator;
pub(crate) const RECOVERY_LIMIT_ERROR: &str = "RecoveryLimitExceeded";
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum RecoveryOutcome {
Rerouted,
Failed,
Skipped,
}
impl Orchestrator {
pub(crate) async fn recover_stale_invocations(
&self,
app_config: &AppConfig,
task_catalog: &TaskCatalog,
runner_id: &RunnerId,
) -> RustvelloResult<u32> {
let mut candidates: Vec<(InvocationId, InvocationStatus)> = Vec::new();
for invocation_id in self
.backends
.invocation_control
.get_stale_pending_invocations(app_config.max_pending_seconds)
.await?
{
candidates.push((invocation_id, InvocationStatus::PendingRecovery));
}
for invocation_id in self
.backends
.invocation_control
.get_stale_running_invocations(app_config.runner_dead_after_seconds)
.await?
{
candidates.push((invocation_id, InvocationStatus::RunningRecovery));
}
candidates.extend(self.interrupted_recoveries(app_config).await);
let mut recovered = 0;
let mut failed = 0;
let mut errors: Vec<String> = Vec::new();
for (invocation_id, recovery_status) in candidates {
match self
.recover_one(
app_config,
task_catalog,
runner_id,
&invocation_id,
recovery_status,
)
.await
{
Ok(RecoveryOutcome::Rerouted) => recovered += 1,
Ok(RecoveryOutcome::Failed) => failed += 1,
Ok(RecoveryOutcome::Skipped) => {}
Err(error) => errors.push(format!("{invocation_id}: {error}")),
}
}
if failed > 0 {
tracing::error!(
"recovery: {failed} invocation(s) failed with {RECOVERY_LIMIT_ERROR} after {} recoveries",
app_config.max_invocation_recoveries
);
}
if !errors.is_empty() {
let shown: Vec<&str> = errors.iter().take(5).map(String::as_str).collect();
tracing::error!(
"recovery: {} invocation(s) left for another runner or the next pass: {}{}",
errors.len(),
shown.join("; "),
if errors.len() > shown.len() {
"; ..."
} else {
""
}
);
}
Ok(recovered)
}
async fn interrupted_recoveries(
&self,
app_config: &AppConfig,
) -> Vec<(InvocationId, InvocationStatus)> {
let mut found = Vec::new();
for (status, stale_after) in [
(
InvocationStatus::PendingRecovery,
app_config.max_pending_seconds,
),
(
InvocationStatus::RunningRecovery,
app_config.runner_dead_after_seconds,
),
] {
let ids = match self
.backends
.invocation_control
.get_invocations_by_status(status, None)
.await
{
Ok(ids) => ids,
Err(error) => {
tracing::warn!("recovery: cannot list {status} invocations: {error}");
continue;
}
};
let cutoff = chrono::Utc::now()
- chrono::Duration::seconds(stale_after.min(i64::MAX as u64) as i64);
for invocation_id in ids {
if let Ok(record) = self
.backends
.invocation_control
.get_invocation_status(&invocation_id)
.await
{
if record.status == status && record.timestamp < cutoff {
found.push((invocation_id, status));
}
}
}
}
found
}
async fn recovery_count(&self, invocation_id: &InvocationId) -> usize {
self.backends
.state_backend
.get_history(invocation_id)
.await
.map(|history| {
history
.iter()
.filter(|entry| {
matches!(
entry.status_record.status,
InvocationStatus::PendingRecovery | InvocationStatus::RunningRecovery
)
})
.count()
})
.unwrap_or(0)
}
pub(crate) async fn recover_one(
&self,
app_config: &AppConfig,
task_catalog: &TaskCatalog,
runner_id: &RunnerId,
invocation_id: &InvocationId,
recovery_status: InvocationStatus,
) -> RustvelloResult<RecoveryOutcome> {
let invocation = self
.backends
.state_backend
.get_invocation(invocation_id)
.await?;
let current = self
.backends
.invocation_control
.get_invocation_status(invocation_id)
.await?
.status;
let resuming = matches!(
current,
InvocationStatus::PendingRecovery | InvocationStatus::RunningRecovery
);
let limit = app_config.max_invocation_recoveries as usize;
let previous = self.recovery_count(invocation_id).await;
let attempts = if resuming { previous } else { previous + 1 };
if limit > 0 && attempts > limit {
return self
.fail_recovery_limit(
invocation_id,
runner_id,
recovery_status,
resuming,
previous,
)
.await;
}
let (queue, priority) = task_catalog
.routing_for(app_config, &invocation.task_id)
.ok_or_else(|| RustvelloError::TaskNotRegistered {
task_id: invocation.task_id.clone(),
})?;
if let Some(publication) = self.publication()? {
let route = rustvello_core::publication::PublicationRoute { queue, priority };
let change = if resuming {
rustvello_core::publication::PublicationChange::Reroute(route)
} else {
let stale_after_seconds = if recovery_status == InvocationStatus::PendingRecovery {
app_config.max_pending_seconds
} else {
app_config.runner_dead_after_seconds
};
rustvello_core::publication::PublicationChange::Recover {
status: recovery_status,
stale_after_seconds,
route,
}
};
let recovered = publication
.change(invocation_id, runner_id, change, false)
.await?
.is_some();
if recovered {
let arguments = self.get_invocation_arguments(invocation_id).await;
let statuses: &[InvocationStatus] = if resuming {
&[InvocationStatus::Rerouted]
} else {
&[recovery_status, InvocationStatus::Rerouted]
};
for status in statuses {
self.report_published_status(
invocation_id,
runner_id,
*status,
&invocation.task_id,
arguments.clone(),
)
.await?;
}
return Ok(RecoveryOutcome::Rerouted);
}
return Ok(RecoveryOutcome::Skipped);
}
if !resuming {
match self
.set_invocation_status(invocation_id, recovery_status, runner_id)
.await
{
Ok(_) => {}
Err(RustvelloError::InvalidStatusTransition { .. }) => {
return Ok(RecoveryOutcome::Skipped)
}
Err(error) => return Err(error),
}
}
self.set_invocation_status(invocation_id, InvocationStatus::Rerouted, runner_id)
.await?;
self.backends
.broker
.route_invocation_with_options(
invocation_id,
Some(&invocation.task_id),
&queue,
priority,
)
.await?;
Ok(RecoveryOutcome::Rerouted)
}
async fn fail_recovery_limit(
&self,
invocation_id: &InvocationId,
runner_id: &RunnerId,
recovery_status: InvocationStatus,
resuming: bool,
previous: usize,
) -> RustvelloResult<RecoveryOutcome> {
if !resuming {
match self
.set_invocation_status(invocation_id, recovery_status, runner_id)
.await
{
Ok(_) => {}
Err(RustvelloError::InvalidStatusTransition { .. }) => {
return Ok(RecoveryOutcome::Skipped)
}
Err(error) => return Err(error),
}
}
let error = TaskError {
error_type: RECOVERY_LIMIT_ERROR.to_owned(),
message: format!(
"invocation {invocation_id} was recovered {previous} time(s) (max_invocation_recoveries); \
its worker died or could not run it each time. Check that the worker image imports \
the task module and that the task does not crash its process"
),
traceback: None,
};
self.backends
.state_backend
.store_error(invocation_id, &error)
.await?;
self.set_invocation_status(invocation_id, InvocationStatus::Failed, runner_id)
.await?;
Ok(RecoveryOutcome::Failed)
}
}