ic-query 0.29.5

Internet Computer query library for NNS, SNS, ICRC, system canisters, and public network metadata
Documentation
//! Module: ic::node_status::projection
//!
//! Responsibility: pure node, Subnet, and provider projections from one status snapshot.
//! Does not own: live calls, cache IO, wire decoding, or text rendering.
//! Boundary: keeps targets and attention filters as views over one collected identity.

use super::{
    IcNodeCountComparison, IcNodeCountComparisonCounts, IcNodeProviderStatusReport,
    IcNodeProviderStatusRow, IcNodeStatusProjectionError, IcNodeStatusReport, IcNodeStatusRow,
    IcNodeStatusScope, IcNodeStatusSnapshot, IcNodeStatusView, IcSubnetStatusReport,
    IcSubnetStatusRow, node_status_group_counts, validate_canonical_node_status_rows,
    validate_default_node_scope,
};
use std::collections::BTreeMap;

/// Project a node-level status report from one complete observed snapshot.
pub fn ic_node_status_report_from_snapshot(
    snapshot: &IcNodeStatusSnapshot,
    view: &IcNodeStatusView,
) -> Result<IcNodeStatusReport, IcNodeStatusProjectionError> {
    validate_snapshot(snapshot)?;
    let resolution = resolve_optional_target(
        "node",
        view.target.as_deref(),
        snapshot.nodes.iter().map(|node| node.node_id.as_str()),
        "node_principal",
    )?;
    let nodes = snapshot
        .nodes
        .iter()
        .filter(|node| view_includes(view, resolution.as_ref(), &node.node_id, node.is_non_up()))
        .cloned()
        .collect::<Vec<_>>();

    Ok(IcNodeStatusReport {
        observation: snapshot.observation.clone(),
        snapshot_node_count: snapshot.node_count,
        counts: snapshot.counts.clone(),
        include_all: view.include_all,
        requested_target: view.target.clone(),
        resolved_target: resolution.as_ref().map(|value| value.resolved.clone()),
        resolved_from: resolution.map(|value| value.resolved_from),
        returned_node_count: nodes.len(),
        nodes,
    })
}

/// Project a Subnet-level status report from one complete observed snapshot.
pub fn ic_subnet_status_report_from_snapshot(
    snapshot: &IcNodeStatusSnapshot,
    view: &IcNodeStatusView,
) -> Result<IcSubnetStatusReport, IcNodeStatusProjectionError> {
    validate_snapshot(snapshot)?;
    let mut grouped = BTreeMap::<String, Vec<&IcNodeStatusRow>>::new();
    for node in &snapshot.nodes {
        if let Some(subnet_id) = &node.subnet_id {
            grouped.entry(subnet_id.clone()).or_default().push(node);
        }
    }
    let resolution = resolve_optional_target(
        "Subnet",
        view.target.as_deref(),
        grouped.keys().map(String::as_str),
        "subnet_principal",
    )?;
    let all_subnets = grouped
        .into_iter()
        .map(|(subnet_id, nodes)| subnet_row(subnet_id, &nodes))
        .collect::<Vec<_>>();
    let subnet_count = all_subnets.len();
    let attention_subnet_count = all_subnets
        .iter()
        .filter(|row| row.statuses.non_up() > 0)
        .count();
    let subnets = all_subnets
        .into_iter()
        .filter(|row| {
            view_includes(
                view,
                resolution.as_ref(),
                &row.subnet_id,
                row.statuses.non_up() > 0,
            )
        })
        .collect::<Vec<_>>();

    Ok(IcSubnetStatusReport {
        observation: snapshot.observation.clone(),
        snapshot_node_count: snapshot.node_count,
        assigned_node_count: snapshot.counts.assignment_statuses.assigned.total,
        subnet_count,
        attention_subnet_count,
        include_all: view.include_all,
        requested_target: view.target.clone(),
        resolved_target: resolution.as_ref().map(|value| value.resolved.clone()),
        resolved_from: resolution.map(|value| value.resolved_from),
        returned_subnet_count: subnets.len(),
        subnets,
    })
}

/// Project a node-provider status report from one complete observed snapshot.
pub fn ic_node_provider_status_report_from_snapshot(
    snapshot: &IcNodeStatusSnapshot,
    view: &IcNodeStatusView,
) -> Result<IcNodeProviderStatusReport, IcNodeStatusProjectionError> {
    validate_snapshot(snapshot)?;
    let mut grouped = BTreeMap::<String, Vec<&IcNodeStatusRow>>::new();
    for node in &snapshot.nodes {
        grouped
            .entry(node.node_provider_id.clone())
            .or_default()
            .push(node);
    }
    let resolution = resolve_optional_target(
        "node provider",
        view.target.as_deref(),
        grouped.keys().map(String::as_str),
        "node_provider_principal",
    )?;
    let all_providers = grouped
        .into_iter()
        .map(|(provider_id, nodes)| provider_row(provider_id, &nodes))
        .collect::<Vec<_>>();
    let provider_count = all_providers.len();
    let attention_provider_count = all_providers
        .iter()
        .filter(|row| row.counts.statuses.non_up() > 0)
        .count();
    let mut up_comparisons = IcNodeCountComparisonCounts::default();
    let mut non_up_comparisons = IcNodeCountComparisonCounts::default();
    for provider in &all_providers {
        increment_comparison(&mut up_comparisons, provider.unassigned_up_vs_assigned_up);
        increment_comparison(
            &mut non_up_comparisons,
            provider.unassigned_non_up_vs_assigned_non_up,
        );
    }
    let providers = all_providers
        .into_iter()
        .filter(|row| {
            view_includes(
                view,
                resolution.as_ref(),
                &row.node_provider_id,
                row.counts.statuses.non_up() > 0,
            )
        })
        .collect::<Vec<_>>();

    Ok(IcNodeProviderStatusReport {
        observation: snapshot.observation.clone(),
        snapshot_node_count: snapshot.node_count,
        provider_count,
        attention_provider_count,
        unassigned_up_vs_assigned_up_provider_counts: up_comparisons,
        unassigned_non_up_vs_assigned_non_up_provider_counts: non_up_comparisons,
        include_all: view.include_all,
        requested_target: view.target.clone(),
        resolved_target: resolution.as_ref().map(|value| value.resolved.clone()),
        resolved_from: resolution.map(|value| value.resolved_from),
        returned_provider_count: providers.len(),
        providers,
    })
}

fn subnet_row(subnet_id: String, nodes: &[&IcNodeStatusRow]) -> IcSubnetStatusRow {
    let statuses = node_status_group_counts(nodes.iter().copied()).statuses;
    let fault_tolerance_node_count = statuses.total.saturating_sub(1) / 3;
    let first_exceeding_count = fault_tolerance_node_count.saturating_add(1);
    let non_up_nodes = non_up_nodes(nodes);
    IcSubnetStatusRow {
        subnet_id,
        additional_down_nodes_to_exceed_fault_tolerance: first_exceeding_count
            .saturating_sub(statuses.down),
        additional_non_up_nodes_to_exceed_fault_tolerance: first_exceeding_count
            .saturating_sub(statuses.non_up()),
        down_fault_tolerance_exceeded: statuses.down > fault_tolerance_node_count,
        conservative_non_up_fault_tolerance_exceeded: statuses.non_up()
            > fault_tolerance_node_count,
        fault_tolerance_node_count,
        statuses,
        non_up_nodes,
    }
}

fn provider_row(node_provider_id: String, nodes: &[&IcNodeStatusRow]) -> IcNodeProviderStatusRow {
    let node_provider_name = nodes
        .first()
        .map_or_else(String::new, |node| node.node_provider_name.clone());
    let non_up_nodes = non_up_nodes(nodes);
    let counts = node_status_group_counts(nodes.iter().copied());
    let assigned = &counts.assignment_statuses.assigned;
    let unassigned = &counts.assignment_statuses.unassigned;
    IcNodeProviderStatusRow {
        node_provider_id,
        node_provider_name,
        unassigned_up_vs_assigned_up: IcNodeCountComparison::from_counts(
            unassigned.up,
            assigned.up,
        ),
        unassigned_non_up_vs_assigned_non_up: IcNodeCountComparison::from_counts(
            unassigned.non_up(),
            assigned.non_up(),
        ),
        counts,
        non_up_nodes,
    }
}

fn non_up_nodes(nodes: &[&IcNodeStatusRow]) -> Vec<IcNodeStatusRow> {
    nodes
        .iter()
        .filter(|node| node.is_non_up())
        .map(|node| (*node).clone())
        .collect()
}

const fn increment_comparison(
    counts: &mut IcNodeCountComparisonCounts,
    comparison: IcNodeCountComparison,
) {
    match comparison {
        IcNodeCountComparison::Less => counts.less += 1,
        IcNodeCountComparison::Equal => counts.equal += 1,
        IcNodeCountComparison::Greater => counts.greater += 1,
    }
}

fn validate_snapshot(snapshot: &IcNodeStatusSnapshot) -> Result<(), IcNodeStatusProjectionError> {
    if snapshot.observation.scope != IcNodeStatusScope::DashboardMainnetDefault
        || snapshot.observation.cloud_engine_nodes_included
    {
        return invalid_snapshot(
            "snapshot does not describe the Dashboard default mainnet node scope",
        );
    }
    validate_canonical_node_status_rows(&snapshot.nodes)
        .map_err(|reason| IcNodeStatusProjectionError::InvalidSnapshot { reason })?;
    validate_default_node_scope(&snapshot.nodes)
        .map_err(|reason| IcNodeStatusProjectionError::InvalidSnapshot { reason })?;
    if snapshot.node_count != snapshot.nodes.len() {
        return invalid_snapshot(format!(
            "node_count is {}, actual row count is {}",
            snapshot.node_count,
            snapshot.nodes.len()
        ));
    }
    let counts = node_status_group_counts(snapshot.nodes.iter());
    if snapshot.counts != counts {
        return invalid_snapshot("counts do not match raw node rows");
    }
    Ok(())
}

fn invalid_snapshot<T>(reason: impl Into<String>) -> Result<T, IcNodeStatusProjectionError> {
    Err(IcNodeStatusProjectionError::InvalidSnapshot {
        reason: reason.into(),
    })
}

struct TargetResolution {
    resolved: String,
    resolved_from: String,
}

fn view_includes(
    view: &IcNodeStatusView,
    resolution: Option<&TargetResolution>,
    identifier: &str,
    requires_attention: bool,
) -> bool {
    resolution.map_or_else(
        || view.include_all || requires_attention,
        |resolution| identifier == resolution.resolved,
    )
}

fn resolve_optional_target<'a>(
    kind: &'static str,
    target: Option<&str>,
    identifiers: impl Iterator<Item = &'a str>,
    exact_label: &'static str,
) -> Result<Option<TargetResolution>, IcNodeStatusProjectionError> {
    let Some(target) = target else {
        return Ok(None);
    };
    let target = target.trim();
    if target.is_empty() {
        return Err(IcNodeStatusProjectionError::EmptyTarget { kind });
    }
    let identifiers = identifiers.collect::<Vec<_>>();
    if identifiers.contains(&target) {
        return Ok(Some(TargetResolution {
            resolved: target.to_string(),
            resolved_from: exact_label.to_string(),
        }));
    }
    let matches = identifiers
        .into_iter()
        .filter(|identifier| identifier.starts_with(target))
        .map(str::to_string)
        .collect::<Vec<_>>();
    match matches.as_slice() {
        [] => Err(IcNodeStatusProjectionError::UnknownTarget {
            kind,
            target: target.to_string(),
        }),
        [resolved] => Ok(Some(TargetResolution {
            resolved: resolved.clone(),
            resolved_from: format!("{exact_label}_prefix"),
        })),
        _ => Err(IcNodeStatusProjectionError::AmbiguousTarget {
            kind,
            prefix: target.to_string(),
            matches,
        }),
    }
}