Skip to content

Commit 7e7acbc

Browse files
fix(cli): cap curator listing fanout and memoize repository fetches
1 parent f36329f commit 7e7acbc

2 files changed

Lines changed: 132 additions & 7 deletions

File tree

crates/skilld-command/src/remote.rs

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::collections::{BTreeMap, BTreeSet};
1+
use std::collections::{BTreeMap, BTreeSet, HashMap};
22
use std::fmt;
33
use std::sync::Arc;
44
#[cfg(not(target_os = "wasi"))]
@@ -28,6 +28,10 @@ const LISTING_PAGE: usize = 200;
2828
/// bounds the loop when the response lies; a partial or empty page also ends
2929
/// paging early.
3030
const MAX_LISTING_PAGES: usize = 25;
31+
/// Most server supplied listing rows skilld trusts per response, mirroring
32+
/// `MAX_LISTING_PAGES`: the curator collection list and each collection's
33+
/// entry list are capped here so a malformed response cannot fan out.
34+
const MAX_LISTING_ENTRIES: usize = MAX_LISTING_PAGES;
3135
const ARTIFACT_LIMIT: usize = 64 * 1024 * 1024;
3236
const DIRECT_BLOB_LIMIT: usize = 12 * 1024 * 1024;
3337
const MAX_REDIRECTS: usize = 3;
@@ -1611,18 +1615,26 @@ impl SkilldRemote {
16111615
self.service_json(url)
16121616
}
16131617

1614-
/// Every Skill one Repository carries, by name.
1618+
/// Every Skill one Repository carries, by name. The owner index fetch is
1619+
/// memoized per Repository in `memo` for one listing, so repeated
1620+
/// collection entries naming the same Repository cost one fetch.
16151621
fn repository_skills(
16161622
&self,
16171623
owner: &str,
16181624
repository: &str,
1625+
memo: &mut HashMap<(String, String), Vec<ListedSkill>>,
16191626
) -> Result<Vec<ListedSkill>, RemoteError> {
1627+
let key = (owner.to_ascii_lowercase(), repository.to_ascii_lowercase());
1628+
if let Some(items) = memo.get(&key) {
1629+
return Ok(items.clone());
1630+
}
16201631
let mut items = self
16211632
.owner_skills(owner)?
16221633
.into_iter()
16231634
.filter(|skill| skill.repository.eq_ignore_ascii_case(repository))
16241635
.collect::<Vec<_>>();
16251636
items.sort_by(|left, right| left.name.cmp(&right.name));
1637+
memo.insert(key, items.clone());
16261638
Ok(items)
16271639
}
16281640

@@ -1653,6 +1665,7 @@ impl SkilldRemote {
16531665
name: row.name,
16541666
reason: row.reason,
16551667
})
1668+
.take(MAX_LISTING_ENTRIES)
16561669
.collect())
16571670
}
16581671

@@ -1676,6 +1689,7 @@ impl SkilldRemote {
16761689
fn expand_entries(
16771690
&self,
16781691
entries: Vec<CollectionEntry>,
1692+
memo: &mut HashMap<(String, String), Vec<ListedSkill>>,
16791693
) -> Result<Vec<ListedSkill>, RemoteError> {
16801694
let mut seen = BTreeSet::new();
16811695
let mut items = Vec::new();
@@ -1686,7 +1700,7 @@ impl SkilldRemote {
16861700
.into_iter()
16871701
.collect()
16881702
}
1689-
None => self.repository_skills(&entry.owner, &entry.repository)?,
1703+
None => self.repository_skills(&entry.owner, &entry.repository, memo)?,
16901704
};
16911705
for skill in expanded {
16921706
if seen.insert(skill.selector()) {
@@ -1732,19 +1746,24 @@ fn not_found_as_source(error: RemoteError, message: String) -> RemoteError {
17321746

17331747
impl RemoteProvider for SkilldRemote {
17341748
fn list_skills(&self, reference: &MultiSkillRef) -> Result<SkillListing, RemoteError> {
1749+
let mut memo = HashMap::new();
17351750
let items = match reference {
17361751
MultiSkillRef::Repository { owner, repository } => {
1737-
self.repository_skills(owner, repository)?
1752+
self.repository_skills(owner, repository, &mut memo)?
17381753
}
17391754
MultiSkillRef::Collection { login, slug } => {
1740-
self.expand_entries(self.collection_entries(login, slug)?)?
1755+
self.expand_entries(self.collection_entries(login, slug)?, &mut memo)?
17411756
}
17421757
MultiSkillRef::Curator { login } => {
17431758
let mut entries = Vec::new();
1744-
for slug in self.curator_slugs(login)? {
1759+
for slug in self
1760+
.curator_slugs(login)?
1761+
.into_iter()
1762+
.take(MAX_LISTING_ENTRIES)
1763+
{
17451764
entries.extend(self.collection_entries(login, &slug)?);
17461765
}
1747-
self.expand_entries(entries)?
1766+
self.expand_entries(entries, &mut memo)?
17481767
}
17491768
};
17501769
Ok(SkillListing {

crates/skilld-command/tests/remote.rs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,112 @@ fn a_curator_ref_lists_every_collection_once() {
681681
);
682682
}
683683

684+
/// Serves a curator payload with 100 collections, answers every collection
685+
/// detail with one name-less Repository entry, and serves a runaway owner
686+
/// index to every owner, so only client side caps and memoization can end
687+
/// the listing.
688+
#[derive(Default)]
689+
struct RunawayCuratorHttp {
690+
requests: Mutex<Vec<String>>,
691+
}
692+
693+
const RUNAWAY_CURATOR_COLLECTIONS: usize = 100;
694+
const RUNAWAY_CURATOR_PAGE_CAP: usize = 25;
695+
696+
/// One curator payload, the capped collection details, and one memoized
697+
/// owner index fetch for the repeated entry spanning the capped pages.
698+
const RUNAWAY_CURATOR_REQUEST_LIMIT: usize = 1 + 2 * RUNAWAY_CURATOR_PAGE_CAP;
699+
700+
impl HttpAdapter for RunawayCuratorHttp {
701+
fn send(
702+
&self,
703+
request: &HttpRequest,
704+
_cancellation: &dyn Cancellation,
705+
_timeout: Option<Duration>,
706+
) -> Result<HttpResponse, RemoteError> {
707+
let mut requests = self.requests.lock().unwrap();
708+
requests.push(request.url.clone());
709+
assert!(
710+
requests.len() <= RUNAWAY_CURATOR_REQUEST_LIMIT,
711+
"the curator listing kept issuing requests: {} requests issued",
712+
requests.len()
713+
);
714+
if request.url.contains("/api/curators/") {
715+
return Ok(response(
716+
200,
717+
serde_json::to_vec(&json!({
718+
"login": "curator",
719+
"collections": (0..RUNAWAY_CURATOR_COLLECTIONS)
720+
.map(|index| {
721+
json!({
722+
"slug": format!("collection-{index}"),
723+
"name": format!("Collection {index}"),
724+
"itemCount": 1,
725+
})
726+
})
727+
.collect::<Vec<_>>(),
728+
}))
729+
.unwrap(),
730+
));
731+
}
732+
if request.url.contains("/api/collections/by-author/") {
733+
return Ok(response(
734+
200,
735+
serde_json::to_vec(&json!({
736+
"skills": [{
737+
"position": 0,
738+
"owner": "big",
739+
"repo": "wanted",
740+
"name": null,
741+
"reason": null,
742+
}]
743+
}))
744+
.unwrap(),
745+
));
746+
}
747+
let page = json!({
748+
"items": (0..RUNAWAY_PAGE_ROWS)
749+
.map(|_| json!({
750+
"name": "wanted",
751+
"owner": "big",
752+
"repo": "wanted",
753+
"description": null,
754+
"stars": 1,
755+
"registryPath": "/gh/big/wanted/wanted",
756+
}))
757+
.collect::<Vec<_>>(),
758+
"total": 20_000_000,
759+
"pages": u64::MAX,
760+
});
761+
Ok(response(200, serde_json::to_vec(&page).unwrap()))
762+
}
763+
}
764+
765+
#[test]
766+
fn a_runaway_curator_stops_fanning_out_and_fetches_one_repository_once() {
767+
let http = Arc::new(RunawayCuratorHttp::default());
768+
let remote = SkilldRemote::new(
769+
http.clone(),
770+
Arc::new(NoTokenProvider),
771+
NativeRemoteConfig::Unconfigured,
772+
)
773+
.with_endpoint("http://127.0.0.1:8787")
774+
.unwrap()
775+
.with_sleeper(Arc::new(NoSleep));
776+
777+
let listing = remote
778+
.list_skills(&MultiSkillRef::Curator {
779+
login: "curator".to_owned(),
780+
})
781+
.unwrap();
782+
783+
assert_eq!(listing.items, [listed("big", "wanted", "wanted", None)]);
784+
assert_eq!(
785+
http.requests.lock().unwrap().len(),
786+
RUNAWAY_CURATOR_REQUEST_LIMIT
787+
);
788+
}
789+
684790
#[test]
685791
fn a_missing_collection_is_a_source_not_found_error() {
686792
let http = Arc::new(FakeHttp::with([response(

0 commit comments

Comments
 (0)