Skip to main content

ic_query/nns/proposals/report/collection/
mod.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 input and derives the next state from one admitted source page.
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};
16#[cfg(feature = "nns-host")]
17use crate::nns::LiveNnsSource;
18use crate::nns::governance::{
19    GovernanceCollectionState, NnsGovernanceRequest, governance_collection_accessors,
20    validate_governance_continuation, validate_governance_request,
21};
22use serde::{Deserialize, Serialize};
23
24/// Version of the persistable resumable NNS proposal collection state.
25pub const NNS_PROPOSAL_COLLECTION_STATE_SCHEMA_VERSION: u32 = 1;
26
27///
28/// NnsProposalCollectionState
29///
30/// Serializable continuation state for an explicitly bounded proposal walk.
31///
32
33#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
34#[serde(deny_unknown_fields)]
35pub struct NnsProposalCollectionState {
36    #[serde(flatten)]
37    collection: GovernanceCollectionState,
38    proposals_fetched: u64,
39    next_before_proposal_id: Option<u64>,
40}
41
42impl NnsProposalCollectionState {
43    /// Start an empty proposal walk with explicit per-page and cumulative call ceilings.
44    pub fn new(
45        request: &NnsGovernanceRequest,
46        page_size: u32,
47        max_pages: u32,
48    ) -> Result<Self, NnsProposalError> {
49        validate_governance_request(request)?;
50        validate_proposal_page_size(page_size)?;
51        if max_pages == 0 {
52            return Err(NnsProposalError::InvalidCollectionMaxPages);
53        }
54        Ok(Self {
55            collection: GovernanceCollectionState::new(request, page_size, max_pages),
56            proposals_fetched: 0,
57            next_before_proposal_id: None,
58        })
59    }
60
61    #[cfg(feature = "nns-host")]
62    pub(in crate::nns) const fn from_complete_collection(
63        collection: GovernanceCollectionState,
64        rows: u64,
65    ) -> Self {
66        Self {
67            collection,
68            proposals_fetched: rows,
69            next_before_proposal_id: None,
70        }
71    }
72
73    #[cfg(feature = "nns-host")]
74    pub(in crate::nns) fn collection_evidence(
75        &self,
76    ) -> crate::nns::governance::collection::NnsGovernanceCollectionEvidence {
77        crate::nns::governance::collection::NnsGovernanceCollectionEvidence::from_state(
78            &self.collection,
79        )
80    }
81
82    governance_collection_accessors!(next_before_proposal_id);
83
84    /// Return the number of successfully admitted proposal rows.
85    #[must_use]
86    pub const fn proposals_fetched(&self) -> u64 {
87        self.proposals_fetched
88    }
89
90    /// Return the exclusive cursor for the next page, when one remains.
91    #[must_use]
92    pub const fn next_before_proposal_id(&self) -> Option<u64> {
93        self.next_before_proposal_id
94    }
95}
96
97///
98/// NnsProposalCollectionStep
99///
100/// One admitted bounded page and the continuation state that follows it.
101///
102
103#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
104pub struct NnsProposalCollectionStep {
105    /// Unfiltered page returned by the shared proposal report builder.
106    pub page: NnsProposalListReport,
107    /// Validated state to persist only after retaining the page.
108    pub state: NnsProposalCollectionState,
109}
110
111/// Advance a resumable proposal walk through the native replica adapter.
112#[cfg(feature = "nns-host")]
113pub async fn advance_nns_proposal_collection(
114    request: &NnsGovernanceRequest,
115    state: &NnsProposalCollectionState,
116) -> Result<NnsProposalCollectionStep, NnsProposalHostError> {
117    Ok(
118        advance_nns_proposal_collection_with_source(request, state, &LiveNnsSource::default())
119            .await?,
120    )
121}
122
123/// Advance a resumable proposal walk by exactly one caller-runtime source call.
124pub async fn advance_nns_proposal_collection_with_source(
125    request: &NnsGovernanceRequest,
126    state: &NnsProposalCollectionState,
127    source: &dyn NnsProposalSource,
128) -> Result<NnsProposalCollectionStep, NnsProposalError> {
129    validate_governance_request(request)?;
130    validate_collection_state(state)?;
131    validate_governance_continuation!(request, state, NnsProposalError)?;
132
133    let mut page_request = NnsProposalListRequest::new(request.clone(), state.collection.page_size);
134    page_request.before_proposal_id = state.next_before_proposal_id;
135    let page = build_nns_proposal_list_report_with_source(&page_request, source).await?;
136    let (collection, proposals_fetched) = state.collection.after_page(
137        request,
138        page.context.source.clone(),
139        state.proposals_fetched,
140        page.proposals.len(),
141        |expected, actual| NnsProposalError::CollectionSourceChanged { expected, actual },
142        NnsProposalError::CollectionAccountingOverflow,
143    )?;
144    let page_count = u32::try_from(page.proposals.len())
145        .map_err(|_| NnsProposalError::CollectionAccountingOverflow)?;
146    let next_before_proposal_id = (page_count == state.collection.page_size)
147        .then(|| {
148            page.proposals
149                .iter()
150                .filter_map(|proposal| proposal.proposal_id)
151                .min()
152                .filter(|proposal_id| *proposal_id > 1)
153        })
154        .flatten();
155    let next_state = NnsProposalCollectionState {
156        collection,
157        proposals_fetched,
158        next_before_proposal_id,
159    };
160    Ok(NnsProposalCollectionStep {
161        page,
162        state: next_state,
163    })
164}
165
166pub(super) fn validate_collection_state(
167    state: &NnsProposalCollectionState,
168) -> Result<(), NnsProposalError> {
169    let invalid = |reason| NnsProposalError::InvalidCollectionState { reason };
170    state
171        .collection
172        .validate(NNS_PROPOSAL_COLLECTION_STATE_SCHEMA_VERSION, invalid)?;
173    validate_proposal_page_size(state.collection.page_size)?;
174
175    let proposals_fetched = state.proposals_fetched;
176    let maximum_rows =
177        state
178            .collection
179            .validate_row_bounds("proposals_fetched", proposals_fetched, invalid)?;
180
181    if state
182        .next_before_proposal_id
183        .is_some_and(|cursor| cursor <= 1)
184    {
185        return Err(invalid(
186            "next_before_proposal_id must be greater than one".to_string(),
187        ));
188    }
189
190    state.collection.validate_lifecycle(
191        proposals_fetched,
192        maximum_rows,
193        state.next_before_proposal_id.is_some(),
194        true,
195        invalid,
196    )
197}
198
199#[cfg(test)]
200mod tests;