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        NnsGovernanceCollectionStatus, NnsGovernanceRequest, NnsGovernanceSourceProvenance,
18        NnsGovernanceSourceSelection, validate_governance_request,
19        validate_governance_time_interval, validate_source_provenance,
20    },
21};
22#[cfg(feature = "nns-host")]
23use crate::{nns::LiveNnsSource, runtime::block_on_current_thread};
24use serde::{Deserialize, Serialize};
25
26/// Version of the persistable resumable NNS neuron collection state.
27pub const NNS_NEURON_COLLECTION_STATE_SCHEMA_VERSION: u32 = 1;
28
29///
30/// NnsNeuronCollectionState
31///
32/// Serializable continuation state for an explicitly bounded public-neuron walk.
33///
34
35#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
36pub struct NnsNeuronCollectionState {
37    schema_version: u32,
38    network: String,
39    governance_canister_id: String,
40    requested_source: NnsGovernanceSourceSelection,
41    source: Option<NnsGovernanceSourceProvenance>,
42    page_size: u32,
43    max_pages: u32,
44    pages_fetched: u32,
45    neurons_fetched: u64,
46    next_start_neuron_id: Option<u64>,
47    started_at: String,
48    updated_at: String,
49    status: NnsGovernanceCollectionStatus,
50}
51
52impl NnsNeuronCollectionState {
53    /// Start an empty neuron walk with explicit per-page and cumulative call ceilings.
54    pub fn new(
55        request: &NnsGovernanceRequest,
56        page_size: u32,
57        max_pages: u32,
58    ) -> Result<Self, NnsNeuronError> {
59        validate_governance_request(request)?;
60        validate_page_size(page_size)?;
61        if max_pages == 0 {
62            return Err(NnsNeuronError::InvalidCollectionMaxPages);
63        }
64        Ok(Self {
65            schema_version: NNS_NEURON_COLLECTION_STATE_SCHEMA_VERSION,
66            network: request.network.clone(),
67            governance_canister_id: MAINNET_GOVERNANCE_CANISTER_ID.to_string(),
68            requested_source: request.source.clone(),
69            source: None,
70            page_size,
71            max_pages,
72            pages_fetched: 0,
73            neurons_fetched: 0,
74            next_start_neuron_id: None,
75            started_at: request.fetched_at.clone(),
76            updated_at: request.fetched_at.clone(),
77            status: NnsGovernanceCollectionStatus::Ready,
78        })
79    }
80
81    /// Return the state schema version.
82    #[must_use]
83    pub const fn schema_version(&self) -> u32 {
84        self.schema_version
85    }
86
87    /// Return the fixed network identity.
88    #[must_use]
89    pub fn network(&self) -> &str {
90        &self.network
91    }
92
93    /// Return the fixed Governance canister identity.
94    #[must_use]
95    pub fn governance_canister_id(&self) -> &str {
96        &self.governance_canister_id
97    }
98
99    /// Return the source selection fixed when collection started.
100    #[must_use]
101    pub const fn requested_source(&self) -> &NnsGovernanceSourceSelection {
102        &self.requested_source
103    }
104
105    /// Return the concrete source provenance after the first admitted page.
106    #[must_use]
107    pub const fn source(&self) -> Option<&NnsGovernanceSourceProvenance> {
108        self.source.as_ref()
109    }
110
111    /// Return the maximum rows requested from each Governance call.
112    #[must_use]
113    pub const fn page_size(&self) -> u32 {
114        self.page_size
115    }
116
117    /// Return the cumulative source-call ceiling.
118    #[must_use]
119    pub const fn max_pages(&self) -> u32 {
120        self.max_pages
121    }
122
123    /// Return the number of successfully admitted pages.
124    #[must_use]
125    pub const fn pages_fetched(&self) -> u32 {
126        self.pages_fetched
127    }
128
129    /// Return the number of successfully admitted neuron rows.
130    #[must_use]
131    pub const fn neurons_fetched(&self) -> u64 {
132        self.neurons_fetched
133    }
134
135    /// Return the exclusive lower neuron-id bound for the next page.
136    #[must_use]
137    pub const fn next_start_neuron_id(&self) -> Option<u64> {
138        self.next_start_neuron_id
139    }
140
141    /// Return the caller-supplied time at which the collection state was created.
142    #[must_use]
143    pub fn started_at(&self) -> &str {
144        &self.started_at
145    }
146
147    /// Return the caller-supplied time attached to the latest admitted page.
148    #[must_use]
149    pub fn updated_at(&self) -> &str {
150        &self.updated_at
151    }
152
153    /// Return the collection lifecycle status.
154    #[must_use]
155    pub const fn status(&self) -> NnsGovernanceCollectionStatus {
156        self.status
157    }
158
159    /// Return whether Governance API exhaustion was observed.
160    #[must_use]
161    pub const fn is_complete(&self) -> bool {
162        matches!(self.status, NnsGovernanceCollectionStatus::Complete)
163    }
164}
165
166///
167/// NnsNeuronCollectionStep
168///
169/// One admitted bounded page and the continuation state that follows it.
170///
171
172#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
173pub struct NnsNeuronCollectionStep {
174    /// Page returned by the shared public-neuron report builder.
175    pub page: NnsNeuronListReport,
176    /// Validated state to persist only after retaining the page.
177    pub state: NnsNeuronCollectionState,
178}
179
180/// Advance a resumable public-neuron walk through the native replica adapter.
181#[cfg(feature = "nns-host")]
182pub fn advance_nns_neuron_collection(
183    request: &NnsGovernanceRequest,
184    state: &NnsNeuronCollectionState,
185) -> Result<NnsNeuronCollectionStep, NnsNeuronHostError> {
186    Ok(block_on_current_thread(
187        advance_nns_neuron_collection_with_source(request, state, &LiveNnsSource),
188    )??)
189}
190
191/// Advance a resumable public-neuron walk by exactly one caller-runtime source call.
192pub async fn advance_nns_neuron_collection_with_source(
193    request: &NnsGovernanceRequest,
194    state: &NnsNeuronCollectionState,
195    source: &dyn NnsNeuronSource,
196) -> Result<NnsNeuronCollectionStep, NnsNeuronError> {
197    validate_governance_request(request)?;
198    validate_collection_state(state)?;
199    validate_continuation_request(request, state)?;
200    validate_governance_time_interval(
201        "updated_at",
202        &state.updated_at,
203        "fetched_at",
204        &request.fetched_at,
205    )?;
206    match state.status {
207        NnsGovernanceCollectionStatus::Complete => {
208            return Err(NnsNeuronError::CollectionComplete {
209                pages_fetched: state.pages_fetched,
210            });
211        }
212        NnsGovernanceCollectionStatus::PageLimitReached => {
213            return Err(NnsNeuronError::CollectionPageLimitReached {
214                pages_fetched: state.pages_fetched,
215                max_pages: state.max_pages,
216            });
217        }
218        NnsGovernanceCollectionStatus::Ready | NnsGovernanceCollectionStatus::Collecting => {}
219    }
220
221    let mut page_request = NnsNeuronListRequest::new(request.clone(), state.page_size);
222    page_request.exclusive_start_neuron_id = state.next_start_neuron_id;
223    let page = build_nns_neuron_list_report_with_source(&page_request, source).await?;
224    if let Some(expected) = &state.source
225        && *expected != page.context.source
226    {
227        return Err(NnsNeuronError::CollectionSourceChanged {
228            expected: expected.clone(),
229            actual: page.context.source,
230        });
231    }
232
233    let pages_fetched = state
234        .pages_fetched
235        .checked_add(1)
236        .ok_or(NnsNeuronError::CollectionAccountingOverflow)?;
237    let page_row_count = u64::try_from(page.returned_neuron_count)
238        .map_err(|_| NnsNeuronError::CollectionAccountingOverflow)?;
239    let neurons_fetched = state
240        .neurons_fetched
241        .checked_add(page_row_count)
242        .ok_or(NnsNeuronError::CollectionAccountingOverflow)?;
243    let next_start_neuron_id = page.next_start_neuron_id;
244    let status = if next_start_neuron_id.is_none() {
245        NnsGovernanceCollectionStatus::Complete
246    } else if pages_fetched == state.max_pages {
247        NnsGovernanceCollectionStatus::PageLimitReached
248    } else {
249        NnsGovernanceCollectionStatus::Collecting
250    };
251    let next_state = NnsNeuronCollectionState {
252        source: Some(page.context.source.clone()),
253        pages_fetched,
254        neurons_fetched,
255        next_start_neuron_id,
256        updated_at: request.fetched_at.clone(),
257        status,
258        ..state.clone()
259    };
260    validate_collection_state(&next_state)?;
261    Ok(NnsNeuronCollectionStep {
262        page,
263        state: next_state,
264    })
265}
266
267fn validate_continuation_request(
268    request: &NnsGovernanceRequest,
269    state: &NnsNeuronCollectionState,
270) -> Result<(), NnsNeuronError> {
271    if request.network != state.network {
272        return Err(NnsNeuronError::CollectionRequestMismatch {
273            field: "network",
274            expected: state.network.clone(),
275            actual: request.network.clone(),
276        });
277    }
278    if request.source != state.requested_source {
279        return Err(NnsNeuronError::CollectionRequestMismatch {
280            field: "requested_source",
281            expected: format!("{:?}", state.requested_source),
282            actual: format!("{:?}", request.source),
283        });
284    }
285    Ok(())
286}
287
288pub(super) fn validate_collection_state(
289    state: &NnsNeuronCollectionState,
290) -> Result<(), NnsNeuronError> {
291    let invalid = |reason| NnsNeuronError::InvalidCollectionState { reason };
292    if state.schema_version != NNS_NEURON_COLLECTION_STATE_SCHEMA_VERSION {
293        return Err(invalid(format!(
294            "schema_version is {}, expected {NNS_NEURON_COLLECTION_STATE_SCHEMA_VERSION}",
295            state.schema_version
296        )));
297    }
298    if state.governance_canister_id != MAINNET_GOVERNANCE_CANISTER_ID {
299        return Err(invalid(format!(
300            "governance_canister_id is {}, expected {MAINNET_GOVERNANCE_CANISTER_ID}",
301            state.governance_canister_id
302        )));
303    }
304    let state_request = NnsGovernanceRequest {
305        network: state.network.clone(),
306        fetched_at: state.started_at.clone(),
307        source: state.requested_source.clone(),
308    };
309    validate_governance_request(&state_request)?;
310    validate_governance_time_interval(
311        "started_at",
312        &state.started_at,
313        "updated_at",
314        &state.updated_at,
315    )?;
316    validate_page_size(state.page_size)?;
317    if state.max_pages == 0 {
318        return Err(invalid("max_pages must be greater than zero".to_string()));
319    }
320    if state.pages_fetched > state.max_pages {
321        return Err(invalid(format!(
322            "pages_fetched {} exceeds max_pages {}",
323            state.pages_fetched, state.max_pages
324        )));
325    }
326    if let Some(source) = &state.source {
327        validate_source_provenance(&state.requested_source, source)?;
328    }
329
330    let page_size = u64::from(state.page_size);
331    let pages_fetched = u64::from(state.pages_fetched);
332    let neurons_fetched = state.neurons_fetched;
333    let maximum_rows = pages_fetched
334        .checked_mul(page_size)
335        .ok_or(NnsNeuronError::CollectionAccountingOverflow)?;
336    let minimum_rows = pages_fetched
337        .saturating_sub(1)
338        .checked_mul(page_size)
339        .ok_or(NnsNeuronError::CollectionAccountingOverflow)?;
340    if neurons_fetched < minimum_rows || neurons_fetched > maximum_rows {
341        return Err(invalid(format!(
342            "neurons_fetched {} is outside {}..={} for {} pages of size {}",
343            state.neurons_fetched, minimum_rows, maximum_rows, state.pages_fetched, state.page_size
344        )));
345    }
346
347    let valid_lifecycle = match state.status {
348        NnsGovernanceCollectionStatus::Ready => {
349            state.pages_fetched == 0
350                && state.neurons_fetched == 0
351                && state.next_start_neuron_id.is_none()
352                && state.source.is_none()
353        }
354        NnsGovernanceCollectionStatus::Collecting => {
355            state.pages_fetched > 0
356                && state.pages_fetched < state.max_pages
357                && neurons_fetched == maximum_rows
358                && state.next_start_neuron_id.is_some()
359                && state.source.is_some()
360        }
361        NnsGovernanceCollectionStatus::Complete => {
362            state.pages_fetched > 0
363                && neurons_fetched < maximum_rows
364                && state.next_start_neuron_id.is_none()
365                && state.source.is_some()
366        }
367        NnsGovernanceCollectionStatus::PageLimitReached => {
368            state.pages_fetched == state.max_pages
369                && neurons_fetched == maximum_rows
370                && state.next_start_neuron_id.is_some()
371                && state.source.is_some()
372        }
373    };
374    if !valid_lifecycle {
375        return Err(invalid(format!(
376            "status {} disagrees with cursor, provenance, or counters",
377            state.status
378        )));
379    }
380    Ok(())
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    use crate::nns::governance::NnsGovernanceError;
387
388    struct UnexpectedSource;
389    impl NnsNeuronSource for UnexpectedSource {
390        fn fetch_neuron_page<'a>(
391            &'a self,
392            _: &'a NnsGovernanceRequest,
393            _: Option<u64>,
394            _: u32,
395        ) -> crate::nns::neuron::NnsNeuronSourceFuture<'a, crate::nns::neuron::NnsNeuronPage>
396        {
397            panic!("unexpected source call")
398        }
399        fn fetch_neuron<'a>(
400            &'a self,
401            _: &'a NnsGovernanceRequest,
402            _: u64,
403        ) -> crate::nns::neuron::NnsNeuronSourceFuture<'a, crate::nns::neuron::NnsNeuronRow>
404        {
405            panic!("unexpected source call")
406        }
407    }
408
409    #[test]
410    fn collection_rejects_backward_continuations_and_restored_intervals() {
411        let start = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs("ic", 100);
412        let state = NnsNeuronCollectionState::new(&start, 2, 3).unwrap();
413        let before = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs("ic", 99);
414        let mut future = std::pin::pin!(advance_nns_neuron_collection_with_source(
415            &before,
416            &state,
417            &UnexpectedSource
418        ));
419        let mut context = std::task::Context::from_waker(std::task::Waker::noop());
420        let result = std::future::Future::poll(future.as_mut(), &mut context);
421        assert!(matches!(
422            result,
423            std::task::Poll::Ready(Err(NnsNeuronError::Governance(
424                NnsGovernanceError::InvalidTimestampOrder { .. }
425            )))
426        ));
427        let mut value = serde_json::to_value(&state).unwrap();
428        value["updated_at"] = serde_json::json!(before.fetched_at);
429        let restored = serde_json::from_value(value).unwrap();
430        assert!(matches!(
431            validate_collection_state(&restored),
432            Err(NnsNeuronError::Governance(
433                NnsGovernanceError::InvalidTimestampOrder { .. }
434            ))
435        ));
436        assert!(
437            validate_governance_time_interval(
438                "updated_at",
439                &state.updated_at,
440                "fetched_at",
441                &start.fetched_at
442            )
443            .is_ok()
444        );
445    }
446
447    #[test]
448    fn collection_validates_request_and_restored_timestamps() {
449        let mut request = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs(
450            "ic",
451            1_700_000_000,
452        );
453        let valid_state = NnsNeuronCollectionState::new(&request, 2, 3).expect("valid state");
454        for timestamp in ["not a UTC timestamp", "+2023-11-14T22:13:20Z"] {
455            request.fetched_at = timestamp.to_string();
456            assert!(matches!(
457                NnsNeuronCollectionState::new(&request, 2, 3),
458                Err(NnsNeuronError::Governance(
459                    NnsGovernanceError::InvalidTimestamp {
460                        field: "fetched_at",
461                        ..
462                    }
463                ))
464            ));
465            for field in ["started_at", "updated_at"] {
466                let mut value = serde_json::to_value(&valid_state).expect("serialize state");
467                value[field] = serde_json::json!(timestamp);
468                let state = serde_json::from_value(value).expect("restore caller state");
469                assert!(matches!(
470                    validate_collection_state(&state),
471                    Err(NnsNeuronError::Governance(
472                        NnsGovernanceError::InvalidTimestamp { value, .. }
473                    )) if value == timestamp
474                ));
475            }
476        }
477    }
478}