Skip to main content

ic_query/nns/neuron/report/
collection.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 state before and after exactly one bounded source call.
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};
14use crate::nns::{
15    MAINNET_GOVERNANCE_CANISTER_ID,
16    governance::{
17        NnsGovernanceRequest, NnsGovernanceSourceProvenance, NnsGovernanceSourceSelection,
18        validate_governance_request, validate_source_provenance,
19    },
20};
21#[cfg(feature = "nns-host")]
22use crate::{nns::LiveNnsSource, runtime::block_on_current_thread};
23use serde::{Deserialize, Serialize};
24use std::fmt;
25
26/// Version of the persistable resumable NNS neuron collection state.
27pub const NNS_NEURON_COLLECTION_STATE_SCHEMA_VERSION: u32 = 1;
28
29///
30/// NnsNeuronCollectionStatus
31///
32/// Lifecycle of a caller-owned resumable public-neuron collection.
33///
34
35#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
36#[serde(rename_all = "snake_case")]
37pub enum NnsNeuronCollectionStatus {
38    /// No source page has been admitted yet.
39    Ready,
40    /// Another bounded page may be requested.
41    Collecting,
42    /// Governance API exhaustion was observed.
43    Complete,
44    /// Another cursor exists, but the configured page ceiling was consumed.
45    PageLimitReached,
46}
47
48impl NnsNeuronCollectionStatus {
49    /// Return the stable JSON and display label.
50    #[must_use]
51    pub const fn as_str(self) -> &'static str {
52        match self {
53            Self::Ready => "ready",
54            Self::Collecting => "collecting",
55            Self::Complete => "complete",
56            Self::PageLimitReached => "page_limit_reached",
57        }
58    }
59}
60
61impl fmt::Display for NnsNeuronCollectionStatus {
62    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
63        formatter.write_str(self.as_str())
64    }
65}
66
67///
68/// NnsNeuronCollectionState
69///
70/// Serializable continuation state for an explicitly bounded public-neuron walk.
71///
72
73#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
74pub struct NnsNeuronCollectionState {
75    schema_version: u32,
76    network: String,
77    governance_canister_id: String,
78    requested_source: NnsGovernanceSourceSelection,
79    source: Option<NnsGovernanceSourceProvenance>,
80    page_size: u32,
81    max_pages: u32,
82    pages_fetched: u32,
83    neurons_fetched: u64,
84    next_start_neuron_id: Option<u64>,
85    started_at: String,
86    updated_at: String,
87    status: NnsNeuronCollectionStatus,
88}
89
90impl NnsNeuronCollectionState {
91    /// Start an empty neuron walk with explicit per-page and cumulative call ceilings.
92    pub fn new(
93        request: &NnsGovernanceRequest,
94        page_size: u32,
95        max_pages: u32,
96    ) -> Result<Self, NnsNeuronError> {
97        validate_governance_request(request)?;
98        validate_page_size(page_size)?;
99        if max_pages == 0 {
100            return Err(NnsNeuronError::InvalidCollectionMaxPages);
101        }
102        Ok(Self {
103            schema_version: NNS_NEURON_COLLECTION_STATE_SCHEMA_VERSION,
104            network: request.network.clone(),
105            governance_canister_id: MAINNET_GOVERNANCE_CANISTER_ID.to_string(),
106            requested_source: request.source.clone(),
107            source: None,
108            page_size,
109            max_pages,
110            pages_fetched: 0,
111            neurons_fetched: 0,
112            next_start_neuron_id: None,
113            started_at: request.fetched_at.clone(),
114            updated_at: request.fetched_at.clone(),
115            status: NnsNeuronCollectionStatus::Ready,
116        })
117    }
118
119    /// Return the state schema version.
120    #[must_use]
121    pub const fn schema_version(&self) -> u32 {
122        self.schema_version
123    }
124
125    /// Return the fixed network identity.
126    #[must_use]
127    pub fn network(&self) -> &str {
128        &self.network
129    }
130
131    /// Return the fixed Governance canister identity.
132    #[must_use]
133    pub fn governance_canister_id(&self) -> &str {
134        &self.governance_canister_id
135    }
136
137    /// Return the source selection fixed when collection started.
138    #[must_use]
139    pub const fn requested_source(&self) -> &NnsGovernanceSourceSelection {
140        &self.requested_source
141    }
142
143    /// Return the concrete source provenance after the first admitted page.
144    #[must_use]
145    pub const fn source(&self) -> Option<&NnsGovernanceSourceProvenance> {
146        self.source.as_ref()
147    }
148
149    /// Return the maximum rows requested from each Governance call.
150    #[must_use]
151    pub const fn page_size(&self) -> u32 {
152        self.page_size
153    }
154
155    /// Return the cumulative source-call ceiling.
156    #[must_use]
157    pub const fn max_pages(&self) -> u32 {
158        self.max_pages
159    }
160
161    /// Return the number of successfully admitted pages.
162    #[must_use]
163    pub const fn pages_fetched(&self) -> u32 {
164        self.pages_fetched
165    }
166
167    /// Return the number of successfully admitted neuron rows.
168    #[must_use]
169    pub const fn neurons_fetched(&self) -> u64 {
170        self.neurons_fetched
171    }
172
173    /// Return the exclusive lower neuron-id bound for the next page.
174    #[must_use]
175    pub const fn next_start_neuron_id(&self) -> Option<u64> {
176        self.next_start_neuron_id
177    }
178
179    /// Return the caller-supplied time at which the collection state was created.
180    #[must_use]
181    pub fn started_at(&self) -> &str {
182        &self.started_at
183    }
184
185    /// Return the caller-supplied time attached to the latest admitted page.
186    #[must_use]
187    pub fn updated_at(&self) -> &str {
188        &self.updated_at
189    }
190
191    /// Return the collection lifecycle status.
192    #[must_use]
193    pub const fn status(&self) -> NnsNeuronCollectionStatus {
194        self.status
195    }
196
197    /// Return whether Governance API exhaustion was observed.
198    #[must_use]
199    pub const fn is_complete(&self) -> bool {
200        matches!(self.status, NnsNeuronCollectionStatus::Complete)
201    }
202}
203
204///
205/// NnsNeuronCollectionStep
206///
207/// One admitted bounded page and the continuation state that follows it.
208///
209
210#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
211pub struct NnsNeuronCollectionStep {
212    /// Page returned by the shared public-neuron report builder.
213    pub page: NnsNeuronListReport,
214    /// Validated state to persist only after retaining the page.
215    pub state: NnsNeuronCollectionState,
216}
217
218/// Advance a resumable public-neuron walk through the native replica adapter.
219#[cfg(feature = "nns-host")]
220pub fn advance_nns_neuron_collection(
221    request: &NnsGovernanceRequest,
222    state: &NnsNeuronCollectionState,
223) -> Result<NnsNeuronCollectionStep, NnsNeuronHostError> {
224    Ok(block_on_current_thread(
225        advance_nns_neuron_collection_with_source(request, state, &LiveNnsSource),
226    )??)
227}
228
229/// Advance a resumable public-neuron walk by exactly one caller-runtime source call.
230pub async fn advance_nns_neuron_collection_with_source(
231    request: &NnsGovernanceRequest,
232    state: &NnsNeuronCollectionState,
233    source: &dyn NnsNeuronSource,
234) -> Result<NnsNeuronCollectionStep, NnsNeuronError> {
235    validate_governance_request(request)?;
236    validate_collection_state(state)?;
237    validate_continuation_request(request, state)?;
238    match state.status {
239        NnsNeuronCollectionStatus::Complete => {
240            return Err(NnsNeuronError::CollectionComplete {
241                pages_fetched: state.pages_fetched,
242            });
243        }
244        NnsNeuronCollectionStatus::PageLimitReached => {
245            return Err(NnsNeuronError::CollectionPageLimitReached {
246                pages_fetched: state.pages_fetched,
247                max_pages: state.max_pages,
248            });
249        }
250        NnsNeuronCollectionStatus::Ready | NnsNeuronCollectionStatus::Collecting => {}
251    }
252
253    let mut page_request = NnsNeuronListRequest::new(request.clone(), state.page_size);
254    page_request.exclusive_start_neuron_id = state.next_start_neuron_id;
255    let page = build_nns_neuron_list_report_with_source(&page_request, source).await?;
256    if let Some(expected) = &state.source
257        && *expected != page.context.source
258    {
259        return Err(NnsNeuronError::CollectionSourceChanged {
260            expected: expected.clone(),
261            actual: page.context.source,
262        });
263    }
264
265    let pages_fetched = state
266        .pages_fetched
267        .checked_add(1)
268        .ok_or(NnsNeuronError::CollectionAccountingOverflow)?;
269    let page_row_count = u64::try_from(page.returned_neuron_count)
270        .map_err(|_| NnsNeuronError::CollectionAccountingOverflow)?;
271    let neurons_fetched = state
272        .neurons_fetched
273        .checked_add(page_row_count)
274        .ok_or(NnsNeuronError::CollectionAccountingOverflow)?;
275    let next_start_neuron_id = page.next_start_neuron_id;
276    let status = if next_start_neuron_id.is_none() {
277        NnsNeuronCollectionStatus::Complete
278    } else if pages_fetched == state.max_pages {
279        NnsNeuronCollectionStatus::PageLimitReached
280    } else {
281        NnsNeuronCollectionStatus::Collecting
282    };
283    let next_state = NnsNeuronCollectionState {
284        source: Some(page.context.source.clone()),
285        pages_fetched,
286        neurons_fetched,
287        next_start_neuron_id,
288        updated_at: request.fetched_at.clone(),
289        status,
290        ..state.clone()
291    };
292    validate_collection_state(&next_state)?;
293    Ok(NnsNeuronCollectionStep {
294        page,
295        state: next_state,
296    })
297}
298
299fn validate_continuation_request(
300    request: &NnsGovernanceRequest,
301    state: &NnsNeuronCollectionState,
302) -> Result<(), NnsNeuronError> {
303    if request.network != state.network {
304        return Err(NnsNeuronError::CollectionRequestMismatch {
305            field: "network",
306            expected: state.network.clone(),
307            actual: request.network.clone(),
308        });
309    }
310    if request.source != state.requested_source {
311        return Err(NnsNeuronError::CollectionRequestMismatch {
312            field: "requested_source",
313            expected: format!("{:?}", state.requested_source),
314            actual: format!("{:?}", request.source),
315        });
316    }
317    Ok(())
318}
319
320pub(super) fn validate_collection_state(
321    state: &NnsNeuronCollectionState,
322) -> Result<(), NnsNeuronError> {
323    let invalid = |reason| NnsNeuronError::InvalidCollectionState { reason };
324    if state.schema_version != NNS_NEURON_COLLECTION_STATE_SCHEMA_VERSION {
325        return Err(invalid(format!(
326            "schema_version is {}, expected {NNS_NEURON_COLLECTION_STATE_SCHEMA_VERSION}",
327            state.schema_version
328        )));
329    }
330    if state.governance_canister_id != MAINNET_GOVERNANCE_CANISTER_ID {
331        return Err(invalid(format!(
332            "governance_canister_id is {}, expected {MAINNET_GOVERNANCE_CANISTER_ID}",
333            state.governance_canister_id
334        )));
335    }
336    let state_request = NnsGovernanceRequest {
337        network: state.network.clone(),
338        fetched_at: state.started_at.clone(),
339        source: state.requested_source.clone(),
340    };
341    validate_governance_request(&state_request)?;
342    validate_page_size(state.page_size)?;
343    if state.max_pages == 0 {
344        return Err(invalid("max_pages must be greater than zero".to_string()));
345    }
346    if state.pages_fetched > state.max_pages {
347        return Err(invalid(format!(
348            "pages_fetched {} exceeds max_pages {}",
349            state.pages_fetched, state.max_pages
350        )));
351    }
352    if let Some(source) = &state.source {
353        validate_source_provenance(&state.requested_source, source)?;
354    }
355
356    let page_size = u64::from(state.page_size);
357    let pages_fetched = u64::from(state.pages_fetched);
358    let neurons_fetched = state.neurons_fetched;
359    let maximum_rows = pages_fetched
360        .checked_mul(page_size)
361        .ok_or(NnsNeuronError::CollectionAccountingOverflow)?;
362    let minimum_rows = pages_fetched
363        .saturating_sub(1)
364        .checked_mul(page_size)
365        .ok_or(NnsNeuronError::CollectionAccountingOverflow)?;
366    if neurons_fetched < minimum_rows || neurons_fetched > maximum_rows {
367        return Err(invalid(format!(
368            "neurons_fetched {} is outside {}..={} for {} pages of size {}",
369            state.neurons_fetched, minimum_rows, maximum_rows, state.pages_fetched, state.page_size
370        )));
371    }
372
373    let valid_lifecycle = match state.status {
374        NnsNeuronCollectionStatus::Ready => {
375            state.pages_fetched == 0
376                && state.neurons_fetched == 0
377                && state.next_start_neuron_id.is_none()
378                && state.source.is_none()
379        }
380        NnsNeuronCollectionStatus::Collecting => {
381            state.pages_fetched > 0
382                && state.pages_fetched < state.max_pages
383                && neurons_fetched == maximum_rows
384                && state.next_start_neuron_id.is_some()
385                && state.source.is_some()
386        }
387        NnsNeuronCollectionStatus::Complete => {
388            state.pages_fetched > 0
389                && neurons_fetched < maximum_rows
390                && state.next_start_neuron_id.is_none()
391                && state.source.is_some()
392        }
393        NnsNeuronCollectionStatus::PageLimitReached => {
394            state.pages_fetched == state.max_pages
395                && neurons_fetched == maximum_rows
396                && state.next_start_neuron_id.is_some()
397                && state.source.is_some()
398        }
399    };
400    if !valid_lifecycle {
401        return Err(invalid(format!(
402            "status {} disagrees with cursor, provenance, or counters",
403            state.status
404        )));
405    }
406    Ok(())
407}