Skip to main content

mkit_server/pipeline/
list.rs

1//! Bounded, key-based `ListRefs` paging over direct refs or D34 index buckets.
2
3use core::future::Future;
4
5use mkit_core::hash::Hash;
6
7use super::{D34Shards, RefEntry, ShardMap};
8use crate::refs::MAX_REF_NAME_BYTES;
9use crate::repo::RepoId;
10use crate::rt::MaybeSend;
11use crate::store::{Key, NamespaceStore, Partition, StoreError, codec, keys};
12
13pub(super) const MAX_RESPONSE_BYTES: usize = 2 * 1024 * 1024;
14const RESPONSE_HEADROOM: usize = 1024;
15const TOKEN_VERSION: u8 = 1;
16// [version:u8][repository+normalized-prefix binding:32][name length:u16 BE]
17// [last emitted full ref name:UTF-8]. Store cursors never enter a token.
18const TOKEN_HEADER: usize = 35;
19
20pub(crate) struct ListPage {
21    pub(crate) refs: Vec<RefEntry>,
22    pub(crate) next: Option<Vec<u8>>,
23}
24
25/// One bounded bucket scan.
26#[derive(Debug)]
27pub struct Scan {
28    pub rows: Vec<(String, Hash)>,
29    pub more: bool,
30}
31
32/// A source owns its key class and partition. It scans the normalized prefix
33/// range strictly after `last`, returning full names in key order. `more`
34/// means additional rows may remain beyond the last fetched key, and
35/// requires at least one row: a scan with `more` and no rows fails the page
36/// as `Corrupt`.
37pub trait BucketSource {
38    fn scan(
39        &self,
40        repo: &RepoId,
41        prefix: &str,
42        last: Option<&str>,
43        limit: u32,
44    ) -> impl Future<Output = Result<Scan, StoreError>> + MaybeSend;
45}
46
47pub(super) struct RefBucket<'a, N> {
48    pub store: &'a N,
49    pub partition: &'a Partition,
50}
51
52impl<N: NamespaceStore> BucketSource for RefBucket<'_, N> {
53    async fn scan(
54        &self,
55        repo: &RepoId,
56        prefix: &str,
57        last: Option<&str>,
58        limit: u32,
59    ) -> Result<Scan, StoreError> {
60        let (prefix_start, end) = keys::ref_prefix_range(&repo.name, prefix);
61        let start = last.map_or_else(
62            || prefix_start.clone(),
63            |name| {
64                let mut bytes = keys::ref_key(&repo.name, name).as_bytes().to_vec();
65                bytes.push(0);
66                std::cmp::max(prefix_start.clone(), Key::new(bytes))
67            },
68        );
69        let page = self
70            .store
71            .scan(self.partition, &start, &end, None, limit)
72            .await?;
73        let rows = page
74            .entries
75            .iter()
76            .map(|(key, value)| match keys::parse(key) {
77                Some(keys::ParsedKey::Ref { name, .. }) => Ok((name, codec::decode_ref_id(value)?)),
78                _ => Err(StoreError::Corrupt("malformed ref key".into())),
79            })
80            .collect::<Result<_, _>>()?;
81        Ok(Scan {
82            rows,
83            more: page.next.is_some(),
84        })
85    }
86}
87
88/// Validated D34 ref-index source.
89#[derive(Debug)]
90pub struct IndexBucket<'a, N> {
91    pub store: &'a N,
92    pub partition: &'a Partition,
93}
94
95impl<N: NamespaceStore> BucketSource for IndexBucket<'_, N> {
96    async fn scan(
97        &self,
98        repo: &RepoId,
99        prefix: &str,
100        last: Option<&str>,
101        limit: u32,
102    ) -> Result<Scan, StoreError> {
103        let (prefix_start, end) = keys::ref_index_prefix_range(&repo.name, prefix);
104        let start = last.map_or_else(
105            || prefix_start.clone(),
106            |name| {
107                let mut bytes = keys::ref_index_key(&repo.name, name).as_bytes().to_vec();
108                bytes.push(0);
109                std::cmp::max(prefix_start.clone(), Key::new(bytes))
110            },
111        );
112        let page = self
113            .store
114            .scan(self.partition, &start, &end, None, limit)
115            .await?;
116        let rows = page
117            .entries
118            .iter()
119            .map(|(key, value)| match keys::parse(key) {
120                Some(keys::ParsedKey::RefIndexEntry {
121                    repo: row_repo,
122                    name,
123                }) if row_repo == repo.name
124                    && crate::refs::validate_ref_name(&name)
125                    && D34Shards.ref_index(repo, &name) == *self.partition =>
126                {
127                    Ok((name, codec::decode_ref_id(value)?))
128                }
129                _ => Err(StoreError::Corrupt(
130                    "malformed or misrouted ref index row".into(),
131                )),
132            })
133            .collect::<Result<_, _>>()?;
134        Ok(Scan {
135            rows,
136            more: page.next.is_some(),
137        })
138    }
139}
140
141fn binding(repo: &RepoId, prefix: &str) -> [u8; 32] {
142    let mut h = blake3::Hasher::new();
143    h.update(b"mkit.list-refs-page-token.v1\0");
144    for part in [repo.namespace.as_str(), repo.name.as_str(), prefix] {
145        h.update(&u64::try_from(part.len()).unwrap_or(u64::MAX).to_be_bytes());
146        h.update(part.as_bytes());
147    }
148    *h.finalize().as_bytes()
149}
150
151fn encode_token(repo: &RepoId, prefix: &str, last: &str) -> Vec<u8> {
152    let mut bytes = Vec::with_capacity(TOKEN_HEADER + last.len());
153    bytes.push(TOKEN_VERSION);
154    bytes.extend_from_slice(&binding(repo, prefix));
155    bytes.extend_from_slice(&u16::try_from(last.len()).unwrap_or(u16::MAX).to_be_bytes());
156    bytes.extend_from_slice(last.as_bytes());
157    bytes
158}
159
160pub(super) fn decode_token(repo: &RepoId, prefix: &str, bytes: &[u8]) -> Option<String> {
161    if bytes.len() < TOKEN_HEADER || bytes.len() > TOKEN_HEADER + MAX_REF_NAME_BYTES {
162        return None;
163    }
164    let name_len = usize::from(u16::from_be_bytes([bytes[33], bytes[34]]));
165    if bytes[0] != TOKEN_VERSION
166        || bytes[1..33] != binding(repo, prefix)
167        || name_len == 0
168        || bytes.len() != TOKEN_HEADER + name_len
169    {
170        return None;
171    }
172    let name = core::str::from_utf8(&bytes[TOKEN_HEADER..]).ok()?;
173    // The name is only a scan position: a stored name that predates today's
174    // grammar must still page. Names never contain NUL (the resume key is
175    // `key(last) ‖ 00`).
176    if name.contains('\0') || !name.starts_with(prefix) {
177        return None;
178    }
179    Some(name.to_owned())
180}
181
182fn varint_len(mut n: usize) -> usize {
183    let mut len = 1;
184    while n >= 128 {
185        n >>= 7;
186        len += 1;
187    }
188    len
189}
190
191// Proto3 ListRefsResponse.refs is a length-delimited RefEntry. JSON uses
192// a base64 object id and a longer envelope. Ref grammar permits only ASCII
193// alphanumerics, '.', '_' and '-', so the name needs no JSON escaping. Use
194// the full name, at least as long as the stripped wire name, and leave room
195// for the continuation token and response envelope.
196fn row_wire_bound(name: &str) -> usize {
197    let inner = 1 + varint_len(name.len()) + name.len() + 1 + 1 + 32;
198    let proto = 1 + varint_len(inner) + inner;
199    proto.max(name.len() + 96)
200}
201
202/// Read one bounded page. Every source is scanned once, sequentially.
203/// A failed source fails the entire page; no partial merge is exposed.
204pub(super) async fn page<S: BucketSource>(
205    sources: &[S],
206    repo: &RepoId,
207    prefix: &str,
208    last: Option<&str>,
209    page_size: u32,
210    byte_budget: usize,
211) -> Result<ListPage, StoreError> {
212    assert!(!sources.is_empty() && page_size > 0);
213    let count = u32::try_from(sources.len()).unwrap_or(u32::MAX);
214    let per_source = page_size.min(2 * page_size.div_ceil(count) + 8);
215    let mut rows = Vec::new();
216    let mut boundary: Option<String> = None;
217    let mut source_more = false;
218    for source in sources {
219        let scan = source.scan(repo, prefix, last, per_source).await?;
220        if scan.more {
221            let Some((name, _)) = scan.rows.last() else {
222                return Err(StoreError::Corrupt("empty continued ref scan".into()));
223            };
224            boundary = Some(boundary.map_or_else(|| name.clone(), |b| b.min(name.clone())));
225            source_more = true;
226        }
227        rows.extend(scan.rows);
228    }
229    rows.sort_unstable_by(|a, b| a.0.cmp(&b.0));
230    let mut out: Vec<RefEntry> = Vec::new();
231    let mut used = RESPONSE_HEADROOM;
232    let mut stopped = false;
233    for (name, id) in rows {
234        if boundary.as_ref().is_some_and(|b| name > *b) {
235            stopped = true;
236            break;
237        }
238        if out.last().is_some_and(|r| r.name == name) {
239            return Err(StoreError::Corrupt("duplicate ref index row".into()));
240        }
241        let bytes = row_wire_bound(&name);
242        if out.len() >= page_size as usize || used + bytes > byte_budget {
243            stopped = true;
244            break;
245        }
246        used += bytes;
247        out.push(RefEntry { name, id });
248    }
249    if out.is_empty() && (source_more || stopped) {
250        return Err(StoreError::Corrupt("ref page made no progress".into()));
251    }
252    let next = if source_more || stopped {
253        out.last().map(|r| encode_token(repo, prefix, &r.name))
254    } else {
255        None
256    };
257    Ok(ListPage { refs: out, next })
258}
259
260#[cfg(test)]
261mod tests {
262    use std::sync::{Arc, Mutex};
263
264    use futures_executor::block_on;
265    use proptest::prelude::*;
266
267    use super::*;
268    use crate::pipeline::{D34Shards, ShardMap};
269    use crate::repo::{NamespaceKey, RepoName};
270
271    #[derive(Clone)]
272    struct MemoryBucket(Arc<Mutex<Vec<(String, Hash)>>>, bool);
273
274    impl BucketSource for MemoryBucket {
275        async fn scan(
276            &self,
277            _repo: &RepoId,
278            prefix: &str,
279            last: Option<&str>,
280            limit: u32,
281        ) -> Result<Scan, StoreError> {
282            if self.1 {
283                return Err(StoreError::Corrupt("failed bucket".into()));
284            }
285            let rows: Vec<_> = self
286                .0
287                .lock()
288                .unwrap()
289                .iter()
290                .filter(|(name, _)| {
291                    name.starts_with(prefix) && last.is_none_or(|v| name.as_str() > v)
292                })
293                .take(limit as usize + 1)
294                .cloned()
295                .collect();
296            let more = rows.len() > limit as usize;
297            Ok(Scan {
298                rows: rows.into_iter().take(limit as usize).collect(),
299                more,
300            })
301        }
302    }
303
304    fn repo(name: &str) -> RepoId {
305        RepoId {
306            namespace: NamespaceKey::deployment_default(),
307            name: RepoName::new(name).unwrap(),
308        }
309    }
310
311    fn buckets(names: &[String], count: usize) -> Vec<MemoryBucket> {
312        assert!(count == 1 || count == 16);
313        let mut parts = vec![Vec::new(); count];
314        for name in names {
315            let bucket = if count == 1 {
316                0
317            } else {
318                let Partition::RefIndex { bucket, .. } =
319                    D34Shards.ref_index(&repo("routing"), name)
320                else {
321                    unreachable!()
322                };
323                usize::from(bucket)
324            };
325            parts[bucket].push((name.clone(), [u8::try_from(bucket).unwrap_or(u8::MAX); 32]));
326        }
327        parts
328            .into_iter()
329            .map(|mut rows| {
330                rows.sort_unstable_by(|a, b| a.0.cmp(&b.0));
331                MemoryBucket(Arc::new(Mutex::new(rows)), false)
332            })
333            .collect()
334    }
335
336    fn all_pages(
337        sources: &[MemoryBucket],
338        repo: &RepoId,
339        prefix: &str,
340        n: u32,
341        budget: usize,
342    ) -> Vec<String> {
343        all_pages_from(sources, repo, prefix, n, budget, None)
344    }
345
346    fn all_pages_from(
347        sources: &[MemoryBucket],
348        repo: &RepoId,
349        prefix: &str,
350        n: u32,
351        budget: usize,
352        mut token: Option<Vec<u8>>,
353    ) -> Vec<String> {
354        let mut names = Vec::new();
355        let mut seen = std::collections::HashSet::new();
356        loop {
357            let last = token
358                .as_deref()
359                .map(|t| decode_token(repo, prefix, t).unwrap());
360            let result = block_on(page(sources, repo, prefix, last.as_deref(), n, budget)).unwrap();
361            assert!(result.refs.iter().all(|r| r.name.starts_with(prefix)));
362            #[cfg(feature = "connect")]
363            assert_encoded_bounds(&result);
364            assert!(
365                RESPONSE_HEADROOM
366                    + result
367                        .refs
368                        .iter()
369                        .map(|r| row_wire_bound(&r.name))
370                        .sum::<usize>()
371                    <= budget
372            );
373            for entry in result.refs {
374                assert!(names.last().is_none_or(|prev| prev < &entry.name));
375                names.push(entry.name);
376            }
377            match result.next {
378                Some(next) => {
379                    assert!(!names.is_empty());
380                    assert!(seen.insert(next.clone()), "repeated token");
381                    token = Some(next);
382                }
383                None => return names,
384            }
385        }
386    }
387
388    fn real_index_pages(
389        store: &crate::MemoryKv,
390        repo: &RepoId,
391        prefix: &str,
392        n: u32,
393    ) -> Vec<String> {
394        let partitions = D34Shards.ref_index_partitions(repo);
395        let sources = partitions
396            .iter()
397            .map(|partition| IndexBucket { store, partition })
398            .collect::<Vec<_>>();
399        let mut token = None;
400        let mut names = Vec::new();
401        loop {
402            let last = token
403                .as_deref()
404                .map(|bytes| decode_token(repo, prefix, bytes).unwrap());
405            let result = block_on(page(&sources, repo, prefix, last.as_deref(), n, 8192)).unwrap();
406            names.extend(result.refs.into_iter().map(|entry| entry.name));
407            match result.next {
408                Some(next) => token = Some(next),
409                None => return names,
410            }
411        }
412    }
413
414    #[cfg(feature = "connect")]
415    fn assert_encoded_bounds(page: &ListPage) {
416        use crate::connect::proto::mkit::transport::v1::{
417            ListRefsResponse, RefEntry as WireRefEntry,
418        };
419        use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
420        use buffa::Message as _;
421
422        let response = ListRefsResponse {
423            refs: page
424                .refs
425                .iter()
426                .map(|r| WireRefEntry {
427                    name: Some(r.name.clone()),
428                    object_id: Some(r.id.to_vec()),
429                    ..Default::default()
430                })
431                .collect(),
432            next_page_token: page.next.as_ref().map(|t| URL_SAFE_NO_PAD.encode(t)),
433            ..Default::default()
434        };
435        assert!(response.encoded_len() <= u32::try_from(MAX_RESPONSE_BYTES).unwrap_or(u32::MAX));
436        assert!(serde_json::to_vec(&response).unwrap().len() <= MAX_RESPONSE_BYTES);
437    }
438
439    #[test]
440    fn token_round_trip_and_binding() {
441        let a = repo("a");
442        let bytes = encode_token(&a, "refs/heads/", "refs/heads/main");
443        assert_eq!(
444            decode_token(&a, "refs/heads/", &bytes).as_deref(),
445            Some("refs/heads/main")
446        );
447        assert_eq!(decode_token(&repo("b"), "refs/heads/", &bytes), None);
448        assert_eq!(decode_token(&a, "refs/tags/", &bytes), None);
449        assert_eq!(decode_token(&a, "refs/heads/", &bytes[..3]), None);
450        let mut bad = bytes.clone();
451        bad[0] = 2;
452        assert_eq!(decode_token(&a, "refs/heads/", &bad), None);
453        assert_eq!(decode_token(&a, "refs/heads/", &vec![0; 1000]), None);
454    }
455
456    #[test]
457    fn failed_source_fails_entire_merge() {
458        let names = vec!["refs/heads/main".to_owned()];
459        let mut sources = buckets(&names, 16);
460        sources[15].1 = true;
461        assert!(block_on(page(&sources, &repo("a"), "", None, 10, 2048)).is_err());
462    }
463
464    #[test]
465    fn ref_bucket_isolates_repositories_in_one_partition() {
466        use crate::store::{Batch, NamespaceStore};
467
468        let store = crate::MemoryKv::default();
469        let a = repo("a");
470        let b = repo("b");
471        let partition = Partition::Namespace(a.namespace.clone());
472        let name = "refs/heads/main";
473        let batch = Batch::new()
474            .put(keys::ref_key(&a.name, name), codec::encode_ref_id(&[1; 32]))
475            .put(keys::ref_key(&b.name, name), codec::encode_ref_id(&[2; 32]));
476        block_on(store.apply(&partition, batch)).unwrap();
477        let source = RefBucket {
478            store: &store,
479            partition: &partition,
480        };
481        let first = block_on(page(&[source], &a, "", None, 10, 2048)).unwrap();
482        assert_eq!(
483            first.refs,
484            vec![RefEntry {
485                name: name.into(),
486                id: [1; 32]
487            }]
488        );
489        let source = RefBucket {
490            store: &store,
491            partition: &partition,
492        };
493        let second = block_on(page(&[source], &b, "", None, 10, 2048)).unwrap();
494        assert_eq!(
495            second.refs,
496            vec![RefEntry {
497                name: name.into(),
498                id: [2; 32]
499            }]
500        );
501    }
502
503    #[test]
504    fn prefix_boundaries_and_two_repositories() {
505        let names = [
506            "refs/heads/feat/x",
507            "refs/heads/featx",
508            "refs/heads/main",
509            "refs/tags/v1",
510        ]
511        .map(str::to_owned);
512        let sources = buckets(&names, 16);
513        let a = repo("a");
514        assert_eq!(
515            all_pages(&sources, &a, "refs/heads/feat/", 1, 2048),
516            vec![names[0].clone()]
517        );
518        assert_eq!(all_pages(&sources, &a, "", 2, 2048), names.to_vec());
519        assert!(all_pages(&sources, &a, "nope/", 1, 2048).is_empty());
520        let token = encode_token(&a, "", &names[0]);
521        assert!(decode_token(&repo("b"), "", &token).is_none());
522    }
523
524    #[test]
525    fn misrouted_index_row_is_corrupt() {
526        use crate::store::{Batch, NamespaceStore};
527        let store = crate::MemoryKv::default();
528        let repo = repo("a");
529        let name = "refs/heads/main";
530        let proper = D34Shards.ref_index(&repo, name);
531        let wrong = D34Shards
532            .ref_index_partitions(&repo)
533            .into_iter()
534            .find(|p| p != &proper)
535            .unwrap();
536        block_on(store.apply(
537            &wrong,
538            Batch::new().put(
539                keys::ref_index_key(&repo.name, name),
540                codec::encode_ref_id(&[1; 32]),
541            ),
542        ))
543        .unwrap();
544        let source = IndexBucket {
545            store: &store,
546            partition: &wrong,
547        };
548        assert!(matches!(
549            block_on(page(&[source], &repo, "", None, 10, 2048)),
550            Err(StoreError::Corrupt(_))
551        ));
552    }
553
554    #[test]
555    fn invalid_ref_name_in_index_is_corrupt() {
556        use crate::store::{Batch, NamespaceStore};
557        let store = crate::MemoryKv::default();
558        let repo = repo("a");
559        let name = "refs/heads//bad";
560        let partition = D34Shards.ref_index(&repo, name);
561        block_on(store.apply(
562            &partition,
563            Batch::new().put(
564                keys::ref_index_key(&repo.name, name),
565                codec::encode_ref_id(&[1; 32]),
566            ),
567        ))
568        .unwrap();
569        let source = IndexBucket {
570            store: &store,
571            partition: &partition,
572        };
573        assert!(matches!(
574            block_on(page(&[source], &repo, "", None, 10, 2048)),
575            Err(StoreError::Corrupt(_))
576        ));
577    }
578
579    #[test]
580    fn inserts_and_deletes_between_pages_preserve_stable_names_once() {
581        let names: Vec<_> = (0..20).map(|i| format!("refs/heads/n{i:03}")).collect();
582        let sources = buckets(&names, 16);
583        let a = repo("a");
584        let first = block_on(page(&sources, &a, "", None, 5, 2048)).unwrap();
585        let first_names: Vec<_> = first.refs.iter().map(|r| r.name.clone()).collect();
586        for source in &sources {
587            source
588                .0
589                .lock()
590                .unwrap()
591                .retain(|(name, _)| name != "refs/heads/n012");
592        }
593        sources[0]
594            .0
595            .lock()
596            .unwrap()
597            .push(("refs/heads/n011a".into(), [0; 32]));
598        sources[0]
599            .0
600            .lock()
601            .unwrap()
602            .sort_unstable_by(|a, b| a.0.cmp(&b.0));
603        let mut actual = first_names;
604        actual.extend(all_pages_from(&sources, &a, "", 5, 2048, first.next));
605        for stable in names.iter().filter(|n| n.as_str() != "refs/heads/n012") {
606            assert_eq!(actual.iter().filter(|n| *n == stable).count(), 1);
607        }
608    }
609
610    #[test]
611    fn listing_over_32_mib_obeys_two_mib_pages() {
612        let names: Vec<_> = (0..64_000)
613            .map(|i| format!("refs/heads/{i:05}{}", "a".repeat(494)))
614            .collect();
615        assert!(names.iter().map(|n| row_wire_bound(n)).sum::<usize>() > 32 * 1024 * 1024);
616        let sources = buckets(&names, 1);
617        assert_eq!(
618            all_pages(&sources, &repo("large"), "", 10_000, MAX_RESPONSE_BYTES),
619            names
620        );
621    }
622
623    proptest! {
624        #![proptest_config(ProptestConfig::with_cases(64))]
625        #[test]
626        fn random_merge_matches_sorted_listing(ids in proptest::collection::vec(0u16..500, 0..150), n in 1u32..80, budget in 2048usize..8192) {
627            let mut names: Vec<_> = ids.into_iter().map(|i| format!("refs/heads/n{i:04}")).collect();
628            names.sort(); names.dedup();
629            let a = repo("random");
630            for count in [1, 16] {
631                let sources = buckets(&names, count);
632                prop_assert_eq!(all_pages(&sources, &a, "", n, budget), names.clone());
633            }
634        }
635
636        #[test]
637        fn real_memory_index_pages_equal_sorted_listing(ids in proptest::collection::vec(0u16..150, 0..50), n in 1u32..25) {
638            use crate::store::{Batch, NamespaceStore};
639            let store = crate::MemoryKv::default();
640            let a = repo("a");
641            let b = repo("b");
642            let mut names = ids.into_iter().map(|id| format!("refs/heads/n{id:04}")).collect::<Vec<_>>();
643            names.sort(); names.dedup();
644            for name in &names {
645                let target = D34Shards.ref_index(&a, name);
646                block_on(store.apply(&target, Batch::new().put(keys::ref_index_key(&a.name, name), codec::encode_ref_id(&[1;32])))).unwrap();
647                let foreign = D34Shards.ref_index(&b, name);
648                block_on(store.apply(&foreign, Batch::new().put(keys::ref_index_key(&b.name, name), codec::encode_ref_id(&[2;32])))).unwrap();
649            }
650            prop_assert_eq!(real_index_pages(&store, &a, "", n), names.clone());
651            prop_assert_eq!(real_index_pages(&store, &b, "", n), names.clone());
652            let expected = names.iter().filter(|name| name.starts_with("refs/heads/n0")).cloned().collect::<Vec<_>>();
653            prop_assert_eq!(real_index_pages(&store, &a, "refs/heads/n0", n), expected);
654        }
655    }
656}