mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-22 16:02:24 +00:00
ae42cbf4a8
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>
414 lines
15 KiB
Rust
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(())
|
|
}
|
|
}
|