runx-core 0.6.19

Pure Rust parity kernel for runx state-machine and policy decisions.
Documentation
use std::collections::BTreeSet;

use super::types::{
    FanoutBranchFailurePolicy, FanoutBranchResult, FanoutGate, FanoutGroupPolicy,
    FanoutSyncDecision, FanoutSyncOutcome, FanoutSyncStrategy, GraphStepStatus,
};

mod conflict;
mod threshold;
mod values;

use conflict::conflict_decision;
use threshold::threshold_decision;

#[must_use]
pub fn fanout_sync_decision_key(group_id: &str, rule_fired: &str) -> String {
    format!("{group_id}:{rule_fired}")
}

#[must_use]
pub fn evaluate_fanout_sync(
    policy: &FanoutGroupPolicy,
    results: &[FanoutBranchResult],
    resolved_gate_keys: Option<&BTreeSet<String>>,
) -> FanoutSyncDecision {
    let counts = Counts::from_results(policy, results);

    if policy.on_branch_failure == FanoutBranchFailurePolicy::Halt && counts.failure_count > 0 {
        return branch_failure_decision(policy, counts);
    }

    if let Some(decision) = threshold_decision(policy, results, resolved_gate_keys, counts) {
        return decision;
    }

    if let Some(decision) = conflict_decision(policy, results, resolved_gate_keys, counts) {
        return decision;
    }

    if counts.success_count >= counts.required_successes {
        return quorum_decision(policy, FanoutSyncOutcome::Proceed, counts);
    }

    quorum_decision(policy, FanoutSyncOutcome::Halt, counts)
}

fn branch_failure_decision(policy: &FanoutGroupPolicy, counts: Counts) -> FanoutSyncDecision {
    sync_decision(
        policy,
        FanoutSyncOutcome::Halt,
        "quorum",
        counts,
        DecisionDetails::new(
            "branch_failure.halt",
            format!(
                "{}/{} branches failed and on_branch_failure is halt",
                counts.failure_count, counts.branch_count
            ),
        ),
    )
}

fn quorum_decision(
    policy: &FanoutGroupPolicy,
    outcome: FanoutSyncOutcome,
    counts: Counts,
) -> FanoutSyncDecision {
    sync_decision(
        policy,
        outcome,
        "quorum",
        counts,
        DecisionDetails::new(
            format!("{}.min_success", strategy_name(&policy.strategy)),
            format!(
                "{}/{} branches succeeded; required {}",
                counts.success_count, counts.branch_count, counts.required_successes
            ),
        ),
    )
}

#[derive(Clone, Copy)]
struct Counts {
    branch_count: usize,
    success_count: usize,
    failure_count: usize,
    required_successes: usize,
}

impl Counts {
    fn from_results(policy: &FanoutGroupPolicy, results: &[FanoutBranchResult]) -> Self {
        let branch_count = results.len();
        let success_count = results
            .iter()
            .filter(|result| result.status == GraphStepStatus::Succeeded)
            .count();
        let failure_count = results
            .iter()
            .filter(|result| result.status == GraphStepStatus::Failed)
            .count();
        let required_successes = required_success_count(policy, branch_count);

        Self::new(
            branch_count,
            success_count,
            failure_count,
            required_successes,
        )
    }

    fn new(
        branch_count: usize,
        success_count: usize,
        failure_count: usize,
        required_successes: usize,
    ) -> Self {
        Self {
            branch_count,
            success_count,
            failure_count,
            required_successes,
        }
    }
}

struct DecisionDetails {
    rule_fired: String,
    reason: String,
    gate: Option<FanoutGate>,
}

impl DecisionDetails {
    fn new(rule_fired: impl Into<String>, reason: impl Into<String>) -> Self {
        Self {
            rule_fired: rule_fired.into(),
            reason: reason.into(),
            gate: None,
        }
    }

    fn with_gate(mut self, gate: FanoutGate) -> Self {
        self.gate = Some(gate);
        self
    }
}

fn is_resolved(
    decision: &FanoutSyncDecision,
    resolved_gate_keys: Option<&BTreeSet<String>>,
) -> bool {
    resolved_gate_keys.is_some_and(|keys| {
        keys.contains(&fanout_sync_decision_key(
            &decision.group_id,
            &decision.rule_fired,
        ))
    })
}

fn sync_decision(
    policy: &FanoutGroupPolicy,
    decision: FanoutSyncOutcome,
    _type: &str,
    counts: Counts,
    details: DecisionDetails,
) -> FanoutSyncDecision {
    FanoutSyncDecision {
        group_id: policy.group_id.clone(),
        decision,
        strategy: policy.strategy.clone(),
        rule_fired: details.rule_fired,
        reason: details.reason,
        branch_count: counts.branch_count,
        success_count: counts.success_count,
        failure_count: counts.failure_count,
        required_successes: counts.required_successes,
        gate: details.gate,
    }
}

fn required_success_count(policy: &FanoutGroupPolicy, branch_count: usize) -> usize {
    match policy.strategy {
        FanoutSyncStrategy::All => branch_count,
        FanoutSyncStrategy::Any => 1,
        FanoutSyncStrategy::Quorum => policy
            .min_success
            .map_or(branch_count, |value| value as usize),
    }
}

fn strategy_name(strategy: &FanoutSyncStrategy) -> &'static str {
    match strategy {
        FanoutSyncStrategy::All => "all",
        FanoutSyncStrategy::Any => "any",
        FanoutSyncStrategy::Quorum => "quorum",
    }
}