mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-24 00:00:46 +00:00
refactor: ungate cascade producers, squash pipeline migrations
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT path FROM flow WHERE workspace_id = $1 AND path = ANY($2) AND archived = false",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0a568f630e069118fe302099a709e89cc4a702158899f7fdc66d0922e8fb9b29"
|
||||
}
|
||||
+2
-2
@@ -5,7 +5,7 @@
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"name": "id!",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
@@ -16,7 +16,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319"
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT kind, path, script_path, is_flow FROM (\n SELECT 'schedule' AS kind, path, script_path, is_flow FROM schedule\n WHERE workspace_id = $1\n AND script_path IS NOT NULL\n UNION ALL\n SELECT 'email', path, script_path, is_flow FROM email_trigger\n WHERE workspace_id = $1\n UNION ALL\n SELECT 'kafka', path, script_path, is_flow FROM kafka_trigger\n WHERE workspace_id = $1\n UNION ALL\n SELECT 'mqtt', path, script_path, is_flow FROM mqtt_trigger\n WHERE workspace_id = $1\n UNION ALL\n SELECT 'nats', path, script_path, is_flow FROM nats_trigger\n WHERE workspace_id = $1\n UNION ALL\n SELECT 'postgres', path, script_path, is_flow FROM postgres_trigger\n WHERE workspace_id = $1\n UNION ALL\n SELECT 'sqs', path, script_path, is_flow FROM sqs_trigger\n WHERE workspace_id = $1\n UNION ALL\n SELECT 'gcp', path, script_path, is_flow FROM gcp_trigger\n WHERE workspace_id = $1\n ) t\n WHERE ($2::text IS NULL OR script_path LIKE $2)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "kind",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "script_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "is_flow",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "2484323d94f249be30f4472ece89659c1e6a24d5454a691e31e0179f58c24366"
|
||||
}
|
||||
+3
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n e.subscriber_path AS \"subscriber_path!\",\n e.asset_kind AS \"asset_kind!: windmill_common::assets::AssetKind\",\n e.asset_path AS \"asset_path!\",\n e.outcome::text AS \"outcome!\",\n e.child_job_id,\n e.partition,\n e.received_inputs,\n e.required_inputs,\n e.debounce_s,\n e.reason,\n e.created_at AS \"created_at!\"\n FROM dispatch_event e\n JOIN v2_job j ON j.id = e.producer_job_id\n WHERE e.producer_job_id = $1\n AND e.workspace_id = $2\n AND ($3::text[] IS NULL OR j.tag = ANY($3))\n ORDER BY e.id",
|
||||
"query": "SELECT\n subscriber_path AS \"subscriber_path!\",\n asset_kind AS \"asset_kind!: windmill_common::assets::AssetKind\",\n asset_path AS \"asset_path!\",\n outcome::text AS \"outcome!\",\n child_job_id,\n partition,\n received_inputs,\n required_inputs,\n debounce_s,\n reason,\n created_at AS \"created_at!\"\n FROM dispatch_event\n WHERE producer_job_id = $1 AND workspace_id = $2\n ORDER BY id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -76,8 +76,7 @@
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text",
|
||||
"TextArray"
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
@@ -94,5 +93,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1c9cc934ae0784c849e140dabd8cfbc43b7822a4a693220eaaf1317ec5f0c6bd"
|
||||
"hash": "26e63135fcd8e7d48e25de190a2f72ece70ec92c5b04baad2622639850445900"
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO schedule (\n workspace_id, path, schedule, timezone, edited_by, script_path,\n is_flow, enabled, email, permissioned_as,\n ws_error_handler_muted, no_flow_overlap, cron_version,\n managed\n )\n VALUES ($1, $2, $3, 'UTC', $4, $2, $5, true, $6, $7, false, false, 'v2', true)\n ON CONFLICT (workspace_id, path) DO UPDATE\n SET schedule = EXCLUDED.schedule,\n edited_at = now(),\n edited_by = EXCLUDED.edited_by,\n managed = true\n WHERE schedule.managed\n OR schedule.script_path = EXCLUDED.script_path\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3fdb89d2eaccb78991c6741bb1f0781c37b753bd3b4a4c7658f8d6ce54c104f9"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT path AS \"path!\" FROM flow WHERE workspace_id = $1 AND archived = false",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path!",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "45b6c748090a0a6bf71a995413b6b571ae0f3355cfd5eb95a227a2a98136e02b"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT has_changes FROM workspace_diff\n WHERE path = 'f/shared/renamed_away' AND kind = 'script' AND source_workspace_id = 'test-workspace'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "has_changes",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "4c81384b579bad74b64c72ca053839f316fe5412f99f3d3bbbdc0c65f55ab794"
|
||||
}
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n runnable_kind AS \"runnable_kind!: AssetUsageKind\",\n runnable_path AS \"runnable_path!\",\n trigger_kind::text AS \"trigger_kind!\",\n trigger_ref AS \"trigger_ref!\"\n FROM script_trigger\n WHERE workspace_id = $1\n AND trigger_kind IN ('asset', 'schedule')\n AND ($2::text IS NULL OR runnable_path LIKE $2)\n ",
|
||||
"query": "\n SELECT\n runnable_kind AS \"runnable_kind!: AssetUsageKind\",\n runnable_path AS \"runnable_path!\",\n trigger_kind::text AS \"trigger_kind!\",\n trigger_ref AS \"trigger_ref!\"\n FROM script_trigger\n WHERE workspace_id = $1\n AND trigger_kind = 'asset'\n AND ($2::text IS NULL OR runnable_path LIKE $2)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -48,5 +48,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ad75a63f5b757de1f4bb9789f8aa7ea25e1dd949ca957a8d5bbfd1166d4b80ce"
|
||||
"hash": "77424d40104cf271e5ee5118100a988159130fc3a8cde91d419a1787b6bb8a51"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO script (workspace_id, path, hash, content, summary, description, language, created_by, created_at, archived, schema_validation, ws_error_handler_muted, deleted)\n VALUES ('wm-fork-test-workspace', 'f/shared/renamed_away', 67890, 'def main(): return 1', '', '', 'python3', 'test@windmill.dev', NOW(), true, false, false, false)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a60306f2bae0702363787c4cf7c1267af8e42a3b6aa382f1dd483bec7219c67c"
|
||||
}
|
||||
+1
-1
@@ -13,4 +13,4 @@
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270"
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT DISTINCT path FROM script WHERE workspace_id = $1 AND path = ANY($2) AND archived = false",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c4966cf071a8504f578eed5518134db7b001d740d524075f19b91bae6fdb41b9"
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT created_by AS \"created_by!\"\n FROM v2_job\n WHERE id = $1 AND workspace_id = $2 AND ($3::text[] IS NULL OR tag = ANY($3))",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "created_by!",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c608cdc9cd2e41992fb86431faa15edc8e637d44031b83c01ef09a96ade472ae"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO workspace_diff\n (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork)\n VALUES ('test-workspace', 'wm-fork-test-workspace', 'f/shared/renamed_away', 'script', 1, 0, true, false, true)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e9c2e8c50fc45576453885340800c3a44de6e32651a9a71c1aef49da2696a04f"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT path AS \"path!\" FROM script\n WHERE workspace_id = $1\n AND archived = false\n AND deleted = false",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path!",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ecce519d0cf0c31df4612e0ccd8d62eef3ab3b920665ca5d6f9ce0ef89e53fb3"
|
||||
}
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT kind, path, script_path, is_flow FROM (\n SELECT 'email' AS kind, path, script_path, is_flow FROM email_trigger\n WHERE workspace_id = $1\n UNION ALL\n SELECT 'kafka', path, script_path, is_flow FROM kafka_trigger\n WHERE workspace_id = $1\n UNION ALL\n SELECT 'mqtt', path, script_path, is_flow FROM mqtt_trigger\n WHERE workspace_id = $1\n UNION ALL\n SELECT 'nats', path, script_path, is_flow FROM nats_trigger\n WHERE workspace_id = $1\n UNION ALL\n SELECT 'postgres', path, script_path, is_flow FROM postgres_trigger\n WHERE workspace_id = $1\n UNION ALL\n SELECT 'sqs', path, script_path, is_flow FROM sqs_trigger\n WHERE workspace_id = $1\n UNION ALL\n SELECT 'gcp', path, script_path, is_flow FROM gcp_trigger\n WHERE workspace_id = $1\n ) t\n WHERE ($2::text IS NULL OR script_path LIKE $2)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "kind",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "script_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "is_flow",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "f360806ee1b8e53f16800219b00064b8d536de4f4091548c8c70ce11199c9df3"
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
DROP TABLE IF EXISTS asset_materializer_history;
|
||||
DROP TABLE IF EXISTS asset_materializer;
|
||||
@@ -1,33 +0,0 @@
|
||||
-- Current materializer: exactly one runnable owns each (workspace, kind, path) asset.
|
||||
-- Updated via last-deploy-wins on `// materialize <path>` annotations.
|
||||
CREATE TABLE asset_materializer (
|
||||
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
asset_kind ASSET_KIND NOT NULL,
|
||||
asset_path VARCHAR(255) NOT NULL,
|
||||
runnable_kind ASSET_USAGE_KIND NOT NULL,
|
||||
runnable_path VARCHAR(255) NOT NULL,
|
||||
deployed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deployed_by VARCHAR(50) NOT NULL,
|
||||
PRIMARY KEY (workspace_id, asset_kind, asset_path)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_asset_materializer_runnable
|
||||
ON asset_materializer (workspace_id, runnable_kind, runnable_path);
|
||||
|
||||
-- Append-only history. Every claim (including takeovers) gets a row.
|
||||
-- Revocations (redeploy without annotation) are represented by absence in
|
||||
-- `asset_materializer` rather than a row here, so "who last materialized X"
|
||||
-- is the most recent history row for X regardless of current state.
|
||||
CREATE TABLE asset_materializer_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
asset_kind ASSET_KIND NOT NULL,
|
||||
asset_path VARCHAR(255) NOT NULL,
|
||||
runnable_kind ASSET_USAGE_KIND NOT NULL,
|
||||
runnable_path VARCHAR(255) NOT NULL,
|
||||
deployed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deployed_by VARCHAR(50) NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_asset_materializer_history_asset
|
||||
ON asset_materializer_history (workspace_id, asset_kind, asset_path, deployed_at DESC);
|
||||
@@ -1 +0,0 @@
|
||||
DROP INDEX IF EXISTS idx_script_materializer_path;
|
||||
@@ -1,8 +0,0 @@
|
||||
-- Fast lookups for:
|
||||
-- 1. "does folder F have a pipeline?" (exists check on prefix)
|
||||
-- 2. "list all folders with a pipeline" (distinct folder from path)
|
||||
-- The partial predicate keeps the index tiny on workspaces with few
|
||||
-- materializer scripts, and text_pattern_ops lets 'f/foo/%' LIKE scans use it.
|
||||
CREATE INDEX IF NOT EXISTS idx_script_materializer_path
|
||||
ON script (workspace_id, path text_pattern_ops)
|
||||
WHERE auto_kind = 'materializer' AND archived = false AND deleted = false;
|
||||
@@ -1,30 +0,0 @@
|
||||
DROP TABLE IF EXISTS script_trigger;
|
||||
DROP TYPE IF EXISTS SCRIPT_TRIGGER_KIND;
|
||||
|
||||
-- Recreate the materializer tables so rolling back to the pre-refactor
|
||||
-- backend code boots. Data is gone either way (irrecoverable).
|
||||
CREATE TABLE asset_materializer (
|
||||
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
asset_kind ASSET_KIND NOT NULL,
|
||||
asset_path VARCHAR(255) NOT NULL,
|
||||
runnable_kind ASSET_USAGE_KIND NOT NULL,
|
||||
runnable_path VARCHAR(255) NOT NULL,
|
||||
deployed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deployed_by VARCHAR(50) NOT NULL,
|
||||
PRIMARY KEY (workspace_id, asset_kind, asset_path)
|
||||
);
|
||||
CREATE INDEX idx_asset_materializer_runnable
|
||||
ON asset_materializer (workspace_id, runnable_kind, runnable_path);
|
||||
|
||||
CREATE TABLE asset_materializer_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
asset_kind ASSET_KIND NOT NULL,
|
||||
asset_path VARCHAR(255) NOT NULL,
|
||||
runnable_kind ASSET_USAGE_KIND NOT NULL,
|
||||
runnable_path VARCHAR(255) NOT NULL,
|
||||
deployed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deployed_by VARCHAR(50) NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_asset_materializer_history_asset
|
||||
ON asset_materializer_history (workspace_id, asset_kind, asset_path, deployed_at DESC);
|
||||
@@ -1,31 +0,0 @@
|
||||
-- Simplified pipeline model: `// materialize` is a bare opt-in marker (sets
|
||||
-- auto_kind='materializer') and the per-asset ownership tracking goes away.
|
||||
-- Writes are already tracked in the `asset` table via the parser, so the
|
||||
-- `asset_materializer*` tables no longer earn their keep.
|
||||
DROP TABLE IF EXISTS asset_materializer_history;
|
||||
DROP TABLE IF EXISTS asset_materializer;
|
||||
|
||||
-- Execution DAG edges declared via `// on <asset | schedule>` annotations.
|
||||
-- For `trigger_kind='asset'`: trigger_ref is `<kind>://<path>` (kind from
|
||||
-- parse_asset_syntax, so downstream lookups match the `asset` table).
|
||||
-- For `trigger_kind='schedule'`: trigger_ref is the raw cron expression.
|
||||
CREATE TYPE SCRIPT_TRIGGER_KIND AS ENUM ('asset', 'schedule');
|
||||
|
||||
CREATE TABLE script_trigger (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
runnable_kind ASSET_USAGE_KIND NOT NULL,
|
||||
runnable_path VARCHAR(255) NOT NULL,
|
||||
trigger_kind SCRIPT_TRIGGER_KIND NOT NULL,
|
||||
trigger_ref TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Per-runnable lookup (wipe-on-deploy, list-triggers-for-script).
|
||||
CREATE INDEX idx_script_trigger_runnable
|
||||
ON script_trigger (workspace_id, runnable_kind, runnable_path);
|
||||
|
||||
-- Reverse lookup: "which scripts are triggered by asset X?" (the asset → script
|
||||
-- edges in the graph). trigger_ref is unbounded text so can't share the
|
||||
-- asset_kind btree, but this covers the common prefix-scan use case.
|
||||
CREATE INDEX idx_script_trigger_ref
|
||||
ON script_trigger (workspace_id, trigger_kind, trigger_ref);
|
||||
@@ -1,12 +0,0 @@
|
||||
-- Postgres doesn't support removing enum values in-place. The only safe
|
||||
-- rollback is to recreate the type with the original set and rewrite the
|
||||
-- column, deleting any rows using values introduced in the up migration.
|
||||
DELETE FROM script_trigger
|
||||
WHERE trigger_kind NOT IN ('asset', 'schedule');
|
||||
|
||||
CREATE TYPE SCRIPT_TRIGGER_KIND_OLD AS ENUM ('asset', 'schedule');
|
||||
ALTER TABLE script_trigger
|
||||
ALTER COLUMN trigger_kind TYPE SCRIPT_TRIGGER_KIND_OLD
|
||||
USING trigger_kind::text::SCRIPT_TRIGGER_KIND_OLD;
|
||||
DROP TYPE SCRIPT_TRIGGER_KIND;
|
||||
ALTER TYPE SCRIPT_TRIGGER_KIND_OLD RENAME TO SCRIPT_TRIGGER_KIND;
|
||||
@@ -1,11 +0,0 @@
|
||||
-- Expand the pipeline trigger enum to cover every non-integration
|
||||
-- (i.e. non-native) trigger kind Windmill supports. Each value mirrors a
|
||||
-- keyword the annotation parser recognises in `// on <kind> <ref>` lines.
|
||||
ALTER TYPE SCRIPT_TRIGGER_KIND ADD VALUE IF NOT EXISTS 'webhook';
|
||||
ALTER TYPE SCRIPT_TRIGGER_KIND ADD VALUE IF NOT EXISTS 'email';
|
||||
ALTER TYPE SCRIPT_TRIGGER_KIND ADD VALUE IF NOT EXISTS 'kafka';
|
||||
ALTER TYPE SCRIPT_TRIGGER_KIND ADD VALUE IF NOT EXISTS 'mqtt';
|
||||
ALTER TYPE SCRIPT_TRIGGER_KIND ADD VALUE IF NOT EXISTS 'nats';
|
||||
ALTER TYPE SCRIPT_TRIGGER_KIND ADD VALUE IF NOT EXISTS 'postgres';
|
||||
ALTER TYPE SCRIPT_TRIGGER_KIND ADD VALUE IF NOT EXISTS 'sqs';
|
||||
ALTER TYPE SCRIPT_TRIGGER_KIND ADD VALUE IF NOT EXISTS 'gcp';
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP INDEX IF EXISTS idx_script_pipeline_path;
|
||||
DROP TABLE IF EXISTS script_trigger;
|
||||
DROP TYPE IF EXISTS SCRIPT_TRIGGER_KIND;
|
||||
@@ -0,0 +1,45 @@
|
||||
-- Execution DAG edges declared via `// on <asset | schedule | ...>`
|
||||
-- annotations.
|
||||
-- For `trigger_kind='asset'`: trigger_ref is `<kind>://<path>` (kind from
|
||||
-- parse_asset_syntax, so downstream lookups match the `asset` table).
|
||||
-- The other kinds mirror the keywords the annotation parser recognises in
|
||||
-- `// on <kind> <ref>` lines (every non-integration trigger kind; their
|
||||
-- trigger_ref is the trigger row path, or empty for marker-only forms).
|
||||
--
|
||||
-- The idempotency guards (IF NOT EXISTS / duplicate_object) are load-bearing:
|
||||
-- this migration squashes several pre-release ones, so databases migrated
|
||||
-- from the unsquashed history already contain the final objects.
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE SCRIPT_TRIGGER_KIND AS ENUM (
|
||||
'asset', 'schedule', 'webhook', 'email', 'kafka', 'mqtt', 'nats',
|
||||
'postgres', 'sqs', 'gcp');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS script_trigger (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
runnable_kind ASSET_USAGE_KIND NOT NULL,
|
||||
runnable_path VARCHAR(255) NOT NULL,
|
||||
trigger_kind SCRIPT_TRIGGER_KIND NOT NULL,
|
||||
trigger_ref TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Per-runnable lookup (wipe-on-deploy, list-triggers-for-script).
|
||||
CREATE INDEX IF NOT EXISTS idx_script_trigger_runnable
|
||||
ON script_trigger (workspace_id, runnable_kind, runnable_path);
|
||||
|
||||
-- Reverse lookup: "which scripts are triggered by asset X?" (the asset → script
|
||||
-- edges in the graph). trigger_ref is unbounded text so can't share the
|
||||
-- asset_kind btree, but this covers the common prefix-scan use case.
|
||||
CREATE INDEX IF NOT EXISTS idx_script_trigger_ref
|
||||
ON script_trigger (workspace_id, trigger_kind, trigger_ref);
|
||||
|
||||
-- Fast lookups for:
|
||||
-- 1. "does folder F have a pipeline?" (exists check on prefix)
|
||||
-- 2. "list all folders with a pipeline" (distinct folder from path)
|
||||
-- The partial predicate keeps the index tiny on workspaces with few
|
||||
-- pipeline scripts, and text_pattern_ops lets 'f/foo/%' LIKE scans use it.
|
||||
CREATE INDEX IF NOT EXISTS idx_script_pipeline_path
|
||||
ON script (workspace_id, path text_pattern_ops)
|
||||
WHERE auto_kind = 'pipeline' AND archived = false AND deleted = false;
|
||||
@@ -1,7 +0,0 @@
|
||||
-- Revert: rename pipeline-membership marker back to materializer.
|
||||
DROP INDEX IF EXISTS idx_script_pipeline_path;
|
||||
CREATE INDEX IF NOT EXISTS idx_script_materializer_path
|
||||
ON script (workspace_id, path text_pattern_ops)
|
||||
WHERE auto_kind = 'materializer' AND archived = false AND deleted = false;
|
||||
|
||||
UPDATE script SET auto_kind = 'materializer' WHERE auto_kind = 'pipeline';
|
||||
@@ -1,14 +0,0 @@
|
||||
-- Rename the pipeline-membership marker from `materializer` to `pipeline`
|
||||
-- to better reflect that membership is broader than materialization
|
||||
-- (test/notify/cleanup scripts are members too without producing assets).
|
||||
-- The change is text-only — `auto_kind` is a varchar column, not an enum,
|
||||
-- so this is just a value swap plus an index rebuild.
|
||||
|
||||
UPDATE script SET auto_kind = 'pipeline' WHERE auto_kind = 'materializer';
|
||||
|
||||
-- Rebuild the partial index that backs the folder picker and graph-scope
|
||||
-- query. The index name and predicate both move to the new keyword.
|
||||
DROP INDEX IF EXISTS idx_script_materializer_path;
|
||||
CREATE INDEX IF NOT EXISTS idx_script_pipeline_path
|
||||
ON script (workspace_id, path text_pattern_ops)
|
||||
WHERE auto_kind = 'pipeline' AND archived = false AND deleted = false;
|
||||
@@ -5,7 +5,7 @@
|
||||
-- (asset-trigger cascade) and the asset-graph lineage view actually query.
|
||||
-- Without this, a pre-feature script "succeeds" but the dispatcher sees
|
||||
-- no writes, no subscribers are matched, and the dispatch_event panel
|
||||
-- stays empty — exactly the gap we hit in https://… (no public link yet).
|
||||
-- stays empty.
|
||||
--
|
||||
-- Idempotent: scoped to (workspace, path) pairs that have ZERO asset rows
|
||||
-- under `usage_kind = 'script'`, so re-running can't duplicate. The
|
||||
|
||||
@@ -472,8 +472,8 @@ fn parse_kv_opts(s: &str) -> BTreeMap<String, String> {
|
||||
// whose first non-whitespace tokens are a comment prefix (`//`, `#`, or
|
||||
// `--`) followed by one of the recognized keywords:
|
||||
// - `pipeline` → opt-in marker (must be alone on the line)
|
||||
// - `schedule "<cron>"` → top-level inline cron schedule
|
||||
// - `on <trigger-spec>` → asset / native trigger edge
|
||||
// - `on <trigger-spec>` → asset / native trigger edge (including
|
||||
// the marker-only `on schedule` form)
|
||||
// - `partitioned <kind> [opts]` → partition declaration
|
||||
// - `freshness <duration>` → SLA / active backstop
|
||||
// - `tag <name>` → worker-tag override (annotation wins
|
||||
@@ -837,7 +837,7 @@ mod pipeline_annotation_tests {
|
||||
let out = parse_pipeline_annotations(
|
||||
"// on s3://lake/raw/{partition}/events.parquet\n\
|
||||
// on s3://lake/dim/customers.parquet\n\
|
||||
// schedule \"@daily\"",
|
||||
// on schedule",
|
||||
);
|
||||
assert_eq!(out.triggers.len(), 3);
|
||||
assert!(out.triggers[0].is_partition_bearing());
|
||||
@@ -1064,7 +1064,7 @@ mod pipeline_annotation_tests {
|
||||
fn combined() {
|
||||
let code = concat!(
|
||||
"// pipeline\n",
|
||||
"// schedule \"0 0 * * *\"\n",
|
||||
"// on schedule\n",
|
||||
"// on s3://in.csv\n",
|
||||
"// partitioned daily tz=\"UTC\"\n",
|
||||
"// freshness 2h\n",
|
||||
|
||||
@@ -32,10 +32,13 @@ async fn seed_script(
|
||||
content: &str,
|
||||
language: &str,
|
||||
) -> anyhow::Result<i64> {
|
||||
// Hash needs to be unique per (workspace, hash). Derive from path so
|
||||
// sibling scripts in the same test don't collide.
|
||||
// Hash needs to be unique per (workspace, hash). Derive from path AND
|
||||
// content: the worker's script cache (`cache::script::fetch`) is keyed
|
||||
// by hash alone and is process-global, so tests running in the same
|
||||
// process that seed the same path with different content would poison
|
||||
// each other's cache if the hash came from the path only.
|
||||
let mut h = 0i64;
|
||||
for b in path.bytes() {
|
||||
for b in path.bytes().chain(content.bytes()) {
|
||||
h = h.wrapping_mul(31).wrapping_add(b as i64);
|
||||
}
|
||||
sqlx::query(
|
||||
|
||||
@@ -20426,6 +20426,149 @@ paths:
|
||||
type: string
|
||||
description: The asset path
|
||||
|
||||
/w/{workspace}/assets/graph:
|
||||
get:
|
||||
summary: Get the workspace-wide asset <-> runnable graph
|
||||
operationId: getAssetsGraph
|
||||
tags:
|
||||
- asset
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: asset_kinds
|
||||
in: query
|
||||
description: Filter by asset kinds (comma-separated list)
|
||||
schema:
|
||||
type: string
|
||||
- name: folder
|
||||
in: query
|
||||
description: Scope the graph to runnables in a single folder
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: asset graph nodes, lineage edges and trigger edges
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [assets, runnables, edges, triggers]
|
||||
properties:
|
||||
assets:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [kind, path]
|
||||
properties:
|
||||
kind:
|
||||
$ref: "#/components/schemas/AssetKind"
|
||||
path:
|
||||
type: string
|
||||
runnables:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [path, usage_kind]
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
usage_kind:
|
||||
$ref: "#/components/schemas/AssetUsageKind"
|
||||
in_pipeline:
|
||||
type: boolean
|
||||
description: True iff the script is a pipeline member (deployed with `// pipeline`). Omitted when false.
|
||||
edges:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
[runnable_path, runnable_kind, asset_kind, asset_path]
|
||||
properties:
|
||||
runnable_path:
|
||||
type: string
|
||||
runnable_kind:
|
||||
$ref: "#/components/schemas/AssetUsageKind"
|
||||
asset_kind:
|
||||
$ref: "#/components/schemas/AssetKind"
|
||||
asset_path:
|
||||
type: string
|
||||
access_type:
|
||||
$ref: "#/components/schemas/AssetUsageAccessType"
|
||||
triggers:
|
||||
type: array
|
||||
items:
|
||||
oneOf:
|
||||
- type: object
|
||||
description: Asset trigger edge (`// on <asset>`)
|
||||
required:
|
||||
[
|
||||
trigger_kind,
|
||||
asset_kind,
|
||||
asset_path,
|
||||
runnable_kind,
|
||||
runnable_path,
|
||||
]
|
||||
properties:
|
||||
trigger_kind:
|
||||
type: string
|
||||
enum: [asset]
|
||||
asset_kind:
|
||||
$ref: "#/components/schemas/AssetKind"
|
||||
asset_path:
|
||||
type: string
|
||||
runnable_kind:
|
||||
$ref: "#/components/schemas/AssetUsageKind"
|
||||
runnable_path:
|
||||
type: string
|
||||
- type: object
|
||||
description: Native trigger edge (schedule, email, kafka, ...). `path` is the trigger row's path.
|
||||
required:
|
||||
[trigger_kind, path, runnable_kind, runnable_path]
|
||||
properties:
|
||||
trigger_kind:
|
||||
type: string
|
||||
enum:
|
||||
- schedule
|
||||
- email
|
||||
- kafka
|
||||
- mqtt
|
||||
- nats
|
||||
- postgres
|
||||
- sqs
|
||||
- gcp
|
||||
path:
|
||||
type: string
|
||||
runnable_kind:
|
||||
$ref: "#/components/schemas/AssetUsageKind"
|
||||
runnable_path:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/assets/pipelines:
|
||||
get:
|
||||
summary: List folders that contain at least one pipeline-member script
|
||||
operationId: listPipelineFolders
|
||||
tags:
|
||||
- asset
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: folders containing pipeline scripts, with their script counts
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [folder, script_count]
|
||||
properties:
|
||||
folder:
|
||||
type: string
|
||||
description: The folder name (without the `f/` prefix)
|
||||
script_count:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Number of pipeline-member scripts in the folder
|
||||
|
||||
/w/{workspace}/volumes/list:
|
||||
get:
|
||||
summary: List all volumes in the workspace
|
||||
|
||||
@@ -8943,8 +8943,10 @@ struct DispatchEvent {
|
||||
}
|
||||
|
||||
async fn get_dispatch_events(
|
||||
OptViewToken(view_token): OptViewToken,
|
||||
OptAuthed(opt_authed): OptAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, id)): Path<(String, Uuid)>,
|
||||
) -> error::JsonResult<Vec<DispatchEvent>> {
|
||||
let tags = opt_authed
|
||||
@@ -8952,32 +8954,59 @@ async fn get_dispatch_events(
|
||||
.map(|authed| get_scope_tags(authed))
|
||||
.flatten();
|
||||
|
||||
// Tag-gate via the producer's v2_job row so token scopes that restrict
|
||||
// job visibility apply here too. The dispatch_event FK to v2_job(id)
|
||||
// guarantees the producer row exists for any extant event.
|
||||
let rows = sqlx::query!(
|
||||
r#"SELECT
|
||||
e.subscriber_path AS "subscriber_path!",
|
||||
e.asset_kind AS "asset_kind!: windmill_common::assets::AssetKind",
|
||||
e.asset_path AS "asset_path!",
|
||||
e.outcome::text AS "outcome!",
|
||||
e.child_job_id,
|
||||
e.partition,
|
||||
e.received_inputs,
|
||||
e.required_inputs,
|
||||
e.debounce_s,
|
||||
e.reason,
|
||||
e.created_at AS "created_at!"
|
||||
FROM dispatch_event e
|
||||
JOIN v2_job j ON j.id = e.producer_job_id
|
||||
WHERE e.producer_job_id = $1
|
||||
AND e.workspace_id = $2
|
||||
AND ($3::text[] IS NULL OR j.tag = ANY($3))
|
||||
ORDER BY e.id"#,
|
||||
// Gate on the producer job's visibility, exactly like
|
||||
// get_completed_job_timing on the same unauthed router: scope tags
|
||||
// first, then per-job read access for authed users, anonymous-only
|
||||
// jobs otherwise. The dispatch_event FK to v2_job(id) guarantees the
|
||||
// producer row exists for any extant event.
|
||||
let producer = sqlx::query!(
|
||||
r#"SELECT created_by AS "created_by!"
|
||||
FROM v2_job
|
||||
WHERE id = $1 AND workspace_id = $2 AND ($3::text[] IS NULL OR tag = ANY($3))"#,
|
||||
id,
|
||||
&w_id,
|
||||
tags.as_ref().map(|v| v.as_slice()) as Option<&[&str]>,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
let producer = not_found_if_none(producer, "Job", id.to_string())?;
|
||||
|
||||
if let Some(authed) = opt_authed.as_ref() {
|
||||
require_job_read_access(
|
||||
&db,
|
||||
&user_db,
|
||||
authed,
|
||||
&w_id,
|
||||
&id,
|
||||
&producer.created_by,
|
||||
view_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
} else if producer.created_by != "anonymous" {
|
||||
return Err(Error::BadRequest(
|
||||
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let rows = sqlx::query!(
|
||||
r#"SELECT
|
||||
subscriber_path AS "subscriber_path!",
|
||||
asset_kind AS "asset_kind!: windmill_common::assets::AssetKind",
|
||||
asset_path AS "asset_path!",
|
||||
outcome::text AS "outcome!",
|
||||
child_job_id,
|
||||
partition,
|
||||
received_inputs,
|
||||
required_inputs,
|
||||
debounce_s,
|
||||
reason,
|
||||
created_at AS "created_at!"
|
||||
FROM dispatch_event
|
||||
WHERE producer_job_id = $1 AND workspace_id = $2
|
||||
ORDER BY id"#,
|
||||
id,
|
||||
&w_id,
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -8,10 +8,11 @@
|
||||
|
||||
//! Runtime fan-out for asset-triggered scripts.
|
||||
//!
|
||||
//! When a producer pipeline script (`// pipeline`) writes an asset and a
|
||||
//! downstream script subscribes to that asset via `// on s3://...`, this
|
||||
//! module pushes a job for each subscriber after the producer's job
|
||||
//! completes successfully.
|
||||
//! When a script writes an asset and a downstream script subscribes to
|
||||
//! that asset via `// on s3://...`, this module pushes a job for each
|
||||
//! subscriber after the producer's job completes successfully. Any
|
||||
//! asset-writing top-level script cascades — there is no `// pipeline`
|
||||
//! gate on the producer side; subscriptions alone define the graph.
|
||||
//!
|
||||
//! Eligibility (V1, narrow on purpose):
|
||||
//! - Producer kind is `Script` or `Preview`. Flows defer.
|
||||
@@ -49,14 +50,15 @@ use sqlx::types::Json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
use windmill_common::assets::{parse_asset_trigger_ref, AssetKind, PARTITION_TOKEN};
|
||||
use windmill_common::assets::{AssetKind, PARTITION_TOKEN};
|
||||
use windmill_common::error::{self, Result};
|
||||
use windmill_common::get_latest_hash_for_path;
|
||||
use windmill_common::get_latest_deployed_hash_for_path;
|
||||
use windmill_common::jobs::{JobKind, JobPayload, JobTriggerKind};
|
||||
use windmill_common::partition::PARTITION_ARG;
|
||||
use windmill_common::runnable_settings::{ConcurrencySettings, DebouncingSettings};
|
||||
use windmill_common::runnable_settings::DebouncingSettings;
|
||||
use windmill_common::scripts::ScriptHash;
|
||||
use windmill_common::triggers::TriggerMetadata;
|
||||
use windmill_common::users::username_to_permissioned_as;
|
||||
use windmill_common::users::{get_email_from_permissioned_as, username_to_permissioned_as};
|
||||
use windmill_common::worker::to_raw_value;
|
||||
use windmill_common::DB;
|
||||
|
||||
@@ -185,8 +187,15 @@ async fn try_dispatch(db: &DB, job: &MiniCompletedJob) -> Result<DispatchResult>
|
||||
_ => return Ok(DispatchResult::default()),
|
||||
};
|
||||
|
||||
// Args were moved from v2_job to v2_job_completed by add_completed_job
|
||||
// before dispatch runs. Fetch from v2_job_completed.
|
||||
// Cheapest filter first: this hook runs on every top-level script
|
||||
// completion, and the overwhelmingly common case is a script that
|
||||
// writes no asset — one indexed query and out, before touching the
|
||||
// job's args JSONB.
|
||||
let writes = fetch_producer_writes(db, &job.workspace_id, runnable_path).await?;
|
||||
if writes.is_empty() {
|
||||
return Ok(DispatchResult::default());
|
||||
}
|
||||
|
||||
let args = fetch_args(db, &job.workspace_id, job.id).await?;
|
||||
if read_skip_arg(args.as_ref()) {
|
||||
return Ok(DispatchResult::default());
|
||||
@@ -210,11 +219,6 @@ async fn try_dispatch(db: &DB, job: &MiniCompletedJob) -> Result<DispatchResult>
|
||||
return Ok(DispatchResult::default());
|
||||
}
|
||||
|
||||
let writes = fetch_producer_writes(db, &job.workspace_id, runnable_path).await?;
|
||||
if writes.is_empty() {
|
||||
return Ok(DispatchResult::default());
|
||||
}
|
||||
|
||||
let mut dispatched = Vec::new();
|
||||
for (asset_kind, asset_path) in writes {
|
||||
let Some(prefix) = prefix_for(asset_kind) else {
|
||||
@@ -507,14 +511,6 @@ async fn fetch_subscribers(
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
// Sanity check: parsing the stored trigger_ref must succeed. If it
|
||||
// doesn't, the row is corrupt; skip it loudly rather than silently.
|
||||
if parse_asset_trigger_ref(trigger_ref).is_none() {
|
||||
tracing::warn!(
|
||||
"asset-trigger dispatch: trigger_ref {} did not round-trip through parse_asset_trigger_ref",
|
||||
trigger_ref
|
||||
);
|
||||
}
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| Subscriber {
|
||||
@@ -662,31 +658,29 @@ async fn push_subscriber(
|
||||
retry_count: Option<i16>,
|
||||
retry_delay_s: Option<i32>,
|
||||
) -> Result<Uuid> {
|
||||
let (
|
||||
hash,
|
||||
tag,
|
||||
_concurrency_key,
|
||||
_concurrent_limit,
|
||||
_concurrency_time_window_s,
|
||||
_debounce_key,
|
||||
_debounce_delay_s,
|
||||
cache_ttl,
|
||||
cache_ignore_s3_path,
|
||||
language,
|
||||
dedicated_worker,
|
||||
priority,
|
||||
_timeout,
|
||||
on_behalf_of_email,
|
||||
created_by,
|
||||
_runnable_settings_handle,
|
||||
labels,
|
||||
) = get_latest_hash_for_path(db, &producer.workspace_id, subscriber_path, false).await?;
|
||||
// Same resolution as every other trigger path (`script_path_to_payload`):
|
||||
// latest deployed hash plus the script's own runnable settings
|
||||
// (concurrency, debounce, timeout), resolved through the
|
||||
// runnable-settings handle. The cascade must not bypass a subscriber's
|
||||
// concurrency limit just because it was triggered by an asset write.
|
||||
let script = get_latest_deployed_hash_for_path(
|
||||
None,
|
||||
db.clone(),
|
||||
&producer.workspace_id,
|
||||
subscriber_path,
|
||||
)
|
||||
.await?
|
||||
.prefetch_cached(db)
|
||||
.await?;
|
||||
let hash = ScriptHash(script.hash);
|
||||
let tag = script.tag;
|
||||
let concurrency_settings = script.runnable_settings.concurrency_settings;
|
||||
|
||||
// Debounce is opt-in per subscriber edge (`// debounce` /
|
||||
// `// on … debounce=`). Default = none (fan-out — the user's intent
|
||||
// unless they ask otherwise). When set, the window is keyed by
|
||||
// `// on … debounce=`). When set, the window is keyed by
|
||||
// (subscriber, partition) so distinct partitions never collapse and
|
||||
// "latest within the window" falls out for free.
|
||||
// "latest within the window" falls out for free. When not set, the
|
||||
// subscriber's own script-level debounce settings (if any) apply.
|
||||
let debouncing_settings = match debounce_s {
|
||||
Some(s) if s > 0 => DebouncingSettings {
|
||||
debounce_key: Some(format!(
|
||||
@@ -697,7 +691,7 @@ async fn push_subscriber(
|
||||
debounce_delay_s: Some(s),
|
||||
..DebouncingSettings::default()
|
||||
},
|
||||
_ => DebouncingSettings::default(),
|
||||
_ => script.runnable_settings.debouncing_settings,
|
||||
};
|
||||
|
||||
// Retry is only available via the flow runtime — wrap the script in a
|
||||
@@ -725,42 +719,45 @@ async fn push_subscriber(
|
||||
error_handler_path: None,
|
||||
error_handler_args: None,
|
||||
skip_handler: None,
|
||||
cache_ttl,
|
||||
cache_ignore_s3_path,
|
||||
priority,
|
||||
cache_ttl: script.cache_ttl,
|
||||
cache_ignore_s3_path: script.cache_ignore_s3_path,
|
||||
priority: script.priority,
|
||||
tag_override: tag.clone(),
|
||||
trigger_path: None,
|
||||
apply_preprocessor: false,
|
||||
concurrency_settings: ConcurrencySettings::default(),
|
||||
concurrency_settings,
|
||||
debouncing_settings,
|
||||
}
|
||||
} else {
|
||||
JobPayload::ScriptHash {
|
||||
hash,
|
||||
path: subscriber_path.to_string(),
|
||||
cache_ttl,
|
||||
cache_ignore_s3_path,
|
||||
dedicated_worker,
|
||||
language,
|
||||
priority,
|
||||
cache_ttl: script.cache_ttl,
|
||||
cache_ignore_s3_path: script.cache_ignore_s3_path,
|
||||
dedicated_worker: script.dedicated_worker,
|
||||
language: script.language,
|
||||
priority: script.priority,
|
||||
apply_preprocessor: false,
|
||||
debouncing_settings,
|
||||
concurrency_settings: ConcurrencySettings::default(),
|
||||
labels,
|
||||
concurrency_settings,
|
||||
labels: script.labels,
|
||||
}
|
||||
};
|
||||
|
||||
// Subscriber's own on_behalf_of_email controls identity when set;
|
||||
// otherwise we run as the producer. This keeps the asset cascade
|
||||
// attributable to whoever originally wrote the asset, while still
|
||||
// honoring scripts that explicitly opted into a service-account email.
|
||||
let (permissioned_as, email) = if let Some(obo) = on_behalf_of_email {
|
||||
(username_to_permissioned_as(&created_by), obo)
|
||||
} else {
|
||||
(
|
||||
producer.permissioned_as.clone(),
|
||||
producer.permissioned_as_email.clone(),
|
||||
)
|
||||
// Run the subscriber under its deployer's identity — never the
|
||||
// producer's. Subscriptions are workspace-wide, so attributing the run
|
||||
// to the producer would let anyone who can deploy a `// on` script
|
||||
// execute code with the permissions of whoever happens to write the
|
||||
// asset (e.g. an admin's scheduled job). `on_behalf_of_email` (an
|
||||
// explicit service-account opt-in at deploy) takes precedence for the
|
||||
// email; otherwise the deployer's email is resolved from their
|
||||
// username.
|
||||
let permissioned_as = username_to_permissioned_as(&script.created_by);
|
||||
let email = match script.on_behalf_of_email {
|
||||
Some(obo) => obo,
|
||||
None => {
|
||||
get_email_from_permissioned_as(&permissioned_as, &producer.workspace_id, db).await?
|
||||
}
|
||||
};
|
||||
|
||||
let mut args: HashMap<String, Box<RawValue>> = HashMap::new();
|
||||
@@ -808,7 +805,7 @@ async fn push_subscriber(
|
||||
None,
|
||||
true,
|
||||
tag,
|
||||
None,
|
||||
script.timeout,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
|
||||
@@ -1754,33 +1754,14 @@
|
||||
class="flex flex-row divide-x divide-gray-800 dark:divide-gray-300 items-stretch"
|
||||
>
|
||||
{#if testIsLoading}
|
||||
<Button
|
||||
on:click={jobLoader?.cancelJob}
|
||||
btnClasses="w-full"
|
||||
unifiedSize="md"
|
||||
>
|
||||
<WindmillIcon
|
||||
white={true}
|
||||
class="mr-2 text-white"
|
||||
height="16px"
|
||||
width="20px"
|
||||
spin="fast"
|
||||
/>
|
||||
Cancel
|
||||
</Button>
|
||||
{@render cancelTestButton('md', 'w-full')}
|
||||
{:else}
|
||||
{@const disableTriggerButton =
|
||||
customUi?.previewPanel?.disableTriggerButton === true}
|
||||
<Button
|
||||
on:click={() => runTest()}
|
||||
unifiedSize="md"
|
||||
btnClasses="w-full {!disableTriggerButton ? 'rounded-r-none' : ''}"
|
||||
variant="accent-secondary"
|
||||
startIcon={{ icon: Play, classes: 'animate-none' }}
|
||||
shortCut={{ Icon: CornerDownLeft }}
|
||||
>
|
||||
Test
|
||||
</Button>
|
||||
{@render runTestButton(
|
||||
'md',
|
||||
`w-full ${!disableTriggerButton ? 'rounded-r-none' : ''}`
|
||||
)}
|
||||
{#if !disableTriggerButton}
|
||||
<CaptureButton on:openTriggers />
|
||||
{/if}
|
||||
@@ -1853,16 +1834,7 @@
|
||||
{/if}
|
||||
<div class="absolute top-1 left-2 z-10">
|
||||
{#if testIsLoading}
|
||||
<Button on:click={jobLoader?.cancelJob} unifiedSize="sm" btnClasses="shadow-md">
|
||||
<WindmillIcon
|
||||
white={true}
|
||||
class="mr-2 text-white"
|
||||
height="14px"
|
||||
width="16px"
|
||||
spin="fast"
|
||||
/>
|
||||
Cancel
|
||||
</Button>
|
||||
{@render cancelTestButton('sm', 'shadow-md')}
|
||||
{:else if (customUi?.previewPanel?.downstreamSubscribers ?? 0) > 0}
|
||||
<!-- Split button: primary "Test" runs just this step
|
||||
(skips the asset-trigger cascade); the caret
|
||||
@@ -1964,16 +1936,7 @@
|
||||
</Popover>
|
||||
</div>
|
||||
{:else}
|
||||
<Button
|
||||
on:click={() => runTest()}
|
||||
unifiedSize="sm"
|
||||
btnClasses="shadow-md"
|
||||
variant="accent-secondary"
|
||||
startIcon={{ icon: Play, classes: 'animate-none' }}
|
||||
shortCut={{ Icon: CornerDownLeft }}
|
||||
>
|
||||
Test
|
||||
</Button>
|
||||
{@render runTestButton('sm', 'shadow-md')}
|
||||
{/if}
|
||||
</div>
|
||||
{#if customUi?.previewPanel?.argsAboveLogs && schema?.properties && Object.keys(schema.properties).length > 0}
|
||||
@@ -1999,44 +1962,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grow min-h-0">
|
||||
<LogPanel
|
||||
bind:this={logPanel}
|
||||
{lang}
|
||||
previewJob={debugMode
|
||||
? ({
|
||||
id: 'debug',
|
||||
logs: $debugState.logs,
|
||||
result: $debugState.result,
|
||||
success: !$debugState.error,
|
||||
type: hasDebugResult ? 'CompletedJob' : 'QueuedJob'
|
||||
} as any)
|
||||
: testJob}
|
||||
{pastPreviews}
|
||||
onTabChange={(tab) => {
|
||||
historyTabActive = tab === 'history'
|
||||
if (historyTabActive) {
|
||||
loadPastTests()
|
||||
}
|
||||
}}
|
||||
previewIsLoading={debugMode
|
||||
? $debugState.running && !$debugState.stopped
|
||||
: testIsLoading}
|
||||
{editor}
|
||||
{diffEditor}
|
||||
args={activeModuleTab !== null ? testPanelArgs : args}
|
||||
{showCaptures}
|
||||
customUi={customUi?.previewPanel}
|
||||
showCustomResultPanel={showDebugPanel}
|
||||
>
|
||||
{#if scriptProgress && !debugMode}
|
||||
<JobProgressBar
|
||||
job={testJob}
|
||||
{scriptProgress}
|
||||
bind:this={jobProgressBar}
|
||||
compact={true}
|
||||
/>
|
||||
{/if}
|
||||
</LogPanel>
|
||||
{@render testLogPanel()}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -2053,31 +1979,9 @@
|
||||
{#if previewLayout === 'bottom' && !(debugMode && isDebuggableScript)}
|
||||
<div class="px-3 pt-2 pb-1 flex items-center gap-2">
|
||||
{#if testIsLoading}
|
||||
<Button
|
||||
on:click={jobLoader?.cancelJob}
|
||||
unifiedSize="sm"
|
||||
btnClasses="w-full"
|
||||
>
|
||||
<WindmillIcon
|
||||
white={true}
|
||||
class="mr-2 text-white"
|
||||
height="14px"
|
||||
width="16px"
|
||||
spin="fast"
|
||||
/>
|
||||
Cancel
|
||||
</Button>
|
||||
{@render cancelTestButton('sm', 'w-full')}
|
||||
{:else}
|
||||
<Button
|
||||
on:click={() => runTest()}
|
||||
unifiedSize="sm"
|
||||
btnClasses="w-full"
|
||||
variant="accent-secondary"
|
||||
startIcon={{ icon: Play, classes: 'animate-none' }}
|
||||
shortCut={{ Icon: CornerDownLeft }}
|
||||
>
|
||||
Test
|
||||
</Button>
|
||||
{@render runTestButton('sm', 'w-full')}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -2152,63 +2056,7 @@
|
||||
{/if}
|
||||
</Pane>
|
||||
<Pane size={previewLayout === 'bottom' ? 60 : 67} class="relative">
|
||||
<LogPanel
|
||||
bind:this={logPanel}
|
||||
{lang}
|
||||
previewJob={debugMode
|
||||
? ({
|
||||
id: 'debug',
|
||||
logs: $debugState.logs,
|
||||
result: $debugState.result,
|
||||
success: !$debugState.error,
|
||||
type: hasDebugResult ? 'CompletedJob' : 'QueuedJob'
|
||||
} as any)
|
||||
: testJob}
|
||||
{pastPreviews}
|
||||
previewIsLoading={debugMode
|
||||
? $debugState.running && !$debugState.stopped
|
||||
: testIsLoading}
|
||||
{editor}
|
||||
{diffEditor}
|
||||
args={activeModuleTab !== null ? testPanelArgs : args}
|
||||
{showCaptures}
|
||||
customUi={customUi?.previewPanel}
|
||||
showCustomResultPanel={showDebugPanel}
|
||||
>
|
||||
{#if scriptProgress && !debugMode}
|
||||
<!-- Put to the slot in logpanel -->
|
||||
<JobProgressBar
|
||||
job={testJob}
|
||||
{scriptProgress}
|
||||
bind:this={jobProgressBar}
|
||||
compact={true}
|
||||
/>
|
||||
{/if}
|
||||
{#snippet capturesTab()}
|
||||
<div class="h-full p-2">
|
||||
<CaptureTable
|
||||
bind:this={captureTable}
|
||||
{hasPreprocessor}
|
||||
canHavePreprocessor={canHavePreprocessor(lang)}
|
||||
isFlow={false}
|
||||
path={stablePathForCaptures}
|
||||
canEdit={true}
|
||||
on:applyArgs
|
||||
on:updateSchema
|
||||
on:addPreprocessor
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet customResultPanel()}
|
||||
<DebugPanel
|
||||
stackFrames={$debugState.stackFrames}
|
||||
scopes={$debugState.scopes}
|
||||
variables={$debugState.variables}
|
||||
client={dapClient}
|
||||
bind:selectedFrameId={selectedDebugFrameId}
|
||||
/>
|
||||
{/snippet}
|
||||
</LogPanel>
|
||||
{@render testLogPanel()}
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
{/key}
|
||||
@@ -2220,6 +2068,95 @@
|
||||
</div>
|
||||
</SplitPanesWrapper>
|
||||
|
||||
{#snippet cancelTestButton(size: 'sm' | 'md', btnClasses: string)}
|
||||
<Button on:click={jobLoader?.cancelJob} unifiedSize={size} {btnClasses}>
|
||||
<WindmillIcon
|
||||
white={true}
|
||||
class="mr-2 text-white"
|
||||
height={size === 'md' ? '16px' : '14px'}
|
||||
width={size === 'md' ? '20px' : '16px'}
|
||||
spin="fast"
|
||||
/>
|
||||
Cancel
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet runTestButton(size: 'sm' | 'md', btnClasses: string)}
|
||||
<Button
|
||||
on:click={() => runTest()}
|
||||
unifiedSize={size}
|
||||
{btnClasses}
|
||||
variant="accent-secondary"
|
||||
startIcon={{ icon: Play, classes: 'animate-none' }}
|
||||
shortCut={{ Icon: CornerDownLeft }}
|
||||
>
|
||||
Test
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
<!-- Single source of truth for the preview LogPanel — rendered by both the
|
||||
compact (hideArgs) layout and the splitpane layouts. One invocation
|
||||
prevents prop drift between copies (the history tab's lazy-load via
|
||||
onTabChange was lost in one copy when they diverged). -->
|
||||
{#snippet testLogPanel()}
|
||||
<LogPanel
|
||||
bind:this={logPanel}
|
||||
{lang}
|
||||
previewJob={debugMode
|
||||
? ({
|
||||
id: 'debug',
|
||||
logs: $debugState.logs,
|
||||
result: $debugState.result,
|
||||
success: !$debugState.error,
|
||||
type: hasDebugResult ? 'CompletedJob' : 'QueuedJob'
|
||||
} as any)
|
||||
: testJob}
|
||||
{pastPreviews}
|
||||
onTabChange={(tab) => {
|
||||
historyTabActive = tab === 'history'
|
||||
if (historyTabActive) {
|
||||
loadPastTests()
|
||||
}
|
||||
}}
|
||||
previewIsLoading={debugMode ? $debugState.running && !$debugState.stopped : testIsLoading}
|
||||
{editor}
|
||||
{diffEditor}
|
||||
args={activeModuleTab !== null ? testPanelArgs : args}
|
||||
{showCaptures}
|
||||
customUi={customUi?.previewPanel}
|
||||
showCustomResultPanel={showDebugPanel}
|
||||
>
|
||||
{#if scriptProgress && !debugMode}
|
||||
<!-- Put to the slot in logpanel -->
|
||||
<JobProgressBar job={testJob} {scriptProgress} bind:this={jobProgressBar} compact={true} />
|
||||
{/if}
|
||||
{#snippet capturesTab()}
|
||||
<div class="h-full p-2">
|
||||
<CaptureTable
|
||||
bind:this={captureTable}
|
||||
{hasPreprocessor}
|
||||
canHavePreprocessor={canHavePreprocessor(lang)}
|
||||
isFlow={false}
|
||||
path={stablePathForCaptures}
|
||||
canEdit={true}
|
||||
on:applyArgs
|
||||
on:updateSchema
|
||||
on:addPreprocessor
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet customResultPanel()}
|
||||
<DebugPanel
|
||||
stackFrames={$debugState.stackFrames}
|
||||
scopes={$debugState.scopes}
|
||||
variables={$debugState.variables}
|
||||
client={dapClient}
|
||||
bind:selectedFrameId={selectedDebugFrameId}
|
||||
/>
|
||||
{/snippet}
|
||||
</LogPanel>
|
||||
{/snippet}
|
||||
|
||||
{#snippet addModuleForm(close: () => void)}
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="module-name-input" class="text-xs font-semibold text-emphasis">File name</label>
|
||||
|
||||
@@ -5,15 +5,10 @@
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import FolderPicker from '$lib/components/FolderPicker.svelte'
|
||||
import Modal from '$lib/components/common/modal/Modal.svelte'
|
||||
import { OpenAPI } from '$lib/gen'
|
||||
import { AssetService, type ListPipelineFoldersResponse } from '$lib/gen'
|
||||
import { resource } from 'runed'
|
||||
import { ArrowRight, Loader2 } from 'lucide-svelte'
|
||||
|
||||
interface PipelineFolder {
|
||||
folder: string
|
||||
script_count: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
// When provided, the current folder is dropped from the existing list
|
||||
@@ -24,15 +19,9 @@
|
||||
|
||||
let pipelines = resource(
|
||||
() => $workspaceStore,
|
||||
async (ws, _prev, { signal }) => {
|
||||
if (!ws) return [] as PipelineFolder[]
|
||||
const base_url = OpenAPI.BASE ?? ''
|
||||
const res = await fetch(`${base_url}/w/${ws}/assets/pipelines`, {
|
||||
credentials: 'include',
|
||||
signal
|
||||
})
|
||||
if (!res.ok) throw new Error(`GET /assets/pipelines → ${res.status}`)
|
||||
return (await res.json()) as PipelineFolder[]
|
||||
async (ws) => {
|
||||
if (!ws) return [] as ListPipelineFoldersResponse
|
||||
return await AssetService.listPipelineFolders({ workspace: ws })
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import {
|
||||
AppService,
|
||||
AssetService,
|
||||
FlowService,
|
||||
type ListableApp,
|
||||
type Script,
|
||||
ScriptService,
|
||||
type Flow,
|
||||
type ListableRawApp,
|
||||
OpenAPI
|
||||
type ListableRawApp
|
||||
} from '$lib/gen'
|
||||
import { resource } from 'runed'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
@@ -79,16 +79,15 @@
|
||||
// thanks to the partial index on script.auto_kind.
|
||||
let pipelineFoldersRes = resource(
|
||||
() => $workspaceStore,
|
||||
async (ws, _prev, { signal }) => {
|
||||
async (ws) => {
|
||||
if (!ws) return new Set<string>()
|
||||
const base_url = OpenAPI.BASE ?? ''
|
||||
const res = await fetch(`${base_url}/w/${ws}/assets/pipelines`, {
|
||||
credentials: 'include',
|
||||
signal
|
||||
})
|
||||
if (!res.ok) return new Set<string>()
|
||||
const rows = (await res.json()) as Array<{ folder: string }>
|
||||
return new Set(rows.map((r) => r.folder))
|
||||
try {
|
||||
const rows = await AssetService.listPipelineFolders({ workspace: ws })
|
||||
return new Set(rows.map((r) => r.folder))
|
||||
} catch {
|
||||
// Decorative tree entry — degrade to "no pipelines" on failure.
|
||||
return new Set<string>()
|
||||
}
|
||||
}
|
||||
)
|
||||
let pipelineFolders = $derived(pipelineFoldersRes.current ?? new Set<string>())
|
||||
|
||||
Reference in New Issue
Block a user