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};
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 pages_fetched = state
238        .pages_fetched
239        .checked_add(1)
240        .ok_or(NnsProposalError::CollectionAccountingOverflow)?;
241    let proposals_fetched = state
242        .proposals_fetched
243        .checked_add(u64::from(page_count))
244        .ok_or(NnsProposalError::CollectionAccountingOverflow)?;
245    let next_before_proposal_id = (page_count == state.page_size)
246        .then(|| {
247            page.proposals
248                .iter()
249                .filter_map(|proposal| proposal.proposal_id)
250                .min()
251                .filter(|proposal_id| *proposal_id > 1)
252        })
253        .flatten();
254    let status = if next_before_proposal_id.is_none() {
255        NnsGovernanceCollectionStatus::Complete
256    } else if pages_fetched == state.max_pages {
257        NnsGovernanceCollectionStatus::PageLimitReached
258    } else {
259        NnsGovernanceCollectionStatus::Collecting
260    };
261    let next_state = NnsProposalCollectionState {
262        source: Some(page.context.source.clone()),
263        pages_fetched,
264        proposals_fetched,
265        next_before_proposal_id,
266        updated_at: request.fetched_at.clone(),
267        status,
268        ..state.clone()
269    };
270    Ok(NnsProposalCollectionStep {
271        page,
272        state: next_state,
273    })
274}
275
276fn validate_continuation_request(
277    request: &NnsGovernanceRequest,
278    state: &NnsProposalCollectionState,
279) -> Result<(), NnsProposalError> {
280    if request.network != state.network {
281        return Err(NnsProposalError::CollectionRequestMismatch {
282            field: "network",
283            expected: state.network.clone(),
284            actual: request.network.clone(),
285        });
286    }
287    if request.source != state.requested_source {
288        return Err(NnsProposalError::CollectionRequestMismatch {
289            field: "requested_source",
290            expected: format!("{:?}", state.requested_source),
291            actual: format!("{:?}", request.source),
292        });
293    }
294    Ok(())
295}
296
297pub(super) fn validate_collection_state(
298    state: &NnsProposalCollectionState,
299) -> Result<(), NnsProposalError> {
300    let invalid = |reason| NnsProposalError::InvalidCollectionState { reason };
301    if state.schema_version != NNS_PROPOSAL_COLLECTION_STATE_SCHEMA_VERSION {
302        return Err(invalid(format!(
303            "schema_version is {}, expected {NNS_PROPOSAL_COLLECTION_STATE_SCHEMA_VERSION}",
304            state.schema_version
305        )));
306    }
307    if state.governance_canister_id != MAINNET_GOVERNANCE_CANISTER_ID {
308        return Err(invalid(format!(
309            "governance_canister_id is {}, expected {MAINNET_GOVERNANCE_CANISTER_ID}",
310            state.governance_canister_id
311        )));
312    }
313    let state_request = NnsGovernanceRequest {
314        network: state.network.clone(),
315        fetched_at: state.started_at.clone(),
316        source: state.requested_source.clone(),
317    };
318    validate_governance_request(&state_request)?;
319    validate_governance_time_interval(
320        "started_at",
321        &state.started_at,
322        "updated_at",
323        &state.updated_at,
324    )?;
325    validate_proposal_page_size(state.page_size)?;
326    if state.max_pages == 0 {
327        return Err(invalid("max_pages must be greater than zero".to_string()));
328    }
329    if state.pages_fetched > state.max_pages {
330        return Err(invalid(format!(
331            "pages_fetched {} exceeds max_pages {}",
332            state.pages_fetched, state.max_pages
333        )));
334    }
335    if let Some(source) = &state.source {
336        validate_source_provenance(&state.requested_source, source)?;
337    }
338
339    let page_size = u64::from(state.page_size);
340    let pages_fetched = u64::from(state.pages_fetched);
341    let proposals_fetched = state.proposals_fetched;
342    // Both operands originate as u32, so their product fits in u64.
343    let maximum_rows = pages_fetched * page_size;
344    let minimum_rows = pages_fetched.saturating_sub(1) * page_size;
345    if proposals_fetched < minimum_rows || proposals_fetched > maximum_rows {
346        return Err(invalid(format!(
347            "proposals_fetched {} is outside {}..={} for {} pages of size {}",
348            state.proposals_fetched,
349            minimum_rows,
350            maximum_rows,
351            state.pages_fetched,
352            state.page_size
353        )));
354    }
355    if state
356        .next_before_proposal_id
357        .is_some_and(|cursor| cursor <= 1)
358    {
359        return Err(invalid(
360            "next_before_proposal_id must be greater than one".to_string(),
361        ));
362    }
363
364    let valid_lifecycle = match state.status {
365        NnsGovernanceCollectionStatus::Ready => {
366            state.pages_fetched == 0
367                && state.proposals_fetched == 0
368                && state.next_before_proposal_id.is_none()
369                && state.source.is_none()
370        }
371        NnsGovernanceCollectionStatus::Collecting => {
372            state.pages_fetched > 0
373                && state.pages_fetched < state.max_pages
374                && proposals_fetched == maximum_rows
375                && state.next_before_proposal_id.is_some()
376                && state.source.is_some()
377        }
378        NnsGovernanceCollectionStatus::Complete => {
379            state.pages_fetched > 0
380                && state.next_before_proposal_id.is_none()
381                && state.source.is_some()
382        }
383        NnsGovernanceCollectionStatus::PageLimitReached => {
384            state.pages_fetched == state.max_pages
385                && proposals_fetched == maximum_rows
386                && state.next_before_proposal_id.is_some()
387                && state.source.is_some()
388        }
389    };
390    if !valid_lifecycle {
391        return Err(invalid(format!(
392            "status {} disagrees with cursor, provenance, or counters",
393            state.status
394        )));
395    }
396    Ok(())
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402    use crate::nns::governance::NnsGovernanceError;
403
404    struct UnexpectedSource;
405    impl NnsProposalSource for UnexpectedSource {
406        fn fetch_proposals<'a>(
407            &'a self,
408            _: &'a NnsGovernanceRequest,
409            _: u32,
410            _: Option<u64>,
411            _: crate::nns::proposals::NnsProposalStatusFilter,
412            _: crate::nns::proposals::NnsProposalRewardStatusFilter,
413        ) -> crate::nns::proposals::NnsProposalSourceFuture<
414            'a,
415            Vec<crate::nns::proposals::NnsProposalRow>,
416        > {
417            panic!("unexpected source call")
418        }
419        fn fetch_proposal<'a>(
420            &'a self,
421            _: &'a NnsGovernanceRequest,
422            _: u64,
423        ) -> crate::nns::proposals::NnsProposalSourceFuture<'a, crate::nns::proposals::NnsProposalRow>
424        {
425            panic!("unexpected source call")
426        }
427    }
428
429    #[test]
430    fn collection_rejects_backward_continuations_and_restored_intervals() {
431        let start = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs("ic", 100);
432        let state = NnsProposalCollectionState::new(&start, 2, 3).unwrap();
433        let before = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs("ic", 99);
434        let mut future = std::pin::pin!(advance_nns_proposal_collection_with_source(
435            &before,
436            &state,
437            &UnexpectedSource
438        ));
439        let mut context = std::task::Context::from_waker(std::task::Waker::noop());
440        let result = std::future::Future::poll(future.as_mut(), &mut context);
441        assert!(matches!(
442            result,
443            std::task::Poll::Ready(Err(NnsProposalError::Governance(
444                NnsGovernanceError::InvalidTimestampOrder { .. }
445            )))
446        ));
447        let mut value = serde_json::to_value(&state).unwrap();
448        value["updated_at"] = serde_json::json!(before.fetched_at);
449        let restored = serde_json::from_value(value).unwrap();
450        assert!(matches!(
451            validate_collection_state(&restored),
452            Err(NnsProposalError::Governance(
453                NnsGovernanceError::InvalidTimestampOrder { .. }
454            ))
455        ));
456        assert!(
457            validate_governance_time_interval(
458                "updated_at",
459                &state.updated_at,
460                "fetched_at",
461                &start.fetched_at
462            )
463            .is_ok()
464        );
465    }
466
467    #[test]
468    fn collection_validates_request_and_restored_timestamps() {
469        let mut request = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs(
470            "ic",
471            1_700_000_000,
472        );
473        let valid_state = NnsProposalCollectionState::new(&request, 2, 3).expect("valid state");
474        for timestamp in ["not a UTC timestamp", "+2023-11-14T22:13:20Z"] {
475            request.fetched_at = timestamp.to_string();
476            assert!(matches!(
477                NnsProposalCollectionState::new(&request, 2, 3),
478                Err(NnsProposalError::Governance(
479                    NnsGovernanceError::InvalidTimestamp {
480                        field: "fetched_at",
481                        ..
482                    }
483                ))
484            ));
485            for field in ["started_at", "updated_at"] {
486                let mut value = serde_json::to_value(&valid_state).expect("serialize state");
487                value[field] = serde_json::json!(timestamp);
488                let state = serde_json::from_value(value).expect("restore caller state");
489                assert!(matches!(
490                    validate_collection_state(&state),
491                    Err(NnsProposalError::Governance(
492                        NnsGovernanceError::InvalidTimestamp { value, .. }
493                    )) if value == timestamp
494                ));
495            }
496        }
497    }
498}