* feat: add scheduled job deletion with configurable retention period Extends delete_after_use with delete_after_secs to enable configurable retention periods for job args/result/logs. At completion, jobs can be scheduled for future deletion via a new job_delete_schedule table, processed by a monitor task. Supports per-script, per-flow, and per-flow-step configuration. Backward compatible. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add integration tests, revert query! macros, fix review issues - Add integration tests for resolve_delete_after_secs, schedule_job_deletion, flow-level and module-level delete_after_secs, backward compat - Revert sqlx::query() back to sqlx::query!() macros for compile-time safety - Regenerate sqlx offline cache - Fix FlowModule/NewScript/FlowValue constructions in all test files - Fix autoscaling_ee.rs for updated script_path_to_payload return type Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref.txt for autoscaling_ee fix Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: gate cleanup_scheduled_job_deletions behind enterprise feature Prevents dead_code warning (which CI treats as error via -D warnings) when compiling without enterprise feature. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: regenerate sqlx cache after merge with main Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review feedback on scheduled deletion - Monitor: roll back transaction on any cleanup error so schedule rows survive for retry on next cycle (instead of best-effort then discard) - Migration: add FK with ON DELETE CASCADE to job_delete_schedule.job_id to prevent orphan rows when jobs are deleted through other means - Simplify bool-to-Option conversion with .then_some(true) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: stop setting delete_after_use alongside delete_after_secs No mixed-version deployment scenario exists, so delete_after_secs alone is sufficient. The backend's resolve_delete_after_secs handles (None, Some(secs)) correctly without needing delete_after_use set. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: remove delete_after_use from public API surface Remove delete_after_use from OpenAPI spec, API client, runtime client, and workspace export. Only delete_after_secs is exposed going forward. The field remains in Rust backend types with #[serde(skip_serializing)] for backward-compatible deserialization of existing scripts/flows that were saved with delete_after_use: true. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref to 1d4b7a31fc115d6aba8640f7cd3fd5a01abe6806 This commit updates the EE repository reference after PR #519 was merged in windmill-ee-private. Previous ee-repo-ref: 9eba09a13b778caafc6ae65098b90e53c91984d3 New ee-repo-ref: 1d4b7a31fc115d6aba8640f7cd3fd5a01abe6806 Automated by sync-ee-ref workflow. * fix: regenerate system prompts, remove unused import - Regenerate auto-generated system prompts after openflow schema change - Remove unused serde_json::json import in test file (CI -D warnings) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: insert dummy v2_job row in schedule tests for FK constraint The job_delete_schedule table has a FK to v2_job, so tests need a real v2_job row before inserting into the schedule table. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: trigger CI re-run * fix: remove heavy flow integration tests to avoid CI worker contention The flow integration tests spawn workers that compete for CPU with the existing relock_skip tests under --test-threads=10, causing consistent 60s timeouts in CI. Keep only the lightweight unit tests and DB integration tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: restore correct ee-repo-ref for our branch The ref was overwritten to main's EE ref during a rebase. Restore to our branch's EE commit that includes the autoscaling tuple fix. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: retrigger CI on fresh runner * fix: remove FK constraint from job_delete_schedule to unblock CI The FK with ON DELETE CASCADE to v2_job may have caused performance overhead during test DB setup (each sqlx::test creates a fresh DB with all migrations). Remove the FK — orphan schedule rows are harmlessly cleaned by the monitor. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * ee-ref --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
415 lines
15 KiB
Rust
415 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(),
|
|
draft_only: None,
|
|
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(())
|
|
}
|
|
}
|