Files
windmill/backend/tests/dependency_map.rs
T
Ruben Fiszel 2d18a68099 feat: add scheduled job deletion with configurable retention period (#8753)
* 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>
2026-04-08 04:15:28 +00:00

542 lines
19 KiB
Rust

use sqlx::{Pool, Postgres};
use tokio_stream::StreamExt;
use windmill_api_client::types::NewScript;
use windmill_test_utils::{
in_test_worker, init_client, listen_for_completed_jobs, rebuild_dmap, ApiServer,
};
mod dependency_map {
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
}
async fn _clear_dmap(db: &Pool<Postgres>) {
sqlx::query!("DELETE FROM dependency_map WHERE workspace_id = 'test-workspace'")
.execute(db)
.await
.unwrap();
}
/// Corrects map according to provided replacements.
/// Only changes importer_path and/or id
/// Does not affect imported_path nor kind!
fn corrected_dmap(replacements: Vec<(&str, &str)>) -> Vec<(String, String, String, String)> {
CORRECT_DMAP
.clone()
.into_iter()
.map(|e| {
let mut r = (
e.0.to_owned(),
e.1.to_owned(),
e.2.to_owned(),
e.3.to_owned(),
);
for (from, to) in &replacements {
r = (
r.0.replace(from, to),
r.1, // Kind should be immutable
r.2, // Imported path should be immutable
// We do not modify script contents in test, so we can assume scripts always import the same path
// Modification of kind or imported path considered to be incorrect.
r.3.replace(from, to),
);
}
r
})
.collect()
}
async fn assert_dmap(
db: &Pool<Postgres>,
importer: Option<String>,
expected: Vec<(
impl Into<String>,
impl Into<String>,
impl Into<String>,
impl Into<String>,
)>,
) {
let mut dmap = sqlx::query_as::<_, (String, String, String, String)>(
"SELECT importer_path, importer_kind::text, imported_path, importer_node_id FROM dependency_map WHERE workspace_id = 'test-workspace' AND ($1::text IS NULL OR importer_path = $1::text)",
)
.bind(importer)
.fetch_all(db)
.await
.unwrap();
let mut expected = expected
.into_iter()
.map(|(f, s, t, fo)| (f.into(), s.into(), t.into(), fo.into()))
.collect::<Vec<(String, String, String, String)>>();
dmap.sort();
expected.sort();
assert_eq!(dmap, expected);
}
lazy_static::lazy_static! {
pub static ref CORRECT_DMAP: Vec<(&'static str, &'static str, &'static str, &'static str)> = vec![
("f/rel/root_script", "script", "dependencies/test.requirements.in", ""),
("f/rel/root_flow", "flow", "dependencies/test.requirements.in", "nstep1"),
("f/rel/root_app", "app", "dependencies/test.requirements.in", "dontpressmeplz"),
("f/rel/branch", "script", "f/rel/leaf_1", ""),
("f/rel/root_script", "script", "f/rel/branch", ""),
("f/rel/root_script", "script", "f/rel/leaf_1", ""),
("f/rel/root_script", "script", "f/rel/leaf_2", ""),
("f/rel/root_app", "app", "f/rel/leaf_2", "dontpressmeplz"),
("f/rel/root_flow", "flow", "f/rel/leaf_2", "failure"),
("f/rel/root_flow", "flow", "f/rel/branch", "nstep1"),
("f/rel/root_flow", "flow", "f/rel/leaf_1", "nstep1"),
("f/rel/root_flow", "flow", "f/rel/leaf_2", "nstep1"),
("f/rel/root_flow", "flow", "f/rel/leaf_2", "nstep2_2"),
("f/rel/root_flow", "flow", "f/rel/branch", "nstep4_1"),
("f/rel/root_flow", "flow", "f/rel/branch", "nstep5_1"),
("f/rel/root_flow", "flow", "f/rel/leaf_1", "nstep5_1"),
("f/rel/root_flow", "flow", "f/rel/leaf_2", "nstep5_1"),
("f/rel/root_flow", "flow", "f/rel/branch", "preprocessor"),
("f/rel/root_flow", "flow", "f/rel/leaf_1", "preprocessor"),
("f/rel/root_flow", "flow", "f/rel/leaf_2", "preprocessor"),
("f/rel/root_app", "app", "f/rel/branch", "pressmeplz"),
("f/rel/root_app", "app", "f/rel/leaf_1", "pressmeplz"),
("f/rel/root_app", "app", "f/rel/leaf_2", "pressmeplz"),
("f/rel/root_flow", "flow", "f/rel/leaf_2", "qtool1"),
("f/rel/root_app", "app", "f/rel/branch", "youcanpressme"),
// Default
("f/rel/leaf_1", "script", "dependencies/requirements.in", ""),
("f/rel/leaf_2", "script", "dependencies/requirements.in", ""),
("f/rel/branch", "script", "dependencies/requirements.in", ""),
("f/rel/root_flow", "flow", "dependencies/requirements.in", "failure"),
("f/rel/root_flow", "flow", "dependencies/package.json", "nstep2_1"),
("f/rel/root_flow", "flow", "dependencies/package.json", "nstep3_2"),
("f/rel/root_flow", "flow", "dependencies/requirements.in", "nstep2_2"),
("f/rel/root_flow", "flow", "dependencies/requirements.in", "nstep3_1"),
("f/rel/root_flow", "flow", "dependencies/requirements.in", "nstep4_1"),
("f/rel/root_flow", "flow", "dependencies/requirements.in", "nstep5_1"),
("f/rel/root_flow", "flow", "dependencies/requirements.in", "preprocessor"),
("f/rel/root_app", "app", "dependencies/requirements.in", "pressmeplz"),
("f/rel/root_flow", "flow", "dependencies/requirements.in", "qtool1"),
("f/rel/root_app", "app", "dependencies/requirements.in", "youcanpressme")
];
}
// TODO:
// Test that checks that we can run rebuild_dmap multiple times in tests.
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base", "dependency_map"))]
async fn relative_imports_test_rebuild_correctness(db: Pool<Postgres>) -> anyhow::Result<()> {
let (client, _port, _s) = init(db.clone()).await;
assert_dmap(&db, None, CORRECT_DMAP.clone()).await;
// rebuild map
assert!(rebuild_dmap(&client).await);
assert_dmap(&db, None, CORRECT_DMAP.clone()).await;
Ok(())
}
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base", "dependency_map"))]
async fn relative_imports_test_rebuild_lock(db: Pool<Postgres>) -> anyhow::Result<()> {
let (client, _port, _s) = init(db.clone()).await;
// Spawn first rebuild
let handle = {
let client = client.clone();
tokio::spawn(async move { rebuild_dmap(&client).await })
};
// Immidiately spawn another
let res = client
.client()
.post(format!(
"{}/w/test-workspace/workspaces/rebuild_dependency_map",
client.baseurl()
))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
// Should tell us there is already rebuilt in progress
// Or if it is too fast we will be able to trigger it second time
assert!(&res == "There is already one task pending, try again later." || &res == "Success");
assert!(handle.await.unwrap());
Ok(())
}
// If you deploy from cli and you use raw requirements you don't want the script be included in dmap
// Otherwise script will be overwritten once any relative import is updated
// #[cfg(feature = "python")]
// #[sqlx::test(fixtures("base", "dependency_map"))]
// async fn relative_imports_test_with_legacy(db: Pool<Postgres>) -> anyhow::Result<()> {
// let (client, _port, _s) = init(db.clone()).await;
// client
// .create_script(
// "test-workspace",
// &quick_ns(
// "
// from f.rel.branch import main as br;
// from f.rel.leaf_1 import main as lf_1;
// from f.rel.leaf_2 import main as lf_2;
// def main():
// return [br(), lf_1(), lf_2];
// ",
// windmill_api_client::types::ScriptLang::Python3,
// "f/rel/root_script",
// Some("# from requirements.txt".to_string()),
// Some("000000000005165B".into()),
// ),
// )
// .await
// .unwrap();
// assert_dmap(
// &db,
// Some("f/rel/root_script".into()),
// vec![
// ("f/rel/root_script", "script", "f/rel/branch", ""),
// ("f/rel/root_script", "script", "f/rel/leaf_1", ""),
// ("f/rel/root_script", "script", "f/rel/leaf_2", ""),
// ],
// )
// .await;
// tokio::time::sleep(std::time::Duration::from_secs(13)).await;
// assert_dmap(
// &db,
// Some("f/rel/root_script".into()),
// vec![
// ("f/rel/root_script", "script", "f/rel/branch", ""),
// ("f/rel/root_script", "script", "f/rel/leaf_1", ""),
// ("f/rel/root_script", "script", "f/rel/leaf_2", ""),
// ],
// )
// .await;
// Ok(())
// }
// Consider simple one. Only referenced directly. No deep connections
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base", "dependency_map"))]
async fn relative_imports_test_rename_leaf_2(db: Pool<Postgres>) -> anyhow::Result<()> {
let (client, port, _s) = init(db.clone()).await;
client
.create_script(
"test-workspace",
&quick_ns(
"
def main():
return 'leaf3';
",
windmill_api_client::types::ScriptLang::Python3,
"f/rel/leaf_2_renamed",
None,
Some("0000000000051659".into()),
),
)
.await
.unwrap();
let mut completed = listen_for_completed_jobs(&db).await;
in_test_worker(&db, completed.next(), port).await;
// Changing leafs should not change dependency map
assert_dmap(
&db,
None,
corrected_dmap(vec![("f/rel/leaf_2", "f/rel/leaf_2_renamed")]),
)
.await;
Ok(())
}
// Consider hard one. Referenced deeply and exists in double references.
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base", "dependency_map"))]
async fn relative_imports_test_rename_leaf_1(db: Pool<Postgres>) -> anyhow::Result<()> {
let (client, port, _s) = init(db.clone()).await;
client
.create_script(
"test-workspace",
&quick_ns(
"
def main():
return 'leaf1';
",
windmill_api_client::types::ScriptLang::Python3,
"f/rel/leaf_1_renamed",
None,
Some("0000000000051658".into()),
),
)
.await
.unwrap();
let mut completed = listen_for_completed_jobs(&db).await;
in_test_worker(&db, completed.next(), port).await;
// Changing leafs should not change dependency map
assert_dmap(
&db,
None,
corrected_dmap(vec![("f/rel/leaf_1", "f/rel/leaf_1_renamed")]),
)
.await;
Ok(())
}
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base", "dependency_map"))]
async fn relative_imports_test_rename_branch(db: Pool<Postgres>) -> anyhow::Result<()> {
let (client, port, _s) = init(db.clone()).await;
client
.create_script(
"test-workspace",
&quick_ns(
"
from f.rel.leaf_1 import main as lf_1;
def main():
return lf_1();
",
windmill_api_client::types::ScriptLang::Python3,
"f/rel/branch_renamed",
None,
Some("000000000005165A".into()),
),
)
.await
.unwrap();
let mut completed = listen_for_completed_jobs(&db).await;
in_test_worker(&db, completed.next(), port).await;
// Changing branches SHOULD change dependency map
// Though it should only change branch item in dmap when it is importer.
// All entries when branch is imported should not change.
let corrected_dmap = CORRECT_DMAP
.clone()
.iter_mut()
.map(|el| {
if el.0 == "f/rel/branch" {
el.0 = "f/rel/branch_renamed";
}
*el
})
.collect::<Vec<_>>();
assert_dmap(&db, None, corrected_dmap).await;
Ok(())
}
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base", "dependency_map"))]
async fn relative_imports_test_rename_primary_script(db: Pool<Postgres>) -> anyhow::Result<()> {
let (client, port, _s) = init(db.clone()).await;
client
.create_script(
"test-workspace",
&quick_ns(
"
# requirements: test
from f.rel.branch import main as br;
from f.rel.leaf_1 import main as lf_1;
from f.rel.leaf_2 import main as lf_2;
def main():
return [br(), lf_1(), lf_2];
",
windmill_api_client::types::ScriptLang::Python3,
"f/rel/root_script_renamed",
None,
Some("000000000005165B".into()),
),
)
.await
.unwrap();
let corrected_dmap = corrected_dmap(vec![("root_script", "root_script_renamed")]);
let mut completed = listen_for_completed_jobs(&db).await;
in_test_worker(&db, completed.next(), port).await;
assert_dmap(&db, None, corrected_dmap.clone()).await;
Ok(())
}
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base", "dependency_map"))]
async fn relative_imports_test_rename_primary_flow(db: Pool<Postgres>) -> anyhow::Result<()> {
use windmill_common::{cache::flow::fetch_version, flows::NewFlow, worker::to_raw_value};
let (client, port, _s) = init(db.clone()).await;
let flow = fetch_version(&db, 1443253234253454).await.unwrap();
let res = client
.client()
.post(format!(
"{}/w/test-workspace/flows/update/{}",
client.baseurl(),
"f/rel/root_flow" // encode_path()
))
.json(&NewFlow {
path: "f/rel/root_flow_renamed".into(),
summary: "".into(),
description: None,
value: to_raw_value(
&serde_json::from_str::<serde_json::Value>(
&serde_json::to_string(flow.value())
.unwrap()
.replace("nstep1", "Foxes")
.replace("nstep2_2", "like")
.replace("nstep_4_1", "Emeralds"),
)
.unwrap(),
),
schema: None,
draft_only: None,
tag: None,
dedicated_worker: None,
timeout: None,
deployment_message: None,
visible_to_runner_only: None,
on_behalf_of_email: None,
preserve_on_behalf_of: None,
ws_error_handler_muted: None,
labels: None,
})
.send()
.await
.unwrap();
assert_eq!(res.text().await.unwrap(), "f/rel/root_flow_renamed");
let mut completed = listen_for_completed_jobs(&db).await;
in_test_worker(&db, completed.next(), port).await;
assert_dmap(
&db,
None,
corrected_dmap(vec![
("f/rel/root_flow", "f/rel/root_flow_renamed"),
("nstep1", "Foxes"),
("nstep2_2", "like"),
("nstep_4_1", "Emeralds"),
]),
)
.await;
Ok(())
}
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base", "dependency_map"))]
async fn relative_imports_test_rename_primary_app(db: Pool<Postgres>) -> anyhow::Result<()> {
let (client, port, _s) = init(db.clone()).await;
let app_value: String =
sqlx::query_scalar!("SELECT value::text FROM app_version WHERE id = 0 AND app_id = 2")
.fetch_one(&db)
.await
.unwrap()
.unwrap();
// TODO: There is:
// 1. update app
// 2. create app
// 3. update app raw
// Ideally all of them should be handled
let res = client
.client()
.post(format!(
"{}/w/test-workspace/apps/update/{}",
client.baseurl(),
"f/rel/root_app" // encode_path()
))
.json(&windmill_api::EditApp {
path: Some("f/rel/root_app_renamed".into()),
summary: None,
value: serde_json::from_str(
&app_value
.replace("dontpressmeplz", "Apps")
.replace("youcanpressme", "Work"),
)
.unwrap(),
policy: None,
deployment_message: None,
custom_path: None,
preserve_on_behalf_of: None,
labels: None,
})
.send()
.await
.unwrap();
assert_eq!(
res.text().await.unwrap(),
"app f/rel/root_app updated (npath: \"f/rel/root_app_renamed\")"
);
let mut completed = listen_for_completed_jobs(&db).await;
in_test_worker(&db, completed.next(), port).await;
assert_dmap(
&db,
None,
corrected_dmap(vec![
("f/rel/root_app", "f/rel/root_app_renamed"),
("dontpressmeplz", "Apps"),
("youcanpressme", "Work"),
]),
)
.await;
Ok(())
}
}