feat(core): Provision cluster creation in an attempt to not have race conditions

This commit is contained in:
LynithDev 2026-07-19 13:28:27 +02:00
parent c338d09c60
commit a9481a5789
5 changed files with 45 additions and 4 deletions

View file

@ -48,6 +48,37 @@ impl ClusterManager {
pub async fn create( pub async fn create(
state: &LauncherState, state: &LauncherState,
options: CreateClusterOptions, options: CreateClusterOptions,
) -> LauncherResult<Cluster> {
let _guard = state.provisioning.lock().await;
Self::create_core(state, options).await
}
/// Create a cluster for a version/loader that doesn't exist yet, and provision it.
#[tracing::instrument(level = "debug", skip(state))]
pub async fn create_provisioned(
state: &LauncherState,
options: CreateClusterOptions,
) -> LauncherResult<Option<Cluster>> {
let _guard = state.provisioning.lock().await;
if cluster_dao::find_by_version_loader(
&state.services.db,
&options.mc_version,
options.mc_loader as i64,
)
.await?
.is_some()
{
return Ok(None);
}
Self::create_core(state, options).await.map(Some)
}
/// Folder-resolve + insert core shared by `create` and `create_provisioned`.
/// Callers MUST hold `state.provisioning` — this does not lock.
#[tracing::instrument(level = "debug", skip(state))]
async fn create_core(
state: &LauncherState,
options: CreateClusterOptions,
) -> LauncherResult<Cluster> { ) -> LauncherResult<Cluster> {
let name = Self::sanitize_name(&options.name); let name = Self::sanitize_name(&options.name);
if name.is_empty() { if name.is_empty() {

View file

@ -39,13 +39,13 @@ pub async fn ensure_from_bundles(state: &LauncherState) -> LauncherResult<Vec<Cl
let mc_version = group.mc_version.clone(); let mc_version = group.mc_version.clone();
let name = format!("{mc_version} {loader}"); let name = format!("{mc_version} {loader}");
match ClusterManager::create( match ClusterManager::create_provisioned(
state, state,
CreateClusterOptions::new(name, mc_version.clone(), loader), CreateClusterOptions::new(name, mc_version.clone(), loader),
) )
.await .await
{ {
Ok(cluster) => { Ok(Some(cluster)) => {
tracing::info!( tracing::info!(
cluster_id = cluster.id, cluster_id = cluster.id,
mc_version = %cluster.mc_version, mc_version = %cluster.mc_version,
@ -54,6 +54,7 @@ pub async fn ensure_from_bundles(state: &LauncherState) -> LauncherResult<Vec<Cl
); );
created.push(cluster); created.push(cluster);
} }
Ok(None) => {}
Err(err) => { Err(err) => {
tracing::warn!( tracing::warn!(
mc_version = %mc_version, mc_version = %mc_version,
@ -100,13 +101,13 @@ pub async fn ensure_from_versions(state: &LauncherState) -> LauncherResult<Vec<C
} }
let name = format!("{mc_version} {loader}"); let name = format!("{mc_version} {loader}");
match ClusterManager::create( match ClusterManager::create_provisioned(
state, state,
CreateClusterOptions::new(name, mc_version.clone(), loader), CreateClusterOptions::new(name, mc_version.clone(), loader),
) )
.await .await
{ {
Ok(cluster) => { Ok(Some(cluster)) => {
tracing::info!( tracing::info!(
cluster_id = cluster.id, cluster_id = cluster.id,
mc_version = %cluster.mc_version, mc_version = %cluster.mc_version,
@ -115,6 +116,7 @@ pub async fn ensure_from_versions(state: &LauncherState) -> LauncherResult<Vec<C
); );
created.push(cluster); created.push(cluster);
} }
Ok(None) => {}
Err(err) => { Err(err) => {
tracing::warn!( tracing::warn!(
mc_version = %mc_version, mc_version = %mc_version,

View file

@ -242,6 +242,7 @@ pub async fn ephemeral_state() -> LauncherResult<Arc<LauncherState>> {
images: crate::images::ImageCacheStore::new(), images: crate::images::ImageCacheStore::new(),
games: crate::game::GameProcessManager::new(), games: crate::game::GameProcessManager::new(),
discord: crate::discord::DiscordRpc::spawn(false), discord: crate::discord::DiscordRpc::spawn(false),
provisioning: tokio::sync::Mutex::new(()),
})) }))
} }

View file

@ -40,6 +40,11 @@ impl RecoveryReport {
pub async fn reconstruct_from_disk(state: &LauncherState) -> LauncherResult<RecoveryReport> { pub async fn reconstruct_from_disk(state: &LauncherState) -> LauncherResult<RecoveryReport> {
let mut report = RecoveryReport::default(); let mut report = RecoveryReport::default();
// Hold the provisioning lock across the whole scan+adopt so a concurrent
// cluster create (folder-on-disk written before its DB row commits) can't be
// misclassified as an orphan and adopted into a duplicate row.
let _guard = state.provisioning.lock().await;
let clusters_dir = paths::clusters_dir()?; let clusters_dir = paths::clusters_dir()?;
let orphans = orphan_cluster_folders(&state.services.db, &clusters_dir).await?; let orphans = orphan_cluster_folders(&state.services.db, &clusters_dir).await?;
if orphans.is_empty() { if orphans.is_empty() {

View file

@ -40,6 +40,7 @@ pub struct LauncherState {
pub images: ImageCacheStore, pub images: ImageCacheStore,
pub games: crate::game::GameProcessManager, pub games: crate::game::GameProcessManager,
pub discord: DiscordRpc, pub discord: DiscordRpc,
pub provisioning: Mutex<()>,
} }
impl LauncherState { impl LauncherState {
@ -75,6 +76,7 @@ impl LauncherState {
images: ImageCacheStore::new(), images: ImageCacheStore::new(),
games: crate::game::GameProcessManager::new(), games: crate::game::GameProcessManager::new(),
discord, discord,
provisioning: Mutex::new(()),
}); });
STATE STATE