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_governance_time_interval, 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    validate_governance_time_interval(
239        "updated_at",
240        &state.updated_at,
241        "fetched_at",
242        &request.fetched_at,
243    )?;
244    match state.status {
245        NnsNeuronCollectionStatus::Complete => {
246            return Err(NnsNeuronError::CollectionComplete {
247                pages_fetched: state.pages_fetched,
248            });
249        }
250        NnsNeuronCollectionStatus::PageLimitReached => {
251            return Err(NnsNeuronError::CollectionPageLimitReached {
252                pages_fetched: state.pages_fetched,
253                max_pages: state.max_pages,
254            });
255        }
256        NnsNeuronCollectionStatus::Ready | NnsNeuronCollectionStatus::Collecting => {}
257    }
258
259    let mut page_request = NnsNeuronListRequest::new(request.clone(), state.page_size);
260    page_request.exclusive_start_neuron_id = state.next_start_neuron_id;
261    let page = build_nns_neuron_list_report_with_source(&page_request, source).await?;
262    if let Some(expected) = &state.source
263        && *expected != page.context.source
264    {
265        return Err(NnsNeuronError::CollectionSourceChanged {
266            expected: expected.clone(),
267            actual: page.context.source,
268        });
269    }
270
271    let pages_fetched = state
272        .pages_fetched
273        .checked_add(1)
274        .ok_or(NnsNeuronError::CollectionAccountingOverflow)?;
275    let page_row_count = u64::try_from(page.returned_neuron_count)
276        .map_err(|_| NnsNeuronError::CollectionAccountingOverflow)?;
277    let neurons_fetched = state
278        .neurons_fetched
279        .checked_add(page_row_count)
280        .ok_or(NnsNeuronError::CollectionAccountingOverflow)?;
281    let next_start_neuron_id = page.next_start_neuron_id;
282    let status = if next_start_neuron_id.is_none() {
283        NnsNeuronCollectionStatus::Complete
284    } else if pages_fetched == state.max_pages {
285        NnsNeuronCollectionStatus::PageLimitReached
286    } else {
287        NnsNeuronCollectionStatus::Collecting
288    };
289    let next_state = NnsNeuronCollectionState {
290        source: Some(page.context.source.clone()),
291        pages_fetched,
292        neurons_fetched,
293        next_start_neuron_id,
294        updated_at: request.fetched_at.clone(),
295        status,
296        ..state.clone()
297    };
298    validate_collection_state(&next_state)?;
299    Ok(NnsNeuronCollectionStep {
300        page,
301        state: next_state,
302    })
303}
304
305fn validate_continuation_request(
306    request: &NnsGovernanceRequest,
307    state: &NnsNeuronCollectionState,
308) -> Result<(), NnsNeuronError> {
309    if request.network != state.network {
310        return Err(NnsNeuronError::CollectionRequestMismatch {
311            field: "network",
312            expected: state.network.clone(),
313            actual: request.network.clone(),
314        });
315    }
316    if request.source != state.requested_source {
317        return Err(NnsNeuronError::CollectionRequestMismatch {
318            field: "requested_source",
319            expected: format!("{:?}", state.requested_source),
320            actual: format!("{:?}", request.source),
321        });
322    }
323    Ok(())
324}
325
326pub(super) fn validate_collection_state(
327    state: &NnsNeuronCollectionState,
328) -> Result<(), NnsNeuronError> {
329    let invalid = |reason| NnsNeuronError::InvalidCollectionState { reason };
330    if state.schema_version != NNS_NEURON_COLLECTION_STATE_SCHEMA_VERSION {
331        return Err(invalid(format!(
332            "schema_version is {}, expected {NNS_NEURON_COLLECTION_STATE_SCHEMA_VERSION}",
333            state.schema_version
334        )));
335    }
336    if state.governance_canister_id != MAINNET_GOVERNANCE_CANISTER_ID {
337        return Err(invalid(format!(
338            "governance_canister_id is {}, expected {MAINNET_GOVERNANCE_CANISTER_ID}",
339            state.governance_canister_id
340        )));
341    }
342    let state_request = NnsGovernanceRequest {
343        network: state.network.clone(),
344        fetched_at: state.started_at.clone(),
345        source: state.requested_source.clone(),
346    };
347    validate_governance_request(&state_request)?;
348    validate_governance_time_interval(
349        "started_at",
350        &state.started_at,
351        "updated_at",
352        &state.updated_at,
353    )?;
354    validate_page_size(state.page_size)?;
355    if state.max_pages == 0 {
356        return Err(invalid("max_pages must be greater than zero".to_string()));
357    }
358    if state.pages_fetched > state.max_pages {
359        return Err(invalid(format!(
360            "pages_fetched {} exceeds max_pages {}",
361            state.pages_fetched, state.max_pages
362        )));
363    }
364    if let Some(source) = &state.source {
365        validate_source_provenance(&state.requested_source, source)?;
366    }
367
368    let page_size = u64::from(state.page_size);
369    let pages_fetched = u64::from(state.pages_fetched);
370    let neurons_fetched = state.neurons_fetched;
371    let maximum_rows = pages_fetched
372        .checked_mul(page_size)
373        .ok_or(NnsNeuronError::CollectionAccountingOverflow)?;
374    let minimum_rows = pages_fetched
375        .saturating_sub(1)
376        .checked_mul(page_size)
377        .ok_or(NnsNeuronError::CollectionAccountingOverflow)?;
378    if neurons_fetched < minimum_rows || neurons_fetched > maximum_rows {
379        return Err(invalid(format!(
380            "neurons_fetched {} is outside {}..={} for {} pages of size {}",
381            state.neurons_fetched, minimum_rows, maximum_rows, state.pages_fetched, state.page_size
382        )));
383    }
384
385    let valid_lifecycle = match state.status {
386        NnsNeuronCollectionStatus::Ready => {
387            state.pages_fetched == 0
388                && state.neurons_fetched == 0
389                && state.next_start_neuron_id.is_none()
390                && state.source.is_none()
391        }
392        NnsNeuronCollectionStatus::Collecting => {
393            state.pages_fetched > 0
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        NnsNeuronCollectionStatus::Complete => {
400            state.pages_fetched > 0
401                && neurons_fetched < maximum_rows
402                && state.next_start_neuron_id.is_none()
403                && state.source.is_some()
404        }
405        NnsNeuronCollectionStatus::PageLimitReached => {
406            state.pages_fetched == state.max_pages
407                && neurons_fetched == maximum_rows
408                && state.next_start_neuron_id.is_some()
409                && state.source.is_some()
410        }
411    };
412    if !valid_lifecycle {
413        return Err(invalid(format!(
414            "status {} disagrees with cursor, provenance, or counters",
415            state.status
416        )));
417    }
418    Ok(())
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use crate::nns::governance::NnsGovernanceError;
425
426    struct UnexpectedSource;
427    impl NnsNeuronSource for UnexpectedSource {
428        fn fetch_neuron_page<'a>(
429            &'a self,
430            _: &'a NnsGovernanceRequest,
431            _: Option<u64>,
432            _: u32,
433        ) -> crate::nns::neuron::NnsNeuronSourceFuture<'a, crate::nns::neuron::NnsNeuronPage>
434        {
435            panic!("unexpected source call")
436        }
437        fn fetch_neuron<'a>(
438            &'a self,
439            _: &'a NnsGovernanceRequest,
440            _: u64,
441        ) -> crate::nns::neuron::NnsNeuronSourceFuture<'a, crate::nns::neuron::NnsNeuronRow>
442        {
443            panic!("unexpected source call")
444        }
445    }
446
447    #[test]
448    fn collection_rejects_backward_continuations_and_restored_intervals() {
449        let start = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs("ic", 100);
450        let state = NnsNeuronCollectionState::new(&start, 2, 3).unwrap();
451        let before = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs("ic", 99);
452        let mut future = std::pin::pin!(advance_nns_neuron_collection_with_source(
453            &before,
454            &state,
455            &UnexpectedSource
456        ));
457        let mut context = std::task::Context::from_waker(std::task::Waker::noop());
458        let result = std::future::Future::poll(future.as_mut(), &mut context);
459        assert!(matches!(
460            result,
461            std::task::Poll::Ready(Err(NnsNeuronError::Governance(
462                NnsGovernanceError::InvalidTimestampOrder { .. }
463            )))
464        ));
465        let mut value = serde_json::to_value(&state).unwrap();
466        value["updated_at"] = serde_json::json!(before.fetched_at);
467        let restored = serde_json::from_value(value).unwrap();
468        assert!(matches!(
469            validate_collection_state(&restored),
470            Err(NnsNeuronError::Governance(
471                NnsGovernanceError::InvalidTimestampOrder { .. }
472            ))
473        ));
474        assert!(
475            validate_governance_time_interval(
476                "updated_at",
477                &state.updated_at,
478                "fetched_at",
479                &start.fetched_at
480            )
481            .is_ok()
482        );
483    }
484
485    #[test]
486    fn collection_validates_request_and_restored_timestamps() {
487        let mut request = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs(
488            "ic",
489            1_700_000_000,
490        );
491        let valid_state = NnsNeuronCollectionState::new(&request, 2, 3).expect("valid state");
492        request.fetched_at = "not a UTC timestamp".to_string();
493        assert!(matches!(
494            NnsNeuronCollectionState::new(&request, 2, 3),
495            Err(NnsNeuronError::Governance(
496                NnsGovernanceError::InvalidTimestamp {
497                    field: "fetched_at",
498                    ..
499                }
500            ))
501        ));
502        for field in ["started_at", "updated_at"] {
503            let mut value = serde_json::to_value(&valid_state).expect("serialize state");
504            value[field] = serde_json::json!("not a UTC timestamp");
505            let state = serde_json::from_value(value).expect("restore caller state");
506            assert!(matches!(
507                validate_collection_state(&state),
508                Err(NnsNeuronError::Governance(
509                    NnsGovernanceError::InvalidTimestamp { .. }
510                ))
511            ));
512        }
513    }
514}