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    if let Some(expected) = &state.collection.source
137        && *expected != page.context.source
138    {
139        return Err(NnsProposalError::CollectionSourceChanged {
140            expected: expected.clone(),
141            actual: page.context.source,
142        });
143    }
144
145    let page_count = u32::try_from(page.proposals.len())
146        .map_err(|_| NnsProposalError::CollectionAccountingOverflow)?;
147    let (pages_fetched, proposals_fetched) = crate::nns::governance::advance_collection_counts(
148        state.collection.pages_fetched,
149        state.proposals_fetched,
150        u64::from(page_count),
151    )
152    .ok_or(NnsProposalError::CollectionAccountingOverflow)?;
153    let next_before_proposal_id = (page_count == state.collection.page_size)
154        .then(|| {
155            page.proposals
156                .iter()
157                .filter_map(|proposal| proposal.proposal_id)
158                .min()
159                .filter(|proposal_id| *proposal_id > 1)
160        })
161        .flatten();
162    let next_state = NnsProposalCollectionState {
163        collection: state.collection.after_page(
164            request,
165            page.context.source.clone(),
166            pages_fetched,
167        ),
168        proposals_fetched,
169        next_before_proposal_id,
170    };
171    Ok(NnsProposalCollectionStep {
172        page,
173        state: next_state,
174    })
175}
176
177pub(super) fn validate_collection_state(
178    state: &NnsProposalCollectionState,
179) -> Result<(), NnsProposalError> {
180    let invalid = |reason| NnsProposalError::InvalidCollectionState { reason };
181    state
182        .collection
183        .validate(NNS_PROPOSAL_COLLECTION_STATE_SCHEMA_VERSION, invalid)?;
184    validate_proposal_page_size(state.collection.page_size)?;
185
186    let proposals_fetched = state.proposals_fetched;
187    let maximum_rows = crate::nns::governance::validate_governance_collection_row_bounds(
188        "proposals_fetched",
189        proposals_fetched,
190        state.collection.pages_fetched,
191        state.collection.page_size,
192        invalid,
193    )?;
194
195    if state
196        .next_before_proposal_id
197        .is_some_and(|cursor| cursor <= 1)
198    {
199        return Err(invalid(
200            "next_before_proposal_id must be greater than one".to_string(),
201        ));
202    }
203
204    let valid_lifecycle = crate::nns::governance::collection_lifecycle_matches(
205        state.status(),
206        state.collection.pages_fetched,
207        state.collection.max_pages,
208        state.proposals_fetched,
209        maximum_rows,
210        state.next_before_proposal_id.is_some(),
211        state.collection.source.is_some(),
212    );
213    if !valid_lifecycle {
214        return Err(invalid(format!(
215            "status {} disagrees with cursor, provenance, or counters",
216            state.status()
217        )));
218    }
219    Ok(())
220}
221
222#[cfg(test)]
223mod tests;