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