1use 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;
16const 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#[derive(Debug)]
27pub struct Scan {
28 pub rows: Vec<(String, Hash)>,
29 pub more: bool,
30}
31
32pub 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#[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 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
191fn 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
202pub(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}