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}