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 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, NnsGovernanceCollectionStatus, NnsGovernanceRequest,
20    NnsGovernanceSourceProvenance, NnsGovernanceSourceSelection, validate_governance_continuation,
21    validate_governance_request,
22};
23use serde::{Deserialize, Serialize};
24
25/// Version of the persistable resumable NNS proposal collection state.
26pub const NNS_PROPOSAL_COLLECTION_STATE_SCHEMA_VERSION: u32 = 1;
27
28///
29/// NnsProposalCollectionState
30///
31/// Serializable continuation state for an explicitly bounded proposal walk.
32///
33
34#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
35#[serde(deny_unknown_fields)]
36pub struct NnsProposalCollectionState {
37    #[serde(flatten)]
38    collection: GovernanceCollectionState,
39    proposals_fetched: u64,
40    next_before_proposal_id: Option<u64>,
41}
42
43impl NnsProposalCollectionState {
44    /// Start an empty proposal walk with explicit per-page and cumulative call ceilings.
45    pub fn new(
46        request: &NnsGovernanceRequest,
47        page_size: u32,
48        max_pages: u32,
49    ) -> Result<Self, NnsProposalError> {
50        validate_governance_request(request)?;
51        validate_proposal_page_size(page_size)?;
52        if max_pages == 0 {
53            return Err(NnsProposalError::InvalidCollectionMaxPages);
54        }
55        Ok(Self {
56            collection: GovernanceCollectionState::new(request, page_size, max_pages),
57            proposals_fetched: 0,
58            next_before_proposal_id: None,
59        })
60    }
61
62    #[cfg(feature = "nns-host")]
63    pub(in crate::nns) const fn from_complete_collection(
64        collection: GovernanceCollectionState,
65        rows: u64,
66    ) -> Self {
67        Self {
68            collection,
69            proposals_fetched: rows,
70            next_before_proposal_id: None,
71        }
72    }
73
74    #[cfg(feature = "nns-host")]
75    pub(in crate::nns) fn collection_evidence(
76        &self,
77    ) -> crate::nns::governance::collection::NnsGovernanceCollectionEvidence {
78        crate::nns::governance::collection::NnsGovernanceCollectionEvidence::from_state(
79            &self.collection,
80        )
81    }
82
83    /// Return the state schema version.
84    #[must_use]
85    pub const fn schema_version(&self) -> u32 {
86        self.collection.schema_version
87    }
88
89    /// Return the fixed network identity.
90    #[must_use]
91    pub fn network(&self) -> &str {
92        &self.collection.network
93    }
94
95    /// Return the fixed Governance canister identity.
96    #[must_use]
97    pub fn governance_canister_id(&self) -> &str {
98        &self.collection.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.collection.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.collection.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.collection.page_size
117    }
118
119    /// Return the cumulative source-call ceiling.
120    #[must_use]
121    pub const fn max_pages(&self) -> u32 {
122        self.collection.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.collection.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.collection.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.collection.updated_at
153    }
154
155    /// Return the collection lifecycle status.
156    #[must_use]
157    pub const fn status(&self) -> NnsGovernanceCollectionStatus {
158        self.collection
159            .status(self.next_before_proposal_id.is_some())
160    }
161
162    /// Return whether Governance API exhaustion was observed.
163    #[must_use]
164    pub const fn is_complete(&self) -> bool {
165        matches!(self.status(), NnsGovernanceCollectionStatus::Complete)
166    }
167}
168
169///
170/// NnsProposalCollectionStep
171///
172/// One admitted bounded page and the continuation state that follows it.
173///
174
175#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
176pub struct NnsProposalCollectionStep {
177    /// Unfiltered page returned by the shared proposal report builder.
178    pub page: NnsProposalListReport,
179    /// Validated state to persist only after retaining the page.
180    pub state: NnsProposalCollectionState,
181}
182
183/// Advance a resumable proposal walk through the native replica adapter.
184#[cfg(feature = "nns-host")]
185pub async fn advance_nns_proposal_collection(
186    request: &NnsGovernanceRequest,
187    state: &NnsProposalCollectionState,
188) -> Result<NnsProposalCollectionStep, NnsProposalHostError> {
189    Ok(
190        advance_nns_proposal_collection_with_source(request, state, &LiveNnsSource::default())
191            .await?,
192    )
193}
194
195/// Advance a resumable proposal walk by exactly one caller-runtime source call.
196pub async fn advance_nns_proposal_collection_with_source(
197    request: &NnsGovernanceRequest,
198    state: &NnsProposalCollectionState,
199    source: &dyn NnsProposalSource,
200) -> Result<NnsProposalCollectionStep, NnsProposalError> {
201    validate_governance_request(request)?;
202    validate_collection_state(state)?;
203    validate_governance_continuation!(request, state, NnsProposalError)?;
204
205    let mut page_request = NnsProposalListRequest::new(request.clone(), state.collection.page_size);
206    page_request.before_proposal_id = state.next_before_proposal_id;
207    let page = build_nns_proposal_list_report_with_source(&page_request, source).await?;
208    if let Some(expected) = &state.collection.source
209        && *expected != page.context.source
210    {
211        return Err(NnsProposalError::CollectionSourceChanged {
212            expected: expected.clone(),
213            actual: page.context.source,
214        });
215    }
216
217    let page_count = u32::try_from(page.proposals.len())
218        .map_err(|_| NnsProposalError::CollectionAccountingOverflow)?;
219    let (pages_fetched, proposals_fetched) = crate::nns::governance::advance_collection_counts(
220        state.collection.pages_fetched,
221        state.proposals_fetched,
222        u64::from(page_count),
223    )
224    .ok_or(NnsProposalError::CollectionAccountingOverflow)?;
225    let next_before_proposal_id = (page_count == state.collection.page_size)
226        .then(|| {
227            page.proposals
228                .iter()
229                .filter_map(|proposal| proposal.proposal_id)
230                .min()
231                .filter(|proposal_id| *proposal_id > 1)
232        })
233        .flatten();
234    let next_state = NnsProposalCollectionState {
235        collection: state.collection.after_page(
236            request,
237            page.context.source.clone(),
238            pages_fetched,
239        ),
240        proposals_fetched,
241        next_before_proposal_id,
242    };
243    Ok(NnsProposalCollectionStep {
244        page,
245        state: next_state,
246    })
247}
248
249pub(super) fn validate_collection_state(
250    state: &NnsProposalCollectionState,
251) -> Result<(), NnsProposalError> {
252    let invalid = |reason| NnsProposalError::InvalidCollectionState { reason };
253    state
254        .collection
255        .validate(NNS_PROPOSAL_COLLECTION_STATE_SCHEMA_VERSION, invalid)?;
256    validate_proposal_page_size(state.collection.page_size)?;
257
258    let proposals_fetched = state.proposals_fetched;
259    let maximum_rows = crate::nns::governance::validate_governance_collection_row_bounds(
260        "proposals_fetched",
261        proposals_fetched,
262        state.collection.pages_fetched,
263        state.collection.page_size,
264        invalid,
265    )?;
266
267    if state
268        .next_before_proposal_id
269        .is_some_and(|cursor| cursor <= 1)
270    {
271        return Err(invalid(
272            "next_before_proposal_id must be greater than one".to_string(),
273        ));
274    }
275
276    let valid_lifecycle = crate::nns::governance::collection_lifecycle_matches(
277        state.status(),
278        state.collection.pages_fetched,
279        state.collection.max_pages,
280        state.proposals_fetched,
281        maximum_rows,
282        state.next_before_proposal_id.is_some(),
283        state.collection.source.is_some(),
284    );
285    if !valid_lifecycle {
286        return Err(invalid(format!(
287            "status {} disagrees with cursor, provenance, or counters",
288            state.status()
289        )));
290    }
291    Ok(())
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use crate::nns::governance::{NnsGovernanceError, validate_governance_time_interval};
298
299    struct UnexpectedSource;
300    impl NnsProposalSource for UnexpectedSource {
301        fn fetch_proposals<'a>(
302            &'a self,
303            _: &'a NnsGovernanceRequest,
304            _: u32,
305            _: Option<u64>,
306            _: crate::nns::proposals::NnsProposalStatusFilter,
307            _: crate::nns::proposals::NnsProposalRewardStatusFilter,
308        ) -> crate::nns::proposals::NnsProposalSourceFuture<
309            'a,
310            Vec<crate::nns::proposals::NnsProposalRow>,
311        > {
312            panic!("unexpected source call")
313        }
314        fn fetch_proposal<'a>(
315            &'a self,
316            _: &'a NnsGovernanceRequest,
317            _: u64,
318        ) -> crate::nns::proposals::NnsProposalSourceFuture<'a, crate::nns::proposals::NnsProposalRow>
319        {
320            panic!("unexpected source call")
321        }
322    }
323
324    #[tokio::test(flavor = "current_thread")]
325    async fn collection_rejects_backward_continuations_and_restored_intervals() {
326        let start = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs("ic", 100);
327        let state = NnsProposalCollectionState::new(&start, 2, 3).unwrap();
328        let before = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs("ic", 99);
329        let mut future = std::pin::pin!(advance_nns_proposal_collection_with_source(
330            &before,
331            &state,
332            &UnexpectedSource
333        ));
334        let mut context = std::task::Context::from_waker(std::task::Waker::noop());
335        let result = std::future::Future::poll(future.as_mut(), &mut context);
336        assert!(matches!(
337            result,
338            std::task::Poll::Ready(Err(NnsProposalError::Governance(
339                NnsGovernanceError::InvalidTimestampOrder { .. }
340            )))
341        ));
342        let mut value = serde_json::to_value(&state).unwrap();
343        value["updated_at"] = serde_json::json!(before.fetched_at);
344        let restored = serde_json::from_value(value).unwrap();
345        assert!(matches!(
346            validate_collection_state(&restored),
347            Err(NnsProposalError::Governance(
348                NnsGovernanceError::InvalidTimestampOrder { .. }
349            ))
350        ));
351        assert!(
352            validate_governance_time_interval(
353                "updated_at",
354                &state.collection.updated_at,
355                "fetched_at",
356                &start.fetched_at
357            )
358            .is_ok()
359        );
360    }
361
362    #[test]
363    fn collection_validates_request_and_restored_timestamps() {
364        let mut request = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs(
365            "ic",
366            1_700_000_000,
367        );
368        let valid_state = NnsProposalCollectionState::new(&request, 2, 3).expect("valid state");
369        for (field, value) in [
370            ("governance_canister_id", serde_json::json!("aaaaa-aa")),
371            ("max_pages", serde_json::json!(0)),
372            ("pages_fetched", serde_json::json!(4)),
373        ] {
374            let mut restored = serde_json::to_value(&valid_state).unwrap();
375            restored[field] = value;
376            let restored = serde_json::from_value(restored).unwrap();
377            assert!(
378                matches!(
379                    validate_collection_state(&restored),
380                    Err(NnsProposalError::InvalidCollectionState { .. })
381                ),
382                "invalid restored {field}"
383            );
384        }
385        for timestamp in ["not a UTC timestamp", "+2023-11-14T22:13:20Z"] {
386            request.fetched_at = timestamp.to_string();
387            assert!(matches!(
388                NnsProposalCollectionState::new(&request, 2, 3),
389                Err(NnsProposalError::Governance(
390                    NnsGovernanceError::InvalidTimestamp {
391                        field: "fetched_at",
392                        ..
393                    }
394                ))
395            ));
396            for field in ["started_at", "updated_at"] {
397                let mut value = serde_json::to_value(&valid_state).expect("serialize state");
398                value[field] = serde_json::json!(timestamp);
399                let state = serde_json::from_value(value).expect("restore caller state");
400                assert!(matches!(
401                    validate_collection_state(&state),
402                    Err(NnsProposalError::Governance(
403                        NnsGovernanceError::InvalidTimestamp { value, .. }
404                    )) if value == timestamp
405                ));
406            }
407        }
408    }
409}