Files
windmill/backend/tests/relock_skip.rs
Diego Imbert ae42cbf4a8 feat(backend): remove draft_only, move never-deployed items into the draft table
Never-deployed "draft only" scripts/flows/apps used to live as a stub row in
their own table (draft_only = true) alongside a draft row. They now live solely
in the `draft` table.

Migration `remove_draft_only`:
- ensures every draft_only stub has a matching draft row (ON CONFLICT DO
  NOTHING preserves the real draft; synthesises one matching the frontend
  draft JSON shape only for stubs that lost their draft), with email = NULL
- deletes the stub rows (FKs to *_version / dependency tables cascade)
- drops the draft_only column from script/flow/app
The down migration re-adds the (nullable) columns; it is fully reversible
schema-wise (deleted stubs are not resurrected — their content lives in draft).

Removes every draft_only / include_draft_only reference from the backend:
handlers, list filters (draft-only items no longer exist in those tables so the
filters are dropped), INSERT/SELECT column lists, NewScript/NewFlow and
*WithDraft structs, the deploy no-op comparison, the delete-permission check
(deleting always requires admin now), git-sync's draft-only skip, and openapi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 16:12:12 +02:00

414 lines
15 KiB
Rust

use sqlx::{Pool, Postgres};
use tokio_stream::StreamExt;
use windmill_api_client::types::NewScript;
use windmill_test_utils::*;
mod relock_skip {
use super::*;
fn quick_ns(
content: &str,
language: windmill_api_client::types::ScriptLang,
path: &str,
lock: Option<String>,
parent_hash: Option<String>,
) -> NewScript {
NewScript {
content: content.into(),
language,
lock,
parent_hash,
path: path.into(),
concurrent_limit: None,
concurrency_time_window_s: None,
cache_ttl: None,
dedicated_worker: None,
description: "".to_string(),
envs: vec![],
is_template: None,
kind: None,
summary: "".to_string(),
tag: None,
schema: std::collections::HashMap::new(),
ws_error_handler_muted: Some(false),
priority: None,
delete_after_secs: None,
timeout: None,
restart_unless_cancelled: None,
deployment_message: None,
concurrency_key: None,
visible_to_runner_only: None,
auto_kind: None,
codebase: None,
has_preprocessor: None,
on_behalf_of_email: None,
assets: vec![],
modules: None,
}
}
async fn init(db: Pool<Postgres>) -> (windmill_api_client::Client, u16, ApiServer) {
init_client(db).await
}
/// Counts occurrences of a pattern in job logs for all jobs created after a given time
async fn count_pattern_in_job_logs(
db: &Pool<Postgres>,
pattern: &str,
after: chrono::DateTime<chrono::Utc>,
) -> i64 {
let logs = sqlx::query_scalar!("SELECT logs FROM job_logs WHERE created_at > $1", after)
.fetch_all(db)
.await
.unwrap();
logs.iter()
.filter_map(|l| l.as_ref())
.map(|l| l.matches(pattern).count() as i64)
.sum()
}
/// Waits for exactly N jobs to complete. Returns the timestamp before waiting.
async fn wait_for_jobs(
completed: &mut (impl futures::Stream<Item = uuid::Uuid> + Unpin),
count: usize,
) -> chrono::DateTime<chrono::Utc> {
let before = chrono::Utc::now();
for _ in 0..count {
completed.next().await;
}
before
}
/// Waits for at least N jobs to complete, then drains any additional jobs
/// that complete within a short timeout. Returns the timestamp before waiting.
async fn wait_for_jobs_ge(
completed: &mut (impl futures::Stream<Item = uuid::Uuid> + Unpin),
min_count: usize,
) -> chrono::DateTime<chrono::Utc> {
let before = chrono::Utc::now();
for _ in 0..min_count {
completed.next().await;
}
// Drain any additional jobs that complete within 5 seconds
loop {
match tokio::time::timeout(std::time::Duration::from_secs(1), completed.next()).await {
Ok(Some(_)) => continue,
_ => break,
}
}
before
}
// Requires `private` feature for the dependency job concurrency limit that prevents
// race conditions between concurrent dep jobs for the same flow/app.
#[cfg(all(feature = "python", feature = "private"))]
#[sqlx::test(fixtures("base", "dependency_map"))]
async fn relock_skip_on_script_redeployment(db: Pool<Postgres>) -> anyhow::Result<()> {
std::env::set_var("DEPENDENCY_JOB_DEBOUNCE_DELAY", "0");
let (client, port, _s) = init(db.clone()).await;
let mut completed = listen_for_completed_jobs(&db).await;
in_test_worker(&db, async {
// Step 1: Redeploy leaf_1 - first time, no hashes exist, all should relock
let before = chrono::Utc::now();
client
.create_script(
"test-workspace",
&quick_ns(
"
def main():
return 'leaf1'
",
windmill_api_client::types::ScriptLang::Python3,
"f/rel/leaf_1",
None,
Some("0000000000051658".into()),
),
)
.await
.unwrap();
// leaf_1(1) + branch(1) + root_script(1-2) + root_flow(1-2) + root_app(1-2) = 5-8 jobs
// With debouncing (private feature), duplicate dep jobs from branch cascade may be coalesced.
wait_for_jobs_ge(&mut completed, 5).await;
let skipping_count = count_pattern_in_job_logs(&db, "Skipping relock", before).await;
let relocking_count = count_pattern_in_job_logs(&db, "Relocking", before).await;
assert_eq!(skipping_count, 0, "First deployment should not skip");
assert!(relocking_count > 0, "First deployment should have relocking jobs");
// Step 2: Redeploy leaf_2 - first time for leaf_2, should relock
let before = chrono::Utc::now();
client
.create_script(
"test-workspace",
&quick_ns(
"
def main():
return 'leaf2'
",
windmill_api_client::types::ScriptLang::Python3,
"f/rel/leaf_2",
None,
Some("0000000000051659".into()),
),
)
.await
.unwrap();
// leaf_2(1) + root_script(1) + root_flow(1) + root_app(1) = 4 jobs (no cascade, branch doesn't depend on leaf_2)
wait_for_jobs(&mut completed, 4).await;
let skipping_count = count_pattern_in_job_logs(&db, "Skipping relock", before).await;
let relocking_count = count_pattern_in_job_logs(&db, "Relocking", before).await;
assert_eq!(skipping_count, 0, "leaf_2 first deployment should not skip");
assert!(relocking_count > 0, "leaf_2 first deployment should have relocking jobs");
// Step 3: Redeploy leaf_2 with trivial change (comment) - lock stays same, should SKIP
// Get current parent hash for leaf_2
let leaf2_hash = sqlx::query_scalar!(
"SELECT hash FROM script WHERE path = 'f/rel/leaf_2' AND workspace_id = 'test-workspace' AND archived = false ORDER BY created_at DESC LIMIT 1"
)
.fetch_one(&db)
.await
.unwrap();
let before = chrono::Utc::now();
client
.create_script(
"test-workspace",
&quick_ns(
"
# comment to change hash but not lock
def main():
return 'leaf2'
",
windmill_api_client::types::ScriptLang::Python3,
"f/rel/leaf_2",
None,
Some(format!("{:016X}", leaf2_hash)),
),
)
.await
.unwrap();
// Same as leaf_2 first deployment: 4 jobs
wait_for_jobs(&mut completed, 4).await;
let skipping_count = count_pattern_in_job_logs(&db, "Skipping relock", before).await;
assert!(skipping_count > 0, "Trivial change (comment only) should skip - lock unchanged");
// Step 4: Redeploy leaf_2 with actual dependency change (add tiny via comment) - should NOT skip
let leaf2_hash = sqlx::query_scalar!(
"SELECT hash FROM script WHERE path = 'f/rel/leaf_2' AND workspace_id = 'test-workspace' AND archived = false ORDER BY created_at DESC LIMIT 1"
)
.fetch_one(&db)
.await
.unwrap();
let before = chrono::Utc::now();
client
.create_script(
"test-workspace",
&quick_ns(
"
# requirements:
# tiny
def main():
return 'leaf2 with tiny'
",
windmill_api_client::types::ScriptLang::Python3,
"f/rel/leaf_2",
None,
Some(format!("{:016X}", leaf2_hash)),
),
)
.await
.unwrap();
// Same as leaf_2 first deployment: 4 jobs
wait_for_jobs(&mut completed, 4).await;
let skipping_count = count_pattern_in_job_logs(&db, "Skipping relock", before).await;
assert_eq!(skipping_count, 0, "Changed dependencies should not skip");
}, port).await;
Ok(())
}
// Requires `private` feature for the dependency job concurrency limit that prevents
// race conditions between concurrent dep jobs for the same flow/app.
#[cfg(all(feature = "python", feature = "private"))]
#[sqlx::test(fixtures("base", "dependency_map"))]
async fn relock_skip_on_workspace_deps_redeployment(db: Pool<Postgres>) -> anyhow::Result<()> {
use windmill_common::scripts::ScriptLang;
use windmill_dep_map::workspace_dependencies::NewWorkspaceDependencies;
std::env::set_var("DEPENDENCY_JOB_DEBOUNCE_DELAY", "0");
std::env::set_var("EXISTS_CACHE_TIMEOUT_MS", "0");
let (_client, port, _s) = init(db.clone()).await;
let mut completed = listen_for_completed_jobs(&db).await;
// Step 1: Redeploy default (unnamed) workspace deps - first time, should relock
let before = chrono::Utc::now();
NewWorkspaceDependencies {
workspace_id: "test-workspace".into(),
language: ScriptLang::Python3,
content: "".into(),
name: None, // Default/unnamed
description: None,
}
.create(("".to_owned(), "".to_owned(), "".to_owned()), db.clone())
.await
.unwrap();
in_test_worker(&db, wait_for_jobs_ge(&mut completed, 10), port).await;
// Note: within a cascade, the same script may be triggered multiple times.
// After the first trigger relocks and stores the hash, subsequent triggers skip.
// We allow up to 3 skips from cascade re-triggers.
let skipping_count = count_pattern_in_job_logs(&db, "Skipping relock", before).await;
let relocking_count = count_pattern_in_job_logs(&db, "Relocking", before).await;
assert!(
skipping_count <= 3,
"First deployment should have at most 3 skips from cascade"
);
assert!(
relocking_count >= 3,
"First deployment should have at least 3 relocking jobs"
);
// Step 2: Redeploy default workspace deps again - should SKIP
let before = chrono::Utc::now();
NewWorkspaceDependencies {
workspace_id: "test-workspace".into(),
language: ScriptLang::Python3,
content: "".into(),
name: None,
description: None,
}
.create(("".to_owned(), "".to_owned(), "".to_owned()), db.clone())
.await
.unwrap();
in_test_worker(&db, wait_for_jobs_ge(&mut completed, 10), port).await;
let skipping_count = count_pattern_in_job_logs(&db, "Skipping relock", before).await;
assert!(
skipping_count >= 3,
"Second deployment of same content should skip at least 3 times"
);
// Step 3: Redeploy default workspace deps with different content - should NOT skip
let before = chrono::Utc::now();
NewWorkspaceDependencies {
workspace_id: "test-workspace".into(),
language: ScriptLang::Python3,
content: "tiny".into(),
name: None,
description: None,
}
.create(("".to_owned(), "".to_owned(), "".to_owned()), db.clone())
.await
.unwrap();
in_test_worker(&db, wait_for_jobs_ge(&mut completed, 10), port).await;
let skipping_count = count_pattern_in_job_logs(&db, "Skipping relock", before).await;
let relocking_count = count_pattern_in_job_logs(&db, "Relocking", before).await;
assert!(
skipping_count <= 4,
"Changed content should have at most 3 skips from cascade"
);
assert!(
relocking_count >= 3,
"Changed content should trigger at least 3 relocking jobs"
);
// Step 4: Deploy named workspace deps first time - should relock (no hash exists yet)
// Named deps trigger exactly 3 independent objects with no cascade
let before = chrono::Utc::now();
NewWorkspaceDependencies {
workspace_id: "test-workspace".into(),
language: ScriptLang::Python3,
content: "".into(),
name: Some("test".to_owned()),
description: None,
}
.create(("".to_owned(), "".to_owned(), "".to_owned()), db.clone())
.await
.unwrap();
in_test_worker(&db, wait_for_jobs(&mut completed, 3), port).await;
let skipping_count = count_pattern_in_job_logs(&db, "Skipping relock", before).await;
let relocking_count = count_pattern_in_job_logs(&db, "Relocking", before).await;
assert_eq!(
skipping_count, 0,
"Named workspace deps first deployment should not skip"
);
assert!(
relocking_count > 0,
"Named workspace deps first deployment should relock"
);
// Step 5: Deploy named workspace deps again with no change - should SKIP
let before = chrono::Utc::now();
NewWorkspaceDependencies {
workspace_id: "test-workspace".into(),
language: ScriptLang::Python3,
content: "".into(),
name: Some("test".to_owned()),
description: None,
}
.create(("".to_owned(), "".to_owned(), "".to_owned()), db.clone())
.await
.unwrap();
in_test_worker(&db, wait_for_jobs(&mut completed, 3), port).await;
let skipping_count = count_pattern_in_job_logs(&db, "Skipping relock", before).await;
let relocking_count = count_pattern_in_job_logs(&db, "Relocking", before).await;
assert!(
skipping_count > 0,
"Named workspace deps second deployment should skip"
);
assert_eq!(
relocking_count, 0,
"Named workspace deps second deployment should not relock"
);
// Step 6: Deploy named workspace deps with small change - should NOT skip
let before = chrono::Utc::now();
NewWorkspaceDependencies {
workspace_id: "test-workspace".into(),
language: ScriptLang::Python3,
content: "tiny".into(),
name: Some("test".to_owned()),
description: None,
}
.create(("".to_owned(), "".to_owned(), "".to_owned()), db.clone())
.await
.unwrap();
in_test_worker(&db, wait_for_jobs(&mut completed, 3), port).await;
let skipping_count = count_pattern_in_job_logs(&db, "Skipping relock", before).await;
let relocking_count = count_pattern_in_job_logs(&db, "Relocking", before).await;
assert_eq!(
skipping_count, 0,
"Named workspace deps with change should not skip"
);
assert!(
relocking_count > 0,
"Named workspace deps with change should relock"
);
Ok(())
}
}