mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-16 00:03:02 +00:00
* fix: create a fork in the background so a proxy timeout cannot cut it create_fork copies the whole workspace inside the request, which can run past the route timeout of an ingress in front of Windmill (Envoy's 15s default), and the UI then reports a failure for a fork still being made. create_fork?background=true now returns once the request is validated and records the copy in workspace_fork_creation, which the new fork_creation_status endpoint reads. The wizard and AI-session forks use it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: drain background forks on shutdown and fork in the background from the CLI Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: settle fork polls by the fork's existence, adopt in-flight creations Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: show which part of the copy a fork being created is in The background copy reports its phase (data tables, settings, resources, scripts, flows, apps, drafts, triggers) through a watch channel. The heartbeat task records it on the fork's creation row as soon as it changes, and fork_creation_status returns it. The fork wizard shows it on its button, and the CLI logs each step. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: join a retried fork creation server-side, settle status by the fork's existence A retry from the same user and parent joins the creation in flight instead of being refused, so clients no longer match the refusal's wording, and another requester can never adopt it. The status route reports a fork that exists under its parent as completed, whatever its run's record says. Clients give up on failing polls after a time window rather than a count, which a rolling deploy can exhaust. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: drop fork-creation joins and existence probes A second request for a fork being created is refused again; only the AI-session fork, whose request never varies, waits for its own earlier one. Clients recognise a server without background forks by its synchronous answer instead of probing for a workspace by id, which could name another parent's fork. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: poll a background fork by the id of its own attempt create_fork?background=true answers with a creation id, and the status route reads that attempt only, for the user who started it. A retry that reuses the fork id is a new attempt, so a poller never reads another attempt's outcome. The AI-session fork no longer adopts a creation in flight, which it could not tell apart from someone else's. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: record a fork's completion in its own commit, resume a session's fork after reload The attempt is marked complete in the transaction that creates the fork, so the status never infers completion from a workspace that may belong to another request. An AI session keeps the creation id on its pending fork and, after a reload mid-copy, waits for that attempt instead of requesting the fork again. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
121 lines
4.6 KiB
Rust
121 lines
4.6 KiB
Rust
use serde_json::{json, Value};
|
|
use sqlx::{Pool, Postgres};
|
|
|
|
use windmill_test_utils::*;
|
|
|
|
async fn wait_for_fork(client: &reqwest::Client, base_url: &str, creation_id: &str) -> Value {
|
|
for _ in 0..100 {
|
|
let status: Value = client
|
|
.get(format!("{base_url}/fork_creation_status/{creation_id}"))
|
|
.header("Authorization", "Bearer SECRET_TOKEN")
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
if status["status"] != "running" {
|
|
return status;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
|
}
|
|
panic!("fork creation {creation_id} still running");
|
|
}
|
|
|
|
/// A fork created in the background is reported by `fork_creation_status`, including a failure
|
|
/// that happens after the request has already returned.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_fork_created_in_background(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let base_url = format!(
|
|
"http://localhost:{}/api/w/test-workspace/workspaces",
|
|
server.addr.port()
|
|
);
|
|
let client = reqwest::Client::new();
|
|
let create = |id: &str| {
|
|
client
|
|
.post(format!("{base_url}/create_fork?background=true"))
|
|
.header("Authorization", "Bearer SECRET_TOKEN")
|
|
.json(&json!({ "id": id, "name": id }))
|
|
.send()
|
|
};
|
|
|
|
let resp = create("wm-fork-bg-ok").await?;
|
|
assert_eq!(resp.status(), 200);
|
|
let ok_creation = resp.text().await?;
|
|
let status = wait_for_fork(&client, &base_url, &ok_creation).await;
|
|
assert_eq!(status, json!({ "status": "completed" }));
|
|
// Nobody but the user who started a creation reads it.
|
|
let resp = client
|
|
.get(format!("{base_url}/fork_creation_status/{ok_creation}"))
|
|
.header("Authorization", "Bearer SECRET_TOKEN_2")
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 404);
|
|
let exists: bool =
|
|
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM workspace WHERE id = 'wm-fork-bg-ok')")
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert!(exists);
|
|
// Under CE's workspace cap.
|
|
sqlx::query("UPDATE workspace SET deleted = true WHERE id = 'wm-fork-bg-ok'")
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Hold the fork's write lock so the request is validated and returns, then commit a workspace
|
|
// with the same id: the background write fails on it.
|
|
let mut blocker = db.begin().await?;
|
|
sqlx::query("SELECT pg_advisory_xact_lock(hashtext('fork:wm-fork-bg-err'))")
|
|
.execute(&mut *blocker)
|
|
.await?;
|
|
let resp = create("wm-fork-bg-err").await?;
|
|
assert_eq!(resp.status(), 200);
|
|
let err_creation = resp.text().await?;
|
|
let resp = create("wm-fork-bg-err").await?;
|
|
assert_eq!(resp.status(), 400, "a second creation while the first is in flight");
|
|
sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ('wm-fork-bg-err', 'x', 'x')")
|
|
.execute(&mut *blocker)
|
|
.await?;
|
|
blocker.commit().await?;
|
|
let status = wait_for_fork(&client, &base_url, &err_creation).await;
|
|
assert_eq!(status["status"], "failed");
|
|
assert!(
|
|
status["error"].as_str().unwrap().contains("workspace_pkey"),
|
|
"{status}"
|
|
);
|
|
|
|
// A retry of the same id is a new attempt: the failed one does not read its outcome.
|
|
sqlx::query("DELETE FROM workspace WHERE id = 'wm-fork-bg-err'")
|
|
.execute(&db)
|
|
.await?;
|
|
let resp = create("wm-fork-bg-err").await?;
|
|
assert_eq!(resp.status(), 200);
|
|
let retry_creation = resp.text().await?;
|
|
assert_eq!(
|
|
wait_for_fork(&client, &base_url, &retry_creation).await,
|
|
json!({ "status": "completed" })
|
|
);
|
|
let resp = client
|
|
.get(format!("{base_url}/fork_creation_status/{err_creation}"))
|
|
.header("Authorization", "Bearer SECRET_TOKEN")
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 404);
|
|
|
|
// Without the flag the fork is created before the answer, which clients asking for a
|
|
// background fork recognise a server without it by.
|
|
sqlx::query("UPDATE workspace SET deleted = true WHERE id = 'wm-fork-bg-err'")
|
|
.execute(&db)
|
|
.await?;
|
|
let resp = client
|
|
.post(format!("{base_url}/create_fork"))
|
|
.header("Authorization", "Bearer SECRET_TOKEN")
|
|
.json(&json!({ "id": "wm-fork-sync", "name": "wm-fork-sync" }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.text().await?, "Created forked workspace wm-fork-sync");
|
|
|
|
Ok(())
|
|
}
|