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