Skip to main content

faucet_cli/serve/triggers/
enqueue.rs

1//! The single choke point all trigger fires funnel through. `build_submit_request`
2//! is pure (text + event → SubmitRequest); `fire` resolves the pipeline ref,
3//! substitutes, submits via the existing runner, and maps the outcome.
4
5use super::compiled::CompiledTrigger;
6use super::context::{self, TriggerEvent};
7use super::{metrics, spec::PipelineRef};
8use crate::serve::runner::{self, ConfigFormatWire, SubmitRequest};
9use crate::serve::state::ServerState;
10
11#[derive(Debug)]
12pub enum FireOutcome {
13    /// A new run was enqueued / a Pending record written (cluster).
14    Enqueued(String),
15    /// Same idempotency key with a conflicting payload hash — treated as a
16    /// committed no-op so a polling watcher does not retry the same event forever.
17    Coalesced,
18    /// Dropped without enqueuing (e.g. queue full); polling watchers must NOT
19    /// advance their cursor/edge on this outcome.
20    Dropped(&'static str),
21    /// An error building or submitting the run.
22    Error(String),
23}
24
25impl FireOutcome {
26    /// Whether the watcher may advance its cursor/edge past this event.
27    pub fn committed(&self) -> bool {
28        matches!(self, FireOutcome::Enqueued(_) | FireOutcome::Coalesced)
29    }
30}
31
32/// Resolve the pipeline ref to config text and substitute `${trigger.*}`.
33pub async fn resolve_config_text(
34    config: &PipelineRef,
35    event: &TriggerEvent,
36    name: &str,
37    fired_at: &str,
38) -> Result<String, String> {
39    let raw = match config {
40        PipelineRef::Path(p) => tokio::fs::read_to_string(p)
41            .await
42            .map_err(|e| format!("reading pipeline config '{p}': {e}"))?,
43        PipelineRef::Inline(v) => {
44            serde_yaml::to_string(v).map_err(|e| format!("serializing inline pipeline: {e}"))?
45        }
46    };
47    context::substitute(&raw, event, name, fired_at)
48}
49
50/// Build the `SubmitRequest` for an already-resolved config text. Pure.
51pub fn build_submit_request(
52    compiled: &CompiledTrigger,
53    event: &TriggerEvent,
54    config_text: String,
55    fired_at: &str,
56) -> SubmitRequest {
57    let name = compiled.name();
58    let mut labels = context::labels(name, event);
59    labels.extend(compiled.spec.run.labels.clone());
60    let run_name = compiled
61        .spec
62        .run
63        .name
64        .as_deref()
65        .map(|tpl| context::render_name(tpl, event, name, fired_at))
66        .unwrap_or_else(|| name.to_string());
67    SubmitRequest {
68        config: config_text,
69        config_format: ConfigFormatWire::Yaml,
70        name: Some(run_name),
71        labels,
72        timeout_secs: compiled.spec.run.timeout_secs,
73        doctor_first: false,
74        idempotency_key: Some(context::idempotency_key(name, event)),
75        clock: None,
76        // Triggers carry no per-run callback (#481). A trigger is declared in the
77        // triggers file, so its destination is static — which is exactly what the
78        // config's `notifications:` block already expresses. A per-run callback
79        // exists for the opposite case: an external caller submitting a run and
80        // naming its own endpoint.
81        callback: None,
82    }
83}
84
85/// Resolve + submit. `fired_at` is RFC3339 (caller-stamped).
86pub async fn fire(
87    state: &ServerState,
88    compiled: &CompiledTrigger,
89    event: TriggerEvent,
90    fired_at: &str,
91) -> FireOutcome {
92    let kind = compiled.kind_label();
93    metrics::fired(compiled.name(), kind);
94
95    let text =
96        match resolve_config_text(&compiled.spec.config, &event, compiled.name(), fired_at).await {
97            Ok(t) => t,
98            Err(e) => {
99                metrics::error(compiled.name(), kind);
100                return FireOutcome::Error(e);
101            }
102        };
103    let req = build_submit_request(compiled, &event, text, fired_at);
104    // True idempotency replays (same key + same payload) return Ok from submit
105    // and are counted as Enqueued here; the serve-layer
106    // faucet_serve_idempotency_hits_total captures them. A Conflict (same key,
107    // different payload hash) is treated as a committed no-op (Coalesced) so a
108    // polling watcher does not retry the same event forever.
109    // Trigger-originated (non-HTTP) submission → attribute the audit record to
110    // the trigger (`trigger:<name>`).
111    let actor = crate::serve::rbac::AuthContext::trigger(compiled.name());
112    match runner::submit(state.clone(), req, actor).await {
113        Ok(resp) => {
114            metrics::enqueued(compiled.name());
115            FireOutcome::Enqueued(resp.run_id)
116        }
117        Err(crate::serve::error::ServeError::QueueFull { .. }) => {
118            metrics::dropped(compiled.name(), "queue_full");
119            FireOutcome::Dropped("queue_full")
120        }
121        Err(crate::serve::error::ServeError::Conflict(_)) => {
122            // Idempotency conflict (same key, different payload) — treat as a
123            // committed no-op so a poll doesn't retry forever.
124            metrics::coalesced(compiled.name());
125            FireOutcome::Coalesced
126        }
127        Err(e) => {
128            metrics::error(compiled.name(), kind);
129            FireOutcome::Error(e.api_error().error.message)
130        }
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::serve::triggers::spec::{RunTemplate, TriggerKind, TriggerSpec};
138
139    fn compiled_webhook() -> CompiledTrigger {
140        CompiledTrigger {
141            spec: TriggerSpec {
142                name: "hook".into(),
143                enabled: true,
144                config: PipelineRef::Path("/tmp/x.yaml".into()),
145                run: RunTemplate {
146                    name: Some("{name}:{object_key}".into()),
147                    labels: Default::default(),
148                    timeout_secs: Some(60),
149                },
150                kind: TriggerKind::Webhook {
151                    methods: vec!["POST".into()],
152                    dedupe_header: None,
153                    debounce_secs: 0,
154                },
155            },
156            webhook_path: Some("/v1/triggers/hook".into()),
157        }
158    }
159
160    #[test]
161    fn builds_request_with_labels_idem_and_timeout() {
162        let event = TriggerEvent::Object {
163            bucket: "b".into(),
164            key: "k".into(),
165            size: 1,
166            last_modified: "2026-06-12T00:00:00Z".into(),
167        };
168        let req = build_submit_request(&compiled_webhook(), &event, "version: 1".into(), "now");
169        assert_eq!(req.name.as_deref(), Some("hook:k"));
170        assert_eq!(req.timeout_secs, Some(60));
171        assert_eq!(
172            req.idempotency_key.as_deref(),
173            Some("trig:hook:b:k:2026-06-12T00:00:00Z")
174        );
175        assert_eq!(
176            req.labels.get("faucet.trigger.name").map(String::as_str),
177            Some("hook")
178        );
179        assert!(!req.doctor_first);
180    }
181}