mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-25 00:01:55 +00:00
68debab877
* feat(triggers): add AMQP (RabbitMQ) trigger using the lapin library Fixes WIN-2214 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(triggers): defer AMQP cross-workspace deploy pending utils-internal publish Revert the amqp_trigger additions to the shared windmill-utils-internal TriggerDeployKind and the frontend cross-workspace deploy adapter: the frontend installs the published npm package, which lacks the new kind until a release is cut. AMQP create/edit/delete/list/sync/capture are unaffected (they use local types); only cross-workspace deploy/merge of AMQP triggers waits on the package bump. Also document the at-most-once ack in the consumer loop. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(triggers): address AMQP review — at-least-once ack, workspace cascade, contracts - ack AMQP deliveries only after successful dispatch; nack+requeue on failure - add ON DELETE CASCADE workspace FK so amqp_trigger rows are cleaned on workspace deletion (and the listener stops) - fix the /amqp_triggers/test OpenAPI body and add amqp_trigger to WorkspaceDiffRow.kind - register AMQP in the generated workspace trigger tool (create_trigger) - drop banned $bindable defaults on optional props in the config section - add build_uri unit tests (encoding, ports, vhost) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(triggers): stop AMQP poison-message loop and reconnect on transient drops Chaos testing against a live RabbitMQ broker showed the previous nack(requeue) + immediate re-poll spun a tight redelivery loop (~1000 critical-error reports/sec) on a poison message, and any connection blip permanently disabled the trigger (lapin has no built-in reconnect). - on dispatch failure: nack+requeue then stop consuming; the listener framework re-lists the trigger after its ping goes stale (~15s), backing redelivery off to that cadence instead of a tight loop (verified: rate dropped from ~1000/s to ~1 per ~26s, message preserved) - on connection/stream error: stop and let the framework reconnect instead of disabling; persistent failures are still disabled via get_consumer (verified: a forced connection close now auto-reconnects and resumes) - finish the AI create-trigger action wiring for AMQP: add amqp to CreatedResourceTriggerKind, the action-card registry, and the drawer registry so the result card renders and its "Open" action works Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(triggers): complete AMQP frontend registries and defer merge rows - add amqp to capturableTriggerTypes (so AmqpCapture mounts), the Runs jobTriggerKinds filter, and CLOUD_DISABLED_TRIGGER_TYPES - wire AMQP into global AI chat mode: TRIGGER_KINDS, the request union, writeTriggerSchema, triggerServices, and the draft adapter - stop emitting actionable AMQP fork-comparison rows (revert amqp_trigger from TRIGGER_OR_SCHEDULE_TABLES) since cross-workspace deploy is deferred until windmill-utils-internal is published — avoids a deploy that fails with "Unknown kind: amqp_trigger" - use design-system TextInput instead of raw <input> in the config section Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(triggers): complete AMQP session/draft registries and constrain prefetch - add amqp to the session-deploy, draft-compare, preview-router, and copilot workspace-item registries so AMQP drafts/deploys/nav/path resolution work - include amqp_count in the MoveDrawer attached-trigger rename warning - replace the raw prefetch <input> with a design-system TextInput bounded to an integer 1-65535 (backend u16) and block save on invalid values Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(triggers): make AMQP disconnect/reconnect consistent with the Kafka trigger lapin, like rdkafka, has no transparent reconnect, so the AMQP listener now mirrors the Kafka trigger's explicit reconnect loop instead of relying on the framework re-list (which disabled the trigger once get_consumer failed on a sustained outage): - get_consumer returns cheaply; consume owns a (re)connect loop that retries with a 30s backoff, reports a critical error every 10 failed attempts, and reports a recovered critical error once it reconnects — never disabling the trigger on a connectivity failure - a consumer/stream error breaks out to reconnect rather than disabling - dispatch failure still nacks+requeues (at-least-once) with a short backoff to avoid a tight poison-message loop, keeping the connection alive Verified against a live RabbitMQ broker: killing the broker keeps the trigger enabled and retrying (attempt N), and restarting it auto-reconnects (logs "reconnected after N attempts") and resumes dispatch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(triggers): complete AMQP capture registries and constrain prefetch contract - add the 'amqp' case to triggerKindToTriggerType so opening the AMQP editor from a capture button no longer throws "Unknown TriggerKind: amqp" - register AmqpIcon in CaptureTable's icon map and add an AMQP entry to the script/flow CaptureButton menu - bound the OpenAPI prefetch_count to an integer 1-65535 (matches the Rust u16) and regenerate clients/prompts - require a non-empty exchange name when the exchange binding is enabled - build_uri: fall back to "/" on a blank vhost and bracket IPv6 hosts (+ tests) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(triggers): wire AMQP into pipeline graph, git-sync, and preprocessor types - asset_graph: discover attached amqp_trigger rows and emit an AMQP TriggerEdge so AMQP triggers render (and can be opened/deleted) on the data-pipeline canvas - frontend pipeline graph: add amqp to NativeTriggerKind, the add-trigger menu, node presentation, event-trigger set, annotation keywords, and the editor/service registrations - git-sync: add the amqp_trigger include pattern (+ test) so an AMQP git-sync deployment stages only its .amqp_trigger.* file, not an unrelated same-path object - preprocessor starters: add the AMQP event to the generated TS/Python/PHP trigger event types (kind/payload/exchange/routing_key/queue_name/redelivered/ delivery_tag) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(triggers): finish AMQP pipeline/parser wiring, prefetch validation, source lists - fix a stray edit that corrupted the pre-existing MqttTriggerEditor import ($lib/... path) in PipelineTriggerEditors.svelte - reject prefetch_count = 0 server-side in validate_config (RabbitMQ treats 0 as unlimited) and defensively skip basic_qos(0) in build_consumer (covers the capture path that bypasses CRUD validation) - recognize `// on amqp` in the canonical parser (TriggerSpec::Amqp) and add amqp to the CLI non-autorun/event-trigger sets so a pipeline cascade never runs an AMQP-only node as a manual root without an event - add amqp to the preprocessor intro lists and both pipeline AI instructions Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(triggers): reject zero AMQP prefetch in all paths and finish guidance lists - extract a shared validate_amqp_options used by both CRUD validate_config and build_consumer, so capture configs (which bypass CRUD validation) also reject prefetch 0 instead of silently connecting with an unlimited buffer (+ unit tests for 0/1/65535/None) - add AMQP to the main script-writing preprocessor-sources prompt and the CLI triggers-skill guidance list Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(triggers): de-duplicate AMQP prefetch comment and fix GET response text - keep the zero-prefetch rationale only on the shared validate_amqp_options doc; drop the redundant call-site comments - correct the getAmqpTrigger OpenAPI 200 description ("deleted" -> "retrieved") Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref to aaa6cb89b05b76139252c64f057e53b94d12ac60 This commit updates the EE repository reference after PR #680 was merged in windmill-ee-private. Previous ee-repo-ref: 5da5fd65aca9594b2611837a52e4677b544b0380 New ee-repo-ref: aaa6cb89b05b76139252c64f057e53b94d12ac60 Automated by sync-ee-ref workflow. * chore(migrations): consolidate the four AMQP migrations into one The table and the three enum ADD VALUE statements (trigger_kind, job_trigger_kind, draft_kind) are one atomic feature. ALTER TYPE ... ADD VALUE runs inside the migration transaction on PG >= 14 (Windmill's minimum) since the amqp_trigger table doesn't reference those enum types, so they can share a single migration instead of four. Verified applying cleanly in a single transaction on a fresh DB. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
990 lines
30 KiB
Rust
990 lines
30 KiB
Rust
use anyhow::Context;
|
|
use axum::response::IntoResponse;
|
|
use http::StatusCode;
|
|
use serde::Deserialize;
|
|
use serde_json::value::RawValue;
|
|
use sqlx::types::Json;
|
|
use std::collections::HashMap;
|
|
use std::future::Future;
|
|
use uuid::Uuid;
|
|
use windmill_common::{
|
|
db::{UserDB, UserDbWithAuthed, DB},
|
|
error::Result,
|
|
flows::{FlowModuleValue, Retry},
|
|
get_latest_deployed_hash_for_path, get_latest_flow_version_info_for_path,
|
|
jobs::{get_has_preprocessor_from_content_and_lang, script_path_to_payload, JobPayload},
|
|
scripts::{get_full_hub_script_by_path, ScriptHash, ScriptLang},
|
|
triggers::{
|
|
HubOrWorkspaceId, RunnableFormat, RunnableFormatVersion, TriggerKind, TriggerMetadata,
|
|
RUNNABLE_FORMAT_VERSION_CACHE,
|
|
},
|
|
users::username_to_permissioned_as,
|
|
utils::{StripPath, HTTP_CLIENT},
|
|
worker::to_raw_value,
|
|
};
|
|
use windmill_queue::{push, PushArgs, PushArgsOwned, PushIsolationLevel};
|
|
|
|
use windmill_api_auth::{check_scopes, ApiAuthed};
|
|
#[cfg(feature = "enterprise")]
|
|
use windmill_api_jobs::execution::check_license_key_valid;
|
|
use windmill_api_jobs::{
|
|
execution::{
|
|
check_tag_available_for_workspace, delete_job_metadata_after_use,
|
|
push_flow_job_by_path_into_queue, push_script_job_by_path_into_queue, result_to_response,
|
|
run_wait_result_internal, schedule_job_deletion,
|
|
},
|
|
types::RunJobQuery,
|
|
};
|
|
|
|
struct ScriptInfo {
|
|
has_preprocessor: Option<bool>,
|
|
language: ScriptLang,
|
|
content: String,
|
|
schema: Option<sqlx::types::Json<PartialSchema>>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct PropertyDefinition {
|
|
r#type: Option<Box<RawValue>>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct PartialSchema {
|
|
properties: Option<HashMap<String, PropertyDefinition>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
|
|
pub enum RunnableId {
|
|
FlowId(FlowId),
|
|
ScriptId(ScriptId),
|
|
HubScript(String),
|
|
}
|
|
|
|
impl RunnableId {
|
|
pub fn from_script_hash(hash: ScriptHash) -> Self {
|
|
Self::ScriptId(ScriptId::ScriptHash(hash))
|
|
}
|
|
|
|
pub fn from_script_path(path: &str) -> Self {
|
|
if path.starts_with("hub/") {
|
|
Self::HubScript(path.to_string())
|
|
} else {
|
|
Self::ScriptId(ScriptId::ScriptPath(path.to_string()))
|
|
}
|
|
}
|
|
|
|
pub fn from_flow_path(path: &str) -> Self {
|
|
Self::FlowId(FlowId::FlowPath(path.to_string()))
|
|
}
|
|
|
|
pub fn from_flow_version(version: i64) -> Self {
|
|
Self::FlowId(FlowId::FlowVersion(version))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
|
|
pub enum FlowId {
|
|
FlowPath(String),
|
|
FlowVersion(i64),
|
|
}
|
|
|
|
impl FlowId {
|
|
async fn get_flow_version_id(self, workspace_id: &str, db: &DB) -> Result<i64> {
|
|
let version_id = match self {
|
|
FlowId::FlowPath(path) => {
|
|
let info =
|
|
get_latest_flow_version_info_for_path(None, db, workspace_id, &path, true)
|
|
.await?;
|
|
info.version
|
|
}
|
|
FlowId::FlowVersion(version) => version,
|
|
};
|
|
|
|
Ok(version_id)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
|
|
pub enum ScriptId {
|
|
ScriptPath(String),
|
|
ScriptHash(ScriptHash),
|
|
}
|
|
|
|
impl ScriptId {
|
|
async fn get_script_hash(self, workspace_id: &str, db: &DB) -> Result<i64> {
|
|
let hash = match self {
|
|
ScriptId::ScriptPath(path) => {
|
|
let info = get_latest_deployed_hash_for_path(None, db.clone(), workspace_id, &path)
|
|
.await?;
|
|
info.hash
|
|
}
|
|
ScriptId::ScriptHash(hash) => hash.0,
|
|
};
|
|
|
|
Ok(hash)
|
|
}
|
|
}
|
|
|
|
async fn get_script_info(
|
|
db: &DB,
|
|
workspace_id: &str,
|
|
hash: i64,
|
|
) -> std::result::Result<ScriptInfo, sqlx::Error> {
|
|
sqlx::query_as!(ScriptInfo, "SELECT has_preprocessor, language as \"language: _\", content, schema as \"schema: _\" FROM script WHERE workspace_id = $1 AND hash = $2", workspace_id, hash)
|
|
.fetch_one(db)
|
|
.await
|
|
}
|
|
|
|
fn runnable_format_from_schema_without_preprocessor(
|
|
trigger_kind: &TriggerKind,
|
|
schema: Option<sqlx::types::Json<PartialSchema>>,
|
|
) -> RunnableFormat {
|
|
match trigger_kind {
|
|
TriggerKind::Mqtt | TriggerKind::Amqp
|
|
if schema.as_ref().is_some_and(|schema| {
|
|
schema.properties.as_ref().is_some_and(|properties| {
|
|
properties.iter().any(|(key, def)| {
|
|
key == "payload"
|
|
&& def.r#type.as_ref().is_some_and(|t| {
|
|
let typ = t.get().trim();
|
|
typ == "array" || (typ.starts_with('[') && typ.ends_with(']'))
|
|
})
|
|
})
|
|
})
|
|
}) =>
|
|
{
|
|
RunnableFormat { version: RunnableFormatVersion::V1, has_preprocessor: false }
|
|
}
|
|
TriggerKind::Kafka | TriggerKind::Nats
|
|
if schema.as_ref().is_some_and(|schema| {
|
|
schema
|
|
.properties
|
|
.as_ref()
|
|
.is_some_and(|properties| properties.keys().any(|key| key == "msg"))
|
|
}) =>
|
|
{
|
|
RunnableFormat { version: RunnableFormatVersion::V1, has_preprocessor: false }
|
|
}
|
|
_ => RunnableFormat { version: RunnableFormatVersion::V2, has_preprocessor: false },
|
|
}
|
|
}
|
|
|
|
fn runnable_format_from_preprocessor_args(
|
|
args: Option<Vec<windmill_parser::Arg>>,
|
|
) -> RunnableFormat {
|
|
if let Some(args) = args {
|
|
if args.iter().any(|arg| arg.name == "wm_trigger")
|
|
|| (args.len() > 0 && args.iter().all(|arg| arg.name != "event"))
|
|
{
|
|
RunnableFormat { version: RunnableFormatVersion::V1, has_preprocessor: true }
|
|
} else {
|
|
RunnableFormat { version: RunnableFormatVersion::V2, has_preprocessor: true }
|
|
}
|
|
} else {
|
|
RunnableFormat { version: RunnableFormatVersion::V2, has_preprocessor: true }
|
|
}
|
|
}
|
|
|
|
enum PreprocessorInfo {
|
|
Preprocessor { content: String, language: ScriptLang },
|
|
NoPreprocessor { schema: Option<sqlx::types::Json<PartialSchema>> },
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct FlowInfo {
|
|
preprocessor_module: Option<sqlx::types::Json<FlowModuleValue>>,
|
|
schema: Option<sqlx::types::Json<PartialSchema>>,
|
|
}
|
|
|
|
fn get_preprocessor_args_from_content_and_language(
|
|
content: &str,
|
|
language: &ScriptLang,
|
|
) -> Result<Option<Vec<windmill_parser::Arg>>> {
|
|
let args = match language {
|
|
ScriptLang::Bun | ScriptLang::Bunnative | ScriptLang::Deno | ScriptLang::Nativets => {
|
|
let args = windmill_parser_ts::parse_deno_signature(
|
|
&content,
|
|
true,
|
|
false,
|
|
Some("preprocessor".to_string()),
|
|
)?;
|
|
Some(args.args)
|
|
}
|
|
#[cfg(feature = "python")]
|
|
ScriptLang::Python3 => {
|
|
let args = windmill_parser_py::parse_python_signature(
|
|
&content,
|
|
Some("preprocessor".to_string()),
|
|
false,
|
|
)?;
|
|
Some(args.args)
|
|
}
|
|
_ => None,
|
|
};
|
|
Ok(args)
|
|
}
|
|
|
|
pub async fn get_runnable_format(
|
|
runnable_id: RunnableId,
|
|
workspace_id: &str,
|
|
db: &DB,
|
|
trigger_kind: &TriggerKind,
|
|
) -> Result<RunnableFormat> {
|
|
let (key, preprocessor_info) = match runnable_id {
|
|
RunnableId::HubScript(path) => {
|
|
let Some(version) = path.split("/").nth(1) else {
|
|
return Err(windmill_common::error::Error::internal_err(
|
|
"Invalid hub script path".to_string(),
|
|
));
|
|
};
|
|
|
|
let version = match version.parse::<i64>() {
|
|
Ok(version) => version,
|
|
Err(_) => {
|
|
return Err(windmill_common::error::Error::internal_err(
|
|
"Invalid hub script version".to_string(),
|
|
));
|
|
}
|
|
};
|
|
|
|
let key = (HubOrWorkspaceId::Hub, version, trigger_kind.clone());
|
|
|
|
let runnable_format = RUNNABLE_FORMAT_VERSION_CACHE.get(&key);
|
|
|
|
if let Some(runnable_format) = runnable_format {
|
|
tracing::debug!("Using cached runnable format for hub script {path}");
|
|
return Ok(runnable_format);
|
|
}
|
|
|
|
let hub_script =
|
|
get_full_hub_script_by_path(StripPath(path.to_string()), &HTTP_CLIENT, Some(db))
|
|
.await?;
|
|
|
|
let has_preprocessor = get_has_preprocessor_from_content_and_lang(
|
|
&hub_script.content,
|
|
&hub_script.language,
|
|
)?;
|
|
|
|
let partial_schema = serde_json::from_str(hub_script.schema.get())?;
|
|
|
|
(
|
|
key,
|
|
if has_preprocessor {
|
|
PreprocessorInfo::Preprocessor {
|
|
content: hub_script.content,
|
|
language: hub_script.language,
|
|
}
|
|
} else {
|
|
PreprocessorInfo::NoPreprocessor {
|
|
schema: Some(sqlx::types::Json(partial_schema)),
|
|
}
|
|
},
|
|
)
|
|
}
|
|
RunnableId::FlowId(flow_id) => {
|
|
let version = flow_id.get_flow_version_id(workspace_id, db).await?;
|
|
|
|
let key = (
|
|
HubOrWorkspaceId::WorkspaceId(workspace_id.to_string()),
|
|
version,
|
|
trigger_kind.clone(),
|
|
);
|
|
|
|
let runnable_format = RUNNABLE_FORMAT_VERSION_CACHE.get(&key);
|
|
|
|
if let Some(runnable_format) = runnable_format {
|
|
tracing::debug!("Using cached runnable format for flow version {version}");
|
|
return Ok(runnable_format);
|
|
}
|
|
|
|
let flow_info = sqlx::query_as!(
|
|
FlowInfo,
|
|
"SELECT
|
|
value->'preprocessor_module'->'value' as \"preprocessor_module: _\",
|
|
schema as \"schema: _\"
|
|
FROM flow_version
|
|
WHERE
|
|
id = $1
|
|
AND workspace_id = $2",
|
|
version,
|
|
workspace_id,
|
|
)
|
|
.fetch_one(db)
|
|
.await?;
|
|
|
|
if let Some(preprocessor_module) = flow_info.preprocessor_module {
|
|
match preprocessor_module.0 {
|
|
FlowModuleValue::RawScript { content, language, .. } => {
|
|
(key, PreprocessorInfo::Preprocessor { content, language })
|
|
}
|
|
FlowModuleValue::Script { path, hash, .. } => {
|
|
let hash = if let Some(hash) = hash {
|
|
hash.0
|
|
} else {
|
|
let script_hash = get_latest_deployed_hash_for_path(
|
|
None,
|
|
db.clone(),
|
|
workspace_id,
|
|
&path,
|
|
)
|
|
.await?;
|
|
script_hash.hash
|
|
};
|
|
let script_info = get_script_info(db, workspace_id, hash).await?;
|
|
(
|
|
key,
|
|
PreprocessorInfo::Preprocessor {
|
|
content: script_info.content,
|
|
language: script_info.language,
|
|
},
|
|
)
|
|
}
|
|
_ => {
|
|
return Err(windmill_common::error::Error::internal_err(
|
|
"Unsupported preprocessor module".to_string(),
|
|
));
|
|
}
|
|
}
|
|
} else {
|
|
(
|
|
key,
|
|
PreprocessorInfo::NoPreprocessor { schema: flow_info.schema },
|
|
)
|
|
}
|
|
}
|
|
RunnableId::ScriptId(script_id) => {
|
|
let hash = script_id.get_script_hash(workspace_id, db).await?;
|
|
let key = (
|
|
HubOrWorkspaceId::WorkspaceId(workspace_id.to_string()),
|
|
hash,
|
|
trigger_kind.clone(),
|
|
);
|
|
let runnable_format = RUNNABLE_FORMAT_VERSION_CACHE.get(&key);
|
|
|
|
if let Some(runnable_format) = runnable_format {
|
|
tracing::debug!("Using cached runnable format for script {hash}");
|
|
return Ok(runnable_format);
|
|
}
|
|
|
|
let script_info = get_script_info(db, workspace_id, hash).await?;
|
|
|
|
if script_info.has_preprocessor.unwrap_or(false) {
|
|
(
|
|
key,
|
|
PreprocessorInfo::Preprocessor {
|
|
content: script_info.content,
|
|
language: script_info.language,
|
|
},
|
|
)
|
|
} else {
|
|
(
|
|
key,
|
|
PreprocessorInfo::NoPreprocessor { schema: script_info.schema },
|
|
)
|
|
}
|
|
}
|
|
};
|
|
|
|
let runnable_format = match preprocessor_info {
|
|
PreprocessorInfo::Preprocessor { content, language } => {
|
|
let args = get_preprocessor_args_from_content_and_language(&content, &language)?;
|
|
runnable_format_from_preprocessor_args(args)
|
|
}
|
|
PreprocessorInfo::NoPreprocessor { schema } => {
|
|
runnable_format_from_schema_without_preprocessor(trigger_kind, schema)
|
|
}
|
|
};
|
|
|
|
RUNNABLE_FORMAT_VERSION_CACHE.insert(key, runnable_format);
|
|
|
|
Ok(runnable_format)
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
|
|
pub trait TriggerJobArgs {
|
|
type Payload: Send + Sync;
|
|
const TRIGGER_KIND: TriggerKind;
|
|
|
|
fn v1_payload_fn(payload: &Self::Payload) -> HashMap<String, Box<RawValue>>;
|
|
fn v2_payload_fn(payload: &Self::Payload) -> HashMap<String, Box<RawValue>> {
|
|
Self::v1_payload_fn(payload)
|
|
}
|
|
|
|
fn build_job_args_v2(
|
|
has_preprocessor: bool,
|
|
payload: &Self::Payload,
|
|
info: HashMap<String, Box<RawValue>>,
|
|
) -> PushArgsOwned {
|
|
let mut args = Self::v2_payload_fn(payload);
|
|
if has_preprocessor {
|
|
args.insert(
|
|
"kind".to_string(),
|
|
to_raw_value(&Self::TRIGGER_KIND.to_key()),
|
|
);
|
|
args.extend(info);
|
|
let args = HashMap::from([("event".to_string(), to_raw_value(&args))]);
|
|
PushArgsOwned { args, extra: None }
|
|
} else {
|
|
PushArgsOwned { args, extra: None }
|
|
}
|
|
}
|
|
|
|
fn build_job_args_v1(
|
|
has_preprocessor: bool,
|
|
payload: &Self::Payload,
|
|
info: HashMap<String, Box<RawValue>>,
|
|
) -> PushArgsOwned {
|
|
let trigger_key = Self::TRIGGER_KIND.to_key();
|
|
let args = Self::v1_payload_fn(payload);
|
|
let extra = if has_preprocessor {
|
|
Some(HashMap::from([(
|
|
"wm_trigger".to_string(),
|
|
to_raw_value(&serde_json::json!({
|
|
"kind": trigger_key,
|
|
trigger_key: info
|
|
})),
|
|
)]))
|
|
} else {
|
|
None
|
|
};
|
|
PushArgsOwned { args, extra }
|
|
}
|
|
|
|
fn build_job_args(
|
|
runnable_path: &str,
|
|
is_flow: bool,
|
|
w_id: &str,
|
|
db: &DB,
|
|
payload: Self::Payload,
|
|
info: HashMap<String, Box<RawValue>>,
|
|
) -> impl Future<Output = Result<PushArgsOwned>> + Send {
|
|
async move {
|
|
let runnable_id = if is_flow {
|
|
RunnableId::from_flow_path(runnable_path)
|
|
} else {
|
|
RunnableId::from_script_path(runnable_path)
|
|
};
|
|
Self::build_job_args_from_runnable_id(runnable_id, w_id, db, payload, info).await
|
|
}
|
|
}
|
|
|
|
fn build_job_args_from_runnable_id(
|
|
runnable_id: RunnableId,
|
|
w_id: &str,
|
|
db: &DB,
|
|
payload: Self::Payload,
|
|
trigger_info: HashMap<String, Box<RawValue>>,
|
|
) -> impl Future<Output = Result<PushArgsOwned>> + Send {
|
|
async move {
|
|
tracing::debug!("Building job args for {runnable_id:?}");
|
|
let runnable_format =
|
|
get_runnable_format(runnable_id, w_id, db, &Self::TRIGGER_KIND).await?;
|
|
let job_args = match runnable_format {
|
|
RunnableFormat { version: RunnableFormatVersion::V1, has_preprocessor } => {
|
|
Self::build_job_args_v1(has_preprocessor, &payload, trigger_info)
|
|
}
|
|
RunnableFormat { version: RunnableFormatVersion::V2, has_preprocessor } => {
|
|
Self::build_job_args_v2(has_preprocessor, &payload, trigger_info)
|
|
}
|
|
};
|
|
|
|
Ok(job_args)
|
|
}
|
|
}
|
|
|
|
fn build_capture_payloads(
|
|
payload: &Self::Payload,
|
|
info: HashMap<String, Box<RawValue>>,
|
|
) -> (PushArgsOwned, PushArgsOwned) {
|
|
let main_args = Self::build_job_args_v2(false, payload, info.clone());
|
|
let preprocessor_args = Self::build_job_args_v2(true, payload, info);
|
|
(main_args, preprocessor_args)
|
|
}
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub async fn trigger_runnable_inner<'c>(
|
|
db: &DB,
|
|
tx_o: Option<sqlx::Transaction<'c, sqlx::Postgres>>,
|
|
user_db: Option<UserDB>,
|
|
authed: ApiAuthed,
|
|
workspace_id: &str,
|
|
runnable_path: &str,
|
|
is_flow: bool,
|
|
args: PushArgsOwned,
|
|
retry: Option<&sqlx::types::Json<Retry>>,
|
|
error_handler_path: Option<&str>,
|
|
error_handler_args: Option<&sqlx::types::Json<HashMap<String, serde_json::Value>>>,
|
|
trigger_path: String,
|
|
job_id: Option<Uuid>,
|
|
trigger: TriggerMetadata,
|
|
suspended_mode: Option<bool>,
|
|
) -> Result<(
|
|
Uuid,
|
|
Option<i32>,
|
|
Option<String>,
|
|
bool,
|
|
Option<sqlx::Transaction<'c, sqlx::Postgres>>,
|
|
)> {
|
|
let error_handler_args = error_handler_args.map(|args| {
|
|
let args = args
|
|
.0
|
|
.iter()
|
|
.map(|(key, value)| (key.to_owned(), to_raw_value(&value)))
|
|
.collect::<HashMap<String, Box<RawValue>>>();
|
|
Json(args)
|
|
});
|
|
|
|
let user_db = user_db.unwrap_or_else(|| UserDB::new(db.clone()));
|
|
let (uuid, resolved_delete_secs, early_return, has_failure_module, tx_out) = if is_flow {
|
|
let run_query = RunJobQuery { job_id, suspended_mode, ..Default::default() };
|
|
let path = StripPath(runnable_path.to_string());
|
|
let (uuid, early_return, has_failure_module, tx_out) = push_flow_job_by_path_into_queue(
|
|
authed,
|
|
db.clone(),
|
|
tx_o,
|
|
user_db,
|
|
workspace_id.to_string(),
|
|
path,
|
|
run_query,
|
|
args,
|
|
Some(trigger),
|
|
)
|
|
.await?;
|
|
(uuid, None, early_return, has_failure_module, tx_out)
|
|
} else {
|
|
let (uuid, resolved_delete_secs, tx_out) = trigger_script_internal(
|
|
db,
|
|
tx_o,
|
|
user_db,
|
|
authed,
|
|
workspace_id,
|
|
runnable_path,
|
|
args,
|
|
retry,
|
|
error_handler_path,
|
|
error_handler_args.as_ref(),
|
|
trigger_path,
|
|
job_id,
|
|
trigger,
|
|
suspended_mode,
|
|
)
|
|
.await?;
|
|
(uuid, resolved_delete_secs, None, false, tx_out)
|
|
};
|
|
|
|
Ok((
|
|
uuid,
|
|
resolved_delete_secs,
|
|
early_return,
|
|
has_failure_module,
|
|
tx_out,
|
|
))
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub async fn trigger_runnable(
|
|
db: &DB,
|
|
user_db: Option<UserDB>,
|
|
authed: ApiAuthed,
|
|
workspace_id: &str,
|
|
runnable_path: &str,
|
|
is_flow: bool,
|
|
args: PushArgsOwned,
|
|
retry: Option<&sqlx::types::Json<Retry>>,
|
|
error_handler_path: Option<&str>,
|
|
error_handler_args: Option<&sqlx::types::Json<HashMap<String, serde_json::Value>>>,
|
|
trigger_path: String,
|
|
job_id: Option<Uuid>,
|
|
suspended_mode: bool,
|
|
trigger: TriggerMetadata,
|
|
) -> Result<axum::response::Response> {
|
|
let uuid = trigger_runnable_inner(
|
|
db,
|
|
None,
|
|
user_db,
|
|
authed,
|
|
workspace_id,
|
|
runnable_path,
|
|
is_flow,
|
|
args,
|
|
retry,
|
|
error_handler_path,
|
|
error_handler_args,
|
|
trigger_path,
|
|
job_id,
|
|
trigger,
|
|
Some(suspended_mode),
|
|
)
|
|
.await?
|
|
.0;
|
|
Ok((StatusCode::CREATED, uuid.to_string()).into_response())
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub async fn trigger_runnable_and_wait_for_result(
|
|
db: &DB,
|
|
user_db: Option<UserDB>,
|
|
authed: ApiAuthed,
|
|
workspace_id: &str,
|
|
runnable_path: &str,
|
|
is_flow: bool,
|
|
args: PushArgsOwned,
|
|
retry: Option<&sqlx::types::Json<Retry>>,
|
|
error_handler_path: Option<&str>,
|
|
error_handler_args: Option<&sqlx::types::Json<HashMap<String, serde_json::Value>>>,
|
|
trigger_path: String,
|
|
trigger: TriggerMetadata,
|
|
) -> Result<axum::response::Response> {
|
|
let username = authed.username.clone();
|
|
let (uuid, resolved_delete_secs, early_return, has_failure_module, _) = trigger_runnable_inner(
|
|
db,
|
|
None,
|
|
user_db,
|
|
authed,
|
|
workspace_id,
|
|
runnable_path,
|
|
is_flow,
|
|
args,
|
|
retry,
|
|
error_handler_path,
|
|
error_handler_args,
|
|
trigger_path,
|
|
None,
|
|
trigger,
|
|
None,
|
|
)
|
|
.await?;
|
|
let (result, success) = run_wait_result_internal(
|
|
db,
|
|
uuid,
|
|
&workspace_id,
|
|
early_return,
|
|
has_failure_module,
|
|
&username,
|
|
)
|
|
.await?;
|
|
|
|
match resolved_delete_secs {
|
|
Some(0) => delete_job_metadata_after_use(&db, uuid).await?,
|
|
Some(secs) => schedule_job_deletion(&db, uuid, &workspace_id, secs).await?,
|
|
None => {}
|
|
}
|
|
|
|
result_to_response(result, success)
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub async fn trigger_runnable_and_wait_for_raw_result(
|
|
db: &DB,
|
|
user_db: Option<UserDB>,
|
|
authed: ApiAuthed,
|
|
workspace_id: &str,
|
|
runnable_path: &str,
|
|
is_flow: bool,
|
|
args: PushArgsOwned,
|
|
retry: Option<&sqlx::types::Json<Retry>>,
|
|
error_handler_path: Option<&str>,
|
|
error_handler_args: Option<&sqlx::types::Json<HashMap<String, serde_json::Value>>>,
|
|
trigger_path: String,
|
|
trigger: TriggerMetadata,
|
|
) -> Result<(Box<RawValue>, bool)> {
|
|
let username = authed.username.clone();
|
|
let (uuid, resolved_delete_secs, early_return, has_failure_module, _) = trigger_runnable_inner(
|
|
db,
|
|
None,
|
|
user_db,
|
|
authed,
|
|
workspace_id,
|
|
runnable_path,
|
|
is_flow,
|
|
args,
|
|
retry,
|
|
error_handler_path,
|
|
error_handler_args,
|
|
trigger_path,
|
|
None,
|
|
trigger,
|
|
None,
|
|
)
|
|
.await?;
|
|
|
|
let (result, success) = run_wait_result_internal(
|
|
db,
|
|
uuid,
|
|
&workspace_id,
|
|
early_return,
|
|
has_failure_module,
|
|
&username,
|
|
)
|
|
.await
|
|
.with_context(|| {
|
|
format!(
|
|
"Error fetching job result for {} {}",
|
|
if is_flow { "flow" } else { "script" },
|
|
runnable_path
|
|
)
|
|
})?;
|
|
|
|
match resolved_delete_secs {
|
|
Some(0) => delete_job_metadata_after_use(&db, uuid).await?,
|
|
Some(secs) => schedule_job_deletion(&db, uuid, &workspace_id, secs).await?,
|
|
None => {}
|
|
}
|
|
|
|
Ok((result, success))
|
|
}
|
|
|
|
pub async fn trigger_runnable_and_wait_for_raw_result_with_error_ctx(
|
|
db: &DB,
|
|
user_db: Option<UserDB>,
|
|
authed: ApiAuthed,
|
|
workspace_id: &str,
|
|
runnable_path: &str,
|
|
is_flow: bool,
|
|
args: PushArgsOwned,
|
|
retry: Option<&sqlx::types::Json<Retry>>,
|
|
error_handler_path: Option<&str>,
|
|
error_handler_args: Option<&sqlx::types::Json<HashMap<String, serde_json::Value>>>,
|
|
trigger_path: String,
|
|
trigger: TriggerMetadata,
|
|
) -> Result<Box<RawValue>> {
|
|
let (result, success) = trigger_runnable_and_wait_for_raw_result(
|
|
db,
|
|
user_db,
|
|
authed,
|
|
workspace_id,
|
|
runnable_path,
|
|
is_flow,
|
|
args,
|
|
retry,
|
|
error_handler_path,
|
|
error_handler_args,
|
|
trigger_path,
|
|
trigger,
|
|
)
|
|
.await?;
|
|
|
|
if !success {
|
|
Err(windmill_common::error::Error::internal_err(format!(
|
|
"{} {runnable_path} failed: {:?}",
|
|
if is_flow { "Flow" } else { "Script" },
|
|
result
|
|
)))
|
|
} else {
|
|
Ok(result)
|
|
}
|
|
}
|
|
|
|
async fn trigger_script_internal<'c>(
|
|
db: &DB,
|
|
tx_o: Option<sqlx::Transaction<'c, sqlx::Postgres>>,
|
|
user_db: UserDB,
|
|
authed: ApiAuthed,
|
|
workspace_id: &str,
|
|
script_path: &str,
|
|
args: PushArgsOwned,
|
|
retry: Option<&sqlx::types::Json<Retry>>,
|
|
error_handler_path: Option<&str>,
|
|
error_handler_args: Option<&sqlx::types::Json<HashMap<String, Box<RawValue>>>>,
|
|
trigger_path: String,
|
|
job_id: Option<Uuid>,
|
|
trigger: TriggerMetadata,
|
|
suspended_mode: Option<bool>,
|
|
) -> Result<(
|
|
Uuid,
|
|
Option<i32>,
|
|
Option<sqlx::Transaction<'c, sqlx::Postgres>>,
|
|
)> {
|
|
if retry.is_none() && error_handler_path.is_none() {
|
|
let run_query = RunJobQuery { job_id, suspended_mode, ..Default::default() };
|
|
let path = StripPath(script_path.to_string());
|
|
let (uuid, resolved_delete_secs, tx_out) = push_script_job_by_path_into_queue(
|
|
authed,
|
|
db.clone(),
|
|
tx_o,
|
|
user_db,
|
|
workspace_id.to_string(),
|
|
path,
|
|
run_query,
|
|
args,
|
|
Some(trigger),
|
|
)
|
|
.await?;
|
|
Ok((uuid, resolved_delete_secs, tx_out))
|
|
} else {
|
|
let (uuid, resolved_delete_secs, tx_out) = trigger_script_with_retry_and_error_handler(
|
|
db,
|
|
tx_o,
|
|
user_db,
|
|
authed,
|
|
workspace_id,
|
|
script_path,
|
|
args,
|
|
retry,
|
|
error_handler_path,
|
|
error_handler_args,
|
|
trigger_path,
|
|
job_id,
|
|
trigger,
|
|
suspended_mode,
|
|
)
|
|
.await?;
|
|
Ok((uuid, resolved_delete_secs, tx_out))
|
|
}
|
|
}
|
|
|
|
async fn trigger_script_with_retry_and_error_handler<'c>(
|
|
db: &DB,
|
|
tx_o: Option<sqlx::Transaction<'c, sqlx::Postgres>>,
|
|
user_db: UserDB,
|
|
authed: ApiAuthed,
|
|
workspace_id: &str,
|
|
script_path: &str,
|
|
args: PushArgsOwned,
|
|
retry: Option<&sqlx::types::Json<Retry>>,
|
|
error_handler_path: Option<&str>,
|
|
error_handler_args: Option<&sqlx::types::Json<HashMap<String, Box<RawValue>>>>,
|
|
trigger_path: String,
|
|
job_id: Option<Uuid>,
|
|
trigger: TriggerMetadata,
|
|
suspended_mode: Option<bool>,
|
|
) -> Result<(
|
|
Uuid,
|
|
Option<i32>,
|
|
Option<sqlx::Transaction<'c, sqlx::Postgres>>,
|
|
)> {
|
|
#[cfg(feature = "enterprise")]
|
|
check_license_key_valid().await?;
|
|
|
|
check_scopes(&authed, || format!("jobs:run:scripts:{script_path}"))?;
|
|
|
|
let retry = retry.map(|r| r.0.clone());
|
|
let error_handler_path = error_handler_path.map(|p| p.to_string());
|
|
let error_handler_args = error_handler_args.map(|args| args.0.clone());
|
|
|
|
let (job_payload, tag, delete_after_use, delete_after_secs, timeout, on_behalf_of) = {
|
|
let db_authed = UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() };
|
|
script_path_to_payload(
|
|
script_path,
|
|
Some(db_authed),
|
|
db.clone(),
|
|
&workspace_id,
|
|
Some(false),
|
|
)
|
|
.await?
|
|
};
|
|
let resolved_delete_secs = windmill_api_jobs::execution::resolve_delete_after_secs(
|
|
delete_after_use,
|
|
delete_after_secs,
|
|
);
|
|
|
|
check_tag_available_for_workspace(&db, &workspace_id, &tag, &authed).await?;
|
|
|
|
let return_tx = tx_o.is_some();
|
|
|
|
let (email, permissioned_as, push_authed, tx) = if let Some(tx) = tx_o {
|
|
(
|
|
authed.email.as_str(),
|
|
username_to_permissioned_as(&authed.username),
|
|
Some(authed.clone().into()),
|
|
PushIsolationLevel::Transaction(tx),
|
|
)
|
|
} else if let Some(on_behalf_of) = on_behalf_of.as_ref() {
|
|
(
|
|
on_behalf_of.email.as_str(),
|
|
on_behalf_of.permissioned_as.clone(),
|
|
None,
|
|
PushIsolationLevel::IsolatedRoot(db.clone()),
|
|
)
|
|
} else {
|
|
(
|
|
authed.email.as_str(),
|
|
username_to_permissioned_as(&authed.username),
|
|
Some(authed.clone().into()),
|
|
PushIsolationLevel::Isolated(user_db, authed.clone().into()),
|
|
)
|
|
};
|
|
|
|
let push_args = PushArgs { args: &args.args, extra: args.extra };
|
|
|
|
let retryable_job_payload = match job_payload {
|
|
JobPayload::ScriptHash {
|
|
hash,
|
|
path,
|
|
concurrency_settings,
|
|
debouncing_settings,
|
|
cache_ttl,
|
|
cache_ignore_s3_path,
|
|
priority,
|
|
apply_preprocessor,
|
|
..
|
|
} => JobPayload::SingleStepFlow {
|
|
path,
|
|
hash: Some(hash),
|
|
flow_version: None,
|
|
// Keep the flow path until native retry covers handler semantics.
|
|
language: None,
|
|
args: HashMap::from(&push_args),
|
|
retry,
|
|
error_handler_path,
|
|
error_handler_args,
|
|
skip_handler: None,
|
|
cache_ttl,
|
|
cache_ignore_s3_path,
|
|
priority,
|
|
tag_override: tag.clone(),
|
|
apply_preprocessor,
|
|
trigger_path: Some(trigger_path.clone()),
|
|
concurrency_settings,
|
|
debouncing_settings,
|
|
},
|
|
_ => {
|
|
return Err(windmill_common::error::Error::internal_err(format!(
|
|
"Unsupported job payload: {:?}",
|
|
job_payload
|
|
)))
|
|
}
|
|
};
|
|
let (uuid, tx) = push(
|
|
&db,
|
|
tx,
|
|
&workspace_id,
|
|
retryable_job_payload,
|
|
push_args,
|
|
authed.display_username(),
|
|
email,
|
|
permissioned_as,
|
|
authed.token_prefix.as_deref(),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
job_id,
|
|
false,
|
|
false,
|
|
None,
|
|
true,
|
|
tag,
|
|
timeout,
|
|
None,
|
|
None,
|
|
push_authed.as_ref(),
|
|
false,
|
|
None,
|
|
Some(trigger),
|
|
suspended_mode,
|
|
)
|
|
.await?;
|
|
|
|
// If we were given a transaction, return it; otherwise commit it
|
|
if return_tx {
|
|
Ok((uuid, resolved_delete_secs, Some(tx)))
|
|
} else {
|
|
tx.commit().await?;
|
|
Ok((uuid, resolved_delete_secs, None))
|
|
}
|
|
}
|