fix(forks): clone only the current raw-app bundle, via server-side copy (#9899)

* fix(forks): clone only the current raw-app bundle, via server-side copy

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(forks): fall back to get+put when object-store copy is unsupported

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-07-03 12:53:16 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent bcb4dd8d27
commit 5c521d808a
@@ -4734,9 +4734,18 @@ async fn clone_apps(
.await?;
let mut app_id_mapping: HashMap<i64, i64> = 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<i64> = HashSet::new();
// Clone apps with new IDs
for app in apps {
if let Some(&current_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<i64, i64> = HashMap::new();
let mut raw_version_ids: HashSet<i64> = 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<i64> = 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<i64> = 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<i64> = 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),
);
}
}
}
}
}