use std::{
collections::{BTreeMap, BTreeSet},
path::{Path, PathBuf},
};
use rusqlite::params;
use super::{
Execution, Receipts, Request,
receipts::{Error, schema_initialized, update_request},
run_control::{retire_execution, retire_request},
};
use crate::{
config::Field,
types::{ExecutionId, FailureCode, RequestId},
};
const REMOVED: &str = "Its Run's repository folder no longer exists.";
pub async fn retire_removed_signoffs(state: &Path) -> Result<(), String> {
let removed = removed_repositories(state).await?;
let Some(first) = removed.first() else {
return Ok(());
};
let receipts = Receipts::open(state, Path::new(first)).await?;
let result = receipts.retire_removed(removed).await;
let closed = receipts.close().await;
result.and(closed)
}
async fn removed_repositories(state: &Path) -> Result<Vec<String>, String> {
let state = crate::workspace::canonical_target(state).map_err(|e| e.to_string())?;
let Some(connection) = super::requests::open_read(&state).await? else {
return Ok(Vec::new());
};
let result = connection
.call(|db| -> Result<Vec<String>, Error> {
super::wait_when_busy(db)?;
let transaction = db.transaction()?;
if !schema_initialized(&transaction)? {
return Ok(Vec::new());
}
let repos = transaction
.prepare("SELECT DISTINCT r.repo FROM requests q JOIN runs r ON r.id=q.run_id WHERE q.status IN ('WAITING_HUMAN','DEFERRED')")?
.query_map([], |row| row.get::<_, String>(0))?
.collect::<Result<Vec<_>, _>>()?;
transaction.commit()?;
Ok(repos)
})
.await
.map_err(|e| e.to_string());
let repos = super::connection::finish(connection, result).await?;
Ok(repos
.into_iter()
.filter(|repo| matches!(Path::new(repo).try_exists(), Ok(false)))
.collect())
}
struct Waiter {
request: Request,
repo: PathBuf,
run_dir: Option<PathBuf>,
removed: bool,
}
enum Fate<'a> {
End,
Narrow {
retired: Vec<&'a Waiter>,
heir: Option<&'a Waiter>,
},
}
fn fate<'a>(original: &RequestId, waiters: &'a [Waiter]) -> Fate<'a> {
let (retired, living): (Vec<_>, Vec<_>) = waiters.iter().partition(|waiter| waiter.removed);
if living.is_empty() {
return Fate::End;
}
let heir = retired
.iter()
.any(|waiter| &waiter.request.id == original)
.then(|| {
living
.into_iter()
.max_by_key(|waiter| (waiter.request.created_at, &waiter.request.id))
})
.flatten();
Fate::Narrow { retired, heir }
}
impl Receipts {
async fn retire_removed(&self, removed: Vec<String>) -> Result<(), String> {
self.connection
.call(move |db| -> Result<(), Error> {
let transaction =
db.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
for (id, (mut execution, waiters)) in affected(&transaction, &removed)? {
match fate(&execution.provenance.request_id, &waiters) {
Fate::End => {
for waiter in &waiters {
unclaim(&transaction, &waiter.request.id)?;
}
retire_execution(
&transaction,
&id,
FailureCode::RepositoryRemoved,
REMOVED,
)?;
}
Fate::Narrow { retired, heir } => {
if let Some(heir) = heir {
hand_over(&transaction, &mut execution, &waiters, heir)?;
}
let at = crate::broker::now();
for waiter in retired {
unclaim(&transaction, &waiter.request.id)?;
retire_request(
&transaction,
waiter.request.clone(),
FailureCode::RepositoryRemoved,
REMOVED,
at,
)?;
}
}
}
}
transaction.commit()?;
Ok(())
})
.await
.map_err(|e| e.to_string())
}
}
fn unclaim(db: &rusqlite::Connection, request: &RequestId) -> Result<(), Error> {
db.execute(
"UPDATE requests SET claimed_by=NULL,claimed_at=NULL WHERE id=?",
[request],
)?;
Ok(())
}
fn affected(
db: &rusqlite::Connection,
removed: &[String],
) -> Result<BTreeMap<ExecutionId, (Execution, Vec<Waiter>)>, Error> {
let removed_set: BTreeSet<&str> = removed.iter().map(String::as_str).collect();
let mut statement = db.prepare(
"SELECT q.data,r.repo,json_extract(r.data,'$.stateDir'),e.id,e.data FROM requests q JOIN runs r ON r.id=q.run_id JOIN executions e ON e.id=q.execution_id WHERE q.status IN ('WAITING_HUMAN','DEFERRED') AND e.status='WAITING_HUMAN' AND e.id IN (SELECT q.execution_id FROM requests q JOIN runs r ON r.id=q.run_id WHERE q.status IN ('WAITING_HUMAN','DEFERRED') AND r.repo IN (SELECT value FROM json_each(?1)))",
)?;
let rows = statement
.query_map([serde_json::to_string(removed)?], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, ExecutionId>(3)?,
row.get::<_, String>(4)?,
))
})?
.collect::<Result<Vec<_>, _>>()?;
let mut found: BTreeMap<ExecutionId, (Execution, Vec<Waiter>)> = BTreeMap::new();
for (request, repo, state_dir, id, execution) in rows {
let (Some(request), Some(execution)) = (
super::unreadable::evidence::<Request>("request", &request),
super::unreadable::evidence::<Execution>("execution", &execution),
) else {
continue;
};
let removed = removed_set.contains(repo.as_str());
let run_dir = state_dir.map(|state| {
PathBuf::from(state)
.join("runs")
.join(request.run_id.as_str())
});
found
.entry(id)
.or_insert_with(|| (execution, Vec::new()))
.1
.push(Waiter {
request,
repo: PathBuf::from(repo),
run_dir,
removed,
});
}
Ok(found)
}
fn hand_over(
db: &rusqlite::Connection,
execution: &mut Execution,
waiters: &[Waiter],
heir: &Waiter,
) -> Result<(), Error> {
let definition = waiters
.iter()
.find(|waiter| waiter.request.id == execution.provenance.request_id)
.and_then(|original| original.request.human_definition.clone());
execution.provenance.request_id = heir.request.id.clone();
execution.provenance.run_id = heir.request.run_id.clone();
execution.provenance.repo_path = heir.repo.clone();
db.execute(
"UPDATE executions SET data=? WHERE id=? AND status='WAITING_HUMAN'",
params![serde_json::to_string(&*execution)?, execution.id],
)?;
let mut request = heir.request.clone();
request.human_definition = definition.map(|mut definition| {
definition.repo = Field::Value(heir.repo.clone());
definition
});
request.provenance = Some(execution.provenance.clone());
request.run_dir = request.run_dir.or_else(|| heir.run_dir.clone());
update_request(db, &request)
}