diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 0bbd8b065a..43d4ef241b 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -4734,9 +4734,18 @@ async fn clone_apps( .await?; let mut app_id_mapping: HashMap = HashMap::new(); + // Only a raw app's current (last) version has a bundle worth carrying into the fork: bundles exist + // only for raw apps, and older versions aren't viewable/runnable (the bundle secret is only ever + // minted for `versions.last()`). Copying a bundle for every version of every app is what makes + // forking a workspace with many app versions hang — a serial S3 round-trip per version, held inside + // the fork transaction. Collect each app's current version here; intersect with raw versions below. + let mut latest_version_ids: HashSet = HashSet::new(); // Clone apps with new IDs for app in apps { + if let Some(¤t_version) = app.versions.last() { + latest_version_ids.insert(current_version); + } let new_app_id = sqlx::query_scalar!( "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, custom_path) VALUES ($1, $2, $3, $4, $5, $6, $7) @@ -4756,6 +4765,7 @@ async fn clone_apps( } let mut version_id_mapping: HashMap = HashMap::new(); + let mut raw_version_ids: HashSet = HashSet::new(); { // Clone app versions @@ -4771,6 +4781,9 @@ async fn clone_apps( for version in app_versions { if let Some(&new_app_id) = app_id_mapping.get(&version.app_id) { + if version.raw_app { + raw_version_ids.insert(version.id); + } let new_version_id = sqlx::query_scalar!( "INSERT INTO app_version (app_id, value, created_by, created_at, raw_app) VALUES ($1, $2, $3, $4, $5) RETURNING id", @@ -4788,9 +4801,16 @@ async fn clone_apps( } } - // Clone app bundles for raw apps - if !version_id_mapping.is_empty() { - let old_ids: Vec = version_id_mapping.keys().copied().collect(); + // The bundles worth cloning: each raw app's current version (latest ∩ raw). Everything else either + // has no bundle (low-code apps) or an unreachable one (older versions), so we don't touch S3 for it. + let bundle_version_ids: HashSet = latest_version_ids + .intersection(&raw_version_ids) + .copied() + .collect(); + + // Clone app bundles — only the current version of each raw app (see bundle_version_ids). + if !bundle_version_ids.is_empty() { + let old_ids: Vec = bundle_version_ids.iter().copied().collect(); let bundles = sqlx::query!( "SELECT app_version_id, file_type, data FROM app_bundles WHERE app_version_id = ANY($1) AND w_id = $2", @@ -4826,40 +4846,31 @@ async fn clone_apps( let object_store = windmill_object_store::get_object_store().await; if let Some(os) = object_store { for (&old_version_id, &new_version_id) in &version_id_mapping { + if !bundle_version_ids.contains(&old_version_id) { + continue; + } for file_type in &["js", "css"] { if cloned_from_db.contains(&(old_version_id, file_type.to_string())) { continue; } - let src_path = format!( - "/app_bundles/{}/{}.{}", - source_workspace_id, old_version_id, file_type - ); - let get_result = os - .get(&windmill_object_store::object_store_reexports::Path::from( - src_path, - )) - .await; - match get_result { - Ok(result) => { - let data = result.bytes().await.map_err( - windmill_object_store::object_store_error_to_error, - )?; - let dst_path = format!( - "/app_bundles/{}/{}.{}", - target_workspace_id, new_version_id, file_type - ); - os.put( - &windmill_object_store::object_store_reexports::Path::from( - dst_path.clone(), - ), - data.into(), - ) - .await - .map_err( - windmill_object_store::object_store_error_to_error, - )?; + // Prefer a server-side copy (no bytes through the backend). A missing source — + // e.g. a raw app with a js bundle but no css — surfaces as NotFound and is + // skipped. Not every object-store provider supports server-side copy, so fall + // back to download+upload on any other error. + let src_path = + windmill_object_store::object_store_reexports::Path::from(format!( + "/app_bundles/{}/{}.{}", + source_workspace_id, old_version_id, file_type + )); + let dst_path = + windmill_object_store::object_store_reexports::Path::from(format!( + "/app_bundles/{}/{}.{}", + target_workspace_id, new_version_id, file_type + )); + match os.copy(&src_path, &dst_path).await { + Ok(()) => { tracing::info!( - "Cloned app bundle from S3: {}.{} -> {}.{}", + "Cloned app bundle in object store: {}.{} -> {}.{}", old_version_id, file_type, new_version_id, @@ -4867,12 +4878,39 @@ async fn clone_apps( ); } Err(windmill_object_store::object_store_reexports::ObjectStoreError::NotFound { .. }) => { - // No bundle in S3 for this version/type, skip + // No bundle in the object store for this version/type, skip } - Err(e) => { - return Err( - windmill_object_store::object_store_error_to_error(e), + Err(copy_err) => { + // Provider may not support server-side copy — fall back to get+put. + tracing::warn!( + "object store copy failed ({copy_err:#}), falling back to get+put for app bundle {}.{}", + old_version_id, file_type ); + match os.get(&src_path).await { + Ok(result) => { + let data = result.bytes().await.map_err( + windmill_object_store::object_store_error_to_error, + )?; + os.put(&dst_path, data.into()).await.map_err( + windmill_object_store::object_store_error_to_error, + )?; + tracing::info!( + "Cloned app bundle via get+put fallback: {}.{} -> {}.{}", + old_version_id, + file_type, + new_version_id, + file_type + ); + } + Err(windmill_object_store::object_store_reexports::ObjectStoreError::NotFound { .. }) => { + // No bundle for this version/type, skip + } + Err(e) => { + return Err( + windmill_object_store::object_store_error_to_error(e), + ); + } + } } } }