use super::{Database, DatabaseValue, FilterableQuery, RUNS, RepresentationError as Error};
use switchy_database::query::SortDirection;
#[cfg(test)]
mod tests;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActivationState {
Skipped,
OutputCommitted,
Unresolved,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AttemptObservation {
pub attempt: u32,
pub dispatch_identity: String,
pub receipt_present: bool,
pub terminal: Option<super::attempts::TerminalOutcome>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActivationObservation {
pub node_id: String,
pub activation_id: String,
pub status: ActivationState,
pub latest_attempt: Option<AttemptObservation>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SchedulingSnapshot {
pub run_id: String,
pub revision: u64,
pub run_status: super::runs::RunState,
pub concurrency_cap: Option<u32>,
pub occupied: u64,
pub user_paused: bool,
pub activations: Vec<ActivationObservation>,
pub next_activation_id: Option<String>,
}
fn text(row: &switchy_database::Row, field: &str) -> Result<String, Error> {
match row.get(field) {
Some(DatabaseValue::String(v)) => Ok(v),
_ => Err(Error::MaintenanceRequired),
}
}
fn integer(row: &switchy_database::Row, field: &str) -> Result<u64, Error> {
match row.get(field) {
Some(DatabaseValue::Int64(v)) => u64::try_from(v).map_err(|_| Error::MaintenanceRequired),
_ => Err(Error::MaintenanceRequired),
}
}
pub async fn snapshot(
db: &dyn Database,
run: &str,
expected_revision: Option<u64>,
after_activation_id: Option<&str>,
limit: u16,
) -> Result<SchedulingSnapshot, Error> {
if !(1..=100).contains(&limit) || (after_activation_id.is_some() && expected_revision.is_none())
{
return Err(Error::InvalidAdmission);
}
let tx = db.begin_transaction().await?;
let result = snapshot_in(&*tx, run, expected_revision, after_activation_id, limit).await?;
tx.commit().await?;
Ok(result)
}
pub async fn snapshot_in(
tx: &dyn switchy_database::DatabaseTransaction,
run: &str,
expected_revision: Option<u64>,
after_activation_id: Option<&str>,
limit: u16,
) -> Result<SchedulingSnapshot, Error> {
if !(1..=100).contains(&limit) || (after_activation_id.is_some() && expected_revision.is_none())
{
return Err(Error::InvalidAdmission);
}
super::verified(tx).await?;
let revision = super::graph_pages::revision(tx, run).await?;
if expected_revision.is_some_and(|v| v != revision) {
return Err(Error::InvalidAdmission);
}
let roots = tx
.select(RUNS)
.where_eq("run_id", run)
.limit(1)
.execute(tx)
.await?;
let root = roots.first().ok_or(Error::InvalidAdmission)?;
let admission: crate::NewWorkflowRun = serde_json::from_str(&text(root, "admission_json")?)
.map_err(|_| Error::MaintenanceRequired)?;
let mut query = tx
.select("workflow_activations_v2")
.columns(&["identity", "node_id", "skipped", "output_json"])
.where_eq("run_id", run)
.where_eq("current", true)
.sort("identity", SortDirection::Asc)
.limit(usize::from(limit) + 1);
if let Some(after) = after_activation_id {
query = query.where_gt("identity", after);
}
let mut rows = query.execute(tx).await?;
let more = rows.len() > usize::from(limit);
rows.truncate(usize::from(limit));
let mut activations = Vec::with_capacity(rows.len());
for row in rows {
let activation_id = text(&row, "identity")?;
let node_id = text(&row, "node_id")?;
let skipped = match row.get("skipped") {
Some(DatabaseValue::Bool(v)) => v,
Some(DatabaseValue::Int64(0)) => false,
Some(DatabaseValue::Int64(1)) => true,
_ => return Err(Error::MaintenanceRequired),
};
let status = if skipped {
ActivationState::Skipped
} else {
match row.get("output_json") {
Some(DatabaseValue::Null) => ActivationState::Unresolved,
Some(DatabaseValue::String(_)) => ActivationState::OutputCommitted,
_ => return Err(Error::MaintenanceRequired),
}
};
let latest_attempt = latest(tx, run, &node_id, &activation_id).await?;
activations.push(ActivationObservation {
node_id,
activation_id,
status,
latest_attempt,
});
}
let next_activation_id = if more {
activations.last().map(|v| v.activation_id.clone())
} else {
None
};
let result = SchedulingSnapshot {
run_id: run.into(),
revision,
run_status: super::runs::RunState::decode(&text(root, "status")?)?,
concurrency_cap: admission.limits.concurrency_cap,
occupied: integer(root, "active_attempts")?,
user_paused: super::controls::user_paused(tx, run).await?,
activations,
next_activation_id,
};
Ok(result)
}
async fn latest(
db: &dyn Database,
run: &str,
node: &str,
activation: &str,
) -> Result<Option<AttemptObservation>, Error> {
let rows = db
.select("workflow_attempts_v2")
.columns(&[
"attempt_number",
"dispatch_identity",
"receipt_json",
"outcome_json",
])
.where_eq("run_id", run)
.where_eq("node_id", node)
.where_eq("activation_id", activation)
.sort("attempt_number", SortDirection::Desc)
.limit(1)
.execute(db)
.await?;
let Some(row) = rows.first() else {
return Ok(None);
};
let receipt_present = match row.get("receipt_json") {
Some(DatabaseValue::Null) => false,
Some(DatabaseValue::String(_)) => true,
_ => return Err(Error::MaintenanceRequired),
};
let terminal = match row.get("outcome_json") {
Some(DatabaseValue::Null) => None,
Some(DatabaseValue::String(v)) => {
Some(serde_json::from_str(&v).map_err(|_| Error::MaintenanceRequired)?)
}
_ => return Err(Error::MaintenanceRequired),
};
if terminal.is_some() && !receipt_present {
return Err(Error::MaintenanceRequired);
}
Ok(Some(AttemptObservation {
attempt: u32::try_from(integer(row, "attempt_number")?)
.map_err(|_| Error::MaintenanceRequired)?,
dispatch_identity: text(row, "dispatch_identity")?,
receipt_present,
terminal,
}))
}