Skip to main content

ic_query/nns/proposals/report/
collection.rs

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