Skip to main content

ic_query/nns/neuron/report/collection/
mod.rs

1//! Module: nns::neuron::report::collection
2//!
3//! Responsibility: advance caller-persisted complete public-neuron walks one page at a time.
4//! Does not own: stable memory, filesystem caches, scheduling, retries, or publication.
5//! Boundary: validates resumable input and derives the next state from one admitted source page.
6
7#[cfg(feature = "nns-host")]
8use super::NnsNeuronHostError;
9use super::{
10    NnsNeuronError,
11    model::{NnsNeuronListReport, NnsNeuronListRequest},
12    source::{NnsNeuronSource, build_nns_neuron_list_report_with_source, validate_page_size},
13};
14#[cfg(feature = "nns-host")]
15use crate::nns::LiveNnsSource;
16use crate::nns::governance::{
17    GovernanceCollectionState, NnsGovernanceCollectionStatus, NnsGovernanceRequest,
18    governance_collection_accessors, validate_governance_continuation, validate_governance_request,
19};
20use serde::{Deserialize, Serialize};
21
22/// Version of the persistable resumable NNS neuron collection state.
23pub const NNS_NEURON_COLLECTION_STATE_SCHEMA_VERSION: u32 = 1;
24
25///
26/// NnsNeuronCollectionState
27///
28/// Serializable continuation state for an explicitly bounded public-neuron walk.
29///
30
31#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
32#[serde(deny_unknown_fields)]
33pub struct NnsNeuronCollectionState {
34    #[serde(flatten)]
35    collection: GovernanceCollectionState,
36    neurons_fetched: u64,
37    next_start_neuron_id: Option<u64>,
38}
39
40impl NnsNeuronCollectionState {
41    /// Start an empty neuron walk with explicit per-page and cumulative call ceilings.
42    pub fn new(
43        request: &NnsGovernanceRequest,
44        page_size: u32,
45        max_pages: u32,
46    ) -> Result<Self, NnsNeuronError> {
47        validate_governance_request(request)?;
48        validate_page_size(page_size)?;
49        if max_pages == 0 {
50            return Err(NnsNeuronError::InvalidCollectionMaxPages);
51        }
52        Ok(Self {
53            collection: GovernanceCollectionState::new(request, page_size, max_pages),
54            neurons_fetched: 0,
55            next_start_neuron_id: None,
56        })
57    }
58
59    #[cfg(feature = "nns-host")]
60    pub(in crate::nns) const fn from_complete_collection(
61        collection: GovernanceCollectionState,
62        rows: u64,
63    ) -> Self {
64        Self {
65            collection,
66            neurons_fetched: rows,
67            next_start_neuron_id: None,
68        }
69    }
70
71    #[cfg(feature = "nns-host")]
72    pub(in crate::nns) fn collection_evidence(
73        &self,
74    ) -> crate::nns::governance::collection::NnsGovernanceCollectionEvidence {
75        crate::nns::governance::collection::NnsGovernanceCollectionEvidence::from_state(
76            &self.collection,
77        )
78    }
79
80    governance_collection_accessors!(next_start_neuron_id);
81
82    /// Return the number of successfully admitted neuron rows.
83    #[must_use]
84    pub const fn neurons_fetched(&self) -> u64 {
85        self.neurons_fetched
86    }
87
88    /// Return the exclusive lower neuron-id bound for the next page.
89    #[must_use]
90    pub const fn next_start_neuron_id(&self) -> Option<u64> {
91        self.next_start_neuron_id
92    }
93}
94
95///
96/// NnsNeuronCollectionStep
97///
98/// One admitted bounded page and the continuation state that follows it.
99///
100
101#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
102pub struct NnsNeuronCollectionStep {
103    /// Page returned by the shared public-neuron report builder.
104    pub page: NnsNeuronListReport,
105    /// Validated state to persist only after retaining the page.
106    pub state: NnsNeuronCollectionState,
107}
108
109/// Advance a resumable public-neuron walk through the native replica adapter.
110#[cfg(feature = "nns-host")]
111pub async fn advance_nns_neuron_collection(
112    request: &NnsGovernanceRequest,
113    state: &NnsNeuronCollectionState,
114) -> Result<NnsNeuronCollectionStep, NnsNeuronHostError> {
115    Ok(
116        advance_nns_neuron_collection_with_source(request, state, &LiveNnsSource::default())
117            .await?,
118    )
119}
120
121/// Advance a resumable public-neuron walk by exactly one caller-runtime source call.
122pub async fn advance_nns_neuron_collection_with_source(
123    request: &NnsGovernanceRequest,
124    state: &NnsNeuronCollectionState,
125    source: &dyn NnsNeuronSource,
126) -> Result<NnsNeuronCollectionStep, NnsNeuronError> {
127    validate_governance_request(request)?;
128    validate_collection_state(state)?;
129    validate_governance_continuation!(request, state, NnsNeuronError)?;
130
131    let mut page_request = NnsNeuronListRequest::new(request.clone(), state.collection.page_size);
132    page_request.exclusive_start_neuron_id = state.next_start_neuron_id;
133    let page = build_nns_neuron_list_report_with_source(&page_request, source).await?;
134    if let Some(expected) = &state.collection.source
135        && *expected != page.context.source
136    {
137        return Err(NnsNeuronError::CollectionSourceChanged {
138            expected: expected.clone(),
139            actual: page.context.source,
140        });
141    }
142
143    let (pages_fetched, neurons_fetched) = crate::nns::governance::advance_collection_counts(
144        state.collection.pages_fetched,
145        state.neurons_fetched,
146        u64::try_from(page.neurons.len())
147            .map_err(|_| NnsNeuronError::CollectionAccountingOverflow)?,
148    )
149    .ok_or(NnsNeuronError::CollectionAccountingOverflow)?;
150    let next_start_neuron_id = page.next_start_neuron_id;
151    let next_state = NnsNeuronCollectionState {
152        collection: state.collection.after_page(
153            request,
154            page.context.source.clone(),
155            pages_fetched,
156        ),
157        neurons_fetched,
158        next_start_neuron_id,
159    };
160    Ok(NnsNeuronCollectionStep {
161        page,
162        state: next_state,
163    })
164}
165
166pub(super) fn validate_collection_state(
167    state: &NnsNeuronCollectionState,
168) -> Result<(), NnsNeuronError> {
169    let invalid = |reason| NnsNeuronError::InvalidCollectionState { reason };
170    state
171        .collection
172        .validate(NNS_NEURON_COLLECTION_STATE_SCHEMA_VERSION, invalid)?;
173    validate_page_size(state.collection.page_size)?;
174
175    let neurons_fetched = state.neurons_fetched;
176    let maximum_rows = crate::nns::governance::validate_governance_collection_row_bounds(
177        "neurons_fetched",
178        neurons_fetched,
179        state.collection.pages_fetched,
180        state.collection.page_size,
181        invalid,
182    )?;
183
184    let valid_lifecycle = crate::nns::governance::collection_lifecycle_matches(
185        state.status(),
186        state.collection.pages_fetched,
187        state.collection.max_pages,
188        state.neurons_fetched,
189        maximum_rows,
190        state.next_start_neuron_id.is_some(),
191        state.collection.source.is_some(),
192    ) && (state.status() != NnsGovernanceCollectionStatus::Complete
193        || neurons_fetched < maximum_rows);
194    if !valid_lifecycle {
195        return Err(invalid(format!(
196            "status {} disagrees with cursor, provenance, or counters",
197            state.status()
198        )));
199    }
200    Ok(())
201}
202
203#[cfg(test)]
204mod tests;