Skip to main content

deps_core/
pagination.rs

1//! Generic paginated-fetch loop for a git-tags-shaped REST API returning `per_page=100` pages.
2//!
3//! Extracted from [`crate::github::paginate_tags`] so a second provider (GitLab CI's
4//! `/repository/tags` and `/releases` endpoints) can share the exact
5//! concurrency/ordering/error-mapping behavior instead of forking it.
6//! [`crate::github::paginate_tags`] is now a thin delegation to [`paginate_pages`].
7
8use crate::error::Result;
9use bytes::Bytes;
10use std::future::Future;
11
12/// Number of pages fetched concurrently per batch, once page 1 is confirmed full.
13///
14/// See [`paginate_pages`]'s doc comment for why page 1 is always fetched alone first.
15const CONCURRENCY: usize = 5;
16
17/// Returns `true` when a fetched page came back full (`per_page=100` entries), meaning a
18/// subsequent page may exist and should be fetched too. A page with fewer entries is
19/// necessarily the last one.
20#[must_use]
21pub const fn page_has_more(page_len: usize) -> bool {
22    page_len >= 100
23}
24
25/// Logs a warning when pagination for `name` stops at `max_pages` while `provider` still had
26/// more pages available (`page_has_more(page_len)`).
27///
28/// Without this, hitting the safety ceiling on a pathological repo/project is
29/// indistinguishable in logs from "there is genuinely no matching version" — this makes
30/// truncation diagnosable. `provider` names the upstream API (`"GitHub"`, `"GitLab"`);
31/// `ecosystem` names the caller ecosystem (e.g. `"Swift"`, `"GitHub Actions"`, `"GitLab
32/// CI"`); `noun` names what is being paginated (`"tags"`, `"releases"`) in the warning text
33/// — a caller pagintating a non-tags endpoint (e.g. GitLab CI's `/releases`) must not have
34/// its warning hardcode "tags" pagination.
35pub fn warn_if_pagination_truncated(
36    provider: &str,
37    ecosystem: &str,
38    noun: &str,
39    name: &str,
40    page: u32,
41    page_len: usize,
42    max_pages: u32,
43) {
44    if page == max_pages && page_has_more(page_len) {
45        tracing::warn!(
46            package = name,
47            pages_fetched = max_pages,
48            "{ecosystem} {noun} pagination for '{name}' stopped at the {max_pages}-page cap \
49             while {provider} reported more pages available; the fetched version list may be \
50             truncated"
51        );
52    }
53}
54
55/// Drives a paginated-fetch loop against a `per_page=100`-shaped REST endpoint: page 1
56/// alone, then subsequent pages in batches of up to `CONCURRENCY` pages.
57///
58/// Page 1 is always fetched by itself before any batching starts, for two reasons: most
59/// repos/projects fit in one page, so this keeps the common case at exactly the one request
60/// it took before this function gained concurrency; and an error on page 1 (bad auth,
61/// tripped rate limit, unknown project) is surfaced from a single request instead of fanning
62/// a doomed request out to `CONCURRENCY` pages at once.
63///
64/// Once page 1 is confirmed full, pages 2+ are fetched in batches of `CONCURRENCY`,
65/// stopping once a partial/empty page is seen or `max_pages` is reached. Pages within a
66/// batch are fetched concurrently, but always processed in page order — the pages
67/// dispatched *after* the batch's partial page are simply discarded once found, not
68/// avoided, since by the time a batch's first result comes back the rest of that batch's
69/// requests are already in flight and cannot be un-sent. This bounds, but does not
70/// eliminate, extra requests: at most `CONCURRENCY - 1` pages beyond the true last page may
71/// be fetched and discarded, only when that last page doesn't land on a batch boundary.
72/// A caller that dedups "first item wins" on page order depends on out-of-order *processing*
73/// never happening — hence ordered `buffered`, not `buffer_unordered`.
74///
75/// `provider`/`ecosystem`/`noun`/`name` are forwarded to [`warn_if_pagination_truncated`] to
76/// name the API, the caller, and what is being paginated in the truncation warning.
77///
78/// # Errors
79///
80/// Propagates the first error seen among `fetch_page`'s results (page 1's own error, or the
81/// first in page order within a batch — any other in-flight futures in that batch are
82/// dropped), or the error from `parse_page` when a page's body cannot be parsed.
83pub async fn paginate_pages<T, F, Fut, P>(
84    provider: &str,
85    ecosystem: &str,
86    noun: &str,
87    name: &str,
88    max_pages: u32,
89    mut fetch_page: F,
90    mut parse_page: P,
91) -> Result<Vec<T>>
92where
93    F: FnMut(u32) -> Fut,
94    Fut: Future<Output = Result<Bytes>>,
95    P: FnMut(&Bytes) -> Result<Vec<T>>,
96{
97    use futures::stream::{self, StreamExt};
98
99    let mut items = Vec::new();
100
101    let first_page = fetch_page(1).await?;
102    let first_items = parse_page(&first_page)?;
103    let first_page_len = first_items.len();
104    items.extend(first_items);
105    if !page_has_more(first_page_len) {
106        // No call to `warn_if_pagination_truncated` here: it only fires at
107        // `page == max_pages`, which page 1 can never equal since `max_pages > 1`.
108        return Ok(items);
109    }
110
111    let mut page = 2u32;
112    'batches: while page <= max_pages {
113        let batch_end = (page + CONCURRENCY as u32 - 1).min(max_pages);
114        let mut stream = stream::iter(page..=batch_end)
115            .map(&mut fetch_page)
116            .buffered(CONCURRENCY);
117
118        let mut current_page = page;
119        while let Some(data) = stream.next().await {
120            let page_items = parse_page(&data?)?;
121            let page_len = page_items.len();
122            items.extend(page_items);
123            if !page_has_more(page_len) {
124                break 'batches;
125            }
126            warn_if_pagination_truncated(
127                provider,
128                ecosystem,
129                noun,
130                name,
131                current_page,
132                page_len,
133                max_pages,
134            );
135            current_page += 1;
136        }
137        page = batch_end + 1;
138    }
139    Ok(items)
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::test_util::capture_tracing_output_async;
146
147    #[test]
148    fn test_page_has_more_full_page_continues() {
149        assert!(page_has_more(100));
150    }
151
152    #[test]
153    fn test_page_has_more_partial_page_stops() {
154        assert!(!page_has_more(99));
155        assert!(!page_has_more(0));
156    }
157
158    /// Regression for #472 critic M8 (GitLab CI plan): the warning must key its fire
159    /// condition and its `pages_fetched` field off the *passed* `max_pages`, not a
160    /// hardcoded constant — a caller with a cap other than 30 must still warn on its own
161    /// last page and never warn early.
162    #[tokio::test]
163    async fn test_warn_if_pagination_truncated_uses_passed_max_pages_not_a_constant() {
164        let output = capture_tracing_output_async(async {
165            warn_if_pagination_truncated("GitLab", "GitLab CI", "tags", "org/repo", 3, 100, 3);
166        })
167        .await;
168        assert!(output.contains("org/repo"), "output was: {output}");
169        assert!(output.contains("GitLab"), "output was: {output}");
170        assert!(output.contains('3'), "output was: {output}");
171
172        let silent = capture_tracing_output_async(async {
173            warn_if_pagination_truncated("GitLab", "GitLab CI", "tags", "org/repo", 2, 100, 3);
174        })
175        .await;
176        assert!(
177            silent.is_empty(),
178            "must not warn below the passed cap: {silent}"
179        );
180    }
181
182    fn page_json(count: usize) -> Bytes {
183        let entries: Vec<String> = (0..count).map(|i| format!(r#"{{"n":{i}}}"#)).collect();
184        Bytes::from(format!("[{}]", entries.join(",")))
185    }
186
187    fn parse_page(data: &Bytes) -> Result<Vec<u32>> {
188        let value: serde_json::Value = crate::parser::parse_json_checked(data)?;
189        Ok(value
190            .as_array()
191            .map(|arr| (0..arr.len() as u32).collect())
192            .unwrap_or_default())
193    }
194
195    #[tokio::test]
196    async fn test_paginate_pages_single_page_fetches_exactly_once() {
197        use std::sync::atomic::{AtomicU32, Ordering};
198
199        let calls = AtomicU32::new(0);
200        let result = paginate_pages(
201            "GitLab",
202            "GitLab CI",
203            "tags",
204            "org/repo",
205            30,
206            |page| {
207                calls.fetch_add(1, Ordering::SeqCst);
208                async move {
209                    match page {
210                        1 => Ok(page_json(42)),
211                        _ => panic!("page {page} must not be fetched"),
212                    }
213                }
214            },
215            parse_page,
216        )
217        .await
218        .unwrap();
219
220        assert_eq!(calls.load(Ordering::SeqCst), 1);
221        assert_eq!(result.len(), 42);
222    }
223
224    #[tokio::test]
225    async fn test_paginate_pages_stops_after_partial_page_at_custom_cap() {
226        let result = paginate_pages(
227            "GitLab",
228            "GitLab CI",
229            "tags",
230            "org/repo",
231            5,
232            |page| async move {
233                match page {
234                    1 => Ok(page_json(100)),
235                    2..=5 => Ok(page_json(10)),
236                    _ => panic!("page {page} must not be fetched beyond the 5-page cap"),
237                }
238            },
239            parse_page,
240        )
241        .await
242        .unwrap();
243
244        assert_eq!(result.len(), 110);
245    }
246}