Skip to main content

ic_query/nns/topology/report/subnet_topology/
model.rs

1#[cfg(feature = "host")]
2use crate::cache_file::JsonCacheReport;
3use crate::subnet_catalog::SubnetKind;
4use candid::Principal;
5use serde::{Deserialize, Serialize};
6#[cfg(feature = "host")]
7use std::path::PathBuf;
8use thiserror::Error as ThisError;
9
10///
11/// NnsSubnetTopologyReport
12///
13/// Canonical Subnet and node-provider topology observed at one exact Registry version.
14///
15
16#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
17#[serde(deny_unknown_fields)]
18pub struct NnsSubnetTopologyReport {
19    /// Serialized report schema version.
20    pub schema_version: u32,
21    /// Network whose Registry supplied the snapshot.
22    pub network: String,
23    /// Registry canister queried for the snapshot.
24    pub registry_canister_id: String,
25    /// Exact Registry version shared by every joined record.
26    pub registry_version: u64,
27    /// UTC timestamp at which collection began.
28    pub fetched_at: String,
29    /// Replica endpoint used to query the Registry.
30    pub source_endpoint: String,
31    /// Collector identity recorded for provenance.
32    pub fetched_by: String,
33    /// Number of Subnet rows in `subnets`.
34    pub subnet_count: usize,
35    /// Total number of Subnet member nodes.
36    pub node_count: u64,
37    /// Canonically ordered Subnet topology rows.
38    pub subnets: Vec<NnsSubnetTopologyRow>,
39}
40
41#[cfg(feature = "host")]
42impl JsonCacheReport for NnsSubnetTopologyReport {
43    fn schema_version(&self) -> u32 {
44        self.schema_version
45    }
46
47    fn network(&self) -> &str {
48        &self.network
49    }
50}
51
52///
53/// NnsSubnetTopologyRow
54///
55/// One Subnet with raw Registry kind and provider membership counts.
56///
57
58#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
59#[serde(deny_unknown_fields)]
60pub struct NnsSubnetTopologyRow {
61    /// Canonical textual Subnet principal.
62    pub subnet_principal: String,
63    /// Raw Subnet classification stored in the Registry.
64    pub subnet_kind: SubnetKind,
65    /// Number of nodes assigned to this Subnet.
66    pub node_count: u32,
67    /// Canonically ordered node-provider membership counts.
68    pub node_providers: Vec<NnsSubnetNodeProviderRow>,
69}
70
71///
72/// NnsSubnetNodeProviderRow
73///
74/// Registry-derived node membership count for one provider on one Subnet.
75///
76
77#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
78#[serde(deny_unknown_fields)]
79pub struct NnsSubnetNodeProviderRow {
80    /// Canonical textual node-provider principal.
81    pub node_provider_principal: String,
82    /// Number of this provider's nodes assigned to the Subnet.
83    pub node_count: u32,
84}
85
86///
87/// NnsSubnetTopologyFreshness
88///
89/// Caller-relative freshness facts derived from a cached report timestamp.
90///
91
92#[derive(Clone, Debug, Eq, PartialEq)]
93pub struct NnsSubnetTopologyFreshness {
94    /// Whether the report is stale under the caller's policy.
95    pub stale: bool,
96    /// Stable machine-readable explanation of the freshness result.
97    pub reason: String,
98    /// Maximum acceptable report age supplied by the caller.
99    pub stale_after_seconds: u64,
100    /// Parsed `fetched_at` timestamp, when valid.
101    pub fetched_at_unix_secs: Option<u64>,
102    /// Report age, when `fetched_at` is valid and not in the future.
103    pub age_seconds: Option<u64>,
104}
105
106///
107/// NnsSubnetTopologyValidationError
108///
109/// Canonical-shape and relation-count failures in a Subnet topology report.
110///
111
112#[derive(Clone, Debug, Eq, PartialEq, ThisError)]
113pub enum NnsSubnetTopologyValidationError {
114    /// The report schema cannot be read by this library version.
115    #[error("unsupported Subnet topology schema version {found}; expected {expected}")]
116    UnsupportedSchemaVersion {
117        /// Schema version found in the report.
118        found: u32,
119        /// Schema version supported by this library.
120        expected: u32,
121    },
122
123    /// The report contains no Subnet rows.
124    #[error("Subnet topology report contains no Subnets")]
125    EmptySubnets,
126
127    /// A Registry or topology principal is syntactically invalid.
128    #[error("invalid principal in {field}: {value}: {reason}")]
129    InvalidPrincipal {
130        /// Name of the invalid principal field.
131        field: &'static str,
132        /// Invalid textual principal value.
133        value: String,
134        /// Principal parser error.
135        reason: String,
136    },
137
138    /// A valid principal does not use its canonical textual representation.
139    #[error("non-canonical principal in {field}: {value}; expected {canonical}")]
140    NonCanonicalPrincipal {
141        /// Name of the non-canonical principal field.
142        field: &'static str,
143        /// Non-canonical textual principal.
144        value: String,
145        /// Canonical textual representation.
146        canonical: String,
147    },
148
149    /// More than one row names the same Subnet.
150    #[error("duplicate Subnet row for {subnet_principal}")]
151    DuplicateSubnet {
152        /// Duplicated Subnet principal.
153        subnet_principal: String,
154    },
155
156    /// Subnet rows are not sorted by canonical principal.
157    #[error(
158        "Subnet rows are not canonically ordered: {previous_subnet_principal} sorts after {subnet_principal}"
159    )]
160    NonCanonicalSubnetOrder {
161        /// Principal in the preceding row.
162        previous_subnet_principal: String,
163        /// Out-of-order Subnet principal.
164        subnet_principal: String,
165    },
166
167    /// A provider appears more than once within a Subnet.
168    #[error(
169        "duplicate node-provider row for {node_provider_principal} on Subnet {subnet_principal}"
170    )]
171    DuplicateNodeProvider {
172        /// Subnet containing the duplicate.
173        subnet_principal: String,
174        /// Duplicated node-provider principal.
175        node_provider_principal: String,
176    },
177
178    /// Provider rows within a Subnet are not sorted by canonical principal.
179    #[error(
180        "node-provider rows on Subnet {subnet_principal} are not canonically ordered: {previous_node_provider_principal} sorts after {node_provider_principal}"
181    )]
182    NonCanonicalNodeProviderOrder {
183        /// Subnet containing the out-of-order rows.
184        subnet_principal: String,
185        /// Principal in the preceding provider row.
186        previous_node_provider_principal: String,
187        /// Out-of-order node-provider principal.
188        node_provider_principal: String,
189    },
190
191    /// A provider row declares no nodes.
192    #[error("node provider {node_provider_principal} on Subnet {subnet_principal} has zero nodes")]
193    ZeroNodeProviderCount {
194        /// Subnet containing the zero-count row.
195        subnet_principal: String,
196        /// Provider whose count is zero.
197        node_provider_principal: String,
198    },
199
200    /// Provider node counts do not sum to the Subnet node count.
201    #[error(
202        "provider node counts on Subnet {subnet_principal} sum to {provider_node_count}, but the Subnet declares {subnet_node_count} nodes"
203    )]
204    SubnetNodeCountMismatch {
205        /// Subnet whose counts disagree.
206        subnet_principal: String,
207        /// Node count declared by the Subnet row.
208        subnet_node_count: u32,
209        /// Sum of the Subnet's provider node counts.
210        provider_node_count: u64,
211    },
212
213    /// The declared Subnet count differs from the row count.
214    #[error("report declares {declared} Subnets but contains {actual} rows")]
215    SubnetCountMismatch {
216        /// Count declared in report metadata.
217        declared: usize,
218        /// Number of Subnet rows present.
219        actual: usize,
220    },
221
222    /// The declared total node count differs from the Subnet-row total.
223    #[error("report declares {declared} nodes but Subnet rows contain {actual}")]
224    NodeCountMismatch {
225        /// Count declared in report metadata.
226        declared: u64,
227        /// Sum of node counts in the Subnet rows.
228        actual: u64,
229    },
230}
231
232impl NnsSubnetTopologyReport {
233    /// Validate schema, canonical ordering, principal syntax, uniqueness, and counts.
234    pub fn validate(&self) -> Result<(), NnsSubnetTopologyValidationError> {
235        if self.schema_version != super::NNS_SUBNET_TOPOLOGY_REPORT_SCHEMA_VERSION {
236            return Err(NnsSubnetTopologyValidationError::UnsupportedSchemaVersion {
237                found: self.schema_version,
238                expected: super::NNS_SUBNET_TOPOLOGY_REPORT_SCHEMA_VERSION,
239            });
240        }
241        if self.subnets.is_empty() {
242            return Err(NnsSubnetTopologyValidationError::EmptySubnets);
243        }
244        validate_principal(&self.registry_canister_id, "registry_canister_id")?;
245
246        let mut previous_subnet: Option<&str> = None;
247        let mut actual_node_count = 0_u64;
248        for subnet in &self.subnets {
249            validate_principal(&subnet.subnet_principal, "subnet_principal")?;
250            if let Some(previous) = previous_subnet {
251                if previous == subnet.subnet_principal {
252                    return Err(NnsSubnetTopologyValidationError::DuplicateSubnet {
253                        subnet_principal: subnet.subnet_principal.clone(),
254                    });
255                }
256                if previous > subnet.subnet_principal.as_str() {
257                    return Err(NnsSubnetTopologyValidationError::NonCanonicalSubnetOrder {
258                        previous_subnet_principal: previous.to_string(),
259                        subnet_principal: subnet.subnet_principal.clone(),
260                    });
261                }
262            }
263            validate_node_providers(subnet)?;
264            actual_node_count = actual_node_count.saturating_add(u64::from(subnet.node_count));
265            previous_subnet = Some(subnet.subnet_principal.as_str());
266        }
267
268        if self.subnet_count != self.subnets.len() {
269            return Err(NnsSubnetTopologyValidationError::SubnetCountMismatch {
270                declared: self.subnet_count,
271                actual: self.subnets.len(),
272            });
273        }
274        if self.node_count != actual_node_count {
275            return Err(NnsSubnetTopologyValidationError::NodeCountMismatch {
276                declared: self.node_count,
277                actual: actual_node_count,
278            });
279        }
280        Ok(())
281    }
282}
283
284fn validate_node_providers(
285    subnet: &NnsSubnetTopologyRow,
286) -> Result<(), NnsSubnetTopologyValidationError> {
287    let mut previous_provider: Option<&str> = None;
288    let mut provider_node_count = 0_u64;
289    for provider in &subnet.node_providers {
290        validate_principal(&provider.node_provider_principal, "node_provider_principal")?;
291        if provider.node_count == 0 {
292            return Err(NnsSubnetTopologyValidationError::ZeroNodeProviderCount {
293                subnet_principal: subnet.subnet_principal.clone(),
294                node_provider_principal: provider.node_provider_principal.clone(),
295            });
296        }
297        if let Some(previous) = previous_provider {
298            if previous == provider.node_provider_principal {
299                return Err(NnsSubnetTopologyValidationError::DuplicateNodeProvider {
300                    subnet_principal: subnet.subnet_principal.clone(),
301                    node_provider_principal: provider.node_provider_principal.clone(),
302                });
303            }
304            if previous > provider.node_provider_principal.as_str() {
305                return Err(
306                    NnsSubnetTopologyValidationError::NonCanonicalNodeProviderOrder {
307                        subnet_principal: subnet.subnet_principal.clone(),
308                        previous_node_provider_principal: previous.to_string(),
309                        node_provider_principal: provider.node_provider_principal.clone(),
310                    },
311                );
312            }
313        }
314        provider_node_count = provider_node_count.saturating_add(u64::from(provider.node_count));
315        previous_provider = Some(provider.node_provider_principal.as_str());
316    }
317    if provider_node_count != u64::from(subnet.node_count) {
318        return Err(NnsSubnetTopologyValidationError::SubnetNodeCountMismatch {
319            subnet_principal: subnet.subnet_principal.clone(),
320            subnet_node_count: subnet.node_count,
321            provider_node_count,
322        });
323    }
324    Ok(())
325}
326
327fn validate_principal(
328    value: &str,
329    field: &'static str,
330) -> Result<(), NnsSubnetTopologyValidationError> {
331    let principal = Principal::from_text(value).map_err(|err| {
332        NnsSubnetTopologyValidationError::InvalidPrincipal {
333            field,
334            value: value.to_string(),
335            reason: err.to_string(),
336        }
337    })?;
338    let canonical = principal.to_text();
339    if canonical != value {
340        return Err(NnsSubnetTopologyValidationError::NonCanonicalPrincipal {
341            field,
342            value: value.to_string(),
343            canonical,
344        });
345    }
346    Ok(())
347}
348
349///
350/// NnsSubnetTopologyCacheRequest
351///
352/// Project root and network identity for a joined Subnet topology cache.
353///
354
355#[cfg(feature = "host")]
356#[derive(Clone, Debug, Eq, PartialEq)]
357pub struct NnsSubnetTopologyCacheRequest {
358    /// Project root containing the shared `.icq` directory.
359    pub icp_root: PathBuf,
360    /// Network cache namespace.
361    pub network: String,
362}
363
364#[cfg(feature = "host")]
365impl NnsSubnetTopologyCacheRequest {
366    /// Create a cache request for a project root and network.
367    #[must_use]
368    pub fn new(icp_root: impl Into<PathBuf>, network: impl Into<String>) -> Self {
369        Self {
370            icp_root: icp_root.into(),
371            network: network.into(),
372        }
373    }
374}
375
376///
377/// NnsSubnetTopologyRefreshRequest
378///
379/// Inputs for one explicit live refresh and atomic cache publication.
380///
381
382#[cfg(feature = "host")]
383#[derive(Clone, Debug, Eq, PartialEq)]
384pub struct NnsSubnetTopologyRefreshRequest {
385    /// Cache identity and destination.
386    pub cache: NnsSubnetTopologyCacheRequest,
387    /// Replica endpoint used for the live Registry query.
388    pub source_endpoint: String,
389    /// Current Unix timestamp used for provenance and lock policy.
390    pub now_unix_secs: u64,
391    /// Age after which an existing refresh lock is reported as stale.
392    pub lock_stale_after_seconds: u64,
393}
394
395#[cfg(feature = "host")]
396impl NnsSubnetTopologyRefreshRequest {
397    /// Create an explicit refresh request.
398    #[must_use]
399    pub fn new(
400        cache: NnsSubnetTopologyCacheRequest,
401        source_endpoint: impl Into<String>,
402        now_unix_secs: u64,
403        lock_stale_after_seconds: u64,
404    ) -> Self {
405        Self {
406            cache,
407            source_endpoint: source_endpoint.into(),
408            now_unix_secs,
409            lock_stale_after_seconds,
410        }
411    }
412}
413
414///
415/// CachedNnsSubnetTopologyReport
416///
417/// Validated Subnet topology report paired with its shared cache path.
418///
419
420#[cfg(feature = "host")]
421#[derive(Clone, Debug, Eq, PartialEq)]
422pub struct CachedNnsSubnetTopologyReport {
423    /// Canonical path from which the report was loaded or to which it was published.
424    pub path: PathBuf,
425    /// Validated joined topology report.
426    pub report: NnsSubnetTopologyReport,
427}