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