Skip to main content

ic_query/cache/
completeness.rs

1//! Module: cache::completeness
2//!
3//! Responsibility: define and validate complete paged-collection evidence.
4//! Does not own: family cache envelopes, row validation, or filesystem IO.
5//! Boundary: preserves the raw persisted status while centralizing the one
6//! complete-collection contract shared by NNS, SNS, and ICRC caches.
7
8use serde::{Deserialize as SerdeDeserialize, Serialize};
9
10const API_EXHAUSTED_STATUS: &str = "api_exhausted";
11
12///
13/// CacheCollectionCompleteness
14///
15/// Persisted evidence that a paged cache collection exhausted its source API.
16///
17
18#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
19pub struct CacheCollectionCompleteness {
20    /// Raw persisted completeness status; complete collections use `api_exhausted`.
21    pub status: String,
22    /// Maximum rows requested per page, or observed width of an unpaged inventory (at least one).
23    pub page_size: u32,
24    /// Number of source pages collected.
25    pub page_count: u32,
26    /// Number of unique rows retained in the complete cache.
27    pub row_count: usize,
28    /// Whether every page is guaranteed to describe one source instant.
29    pub point_in_time_guaranteed: bool,
30}
31
32impl CacheCollectionCompleteness {
33    /// Construct evidence for an API-exhausted paged collection.
34    #[must_use]
35    pub fn api_exhausted(
36        page_size: u32,
37        page_count: u32,
38        row_count: usize,
39        point_in_time_guaranteed: bool,
40    ) -> Self {
41        Self {
42            status: API_EXHAUSTED_STATUS.to_string(),
43            page_size,
44            page_count,
45            row_count,
46            point_in_time_guaranteed,
47        }
48    }
49
50    /// Return whether the raw status claims source API exhaustion.
51    #[must_use]
52    pub fn is_api_exhausted(&self) -> bool {
53        self.status == API_EXHAUSTED_STATUS
54    }
55}
56
57/// Validate shared API-exhaustion, page, and row-count evidence.
58pub fn validate_cache_collection_completeness(
59    completeness: &CacheCollectionCompleteness,
60    actual_row_count: usize,
61) -> Result<(), String> {
62    if !completeness.is_api_exhausted() {
63        return Err(format!(
64            "completeness status is {}, expected {API_EXHAUSTED_STATUS}",
65            completeness.status
66        ));
67    }
68    if completeness.page_size == 0 {
69        return Err("completeness page_size must be greater than zero".to_string());
70    }
71    if completeness.page_count == 0 {
72        return Err("completeness page_count must be greater than zero".to_string());
73    }
74    if completeness.row_count != actual_row_count {
75        return Err(format!(
76            "completeness row_count is {}, actual row count is {actual_row_count}",
77            completeness.row_count
78        ));
79    }
80    let maximum_row_count = u64::from(completeness.page_size) * u64::from(completeness.page_count);
81    if actual_row_count as u128 > u128::from(maximum_row_count) {
82        return Err(format!(
83            "completeness row_count is {actual_row_count}, maximum for {} pages of size {} is {maximum_row_count}",
84            completeness.page_count, completeness.page_size
85        ));
86    }
87    Ok(())
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn completeness_requires_api_exhaustion_and_consistent_page_evidence() {
96        for page_size in 0..=2 {
97            for page_count in 0..=2 {
98                for declared_row_count in 0..=2 {
99                    for actual_row_count in 0..=2 {
100                        let completeness = CacheCollectionCompleteness::api_exhausted(
101                            page_size,
102                            page_count,
103                            declared_row_count,
104                            false,
105                        );
106                        let expected_valid = page_size > 0
107                            && page_count > 0
108                            && declared_row_count == actual_row_count
109                            && actual_row_count <= (page_size * page_count) as usize;
110
111                        assert_eq!(
112                            validate_cache_collection_completeness(&completeness, actual_row_count)
113                                .is_ok(),
114                            expected_valid,
115                            "page_size={page_size}, page_count={page_count}, declared_row_count={declared_row_count}, actual_row_count={actual_row_count}"
116                        );
117                    }
118                }
119            }
120        }
121
122        let mut unknown = CacheCollectionCompleteness::api_exhausted(1, 1, 0, false);
123        unknown.status = "unknown".to_string();
124        assert_eq!(
125            validate_cache_collection_completeness(&unknown, 0),
126            Err("completeness status is unknown, expected api_exhausted".to_string())
127        );
128        assert_eq!(
129            serde_json::to_value(CacheCollectionCompleteness::api_exhausted(1, 1, 0, false))
130                .expect("serialize completeness")["status"],
131            "api_exhausted"
132        );
133    }
134
135    #[test]
136    fn completeness_enforces_page_capacity_without_narrow_multiplication() {
137        for (page_size, page_count, row_count, valid) in [
138            (2, 2, 4, true),
139            (2, 2, 5, false),
140            (1, 1, 0, true),
141            (u32::MAX, 2, 100, true),
142            (u32::MAX, u32::MAX, 0, true),
143        ] {
144            let completeness =
145                CacheCollectionCompleteness::api_exhausted(page_size, page_count, row_count, false);
146            assert_eq!(
147                validate_cache_collection_completeness(&completeness, row_count).is_ok(),
148                valid
149            );
150        }
151    }
152}