Compare commits
3 Commits
debounce-a
...
debouncing
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f40a3b420b | ||
|
|
993fbcde59 | ||
|
|
b1142421b8 |
@@ -9,23 +9,7 @@ export async function main() {
|
||||
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
|
||||
'',
|
||||
'',
|
||||
'f/leafs/ts', 500001, 'nativets', '');
|
||||
|
||||
INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES (
|
||||
'test-workspace',
|
||||
'test-user',
|
||||
'
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
fmt.Println("Go leaf")
|
||||
}',
|
||||
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
|
||||
'',
|
||||
'',
|
||||
'f/leafs/go', 500002, 'go', '');
|
||||
'f/leafs/ts', 500001, 'bun', '');
|
||||
|
||||
INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES (
|
||||
'test-workspace',
|
||||
@@ -52,3 +36,9 @@ function main() {
|
||||
'',
|
||||
'f/leafs/php', 500004, 'php', '');
|
||||
|
||||
-- Link scripts to named workspace dependencies (name: "test")
|
||||
INSERT INTO dependency_map (workspace_id, importer_path, importer_kind, imported_path, importer_node_id) VALUES
|
||||
('test-workspace', 'f/leafs/ts', 'script', 'dependencies/test.package.json', ''),
|
||||
('test-workspace', 'f/leafs/python', 'script', 'dependencies/test.requirements.in', ''),
|
||||
('test-workspace', 'f/leafs/php', 'script', 'dependencies/test.composer.json', '');
|
||||
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
mod workspace_dependencies {
|
||||
|
||||
use windmill_test_utils::in_test_worker;
|
||||
use windmill_test_utils::init_client;
|
||||
use windmill_test_utils::listen_for_completed_jobs;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use tokio_stream::StreamExt;
|
||||
use windmill_common::scripts::ScriptLang;
|
||||
use windmill_common::workspace_dependencies::WorkspaceDependencies;
|
||||
use windmill_dep_map::workspace_dependencies::NewWorkspaceDependencies;
|
||||
use windmill_test_utils::in_test_worker;
|
||||
use windmill_test_utils::init_client;
|
||||
use windmill_test_utils::listen_for_completed_jobs;
|
||||
|
||||
mod deps {
|
||||
pub const REQUIREMENTS_IN: &'static str = "tiny==0.1.3";
|
||||
// pub const GO_MOD: &'static str = r##"
|
||||
// module example.com/project
|
||||
|
||||
// go 1.20
|
||||
|
||||
// require github.com/gin-gonic/gin v1.8.1
|
||||
// "##;
|
||||
pub const REQUIREMENTS_IN_V2: &'static str = "tiny==0.2.0";
|
||||
|
||||
pub const PACKAGE_JSON: &'static str = r##"
|
||||
{
|
||||
@@ -25,6 +21,18 @@ mod workspace_dependencies {
|
||||
"express": "^4.17.1"
|
||||
}
|
||||
}
|
||||
"##;
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub const PACKAGE_JSON_V2: &'static str = r##"
|
||||
{
|
||||
"name": "example-project",
|
||||
"version": "2.0.0",
|
||||
"dependencies": {
|
||||
"express": "^4.18.0",
|
||||
"axios": "^1.0.0"
|
||||
}
|
||||
}
|
||||
"##;
|
||||
|
||||
pub const COMPOSER_JSON: &'static str = r##"
|
||||
@@ -37,9 +45,510 @@ mod workspace_dependencies {
|
||||
"##;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// CRUD Tests
|
||||
// =========================================================================
|
||||
|
||||
/// Test: Create workspace dependencies and verify they are stored correctly.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_create_workspace_dependencies(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let id = NewWorkspaceDependencies {
|
||||
workspace_id: "test-workspace".into(),
|
||||
language: ScriptLang::Python3,
|
||||
content: deps::REQUIREMENTS_IN.into(),
|
||||
name: Some("test-deps".to_owned()),
|
||||
description: Some("Test dependencies".to_owned()),
|
||||
}
|
||||
.create(
|
||||
(
|
||||
"test@test.com".to_owned(),
|
||||
"u/test".to_owned(),
|
||||
"test".to_owned(),
|
||||
),
|
||||
db.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert!(id > 0, "Should return a valid ID");
|
||||
|
||||
// Verify it was stored correctly
|
||||
let stored = WorkspaceDependencies::get(id, "test-workspace".to_owned(), &db).await?;
|
||||
assert_eq!(stored.name, Some("test-deps".to_owned()));
|
||||
assert_eq!(stored.content, deps::REQUIREMENTS_IN);
|
||||
assert_eq!(stored.language, ScriptLang::Python3);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test: Create unnamed (default) workspace dependencies.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_create_unnamed_workspace_dependencies(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let id = NewWorkspaceDependencies {
|
||||
workspace_id: "test-workspace".into(),
|
||||
language: ScriptLang::Bun,
|
||||
content: deps::PACKAGE_JSON.into(),
|
||||
name: None, // Unnamed = default
|
||||
description: None,
|
||||
}
|
||||
.create(
|
||||
(
|
||||
"test@test.com".to_owned(),
|
||||
"u/test".to_owned(),
|
||||
"test".to_owned(),
|
||||
),
|
||||
db.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert!(id > 0, "Should return a valid ID");
|
||||
|
||||
// Verify it was stored correctly
|
||||
let stored = WorkspaceDependencies::get(id, "test-workspace".to_owned(), &db).await?;
|
||||
assert_eq!(stored.name, None);
|
||||
assert_eq!(stored.language, ScriptLang::Bun);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test: List workspace dependencies returns all active entries.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_list_workspace_dependencies(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
// Create multiple workspace dependencies
|
||||
for (lang, content, name) in [
|
||||
(ScriptLang::Python3, deps::REQUIREMENTS_IN, Some("python-deps")),
|
||||
(ScriptLang::Bun, deps::PACKAGE_JSON, Some("bun-deps")),
|
||||
(ScriptLang::Bun, deps::PACKAGE_JSON, None), // Default bun deps
|
||||
] {
|
||||
NewWorkspaceDependencies {
|
||||
workspace_id: "test-workspace".into(),
|
||||
language: lang,
|
||||
content: content.into(),
|
||||
name: name.map(|s| s.to_owned()),
|
||||
description: None,
|
||||
}
|
||||
.create(
|
||||
(
|
||||
"test@test.com".to_owned(),
|
||||
"u/test".to_owned(),
|
||||
"test".to_owned(),
|
||||
),
|
||||
db.clone(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let list = WorkspaceDependencies::list("test-workspace", &db).await?;
|
||||
assert_eq!(list.len(), 3, "Should have 3 workspace dependencies");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test: Archive workspace dependencies marks them as archived.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_archive_workspace_dependencies(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
// Create workspace dependencies
|
||||
let _id = NewWorkspaceDependencies {
|
||||
workspace_id: "test-workspace".into(),
|
||||
language: ScriptLang::Python3,
|
||||
content: deps::REQUIREMENTS_IN.into(),
|
||||
name: Some("to-archive".to_owned()),
|
||||
description: None,
|
||||
}
|
||||
.create(
|
||||
(
|
||||
"test@test.com".to_owned(),
|
||||
"u/test".to_owned(),
|
||||
"test".to_owned(),
|
||||
),
|
||||
db.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Verify it exists
|
||||
let list_before = WorkspaceDependencies::list("test-workspace", &db).await?;
|
||||
assert_eq!(list_before.len(), 1);
|
||||
|
||||
// Archive it
|
||||
WorkspaceDependencies::archive(
|
||||
Some("to-archive".to_owned()),
|
||||
ScriptLang::Python3,
|
||||
"test-workspace",
|
||||
&db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Verify it's no longer in the active list
|
||||
let list_after = WorkspaceDependencies::list("test-workspace", &db).await?;
|
||||
assert_eq!(list_after.len(), 0, "Archived deps should not appear in list");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test: Delete workspace dependencies permanently removes them.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_delete_workspace_dependencies(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
// Create workspace dependencies
|
||||
let id = NewWorkspaceDependencies {
|
||||
workspace_id: "test-workspace".into(),
|
||||
language: ScriptLang::Python3,
|
||||
content: deps::REQUIREMENTS_IN.into(),
|
||||
name: Some("to-delete".to_owned()),
|
||||
description: None,
|
||||
}
|
||||
.create(
|
||||
(
|
||||
"test@test.com".to_owned(),
|
||||
"u/test".to_owned(),
|
||||
"test".to_owned(),
|
||||
),
|
||||
db.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Verify it exists
|
||||
assert!(
|
||||
WorkspaceDependencies::get(id, "test-workspace".to_owned(), &db)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
// Delete it
|
||||
WorkspaceDependencies::delete(
|
||||
Some("to-delete".to_owned()),
|
||||
ScriptLang::Python3,
|
||||
"test-workspace",
|
||||
&db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Verify it's gone (should error)
|
||||
let result = WorkspaceDependencies::get(id, "test-workspace".to_owned(), &db).await;
|
||||
assert!(result.is_err(), "Deleted deps should not be retrievable");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Version History Tests
|
||||
// =========================================================================
|
||||
|
||||
/// Test: Creating new version archives the old one.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_versioning_archives_previous(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
// Create first version
|
||||
let id1 = NewWorkspaceDependencies {
|
||||
workspace_id: "test-workspace".into(),
|
||||
language: ScriptLang::Python3,
|
||||
content: deps::REQUIREMENTS_IN.into(),
|
||||
name: Some("versioned".to_owned()),
|
||||
description: Some("Version 1".to_owned()),
|
||||
}
|
||||
.create(
|
||||
(
|
||||
"test@test.com".to_owned(),
|
||||
"u/test".to_owned(),
|
||||
"test".to_owned(),
|
||||
),
|
||||
db.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Create second version with same name
|
||||
let id2 = NewWorkspaceDependencies {
|
||||
workspace_id: "test-workspace".into(),
|
||||
language: ScriptLang::Python3,
|
||||
content: deps::REQUIREMENTS_IN_V2.into(),
|
||||
name: Some("versioned".to_owned()),
|
||||
description: Some("Version 2".to_owned()),
|
||||
}
|
||||
.create(
|
||||
(
|
||||
"test@test.com".to_owned(),
|
||||
"u/test".to_owned(),
|
||||
"test".to_owned(),
|
||||
),
|
||||
db.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_ne!(id1, id2, "Should create a new entry");
|
||||
|
||||
// List should only show the active (latest) version
|
||||
let list = WorkspaceDependencies::list("test-workspace", &db).await?;
|
||||
assert_eq!(list.len(), 1, "Should only have 1 active entry");
|
||||
assert_eq!(list[0].content, deps::REQUIREMENTS_IN_V2);
|
||||
|
||||
// History should show both versions
|
||||
let history = WorkspaceDependencies::get_history(
|
||||
Some("versioned".to_owned()),
|
||||
ScriptLang::Python3,
|
||||
"test-workspace",
|
||||
&db,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(history.len(), 2, "Should have 2 versions in history");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test: Description is inherited from previous version if not provided.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_description_inheritance(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
// Create first version with description
|
||||
NewWorkspaceDependencies {
|
||||
workspace_id: "test-workspace".into(),
|
||||
language: ScriptLang::Python3,
|
||||
content: deps::REQUIREMENTS_IN.into(),
|
||||
name: Some("inherit-desc".to_owned()),
|
||||
description: Some("Original description".to_owned()),
|
||||
}
|
||||
.create(
|
||||
(
|
||||
"test@test.com".to_owned(),
|
||||
"u/test".to_owned(),
|
||||
"test".to_owned(),
|
||||
),
|
||||
db.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Create second version without description
|
||||
let id2 = NewWorkspaceDependencies {
|
||||
workspace_id: "test-workspace".into(),
|
||||
language: ScriptLang::Python3,
|
||||
content: deps::REQUIREMENTS_IN_V2.into(),
|
||||
name: Some("inherit-desc".to_owned()),
|
||||
description: None, // Should inherit
|
||||
}
|
||||
.create(
|
||||
(
|
||||
"test@test.com".to_owned(),
|
||||
"u/test".to_owned(),
|
||||
"test".to_owned(),
|
||||
),
|
||||
db.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let stored = WorkspaceDependencies::get(id2, "test-workspace".to_owned(), &db).await?;
|
||||
assert_eq!(
|
||||
stored.description,
|
||||
Some("Original description".to_owned()),
|
||||
"Description should be inherited from previous version"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Workspace Isolation Tests
|
||||
// =========================================================================
|
||||
|
||||
/// Test: Workspace dependencies are isolated between workspaces.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_workspace_isolation(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
// Create another workspace
|
||||
sqlx::query!(
|
||||
"INSERT INTO workspace (id, name, owner) VALUES ('other-workspace', 'other', 'test-user')"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query!("INSERT INTO workspace_settings (workspace_id) VALUES ('other-workspace')")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// Create deps in test-workspace
|
||||
NewWorkspaceDependencies {
|
||||
workspace_id: "test-workspace".into(),
|
||||
language: ScriptLang::Python3,
|
||||
content: deps::REQUIREMENTS_IN.into(),
|
||||
name: Some("shared-name".to_owned()),
|
||||
description: None,
|
||||
}
|
||||
.create(
|
||||
(
|
||||
"test@test.com".to_owned(),
|
||||
"u/test".to_owned(),
|
||||
"test".to_owned(),
|
||||
),
|
||||
db.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Create deps in other-workspace with same name
|
||||
NewWorkspaceDependencies {
|
||||
workspace_id: "other-workspace".into(),
|
||||
language: ScriptLang::Python3,
|
||||
content: deps::REQUIREMENTS_IN_V2.into(),
|
||||
name: Some("shared-name".to_owned()),
|
||||
description: None,
|
||||
}
|
||||
.create(
|
||||
(
|
||||
"test@test.com".to_owned(),
|
||||
"u/test".to_owned(),
|
||||
"test".to_owned(),
|
||||
),
|
||||
db.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Each workspace should have exactly 1 entry
|
||||
let list1 = WorkspaceDependencies::list("test-workspace", &db).await?;
|
||||
let list2 = WorkspaceDependencies::list("other-workspace", &db).await?;
|
||||
|
||||
assert_eq!(list1.len(), 1);
|
||||
assert_eq!(list2.len(), 1);
|
||||
|
||||
// Content should be different
|
||||
assert_eq!(list1[0].content, deps::REQUIREMENTS_IN);
|
||||
assert_eq!(list2[0].content, deps::REQUIREMENTS_IN_V2);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Language-specific Tests
|
||||
// =========================================================================
|
||||
|
||||
/// Test: Different languages can have same-named workspace dependencies.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_same_name_different_languages(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
// Create Python deps
|
||||
NewWorkspaceDependencies {
|
||||
workspace_id: "test-workspace".into(),
|
||||
language: ScriptLang::Python3,
|
||||
content: deps::REQUIREMENTS_IN.into(),
|
||||
name: Some("common".to_owned()),
|
||||
description: None,
|
||||
}
|
||||
.create(
|
||||
(
|
||||
"test@test.com".to_owned(),
|
||||
"u/test".to_owned(),
|
||||
"test".to_owned(),
|
||||
),
|
||||
db.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Create Bun deps with same name
|
||||
NewWorkspaceDependencies {
|
||||
workspace_id: "test-workspace".into(),
|
||||
language: ScriptLang::Bun,
|
||||
content: deps::PACKAGE_JSON.into(),
|
||||
name: Some("common".to_owned()),
|
||||
description: None,
|
||||
}
|
||||
.create(
|
||||
(
|
||||
"test@test.com".to_owned(),
|
||||
"u/test".to_owned(),
|
||||
"test".to_owned(),
|
||||
),
|
||||
db.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let list = WorkspaceDependencies::list("test-workspace", &db).await?;
|
||||
assert_eq!(list.len(), 2, "Should have 2 entries (different languages)");
|
||||
|
||||
let python_deps: Vec<_> = list
|
||||
.iter()
|
||||
.filter(|d| d.language == ScriptLang::Python3)
|
||||
.collect();
|
||||
let bun_deps: Vec<_> = list
|
||||
.iter()
|
||||
.filter(|d| d.language == ScriptLang::Bun)
|
||||
.collect();
|
||||
|
||||
assert_eq!(python_deps.len(), 1);
|
||||
assert_eq!(bun_deps.len(), 1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test: Nativets and Bunnative use Bun workspace dependencies.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_nativets_uses_bun_deps(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
use windmill_common::worker::Connection;
|
||||
|
||||
// Create Bun deps (which Nativets should use)
|
||||
NewWorkspaceDependencies {
|
||||
workspace_id: "test-workspace".into(),
|
||||
language: ScriptLang::Bun,
|
||||
content: deps::PACKAGE_JSON.into(),
|
||||
name: None,
|
||||
description: None,
|
||||
}
|
||||
.create(
|
||||
(
|
||||
"test@test.com".to_owned(),
|
||||
"u/test".to_owned(),
|
||||
"test".to_owned(),
|
||||
),
|
||||
db.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Query for Nativets should return Bun deps
|
||||
let result = WorkspaceDependencies::get_latest(
|
||||
None,
|
||||
ScriptLang::Nativets,
|
||||
"test-workspace",
|
||||
Connection::Sql(db.clone()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert!(result.is_some(), "Nativets should find Bun deps");
|
||||
assert_eq!(result.unwrap().language, ScriptLang::Bun);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Path Generation Tests
|
||||
// =========================================================================
|
||||
|
||||
/// Test: to_path generates correct paths for named and unnamed deps.
|
||||
#[test]
|
||||
fn test_to_path_generation() {
|
||||
// Unnamed (default) deps
|
||||
let path = WorkspaceDependencies::to_path(&None, ScriptLang::Python3).unwrap();
|
||||
assert_eq!(path, "dependencies/requirements.in");
|
||||
|
||||
let path = WorkspaceDependencies::to_path(&None, ScriptLang::Bun).unwrap();
|
||||
assert_eq!(path, "dependencies/package.json");
|
||||
|
||||
let path = WorkspaceDependencies::to_path(&None, ScriptLang::Php).unwrap();
|
||||
assert_eq!(path, "dependencies/composer.json");
|
||||
|
||||
// Named deps
|
||||
let path =
|
||||
WorkspaceDependencies::to_path(&Some("custom".to_owned()), ScriptLang::Python3).unwrap();
|
||||
assert_eq!(path, "dependencies/custom.requirements.in");
|
||||
|
||||
let path =
|
||||
WorkspaceDependencies::to_path(&Some("custom".to_owned()), ScriptLang::Bun).unwrap();
|
||||
assert_eq!(path, "dependencies/custom.package.json");
|
||||
}
|
||||
|
||||
/// Test: to_path returns error for unsupported languages.
|
||||
#[test]
|
||||
fn test_to_path_unsupported_language() {
|
||||
// Deno doesn't support workspace dependencies
|
||||
let result = WorkspaceDependencies::to_path(&None, ScriptLang::Deno);
|
||||
assert!(result.is_err(), "Deno should not support workspace deps");
|
||||
}
|
||||
|
||||
/// Test E2E: Creating named workspace dependencies triggers re-lock jobs for dependent scripts.
|
||||
///
|
||||
/// This test:
|
||||
/// 1. Uses fixture with Python, Bun, PHP scripts linked to named workspace deps via dependency_map
|
||||
/// 2. Creates named workspace dependencies for each language
|
||||
/// 3. Verifies dependency jobs are triggered for all linked scripts
|
||||
#[cfg(feature = "python")]
|
||||
#[sqlx::test(fixtures("base", "workspace_dependencies_leafs"))]
|
||||
#[ignore]
|
||||
async fn basic_manual_named(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let ((_client, port, _s), db, mut completed) = (
|
||||
init_client(db.clone()).await,
|
||||
@@ -47,67 +556,69 @@ mod workspace_dependencies {
|
||||
listen_for_completed_jobs(&db).await,
|
||||
);
|
||||
|
||||
for (idx, (l, c)) in [
|
||||
// Create named workspace dependencies for Python, Bun, and PHP
|
||||
// These will trigger dependency jobs for scripts linked via dependency_map
|
||||
for (lang, content) in [
|
||||
(ScriptLang::Python3, deps::REQUIREMENTS_IN),
|
||||
(ScriptLang::Bun, deps::PACKAGE_JSON),
|
||||
(ScriptLang::Php, deps::COMPOSER_JSON),
|
||||
// (ScriptLang::Go, deps::GO_MOD),
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let id = NewWorkspaceDependencies {
|
||||
] {
|
||||
NewWorkspaceDependencies {
|
||||
workspace_id: "test-workspace".into(),
|
||||
language: *l,
|
||||
content: (*c).into(),
|
||||
language: lang,
|
||||
content: content.into(),
|
||||
name: Some("test".to_owned()),
|
||||
description: None,
|
||||
}
|
||||
.create(("".to_owned(), "".to_owned(), "".to_owned()), db.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(idx + 1, id as usize);
|
||||
.create(
|
||||
(
|
||||
"test@test.com".to_owned(),
|
||||
"u/test-user".to_owned(),
|
||||
"test-user".to_owned(),
|
||||
),
|
||||
db.clone(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Wait for 4 jobs.
|
||||
// Creating those dependencies will trigger redeployment of all scripts in workspace_dependencies_leafs.sql
|
||||
in_test_worker(
|
||||
db,
|
||||
async {
|
||||
completed.next().await;
|
||||
completed.next().await;
|
||||
completed.next().await;
|
||||
// completed.next().await;
|
||||
},
|
||||
port,
|
||||
)
|
||||
.await;
|
||||
// Wait for 3 dependency jobs (one per script in fixture)
|
||||
let mut completed_paths = vec![];
|
||||
for _ in 0..3 {
|
||||
let job_id = in_test_worker(db, async { completed.next().await }, port)
|
||||
.await
|
||||
.expect("Expected a dependency job to complete");
|
||||
|
||||
// Verify all scripts have correct locks
|
||||
// let mut langs = vec![];
|
||||
// for r in sqlx::query!(
|
||||
// r#"SELECT language AS "language: ScriptLang",lock FROM script WHERE archived = false"#
|
||||
// )
|
||||
// .fetch_all(db)
|
||||
// .await
|
||||
// .unwrap()
|
||||
// {
|
||||
// match r.language {
|
||||
// ScriptLang::Python3 => assert_eq!("", &r.lock.unwrap()),
|
||||
// ScriptLang::Go => todo!(),
|
||||
// ScriptLang::Bun => todo!(),
|
||||
// ScriptLang::Bunnative => todo!(),
|
||||
// ScriptLang::Php => todo!(),
|
||||
// _ => panic!("Unsupported language"),
|
||||
// }
|
||||
let job_path = sqlx::query_scalar!(
|
||||
"SELECT runnable_path FROM v2_job WHERE id = $1",
|
||||
job_id
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
|
||||
// langs.push(r.language);
|
||||
// }
|
||||
if let Some(path) = job_path {
|
||||
completed_paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
// langs.sort();
|
||||
// // Just tiny additional verification for peace of mind.
|
||||
// assert_eq!(langs.as_slice(), &[]);
|
||||
// Verify all 3 scripts received dependency jobs
|
||||
completed_paths.sort();
|
||||
let expected = vec![
|
||||
"f/leafs/php".to_string(),
|
||||
"f/leafs/python".to_string(),
|
||||
"f/leafs/ts".to_string(),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
completed_paths, expected,
|
||||
"All scripts should have received dependency jobs"
|
||||
);
|
||||
|
||||
// Verify no extra jobs were created
|
||||
let total_jobs = sqlx::query_scalar!("SELECT COUNT(*) FROM v2_job")
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
|
||||
assert_eq!(total_jobs, Some(3), "Should have exactly 3 jobs");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -445,8 +445,6 @@ pub struct FlowModule {
|
||||
pub apply_preprocessor: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pass_flow_input_directly: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub debouncing: Option<DebouncingSettings>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
@@ -1119,7 +1117,6 @@ pub fn add_virtual_items_if_necessary(modules: &mut Vec<FlowModule>) {
|
||||
skip_if: None,
|
||||
apply_preprocessor: None,
|
||||
pass_flow_input_directly: None,
|
||||
debouncing: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3342,51 +3342,8 @@ async fn push_next_flow_job(
|
||||
let continue_on_same_worker =
|
||||
(flow.same_worker || job_same_worker) && module.suspend.is_none() && module.sleep.is_none();
|
||||
|
||||
// Node-level debouncing: compute debounce key and adjust scheduled_for if configured
|
||||
#[cfg(feature = "enterprise")]
|
||||
let node_debounce_key = if let Some(ref debouncing) = module.debouncing {
|
||||
if matches!(step, Step::Step { .. })
|
||||
&& debouncing.debounce_delay_s.filter(|x| *x > 0).is_some()
|
||||
{
|
||||
let delay = debouncing.debounce_delay_s.unwrap();
|
||||
scheduled_for_o = scheduled_for_o.or(Some(
|
||||
chrono::Utc::now() + chrono::Duration::seconds(delay as i64),
|
||||
));
|
||||
let key = if let Some(custom_key) = debouncing.debounce_key.clone() {
|
||||
// Interpolate $workspace and $args[...] in the custom key
|
||||
if let Ok(ref args_map) = args {
|
||||
let push_args = PushArgs::from(args_map.as_ref());
|
||||
interpolate_args(custom_key, &push_args, &flow_job.workspace_id)
|
||||
} else {
|
||||
custom_key.replace("$workspace", &flow_job.workspace_id)
|
||||
}
|
||||
} else {
|
||||
format!(
|
||||
"{}/{}/{}",
|
||||
&flow_job.workspace_id,
|
||||
flow_job.runnable_path(),
|
||||
&module.id
|
||||
)
|
||||
};
|
||||
let key = if key.len() <= 255 {
|
||||
key
|
||||
} else {
|
||||
windmill_common::utils::calculate_hash(&key)
|
||||
};
|
||||
Some(key)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
let _node_debounce_key: Option<String> = None;
|
||||
|
||||
/* Finally, push the job into the queue */
|
||||
let mut uuids = vec![];
|
||||
#[allow(unused_mut)]
|
||||
let mut debounced_prev_parent_flow: Option<Uuid> = None;
|
||||
|
||||
let job_payloads = match job_payloads {
|
||||
ContinuePayload::SingleJob(payload) => vec![payload],
|
||||
@@ -3687,112 +3644,6 @@ async fn push_next_flow_job(
|
||||
|
||||
tracing::debug!(id = %flow_job.id, root_id = %job_root, "pushed next flow job: {uuid}");
|
||||
|
||||
// Node-level debouncing: register step job in debounce_key table.
|
||||
// If a previous step job exists for the same key, cancel it (skip it),
|
||||
// record both jobs in v2_job_debounce_batch for arg accumulation,
|
||||
// and collect the parent flow ID so we can notify it after commit.
|
||||
#[cfg(feature = "enterprise")]
|
||||
if let Some(ref debounce_key) = node_debounce_key {
|
||||
if i == 0 {
|
||||
let prev_step_job_id: Option<Uuid> = sqlx::query_scalar(
|
||||
"WITH dk AS (
|
||||
INSERT INTO debounce_key (job_id, key)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
previous_job_id = debounce_key.job_id,
|
||||
job_id = EXCLUDED.job_id,
|
||||
debounced_times = debounce_key.debounced_times + 1
|
||||
RETURNING previous_job_id
|
||||
), _batch AS (
|
||||
INSERT INTO v2_job_debounce_batch (id, debounce_batch)
|
||||
SELECT
|
||||
$1,
|
||||
COALESCE(
|
||||
(SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = dk.previous_job_id LIMIT 1),
|
||||
nextval('debounce_batch_seq')
|
||||
)
|
||||
FROM dk
|
||||
)
|
||||
SELECT previous_job_id FROM dk",
|
||||
)
|
||||
.bind(uuid)
|
||||
.bind(debounce_key)
|
||||
.fetch_one(&mut *inner_tx)
|
||||
.await?;
|
||||
|
||||
// Store the module's debouncing settings in runnable_settings_handle
|
||||
// so that maybe_apply_debouncing can find them at execution time
|
||||
// and perform argument accumulation from the batch.
|
||||
let debouncing = module.debouncing.as_ref().unwrap();
|
||||
let debouncing_hash = debouncing.insert_cached(&db).await.unwrap_or_else(|e| {
|
||||
tracing::error!("Failed to insert node debouncing settings: {e:#}");
|
||||
None
|
||||
});
|
||||
let rs_handle = windmill_common::runnable_settings::insert_rs(
|
||||
windmill_common::runnable_settings::RunnableSettings {
|
||||
debouncing_settings: debouncing_hash,
|
||||
concurrency_settings: None,
|
||||
},
|
||||
&db,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::error!("Failed to insert runnable settings for node debounce: {e:#}");
|
||||
None
|
||||
});
|
||||
if rs_handle.is_some() {
|
||||
sqlx::query(
|
||||
"UPDATE v2_job_queue SET runnable_settings_handle = $1 WHERE id = $2",
|
||||
)
|
||||
.bind(rs_handle)
|
||||
.bind(uuid)
|
||||
.execute(&mut *inner_tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(prev_step_job_id) = prev_step_job_id {
|
||||
tracing::info!(
|
||||
id = %flow_job.id,
|
||||
prev_step_job = %prev_step_job_id,
|
||||
debounce_key = %debounce_key,
|
||||
"Node debounce: cancelling previous step job"
|
||||
);
|
||||
// Complete the previous step job as skipped
|
||||
let result =
|
||||
serde_json::to_string(&format!("Debounced by {uuid}")).unwrap_or_default();
|
||||
sqlx::query(
|
||||
"WITH completed AS (
|
||||
INSERT INTO v2_job_completed
|
||||
(workspace_id, id, started_at, duration_ms, result, status, worker)
|
||||
SELECT
|
||||
q.workspace_id, q.id, q.started_at,
|
||||
(EXTRACT('epoch' FROM now()) - EXTRACT('epoch' FROM COALESCE(q.started_at, now()))) * 1000,
|
||||
$2::text::jsonb,
|
||||
'skipped'::job_status,
|
||||
q.worker
|
||||
FROM v2_job_queue q
|
||||
WHERE q.id = $1
|
||||
ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = EXCLUDED.result
|
||||
)
|
||||
DELETE FROM v2_job_queue WHERE id = $1",
|
||||
)
|
||||
.bind(prev_step_job_id)
|
||||
.bind(&result)
|
||||
.execute(&mut *inner_tx)
|
||||
.await?;
|
||||
|
||||
// Get the parent flow of the cancelled step so we can notify it
|
||||
let parent_flow_id: Option<Uuid> =
|
||||
sqlx::query_scalar("SELECT parent_job FROM v2_job WHERE id = $1")
|
||||
.bind(prev_step_job_id)
|
||||
.fetch_optional(&mut *inner_tx)
|
||||
.await?;
|
||||
|
||||
debounced_prev_parent_flow = parent_flow_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if value_with_parallel.type_ == "forloopflow"
|
||||
&& value_with_parallel.parallel.unwrap_or(false)
|
||||
{
|
||||
@@ -4082,39 +3933,6 @@ async fn push_next_flow_job(
|
||||
tx.commit().warn_after_seconds(3).await?;
|
||||
tracing::info!(id = %flow_job.id, root_id = %job_root, "all next flow jobs pushed: {uuids:?}");
|
||||
|
||||
// After commit: notify the debounced step's parent flow so it processes the
|
||||
// skipped step and stops early (skipping descendants while keeping sibling branches).
|
||||
if let Some(parent_flow_id) = debounced_prev_parent_flow {
|
||||
let debounced_result =
|
||||
serde_json::value::to_raw_value(&serde_json::json!({"debounced": true}))
|
||||
.unwrap_or_else(|_| RawValue::from_string("null".to_string()).unwrap());
|
||||
tracing::info!(
|
||||
id = %flow_job.id,
|
||||
debounced_parent = %parent_flow_id,
|
||||
"Node debounce: notifying debounced flow's parent to stop early"
|
||||
);
|
||||
job_completed_tx
|
||||
.send(
|
||||
SendResultPayload::UpdateFlow(UpdateFlow {
|
||||
flow: parent_flow_id,
|
||||
w_id: flow_job.workspace_id.clone(),
|
||||
success: true,
|
||||
result: debounced_result,
|
||||
worker_dir: worker_dir.to_string(),
|
||||
stop_early_override: Some(true), // skip_if_stopped = true
|
||||
token: client.token.clone(),
|
||||
}),
|
||||
false,
|
||||
)
|
||||
.warn_after_seconds(3)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::internal_err(format!(
|
||||
"error sending debounced UpdateFlow to job completed channel: {e:#}"
|
||||
))
|
||||
})?;
|
||||
}
|
||||
|
||||
if continue_on_same_worker || continue_with_runners {
|
||||
let flow_runners = if start_runners {
|
||||
tracing::info!(id = %flow_job.id, "starting flow runners for module {}", module.id);
|
||||
|
||||
@@ -4159,16 +4159,14 @@ This is a paragraph.
|
||||
type: 'static',
|
||||
fieldType: 'resource',
|
||||
subFieldType: 'mysql',
|
||||
value: '',
|
||||
allowTypeChange: false
|
||||
value: ''
|
||||
} as StaticAppInput,
|
||||
table: {
|
||||
fieldType: 'select',
|
||||
subFieldType: 'db-table',
|
||||
type: 'static',
|
||||
selectOptions: [],
|
||||
value: undefined,
|
||||
allowTypeChange: false
|
||||
value: undefined
|
||||
}
|
||||
},
|
||||
ms_sql_server: {
|
||||
@@ -4176,16 +4174,14 @@ This is a paragraph.
|
||||
type: 'static',
|
||||
fieldType: 'resource',
|
||||
subFieldType: 'ms_sql_server',
|
||||
value: '',
|
||||
allowTypeChange: false
|
||||
value: ''
|
||||
} as StaticAppInput,
|
||||
table: {
|
||||
fieldType: 'select',
|
||||
subFieldType: 'db-table',
|
||||
type: 'static',
|
||||
selectOptions: [],
|
||||
value: undefined,
|
||||
allowTypeChange: false
|
||||
value: undefined
|
||||
}
|
||||
},
|
||||
snowflake: {
|
||||
@@ -4193,16 +4189,14 @@ This is a paragraph.
|
||||
type: 'static',
|
||||
fieldType: 'resource',
|
||||
subFieldType: 'snowflake',
|
||||
value: '',
|
||||
allowTypeChange: false
|
||||
value: ''
|
||||
} as StaticAppInput,
|
||||
table: {
|
||||
fieldType: 'select',
|
||||
subFieldType: 'db-table',
|
||||
type: 'static',
|
||||
selectOptions: [],
|
||||
value: undefined,
|
||||
allowTypeChange: false
|
||||
value: undefined
|
||||
}
|
||||
},
|
||||
bigquery: {
|
||||
@@ -4210,16 +4204,14 @@ This is a paragraph.
|
||||
type: 'static',
|
||||
fieldType: 'resource',
|
||||
subFieldType: 'bigquery',
|
||||
value: '',
|
||||
allowTypeChange: false
|
||||
value: ''
|
||||
} as StaticAppInput,
|
||||
table: {
|
||||
fieldType: 'select',
|
||||
subFieldType: 'db-table',
|
||||
type: 'static',
|
||||
selectOptions: [],
|
||||
value: undefined,
|
||||
allowTypeChange: false
|
||||
value: undefined
|
||||
}
|
||||
},
|
||||
ducklake: {
|
||||
@@ -4227,16 +4219,14 @@ This is a paragraph.
|
||||
type: 'static',
|
||||
fieldType: 'ducklake',
|
||||
subFieldType: 'ducklake',
|
||||
value: '',
|
||||
allowTypeChange: false
|
||||
value: ''
|
||||
} as StaticAppInput,
|
||||
table: {
|
||||
fieldType: 'select',
|
||||
subFieldType: 'db-table',
|
||||
type: 'static',
|
||||
selectOptions: [],
|
||||
value: undefined,
|
||||
allowTypeChange: false
|
||||
value: undefined
|
||||
}
|
||||
},
|
||||
datatable: {
|
||||
@@ -4244,16 +4234,14 @@ This is a paragraph.
|
||||
type: 'static',
|
||||
fieldType: 'datatable',
|
||||
subFieldType: 'datatable',
|
||||
value: '',
|
||||
allowTypeChange: false
|
||||
value: ''
|
||||
} as StaticAppInput,
|
||||
table: {
|
||||
fieldType: 'select',
|
||||
subFieldType: 'db-table',
|
||||
type: 'static',
|
||||
selectOptions: [],
|
||||
value: undefined,
|
||||
allowTypeChange: false
|
||||
value: undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
import type { FlowEditorContext, FlowGraphAssetContext } from '../types'
|
||||
import FlowModuleScript from './FlowModuleScript.svelte'
|
||||
import FlowModuleEarlyStop from './FlowModuleEarlyStop.svelte'
|
||||
import DebounceLimit from '../DebounceLimit.svelte'
|
||||
import FlowModuleSuspend from './FlowModuleSuspend.svelte'
|
||||
import FlowModuleCache from './FlowModuleCache.svelte'
|
||||
import FlowModuleDeleteAfterUse from './FlowModuleDeleteAfterUse.svelte'
|
||||
@@ -156,12 +155,6 @@
|
||||
let selected = $state(untrack(() => preprocessorModule) ? 'test' : 'inputs')
|
||||
let advancedSelected = $state('retries')
|
||||
let advancedRuntimeSelected = $state('concurrency')
|
||||
|
||||
$effect(() => {
|
||||
if (advancedSelected === 'debouncing' && !flowModule.debouncing) {
|
||||
flowModule.debouncing = {}
|
||||
}
|
||||
})
|
||||
let s3Kind = $state('s3_client')
|
||||
let validCode = $state(true)
|
||||
let width = $state(1200)
|
||||
@@ -1133,13 +1126,6 @@
|
||||
label="Suspend"
|
||||
/>
|
||||
<Tab value="sleep" active={Boolean(flowModule.sleep)} label="Sleep" />
|
||||
{#if !parentModule?.value?.type || (parentModule.value.type !== 'forloopflow' && parentModule.value.type !== 'whileloopflow')}
|
||||
<Tab
|
||||
value="debouncing"
|
||||
active={Boolean(flowModule.debouncing?.debounce_delay_s)}
|
||||
label="Debouncing"
|
||||
/>
|
||||
{/if}
|
||||
<Tab
|
||||
value="mock"
|
||||
active={Boolean(flowModule.mock?.enabled)}
|
||||
@@ -1326,22 +1312,6 @@
|
||||
bind:flowModule
|
||||
/>
|
||||
</div>
|
||||
{:else if advancedSelected === 'debouncing'}
|
||||
<div>
|
||||
{#if flowModule.debouncing}
|
||||
<DebounceLimit
|
||||
size="xs"
|
||||
fontClass="font-medium"
|
||||
bind:debounce_delay_s={flowModule.debouncing.debounce_delay_s}
|
||||
bind:debounce_key={flowModule.debouncing.debounce_key}
|
||||
bind:debounce_args_to_accumulate={flowModule.debouncing.debounce_args_to_accumulate}
|
||||
bind:max_total_debouncing_time={flowModule.debouncing.max_total_debouncing_time}
|
||||
bind:max_total_debounces_amount={flowModule.debouncing.max_total_debounces_amount}
|
||||
schema={flowStateStore.val[flowModule.id]?.schema as any}
|
||||
placeholder={`$workspace/flow/$flow_path-${flowModule.id}`}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if advancedSelected === 'mock'}
|
||||
<div>
|
||||
<FlowModuleMockTransitionMessage />
|
||||
|
||||
@@ -326,30 +326,10 @@ components:
|
||||
retry:
|
||||
description: Retry configuration if this step fails
|
||||
$ref: '#/components/schemas/Retry'
|
||||
debouncing:
|
||||
description: Debouncing configuration for this step
|
||||
$ref: '#/components/schemas/DebouncingSettings'
|
||||
required:
|
||||
- value
|
||||
- id
|
||||
|
||||
DebouncingSettings:
|
||||
type: object
|
||||
description: Debouncing configuration for a flow module
|
||||
properties:
|
||||
debounce_key:
|
||||
type: string
|
||||
debounce_delay_s:
|
||||
type: integer
|
||||
max_total_debouncing_time:
|
||||
type: integer
|
||||
max_total_debounces_amount:
|
||||
type: integer
|
||||
debounce_args_to_accumulate:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
InputTransform:
|
||||
description: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs
|
||||
oneOf:
|
||||
|
||||
Reference in New Issue
Block a user