fix(datatables): keep fork copies from other workspaces and from cleanup while in use

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-09-27 00:18:17 +02:00
co-authored by Claude Opus 5.5
parent 649c43e7c1
commit ce35cf1217
4 changed files with 89 additions and 7 deletions
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT format('data table ''%s'' of ''%s''', d.key, ws.workspace_id) AS \"use!\"\n FROM workspace_settings ws,\n jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) d\n WHERE jsonb_typeof(d.value) = 'object'\n AND NOT (ws.workspace_id = $1 AND d.key = $2)\n AND ((d.value->'reference'->>'workspace_id' = $1\n AND d.value->'reference'->>'datatable' = $2)\n OR (d.value->'database'->>'resource_type' = 'instance'\n AND d.value->'database'->>'resource_path' = $3))\n UNION ALL\n SELECT format('Ducklake ''%s'' of ''%s''', l.key, ws.workspace_id)\n FROM workspace_settings ws,\n jsonb_each(COALESCE(ws.ducklake->'ducklakes', '{}'::jsonb)) l\n WHERE jsonb_typeof(l.value) = 'object'\n AND l.value->'catalog'->>'resource_type' = 'instance'\n AND l.value->'catalog'->>'resource_path' = $3",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "use!",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "3c1be20dfcfe86949a8073382efb5ae764046b218dfde6b4e7bbd3b9f42b7d4b"
}
@@ -3647,13 +3647,15 @@ async fn import_pg_database(
resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.target).await?;
if let Some(ref override_dbname) = req.target_dbname_override {
// A `wm_fork_` name does not say whose copy it is: every instance database answers to the
// same connection user, so an override would reach another workspace's fork copy. Forks
// make their own copies, so nothing but a superadmin needs one.
if !windmill_api_auth::is_super_admin_authed(&db, &authed).await? {
if !override_dbname.starts_with("wm_fork_") {
return Err(Error::BadRequest(
"Non-superadmin users can only override target dbname with names starting with 'wm_fork_'"
.to_string(),
));
}
return Err(Error::BadRequest(
"Only superadmins can override the target database: create forks through the \
fork wizard or `wmill workspace fork`, which copy each data table themselves"
.to_string(),
));
}
target_pg.dbname = override_dbname.clone();
}
@@ -1430,6 +1430,23 @@ pub async fn drop_forked_datatable_databases(
_ => continue,
};
match forked_database_uses(&db, &w_id, dt_name, database).await {
Ok(uses) if uses.is_empty() => {}
Ok(uses) => {
errors.push(format!(
"Refusing to drop the database of datatable://{dt_name}: still used by {}",
uses.join(", ")
));
continue;
}
Err(e) => {
errors.push(format!(
"Could not check what still uses the database of datatable://{dt_name}: {e}"
));
continue;
}
}
if database.resource_type
== windmill_common::workspaces::DataTableCatalogResourceType::Instance
{
@@ -1531,6 +1548,45 @@ pub async fn drop_forked_datatable_databases(
Ok(Json(errors))
}
/// What besides data table `dt_name` of `w_id` still uses its database: a data table anywhere
/// pointing at that entry (a child fork keeping it), and for an instance database, any other data
/// table entry or Ducklake catalog naming it. Instance databases share one connection user, so
/// nothing but these settings tells a copy still in use from one that is not.
async fn forked_database_uses(
db: &DB,
w_id: &str,
dt_name: &str,
database: &windmill_common::workspaces::DataTableDatabase,
) -> Result<Vec<String>> {
let instance_db = (database.resource_type
== windmill_common::workspaces::DataTableCatalogResourceType::Instance)
.then_some(database.resource_path.as_str());
let uses = sqlx::query_scalar!(
r#"SELECT format('data table ''%s'' of ''%s''', d.key, ws.workspace_id) AS "use!"
FROM workspace_settings ws,
jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) d
WHERE jsonb_typeof(d.value) = 'object'
AND NOT (ws.workspace_id = $1 AND d.key = $2)
AND ((d.value->'reference'->>'workspace_id' = $1
AND d.value->'reference'->>'datatable' = $2)
OR (d.value->'database'->>'resource_type' = 'instance'
AND d.value->'database'->>'resource_path' = $3))
UNION ALL
SELECT format('Ducklake ''%s'' of ''%s''', l.key, ws.workspace_id)
FROM workspace_settings ws,
jsonb_each(COALESCE(ws.ducklake->'ducklakes', '{}'::jsonb)) l
WHERE jsonb_typeof(l.value) = 'object'
AND l.value->'catalog'->>'resource_type' = 'instance'
AND l.value->'catalog'->>'resource_path' = $3"#,
w_id,
dt_name,
instance_db,
)
.fetch_all(db)
.await?;
Ok(uses)
}
/// Drop this fork workspace's ducklake namespaces: the `wm_fork_*` metadata schema in each
/// lake's catalog database, plus (best effort) the fork's `__wm_forks/<wid>/…` data files in
/// the workspace storage. Driven by the `fork_ducklake_namespace` registry written at first
+1 -1
View File
@@ -6159,7 +6159,7 @@ paths:
description: "Target database: 'datatable://name' or '$res:path'"
target_dbname_override:
type: string
description: "Override the target database name"
description: "Override the target database name (superadmins only)"
fork_behavior:
type: string
enum: