//! End-to-end tests for the pipeline freshness watchdog (Enterprise). //! //! `windmill_queue::freshness_watchdog::tick` is called directly against //! seeded `script` / `v2_job(_completed)` rows — no worker or API server is //! needed, since the watchdog's job ends at the push (the pushed job sitting //! in `v2_job_queue` is itself part of the assertions). Covers: staleness on //! never-ran and aged-out members, the fresh short-circuit + state reset, //! the in-flight suppression, the backoff claim, and the skip rules //! (partitioned, malformed window, non-pipeline scripts). #![cfg(feature = "private")] use sqlx::{Pool, Postgres}; use windmill_queue::freshness_watchdog::tick; use windmill_test_utils::initialize_tracing; const WS: &str = "test-workspace"; const PATH: &str = "u/test-user/freshness_producer"; /// Seed a deployed pipeline-member script. Mirrors the deploy path's output: /// `auto_kind = 'pipeline'`, empty (non-NULL) lock so run-by-path resolution /// treats it as deployed, hash derived from path+content for uniqueness. async fn seed_pipeline_script( db: &Pool, path: &str, content: &str, ) -> anyhow::Result<()> { let mut h = 0i64; for b in path.bytes().chain(content.bytes()) { h = h.wrapping_mul(31).wrapping_add(b as i64); } sqlx::query( r#"INSERT INTO script (workspace_id, hash, path, summary, description, content, created_by, language, tag, lock, auto_kind) VALUES ($1, $2, $3, '', '', $4, 'test-user', 'bash'::script_lang, 'bash', '', 'pipeline') ON CONFLICT DO NOTHING"#, ) .bind(WS) .bind(h) .bind(path) .bind(content) .execute(db) .await?; // Process-global deployed-script caches are keyed by (workspace, path) / // (workspace, hash) and would leak between #[sqlx::test] isolated DBs // that reuse both — resolve everything from this test's own DB. windmill_common::DEPLOYED_SCRIPT_CACHE_DISABLED .store(true, std::sync::atomic::Ordering::Relaxed); Ok(()) } /// Seed a completed root run of `path` that finished `age_s` seconds ago. async fn seed_completed_run( db: &Pool, path: &str, age_s: i64, success: bool, ) -> anyhow::Result<()> { let id = uuid::Uuid::new_v4(); sqlx::query( r#"INSERT INTO v2_job (id, workspace_id, runnable_path, kind, created_at, created_by, permissioned_as, permissioned_as_email, tag) VALUES ($1, $2, $3, 'script'::job_kind, now() - ($4::bigint::text || ' seconds')::interval, 'test-user', 'u/test-user', 'test@windmill.dev', 'bash')"#, ) .bind(id) .bind(WS) .bind(path) .bind(age_s) .execute(db) .await?; sqlx::query( r#"INSERT INTO v2_job_completed (id, workspace_id, duration_ms, status, started_at, completed_at) VALUES ($1, $2, 0, CASE WHEN $3 THEN 'success'::job_status ELSE 'failure'::job_status END, now() - ($4::bigint::text || ' seconds')::interval, now() - ($4::bigint::text || ' seconds')::interval)"#, ) .bind(id) .bind(WS) .bind(success) .bind(age_s) .execute(db) .await?; Ok(()) } /// Jobs the watchdog pushed: (path, created_by, args) rows attributed to /// `trigger_kind = 'freshness'`. async fn fetch_pushed( db: &Pool, ) -> anyhow::Result)>> { let rows = sqlx::query!( r#"SELECT runnable_path AS "runnable_path!", created_by AS "created_by!", args AS "args: sqlx::types::Json" FROM v2_job WHERE workspace_id = $1 AND trigger_kind = 'freshness' ORDER BY created_at"#, WS, ) .fetch_all(db) .await?; Ok(rows .into_iter() .map(|r| (r.runnable_path, r.created_by, r.args.map(|a| a.0))) .collect()) } async fn state_row(db: &Pool, path: &str) -> anyhow::Result> { Ok(sqlx::query_scalar!( "SELECT attempts FROM pipeline_freshness_state WHERE workspace_id = $1 AND script_path = $2", WS, path, ) .fetch_optional(db) .await?) } #[sqlx::test(fixtures("base"))] async fn never_ran_member_is_pushed_once(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; seed_pipeline_script(&db, PATH, "# pipeline\n# freshness 30s\necho hi\n").await?; tick(&db).await; let pushed = fetch_pushed(&db).await?; assert_eq!(pushed.len(), 1, "one watchdog push expected"); let (path, created_by, args) = &pushed[0]; assert_eq!(path, PATH); assert_eq!(created_by, &format!("freshness-{PATH}")); let args = args.as_ref().expect("args recorded"); assert_eq!( args.get("_wmill_skip_asset_dispatch"), Some(&serde_json::json!(true)), "watchdog runs must not re-fire the cascade" ); assert_eq!( args.pointer("/trigger/kind"), Some(&serde_json::json!("freshness")) ); assert_eq!(state_row(&db, PATH).await?, Some(1), "claim row recorded"); // Second tick: the pushed job is queued-and-due, so the in-flight guard // suppresses a duplicate regardless of backoff. tick(&db).await; assert_eq!( fetch_pushed(&db).await?.len(), 1, "no duplicate while queued" ); // Simulate the queued job vanishing without a completion: the backoff // claim (next_attempt_at in the future) now carries the suppression. sqlx::query!("DELETE FROM v2_job_queue WHERE workspace_id = $1", WS) .execute(&db) .await?; tick(&db).await; assert_eq!(fetch_pushed(&db).await?.len(), 1, "backoff holds the retry"); // Force the backoff window open: the watchdog retries and escalates. sqlx::query!( "UPDATE pipeline_freshness_state SET next_attempt_at = now() - interval '1 second' WHERE workspace_id = $1 AND script_path = $2", WS, PATH, ) .execute(&db) .await?; tick(&db).await; assert_eq!(fetch_pushed(&db).await?.len(), 2, "due retry pushed"); assert_eq!(state_row(&db, PATH).await?, Some(2), "attempts escalated"); Ok(()) } #[sqlx::test(fixtures("base"))] async fn fresh_member_is_skipped_and_state_reset(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; seed_pipeline_script(&db, PATH, "# pipeline\n# freshness 1h\necho hi\n").await?; seed_completed_run(&db, PATH, 10, true).await?; // Leftover backoff row from an earlier staleness episode. sqlx::query!( "INSERT INTO pipeline_freshness_state (workspace_id, script_path, attempts) VALUES ($1, $2, 3)", WS, PATH, ) .execute(&db) .await?; tick(&db).await; assert!( fetch_pushed(&db).await?.is_empty(), "fresh member not pushed" ); assert_eq!(state_row(&db, PATH).await?, None, "backoff reset on fresh"); Ok(()) } #[sqlx::test(fixtures("base"))] async fn aged_out_member_is_pushed(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; seed_pipeline_script(&db, PATH, "# pipeline\n# freshness 1h\necho hi\n").await?; // Old success outside the window + a recent failure: still stale. seed_completed_run(&db, PATH, 7200, true).await?; seed_completed_run(&db, PATH, 60, false).await?; tick(&db).await; assert_eq!(fetch_pushed(&db).await?.len(), 1, "aged-out member pushed"); Ok(()) } #[sqlx::test(fixtures("base"))] async fn partitioned_malformed_and_plain_members_are_skipped( db: Pool, ) -> anyhow::Result<()> { initialize_tracing().await; // Partitioned: freshness means partition-gap detection, out of scope. seed_pipeline_script( &db, "u/test-user/partitioned", "# pipeline\n# partitioned daily\n# freshness 1h\necho hi\n", ) .await?; // Malformed window: fails safe to unwatched. seed_pipeline_script( &db, "u/test-user/malformed", "# pipeline\n# freshness soonish\necho hi\n", ) .await?; // Freshness only in prose (parser must reject; ILIKE prefilter passes). seed_pipeline_script( &db, "u/test-user/prose", "# pipeline\n# ensure freshness of data below\necho hi\n", ) .await?; tick(&db).await; assert!(fetch_pushed(&db).await?.is_empty(), "no member is watched"); let rows = sqlx::query_scalar!( r#"SELECT COUNT(*) AS "count!" FROM pipeline_freshness_state WHERE workspace_id = $1"#, WS, ) .fetch_one(&db) .await?; assert_eq!(rows, 0, "no state rows for unwatched members"); Ok(()) } #[sqlx::test(fixtures("base"))] async fn archived_workspace_is_not_resurrected(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; // Workspace archival stops all execution but keeps script rows for // unarchival — the watchdog must not keep pushing runs there. seed_pipeline_script(&db, PATH, "# pipeline\n# freshness 30s\necho hi\n").await?; sqlx::query!("UPDATE workspace SET deleted = true WHERE id = $1", WS) .execute(&db) .await?; tick(&db).await; assert!( fetch_pushed(&db).await?.is_empty(), "no pushes into an archived workspace" ); assert_eq!( state_row(&db, PATH).await?, None, "no state bookkeeping either" ); Ok(()) } #[sqlx::test(fixtures("base"))] async fn state_of_unwatched_member_is_cleaned_up(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; // A stale-bookkeeping row whose script no longer declares freshness // (e.g. annotation removed and redeployed) must not survive the sweep. sqlx::query!( "INSERT INTO pipeline_freshness_state (workspace_id, script_path) VALUES ($1, $2)", WS, "u/test-user/gone", ) .execute(&db) .await?; tick(&db).await; assert_eq!(state_row(&db, "u/test-user/gone").await?, None); Ok(()) }