Marketplace: lock each cache on its own; a removed marketplace stays removed (PR review #5, #6)

The single global repo lock becomes one lock per marketplace, and
refresh_pins takes each marketplace's lock only while setting its pins, so
a slow fetch no longer queues installs and refreshes of other marketplaces.

refresh_marketplace now takes the lock first and reads the settings store
under it (a closure, not a copy captured earlier); a marketplace removed
meanwhile gets no cache and no snapshot. Removing a marketplace deletes its
snapshot and cache under the same lock (remove_marketplace_cache).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 13:10:04 -07:00
co-authored by Claude Opus 5.5
parent da65d51f09
commit e62ca8795a
3 changed files with 187 additions and 64 deletions
@@ -322,7 +322,8 @@ pub(crate) mod ops {
branch: None,
account_id: None,
});
let snap = crate::marketplace::refresh_marketplace(&mgr, &settings, "m1").await;
let snap =
crate::marketplace::refresh_marketplace(&mgr, &|| settings.clone(), "m1").await;
let repo = crate::marketplace::git::cache_path(data.path(), "m1");
let tree = GitTree::open(&repo, &head).unwrap();
@@ -568,21 +569,23 @@ async fn snapshot_blocking(
}
/// Make each cache's pin refs exactly the commits installs reference, so a
/// pinned version can never be garbage-collected away. Under the repo lock
/// (pre-flight F11): a concurrent fetch writes refs in the same repos.
/// pinned version can never be garbage-collected away. Each marketplace's
/// pins are set under that marketplace's repo lock only (pre-flight F11, PR
/// review #5): a concurrent fetch writes refs in the same repo.
pub(crate) async fn refresh_pins(state: &AppState) {
let settings = state.settings_store.get();
let pins = mk::pins_by_marketplace(&settings, &state.projects_store.list());
let root = state.marketplace.data_root().to_path_buf();
let ids: Vec<String> = settings.marketplaces.iter().map(|m| m.id.clone()).collect();
let _repo_guard = state.marketplace.repo_lock().lock().await;
let _ = tokio::task::spawn_blocking(move || {
for id in ids {
let repo = git::cache_path(&root, &id);
for m in &settings.marketplaces {
let lock = state.marketplace.repo_lock(&m.id);
let _repo_guard = lock.lock().await;
let repo = git::cache_path(&root, &m.id);
let commits = pins.get(&m.id).cloned().unwrap_or_default();
let id = m.id.clone();
let _ = tokio::task::spawn_blocking(move || {
if !repo.exists() {
continue;
return;
}
let commits = pins.get(&id).cloned().unwrap_or_default();
if let Err(e) = git::set_pins(&repo, &commits) {
log::warn!(
"Could not update the pinned commits of marketplace {}: {}",
@@ -590,28 +593,14 @@ pub(crate) async fn refresh_pins(state: &AppState) {
e
);
}
}
})
.await;
})
.await;
}
}
/// Forget a marketplace's snapshot and delete its cache, under the repo lock.
/// Forget a marketplace's snapshot and delete its cache, under its repo lock.
pub(crate) async fn remove_cache(state: &AppState, marketplace_id: &str) {
state.marketplace.remove_snapshot(marketplace_id);
let path = git::cache_path(state.marketplace.data_root(), marketplace_id);
let _repo_guard = state.marketplace.repo_lock().lock().await;
let _ = tokio::task::spawn_blocking(move || {
if path.exists() {
if let Err(e) = std::fs::remove_dir_all(&path) {
log::warn!(
"Could not delete the marketplace cache {}: {}",
path.display(),
e
);
}
}
})
.await;
mk::remove_marketplace_cache(&state.marketplace, marketplace_id).await;
}
fn save_new_account(
@@ -665,7 +654,7 @@ pub async fn refresh_marketplaces(
.iter()
.filter(|m| marketplace_id.as_deref().is_none_or(|id| id == m.id))
{
mk::refresh_marketplace(&state.marketplace, &settings, &m.id).await;
mk::refresh_marketplace(&state.marketplace, &|| state.settings_store.get(), &m.id).await;
}
refresh_pins(&state).await;
list_marketplace_snapshots(state).await
@@ -696,7 +685,8 @@ pub async fn add_marketplace(
let mut trial = settings.clone();
trial.marketplaces.push(m.clone());
let snap = mk::refresh_marketplace(&state.marketplace, &trial, &m.id).await;
// Not yet in the store: the trial settings stand in for it.
let snap = mk::refresh_marketplace(&state.marketplace, &|| trial.clone(), &m.id).await;
let failure = snap.fetch_error.clone().or_else(|| {
snap.head_commit
.is_none()