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)]
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    /// Return the state schema version.
83    #[must_use]
84    pub const fn schema_version(&self) -> u32 {
85        self.collection.schema_version
86    }
87
88    /// Return the fixed network identity.
89    #[must_use]
90    pub fn network(&self) -> &str {
91        &self.collection.network
92    }
93
94    /// Return the fixed Governance canister identity.
95    #[must_use]
96    pub fn governance_canister_id(&self) -> &str {
97        &self.collection.governance_canister_id
98    }
99
100    /// Return the source selection fixed when collection started.
101    #[must_use]
102    pub const fn requested_source(&self) -> &NnsGovernanceSourceSelection {
103        &self.collection.requested_source
104    }
105
106    /// Return the concrete source provenance after the first admitted page.
107    #[must_use]
108    pub const fn source(&self) -> Option<&NnsGovernanceSourceProvenance> {
109        self.collection.source.as_ref()
110    }
111
112    /// Return the maximum rows requested from each Governance call.
113    #[must_use]
114    pub const fn page_size(&self) -> u32 {
115        self.collection.page_size
116    }
117
118    /// Return the cumulative source-call ceiling.
119    #[must_use]
120    pub const fn max_pages(&self) -> u32 {
121        self.collection.max_pages
122    }
123
124    /// Return the number of successfully admitted pages.
125    #[must_use]
126    pub const fn pages_fetched(&self) -> u32 {
127        self.collection.pages_fetched
128    }
129
130    /// Return the number of successfully admitted proposal rows.
131    #[must_use]
132    pub const fn proposals_fetched(&self) -> u64 {
133        self.proposals_fetched
134    }
135
136    /// Return the exclusive cursor for the next page, when one remains.
137    #[must_use]
138    pub const fn next_before_proposal_id(&self) -> Option<u64> {
139        self.next_before_proposal_id
140    }
141
142    /// Return the caller-supplied time at which the collection state was created.
143    #[must_use]
144    pub fn started_at(&self) -> &str {
145        &self.collection.started_at
146    }
147
148    /// Return the caller-supplied time attached to the latest admitted page.
149    #[must_use]
150    pub fn updated_at(&self) -> &str {
151        &self.collection.updated_at
152    }
153
154    /// Return the collection lifecycle status.
155    #[must_use]
156    pub const fn status(&self) -> NnsGovernanceCollectionStatus {
157        self.collection.status
158    }
159
160    /// Return whether Governance API exhaustion was observed.
161    #[must_use]
162    pub const fn is_complete(&self) -> bool {
163        matches!(
164            self.collection.status,
165            NnsGovernanceCollectionStatus::Complete
166        )
167    }
168}
169
170///
171/// NnsProposalCollectionStep
172///
173/// One admitted bounded page and the continuation state that follows it.
174///
175
176#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
177pub struct NnsProposalCollectionStep {
178    /// Unfiltered page returned by the shared proposal report builder.
179    pub page: NnsProposalListReport,
180    /// Validated state to persist only after retaining the page.
181    pub state: NnsProposalCollectionState,
182}
183
184/// Advance a resumable proposal walk through the native replica adapter.
185#[cfg(feature = "nns-host")]
186pub async fn advance_nns_proposal_collection(
187    request: &NnsGovernanceRequest,
188    state: &NnsProposalCollectionState,
189) -> Result<NnsProposalCollectionStep, NnsProposalHostError> {
190    Ok(
191        advance_nns_proposal_collection_with_source(request, state, &LiveNnsSource::default())
192            .await?,
193    )
194}
195
196/// Advance a resumable proposal walk by exactly one caller-runtime source call.
197pub async fn advance_nns_proposal_collection_with_source(
198    request: &NnsGovernanceRequest,
199    state: &NnsProposalCollectionState,
200    source: &dyn NnsProposalSource,
201) -> Result<NnsProposalCollectionStep, NnsProposalError> {
202    validate_governance_request(request)?;
203    validate_collection_state(state)?;
204    validate_governance_continuation!(request, state, NnsProposalError)?;
205
206    let mut page_request = NnsProposalListRequest::new(request.clone(), state.collection.page_size);
207    page_request.before_proposal_id = state.next_before_proposal_id;
208    let page = build_nns_proposal_list_report_with_source(&page_request, source).await?;
209    if let Some(expected) = &state.collection.source
210        && *expected != page.context.source
211    {
212        return Err(NnsProposalError::CollectionSourceChanged {
213            expected: expected.clone(),
214            actual: page.context.source,
215        });
216    }
217
218    let page_count = u32::try_from(page.proposal_count)
219        .map_err(|_| NnsProposalError::CollectionAccountingOverflow)?;
220    let (pages_fetched, proposals_fetched) = crate::nns::governance::advance_collection_counts(
221        state.collection.pages_fetched,
222        state.proposals_fetched,
223        u64::from(page_count),
224    )
225    .ok_or(NnsProposalError::CollectionAccountingOverflow)?;
226    let next_before_proposal_id = (page_count == state.collection.page_size)
227        .then(|| {
228            page.proposals
229                .iter()
230                .filter_map(|proposal| proposal.proposal_id)
231                .min()
232                .filter(|proposal_id| *proposal_id > 1)
233        })
234        .flatten();
235    let next_state = NnsProposalCollectionState {
236        collection: state.collection.after_page(
237            request,
238            page.context.source.clone(),
239            pages_fetched,
240            next_before_proposal_id.is_some(),
241        ),
242        proposals_fetched,
243        next_before_proposal_id,
244    };
245    Ok(NnsProposalCollectionStep {
246        page,
247        state: next_state,
248    })
249}
250
251pub(super) fn validate_collection_state(
252    state: &NnsProposalCollectionState,
253) -> Result<(), NnsProposalError> {
254    let invalid = |reason| NnsProposalError::InvalidCollectionState { reason };
255    state
256        .collection
257        .validate(NNS_PROPOSAL_COLLECTION_STATE_SCHEMA_VERSION, invalid)?;
258    validate_proposal_page_size(state.collection.page_size)?;
259
260    let proposals_fetched = state.proposals_fetched;
261    let maximum_rows = crate::nns::governance::validate_governance_collection_row_bounds(
262        "proposals_fetched",
263        proposals_fetched,
264        state.collection.pages_fetched,
265        state.collection.page_size,
266        invalid,
267    )?;
268
269    if state
270        .next_before_proposal_id
271        .is_some_and(|cursor| cursor <= 1)
272    {
273        return Err(invalid(
274            "next_before_proposal_id must be greater than one".to_string(),
275        ));
276    }
277
278    let valid_lifecycle = crate::nns::governance::collection_lifecycle_matches(
279        state.collection.status,
280        state.collection.pages_fetched,
281        state.collection.max_pages,
282        state.proposals_fetched,
283        maximum_rows,
284        state.next_before_proposal_id.is_some(),
285        state.collection.source.is_some(),
286    );
287    if !valid_lifecycle {
288        return Err(invalid(format!(
289            "status {} disagrees with cursor, provenance, or counters",
290            state.collection.status
291        )));
292    }
293    Ok(())
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use crate::nns::governance::{NnsGovernanceError, validate_governance_time_interval};
300
301    struct UnexpectedSource;
302    impl NnsProposalSource for UnexpectedSource {
303        fn fetch_proposals<'a>(
304            &'a self,
305            _: &'a NnsGovernanceRequest,
306            _: u32,
307            _: Option<u64>,
308            _: crate::nns::proposals::NnsProposalStatusFilter,
309            _: crate::nns::proposals::NnsProposalRewardStatusFilter,
310        ) -> crate::nns::proposals::NnsProposalSourceFuture<
311            'a,
312            Vec<crate::nns::proposals::NnsProposalRow>,
313        > {
314            panic!("unexpected source call")
315        }
316        fn fetch_proposal<'a>(
317            &'a self,
318            _: &'a NnsGovernanceRequest,
319            _: u64,
320        ) -> crate::nns::proposals::NnsProposalSourceFuture<'a, crate::nns::proposals::NnsProposalRow>
321        {
322            panic!("unexpected source call")
323        }
324    }
325
326    #[tokio::test(flavor = "current_thread")]
327    async fn collection_rejects_backward_continuations_and_restored_intervals() {
328        let start = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs("ic", 100);
329        let state = NnsProposalCollectionState::new(&start, 2, 3).unwrap();
330        let before = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs("ic", 99);
331        let mut future = std::pin::pin!(advance_nns_proposal_collection_with_source(
332            &before,
333            &state,
334            &UnexpectedSource
335        ));
336        let mut context = std::task::Context::from_waker(std::task::Waker::noop());
337        let result = std::future::Future::poll(future.as_mut(), &mut context);
338        assert!(matches!(
339            result,
340            std::task::Poll::Ready(Err(NnsProposalError::Governance(
341                NnsGovernanceError::InvalidTimestampOrder { .. }
342            )))
343        ));
344        let mut value = serde_json::to_value(&state).unwrap();
345        value["updated_at"] = serde_json::json!(before.fetched_at);
346        let restored = serde_json::from_value(value).unwrap();
347        assert!(matches!(
348            validate_collection_state(&restored),
349            Err(NnsProposalError::Governance(
350                NnsGovernanceError::InvalidTimestampOrder { .. }
351            ))
352        ));
353        assert!(
354            validate_governance_time_interval(
355                "updated_at",
356                &state.collection.updated_at,
357                "fetched_at",
358                &start.fetched_at
359            )
360            .is_ok()
361        );
362    }
363
364    #[test]
365    fn collection_validates_request_and_restored_timestamps() {
366        let mut request = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs(
367            "ic",
368            1_700_000_000,
369        );
370        let valid_state = NnsProposalCollectionState::new(&request, 2, 3).expect("valid state");
371        for (field, value) in [
372            ("governance_canister_id", serde_json::json!("aaaaa-aa")),
373            ("max_pages", serde_json::json!(0)),
374            ("pages_fetched", serde_json::json!(4)),
375        ] {
376            let mut restored = serde_json::to_value(&valid_state).unwrap();
377            restored[field] = value;
378            let restored = serde_json::from_value(restored).unwrap();
379            assert!(
380                matches!(
381                    validate_collection_state(&restored),
382                    Err(NnsProposalError::InvalidCollectionState { .. })
383                ),
384                "invalid restored {field}"
385            );
386        }
387        for timestamp in ["not a UTC timestamp", "+2023-11-14T22:13:20Z"] {
388            request.fetched_at = timestamp.to_string();
389            assert!(matches!(
390                NnsProposalCollectionState::new(&request, 2, 3),
391                Err(NnsProposalError::Governance(
392                    NnsGovernanceError::InvalidTimestamp {
393                        field: "fetched_at",
394                        ..
395                    }
396                ))
397            ));
398            for field in ["started_at", "updated_at"] {
399                let mut value = serde_json::to_value(&valid_state).expect("serialize state");
400                value[field] = serde_json::json!(timestamp);
401                let state = serde_json::from_value(value).expect("restore caller state");
402                assert!(matches!(
403                    validate_collection_state(&state),
404                    Err(NnsProposalError::Governance(
405                        NnsGovernanceError::InvalidTimestamp { value, .. }
406                    )) if value == timestamp
407                ));
408            }
409        }
410    }
411}