Files
windmill/backend/windmill-trigger-amqp/src/lib.rs
T
68debab877 feat(triggers): add AMQP (RabbitMQ) trigger via lapin (#10230)
* 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>
2026-07-21 15:10:29 +00:00

342 lines
11 KiB
Rust

use base64::engine;
use base64::prelude::*;
use lapin::{
options::{BasicConsumeOptions, BasicQosOptions, QueueBindOptions, QueueDeclareOptions},
types::FieldTable,
Channel, Connection, ConnectionProperties, Consumer,
};
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use sqlx::{types::Json as SqlxJson, FromRow};
use std::collections::HashMap;
use windmill_common::{error::Error, triggers::TriggerKind, worker::to_raw_value};
use windmill_trigger::trigger_helpers::TriggerJobArgs;
pub mod handler;
pub mod listener;
#[derive(Clone, Copy)]
pub struct AmqpTrigger;
impl TriggerJobArgs for AmqpTrigger {
type Payload = Vec<u8>;
const TRIGGER_KIND: TriggerKind = TriggerKind::Amqp;
fn v1_payload_fn(payload: &Self::Payload) -> HashMap<String, Box<RawValue>> {
HashMap::from([("payload".to_string(), to_raw_value(&payload))])
}
fn v2_payload_fn(payload: &Self::Payload) -> HashMap<String, Box<RawValue>> {
let base64_payload = engine::general_purpose::STANDARD.encode(payload);
HashMap::from([("payload".to_string(), to_raw_value(&base64_payload))])
}
}
#[derive(Debug, Deserialize)]
pub struct AmqpResource {
pub host: String,
pub port: Option<u16>,
pub username: Option<String>,
pub password: Option<String>,
pub vhost: Option<String>,
pub tls: Option<bool>,
}
/// Binding of the consumed queue to an exchange. When present, the queue is bound
/// to `exchange_name` for each routing key so messages published to the exchange
/// are routed to it.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ExchangeConfig {
pub exchange_name: String,
#[serde(default)]
pub routing_keys: Vec<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct AmqpOptions {
/// Declare the queue (durable) before consuming. When false the queue is
/// declared passively, i.e. it must already exist on the broker.
pub declare_queue: Option<bool>,
/// Maximum number of unacknowledged messages the broker delivers at once.
pub prefetch_count: Option<u16>,
}
/// Shared validation for AMQP options used by both the CRUD handler and the
/// consumer builder (which also covers capture configs, that bypass CRUD
/// validation). RabbitMQ treats prefetch 0 as unlimited (unbounded consumer
/// buffer), so a set prefetch must be at least 1.
pub fn validate_amqp_options(options: Option<&AmqpOptions>) -> Result<(), String> {
if options.and_then(|o| o.prefetch_count) == Some(0) {
return Err("Prefetch count must be at least 1".to_string());
}
Ok(())
}
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct AmqpConfig {
pub amqp_resource_path: String,
pub queue_name: String,
pub exchange: Option<SqlxJson<ExchangeConfig>>,
pub options: Option<SqlxJson<AmqpOptions>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AmqpConfigRequest {
pub amqp_resource_path: String,
pub queue_name: String,
pub exchange: Option<ExchangeConfig>,
pub options: Option<AmqpOptions>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestAmqpConfig {
pub amqp_resource_path: String,
}
#[derive(Debug, thiserror::Error)]
pub enum AmqpError {
#[error("{0}")]
Common(#[from] Error),
#[error("{0}")]
Lapin(#[from] lapin::Error),
}
/// Consumer bundle. The connection and channel are kept alive for as long as the
/// consumer stream is polled: dropping them would tear down the AMQP consumer.
pub struct AmqpConsumer {
pub connection: Connection,
pub channel: Channel,
pub consumer: Consumer,
pub queue_name: String,
}
pub const CONSUMER_TAG: &str = "windmill";
fn build_uri(resource: &AmqpResource) -> String {
let tls = resource.tls.unwrap_or(false);
let scheme = if tls { "amqps" } else { "amqp" };
let port = resource.port.unwrap_or(if tls { 5671 } else { 5672 });
let credentials = match (resource.username.as_deref(), resource.password.as_deref()) {
(Some(user), password) if !user.is_empty() => format!(
"{}:{}@",
urlencoding::encode(user),
urlencoding::encode(password.unwrap_or("")),
),
_ => String::new(),
};
// The URI path is the virtual host and must be percent-encoded; the default
// vhost "/" therefore becomes "%2F". An empty vhost also falls back to "/".
let vhost = match resource.vhost.as_deref() {
Some(v) if !v.is_empty() => v,
_ => "/",
};
let vhost_encoded = urlencoding::encode(vhost);
// Bracket an IPv6 literal host so `host:port` parses correctly.
let host = if resource.host.contains(':') && !resource.host.starts_with('[') {
format!("[{}]", resource.host)
} else {
resource.host.clone()
};
format!(
"{}://{}{}:{}/{}",
scheme, credentials, host, port, vhost_encoded
)
}
fn connection_properties() -> ConnectionProperties {
ConnectionProperties::default()
.with_executor(tokio_executor_trait::Tokio::current())
.with_reactor(tokio_reactor_trait::Tokio)
}
pub struct AmqpClientBuilder<'client> {
resource: AmqpResource,
queue_name: &'client str,
exchange: Option<&'client ExchangeConfig>,
options: Option<&'client AmqpOptions>,
}
impl<'client> AmqpClientBuilder<'client> {
pub fn new(
resource: AmqpResource,
queue_name: &'client str,
exchange: Option<&'client ExchangeConfig>,
options: Option<&'client AmqpOptions>,
) -> Self {
Self { resource, queue_name, exchange, options }
}
async fn connect(&self) -> Result<Connection, AmqpError> {
let uri = build_uri(&self.resource);
let connection = Connection::connect(&uri, connection_properties()).await?;
Ok(connection)
}
/// Establish a connection and open a channel to verify the broker is reachable
/// with the provided credentials.
pub async fn test_connection(&self) -> Result<(), AmqpError> {
let connection = self.connect().await?;
connection.create_channel().await?;
Ok(())
}
pub async fn build_consumer(&self) -> Result<AmqpConsumer, AmqpError> {
let connection = self.connect().await?;
let channel = connection.create_channel().await?;
validate_amqp_options(self.options).map_err(|e| AmqpError::Common(Error::BadConfig(e)))?;
if let Some(prefetch_count) = self.options.and_then(|o| o.prefetch_count) {
channel
.basic_qos(prefetch_count, BasicQosOptions::default())
.await?;
}
let declare_queue = self.options.and_then(|o| o.declare_queue).unwrap_or(true);
let queue_declare_options = QueueDeclareOptions {
passive: !declare_queue,
durable: declare_queue,
exclusive: false,
auto_delete: false,
nowait: false,
};
channel
.queue_declare(
self.queue_name,
queue_declare_options,
FieldTable::default(),
)
.await?;
if let Some(exchange) = self.exchange {
if !exchange.exchange_name.trim().is_empty() {
// Bind the queue for every routing key; an empty list binds once
// with an empty routing key (fanout exchanges ignore it anyway).
let routing_keys = if exchange.routing_keys.is_empty() {
vec![String::new()]
} else {
exchange.routing_keys.clone()
};
for routing_key in routing_keys {
channel
.queue_bind(
self.queue_name,
&exchange.exchange_name,
&routing_key,
QueueBindOptions::default(),
FieldTable::default(),
)
.await?;
}
}
}
let consumer = channel
.basic_consume(
self.queue_name,
CONSUMER_TAG,
BasicConsumeOptions::default(),
FieldTable::default(),
)
.await?;
Ok(AmqpConsumer { connection, channel, consumer, queue_name: self.queue_name.to_string() })
}
}
#[cfg(test)]
mod tests {
use super::*;
fn resource(
host: &str,
port: Option<u16>,
username: Option<&str>,
password: Option<&str>,
vhost: Option<&str>,
tls: Option<bool>,
) -> AmqpResource {
AmqpResource {
host: host.to_string(),
port,
username: username.map(str::to_string),
password: password.map(str::to_string),
vhost: vhost.map(str::to_string),
tls,
}
}
#[test]
fn build_uri_defaults_encode_root_vhost_and_pick_plaintext_port() {
let uri = build_uri(&resource("broker", None, None, None, None, None));
assert_eq!(uri, "amqp://broker:5672/%2F");
}
#[test]
fn build_uri_tls_picks_amqps_and_5671() {
let uri = build_uri(&resource("broker", None, None, None, None, Some(true)));
assert_eq!(uri, "amqps://broker:5671/%2F");
}
#[test]
fn build_uri_encodes_credentials_and_custom_vhost() {
let uri = build_uri(&resource(
"broker",
Some(5673),
Some("us er"),
Some("p@ss/word"),
Some("my/vhost"),
None,
));
assert_eq!(uri, "amqp://us%20er:p%40ss%2Fword@broker:5673/my%2Fvhost");
}
#[test]
fn build_uri_omits_credentials_when_username_empty() {
let uri = build_uri(&resource(
"broker",
None,
Some(""),
Some("secret"),
None,
None,
));
assert_eq!(uri, "amqp://broker:5672/%2F");
}
#[test]
fn build_uri_blank_vhost_falls_back_to_root() {
let uri = build_uri(&resource("broker", None, None, None, Some(""), None));
assert_eq!(uri, "amqp://broker:5672/%2F");
}
#[test]
fn build_uri_brackets_ipv6_host() {
let uri = build_uri(&resource("::1", Some(5672), None, None, None, None));
assert_eq!(uri, "amqp://[::1]:5672/%2F");
}
fn options(prefetch: Option<u16>) -> AmqpOptions {
AmqpOptions { declare_queue: None, prefetch_count: prefetch }
}
#[test]
fn validate_amqp_options_rejects_zero_prefetch() {
assert!(validate_amqp_options(Some(&options(Some(0)))).is_err());
}
#[test]
fn validate_amqp_options_accepts_valid_prefetch_and_none() {
assert!(validate_amqp_options(Some(&options(Some(1)))).is_ok());
assert!(validate_amqp_options(Some(&options(Some(65535)))).is_ok());
assert!(validate_amqp_options(Some(&options(None))).is_ok());
assert!(validate_amqp_options(None).is_ok());
}
}