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_collection_identity,
23        validate_governance_collection_page_bounds, validate_governance_continuation_request,
24        validate_governance_request, validate_governance_time_interval, validate_source_provenance,
25    },
26};
27use serde::{Deserialize, Serialize};
28
29/// Version of the persistable resumable NNS proposal collection state.
30pub const NNS_PROPOSAL_COLLECTION_STATE_SCHEMA_VERSION: u32 = 1;
31
32///
33/// NnsProposalCollectionState
34///
35/// Serializable continuation state for an explicitly bounded proposal walk.
36///
37
38#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
39pub struct NnsProposalCollectionState {
40    schema_version: u32,
41    network: String,
42    governance_canister_id: String,
43    requested_source: NnsGovernanceSourceSelection,
44    source: Option<NnsGovernanceSourceProvenance>,
45    page_size: u32,
46    max_pages: u32,
47    pages_fetched: u32,
48    proposals_fetched: u64,
49    next_before_proposal_id: Option<u64>,
50    started_at: String,
51    updated_at: String,
52    status: NnsGovernanceCollectionStatus,
53}
54
55impl NnsProposalCollectionState {
56    /// Start an empty proposal walk with explicit per-page and cumulative call ceilings.
57    pub fn new(
58        request: &NnsGovernanceRequest,
59        page_size: u32,
60        max_pages: u32,
61    ) -> Result<Self, NnsProposalError> {
62        validate_governance_request(request)?;
63        validate_proposal_page_size(page_size)?;
64        if max_pages == 0 {
65            return Err(NnsProposalError::InvalidCollectionMaxPages);
66        }
67        Ok(Self {
68            schema_version: NNS_PROPOSAL_COLLECTION_STATE_SCHEMA_VERSION,
69            network: request.network.clone(),
70            governance_canister_id: MAINNET_GOVERNANCE_CANISTER_ID.to_string(),
71            requested_source: request.source.clone(),
72            source: None,
73            page_size,
74            max_pages,
75            pages_fetched: 0,
76            proposals_fetched: 0,
77            next_before_proposal_id: None,
78            started_at: request.fetched_at.clone(),
79            updated_at: request.fetched_at.clone(),
80            status: NnsGovernanceCollectionStatus::Ready,
81        })
82    }
83
84    /// Return the state schema version.
85    #[must_use]
86    pub const fn schema_version(&self) -> u32 {
87        self.schema_version
88    }
89
90    /// Return the fixed network identity.
91    #[must_use]
92    pub fn network(&self) -> &str {
93        &self.network
94    }
95
96    /// Return the fixed Governance canister identity.
97    #[must_use]
98    pub fn governance_canister_id(&self) -> &str {
99        &self.governance_canister_id
100    }
101
102    /// Return the source selection fixed when collection started.
103    #[must_use]
104    pub const fn requested_source(&self) -> &NnsGovernanceSourceSelection {
105        &self.requested_source
106    }
107
108    /// Return the concrete source provenance after the first admitted page.
109    #[must_use]
110    pub const fn source(&self) -> Option<&NnsGovernanceSourceProvenance> {
111        self.source.as_ref()
112    }
113
114    /// Return the maximum rows requested from each Governance call.
115    #[must_use]
116    pub const fn page_size(&self) -> u32 {
117        self.page_size
118    }
119
120    /// Return the cumulative source-call ceiling.
121    #[must_use]
122    pub const fn max_pages(&self) -> u32 {
123        self.max_pages
124    }
125
126    /// Return the number of successfully admitted pages.
127    #[must_use]
128    pub const fn pages_fetched(&self) -> u32 {
129        self.pages_fetched
130    }
131
132    /// Return the number of successfully admitted proposal rows.
133    #[must_use]
134    pub const fn proposals_fetched(&self) -> u64 {
135        self.proposals_fetched
136    }
137
138    /// Return the exclusive cursor for the next page, when one remains.
139    #[must_use]
140    pub const fn next_before_proposal_id(&self) -> Option<u64> {
141        self.next_before_proposal_id
142    }
143
144    /// Return the caller-supplied time at which the collection state was created.
145    #[must_use]
146    pub fn started_at(&self) -> &str {
147        &self.started_at
148    }
149
150    /// Return the caller-supplied time attached to the latest admitted page.
151    #[must_use]
152    pub fn updated_at(&self) -> &str {
153        &self.updated_at
154    }
155
156    /// Return the collection lifecycle status.
157    #[must_use]
158    pub const fn status(&self) -> NnsGovernanceCollectionStatus {
159        self.status
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(
204        request,
205        &state.network,
206        &state.requested_source,
207        |field, expected, actual| NnsProposalError::CollectionRequestMismatch {
208            field,
209            expected,
210            actual,
211        },
212    )?;
213    validate_governance_time_interval(
214        "updated_at",
215        &state.updated_at,
216        "fetched_at",
217        &request.fetched_at,
218    )?;
219    match state.status {
220        NnsGovernanceCollectionStatus::Complete => {
221            return Err(NnsProposalError::CollectionComplete {
222                pages_fetched: state.pages_fetched,
223            });
224        }
225        NnsGovernanceCollectionStatus::PageLimitReached => {
226            return Err(NnsProposalError::CollectionPageLimitReached {
227                pages_fetched: state.pages_fetched,
228                max_pages: state.max_pages,
229            });
230        }
231        NnsGovernanceCollectionStatus::Ready | NnsGovernanceCollectionStatus::Collecting => {}
232    }
233
234    let mut page_request = NnsProposalListRequest::new(request.clone(), state.page_size);
235    page_request.before_proposal_id = state.next_before_proposal_id;
236    let page = build_nns_proposal_list_report_with_source(&page_request, source).await?;
237    if let Some(expected) = &state.source
238        && *expected != page.context.source
239    {
240        return Err(NnsProposalError::CollectionSourceChanged {
241            expected: expected.clone(),
242            actual: page.context.source,
243        });
244    }
245
246    let page_count = u32::try_from(page.proposal_count)
247        .map_err(|_| NnsProposalError::CollectionAccountingOverflow)?;
248    let pages_fetched = state
249        .pages_fetched
250        .checked_add(1)
251        .ok_or(NnsProposalError::CollectionAccountingOverflow)?;
252    let proposals_fetched = state
253        .proposals_fetched
254        .checked_add(u64::from(page_count))
255        .ok_or(NnsProposalError::CollectionAccountingOverflow)?;
256    let next_before_proposal_id = (page_count == state.page_size)
257        .then(|| {
258            page.proposals
259                .iter()
260                .filter_map(|proposal| proposal.proposal_id)
261                .min()
262                .filter(|proposal_id| *proposal_id > 1)
263        })
264        .flatten();
265    let status = if next_before_proposal_id.is_none() {
266        NnsGovernanceCollectionStatus::Complete
267    } else if pages_fetched == state.max_pages {
268        NnsGovernanceCollectionStatus::PageLimitReached
269    } else {
270        NnsGovernanceCollectionStatus::Collecting
271    };
272    let next_state = NnsProposalCollectionState {
273        source: Some(page.context.source.clone()),
274        pages_fetched,
275        proposals_fetched,
276        next_before_proposal_id,
277        updated_at: request.fetched_at.clone(),
278        status,
279        ..state.clone()
280    };
281    Ok(NnsProposalCollectionStep {
282        page,
283        state: next_state,
284    })
285}
286
287pub(super) fn validate_collection_state(
288    state: &NnsProposalCollectionState,
289) -> Result<(), NnsProposalError> {
290    let invalid = |reason| NnsProposalError::InvalidCollectionState { reason };
291    if state.schema_version != NNS_PROPOSAL_COLLECTION_STATE_SCHEMA_VERSION {
292        return Err(invalid(format!(
293            "schema_version is {}, expected {NNS_PROPOSAL_COLLECTION_STATE_SCHEMA_VERSION}",
294            state.schema_version
295        )));
296    }
297    validate_governance_collection_identity(
298        &state.network,
299        &state.governance_canister_id,
300        &state.requested_source,
301        (&state.started_at, &state.updated_at),
302        invalid,
303    )?;
304    validate_proposal_page_size(state.page_size)?;
305    validate_governance_collection_page_bounds(state.max_pages, state.pages_fetched, invalid)?;
306    if let Some(source) = &state.source {
307        validate_source_provenance(&state.requested_source, source)?;
308    }
309
310    let page_size = u64::from(state.page_size);
311    let pages_fetched = u64::from(state.pages_fetched);
312    let proposals_fetched = state.proposals_fetched;
313    // Both operands originate as u32, so their product fits in u64.
314    let maximum_rows = pages_fetched * page_size;
315    let minimum_rows = pages_fetched.saturating_sub(1) * page_size;
316    if proposals_fetched < minimum_rows || proposals_fetched > maximum_rows {
317        return Err(invalid(format!(
318            "proposals_fetched {} is outside {}..={} for {} pages of size {}",
319            state.proposals_fetched,
320            minimum_rows,
321            maximum_rows,
322            state.pages_fetched,
323            state.page_size
324        )));
325    }
326    if state
327        .next_before_proposal_id
328        .is_some_and(|cursor| cursor <= 1)
329    {
330        return Err(invalid(
331            "next_before_proposal_id must be greater than one".to_string(),
332        ));
333    }
334
335    let valid_lifecycle = match state.status {
336        NnsGovernanceCollectionStatus::Ready => {
337            state.pages_fetched == 0
338                && state.proposals_fetched == 0
339                && state.next_before_proposal_id.is_none()
340                && state.source.is_none()
341        }
342        NnsGovernanceCollectionStatus::Collecting => {
343            state.pages_fetched > 0
344                && state.pages_fetched < state.max_pages
345                && proposals_fetched == maximum_rows
346                && state.next_before_proposal_id.is_some()
347                && state.source.is_some()
348        }
349        NnsGovernanceCollectionStatus::Complete => {
350            state.pages_fetched > 0
351                && state.next_before_proposal_id.is_none()
352                && state.source.is_some()
353        }
354        NnsGovernanceCollectionStatus::PageLimitReached => {
355            state.pages_fetched == state.max_pages
356                && proposals_fetched == maximum_rows
357                && state.next_before_proposal_id.is_some()
358                && state.source.is_some()
359        }
360    };
361    if !valid_lifecycle {
362        return Err(invalid(format!(
363            "status {} disagrees with cursor, provenance, or counters",
364            state.status
365        )));
366    }
367    Ok(())
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use crate::nns::governance::NnsGovernanceError;
374
375    struct UnexpectedSource;
376    impl NnsProposalSource for UnexpectedSource {
377        fn fetch_proposals<'a>(
378            &'a self,
379            _: &'a NnsGovernanceRequest,
380            _: u32,
381            _: Option<u64>,
382            _: crate::nns::proposals::NnsProposalStatusFilter,
383            _: crate::nns::proposals::NnsProposalRewardStatusFilter,
384        ) -> crate::nns::proposals::NnsProposalSourceFuture<
385            'a,
386            Vec<crate::nns::proposals::NnsProposalRow>,
387        > {
388            panic!("unexpected source call")
389        }
390        fn fetch_proposal<'a>(
391            &'a self,
392            _: &'a NnsGovernanceRequest,
393            _: u64,
394        ) -> crate::nns::proposals::NnsProposalSourceFuture<'a, crate::nns::proposals::NnsProposalRow>
395        {
396            panic!("unexpected source call")
397        }
398    }
399
400    #[tokio::test(flavor = "current_thread")]
401    async fn collection_rejects_backward_continuations_and_restored_intervals() {
402        let start = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs("ic", 100);
403        let state = NnsProposalCollectionState::new(&start, 2, 3).unwrap();
404        let before = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs("ic", 99);
405        let mut future = std::pin::pin!(advance_nns_proposal_collection_with_source(
406            &before,
407            &state,
408            &UnexpectedSource
409        ));
410        let mut context = std::task::Context::from_waker(std::task::Waker::noop());
411        let result = std::future::Future::poll(future.as_mut(), &mut context);
412        assert!(matches!(
413            result,
414            std::task::Poll::Ready(Err(NnsProposalError::Governance(
415                NnsGovernanceError::InvalidTimestampOrder { .. }
416            )))
417        ));
418        let mut value = serde_json::to_value(&state).unwrap();
419        value["updated_at"] = serde_json::json!(before.fetched_at);
420        let restored = serde_json::from_value(value).unwrap();
421        assert!(matches!(
422            validate_collection_state(&restored),
423            Err(NnsProposalError::Governance(
424                NnsGovernanceError::InvalidTimestampOrder { .. }
425            ))
426        ));
427        assert!(
428            validate_governance_time_interval(
429                "updated_at",
430                &state.updated_at,
431                "fetched_at",
432                &start.fetched_at
433            )
434            .is_ok()
435        );
436    }
437
438    #[test]
439    fn collection_validates_request_and_restored_timestamps() {
440        let mut request = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs(
441            "ic",
442            1_700_000_000,
443        );
444        let valid_state = NnsProposalCollectionState::new(&request, 2, 3).expect("valid state");
445        for (field, value) in [
446            ("governance_canister_id", serde_json::json!("aaaaa-aa")),
447            ("max_pages", serde_json::json!(0)),
448            ("pages_fetched", serde_json::json!(4)),
449        ] {
450            let mut restored = serde_json::to_value(&valid_state).unwrap();
451            restored[field] = value;
452            let restored = serde_json::from_value(restored).unwrap();
453            assert!(
454                matches!(
455                    validate_collection_state(&restored),
456                    Err(NnsProposalError::InvalidCollectionState { .. })
457                ),
458                "invalid restored {field}"
459            );
460        }
461        for timestamp in ["not a UTC timestamp", "+2023-11-14T22:13:20Z"] {
462            request.fetched_at = timestamp.to_string();
463            assert!(matches!(
464                NnsProposalCollectionState::new(&request, 2, 3),
465                Err(NnsProposalError::Governance(
466                    NnsGovernanceError::InvalidTimestamp {
467                        field: "fetched_at",
468                        ..
469                    }
470                ))
471            ));
472            for field in ["started_at", "updated_at"] {
473                let mut value = serde_json::to_value(&valid_state).expect("serialize state");
474                value[field] = serde_json::json!(timestamp);
475                let state = serde_json::from_value(value).expect("restore caller state");
476                assert!(matches!(
477                    validate_collection_state(&state),
478                    Err(NnsProposalError::Governance(
479                        NnsGovernanceError::InvalidTimestamp { value, .. }
480                    )) if value == timestamp
481                ));
482            }
483        }
484    }
485}