#[cfg(feature = "trigger-polling-http")]
pub mod http;
#[cfg(feature = "trigger-polling-sql")]
pub mod sql;
use std::future::Future;
use std::pin::Pin;
use std::time::Duration;
use serde_json::Value;
use sha2::{Digest, Sha256};
use tokio::time::interval;
use tokio_util::sync::CancellationToken;
use tracing::{info, warn};
use ironflow_store::entities::TriggerKind;
use super::{Trigger, TriggerEvent, TriggerFuture, TriggerSink};
pub type ProbeFuture<'a> =
Pin<Box<dyn Future<Output = Result<Option<ProbeResult>, ProbeError>> + Send + 'a>>;
pub trait PollingProbe: Send + Sync {
fn name(&self) -> &str;
fn poll(&self) -> ProbeFuture<'_>;
}
#[derive(Debug, Clone)]
pub struct ProbeResult {
data: Value,
content_hash: String,
}
impl ProbeResult {
pub fn new(data: Value) -> Self {
let json_bytes = serde_json::to_vec(&data).unwrap_or_default();
let hash = Sha256::digest(&json_bytes);
let content_hash = hex::encode(hash);
Self { data, content_hash }
}
pub fn with_hash(data: Value, content_hash: String) -> Self {
Self { data, content_hash }
}
pub fn data(&self) -> &Value {
&self.data
}
pub fn content_hash(&self) -> &str {
&self.content_hash
}
}
#[derive(Debug, thiserror::Error)]
pub enum ProbeError {
#[error("probe failed: {0}")]
Failed(String),
}
pub struct PollingTriggerConfig {
pub interval: Duration,
pub probe: Box<dyn PollingProbe>,
pub workflow_name: String,
pub dedup: bool,
}
#[cfg(feature = "prometheus")]
mod metric_names {
pub const POLLING_TRIGGER_TOTAL: &str = "ironflow_polling_trigger_total";
}
pub struct PollingTrigger {
config: PollingTriggerConfig,
}
impl PollingTrigger {
pub fn new(config: PollingTriggerConfig) -> Self {
Self { config }
}
#[cfg(feature = "prometheus")]
fn record_metric(probe_name: &str, outcome: &str) {
use metrics::counter;
counter!(
metric_names::POLLING_TRIGGER_TOTAL,
"probe" => probe_name.to_string(),
"outcome" => outcome.to_string()
)
.increment(1);
}
}
impl Trigger for PollingTrigger {
fn name(&self) -> &str {
"polling-trigger"
}
fn start<'a>(&'a self, sink: TriggerSink, token: &'a CancellationToken) -> TriggerFuture<'a> {
Box::pin(async move {
let mut ticker = interval(self.config.interval);
let mut last_hash: Option<String> = None;
let probe_name = self.config.probe.name().to_string();
info!(
probe = %probe_name,
interval_secs = self.config.interval.as_secs(),
workflow = %self.config.workflow_name,
dedup = self.config.dedup,
"polling trigger started"
);
loop {
tokio::select! {
_ = token.cancelled() => {
info!(probe = %probe_name, "polling trigger shutting down");
return Ok(());
}
_ = ticker.tick() => {
match self.config.probe.poll().await {
Ok(Some(result)) => {
let should_trigger = if self.config.dedup {
match &last_hash {
Some(prev) => prev != result.content_hash(),
None => true,
}
} else {
true
};
if should_trigger {
let event = TriggerEvent {
workflow_name: self.config.workflow_name.clone(),
payload: result.data().clone(),
trigger_kind: TriggerKind::Polling {
probe: probe_name.clone(),
},
};
if let Err(e) = sink.send(event).await {
warn!(
probe = %probe_name,
error = %e,
"failed to emit polling trigger event"
);
return Err(e);
}
info!(
probe = %probe_name,
workflow = %self.config.workflow_name,
"polling trigger fired"
);
#[cfg(feature = "prometheus")]
Self::record_metric(&probe_name, "triggered");
last_hash = Some(result.content_hash().to_string());
} else {
info!(
probe = %probe_name,
"poll result unchanged, skipping"
);
#[cfg(feature = "prometheus")]
Self::record_metric(&probe_name, "unchanged");
}
}
Ok(None) => {
info!(
probe = %probe_name,
"probe returned no data, skipping"
);
#[cfg(feature = "prometheus")]
Self::record_metric(&probe_name, "empty");
}
Err(e) => {
warn!(
probe = %probe_name,
error = %e,
"probe error, skipping this poll"
);
#[cfg(feature = "prometheus")]
Self::record_metric(&probe_name, "error");
}
}
}
}
}
})
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
use serde_json::json;
use tokio::time::timeout;
use tokio_util::sync::CancellationToken;
use super::*;
struct FixedProbe {
data: Value,
}
impl FixedProbe {
fn new(data: Value) -> Self {
Self { data }
}
}
impl PollingProbe for FixedProbe {
fn name(&self) -> &str {
"fixed"
}
fn poll(&self) -> ProbeFuture<'_> {
let data = self.data.clone();
Box::pin(async move { Ok(Some(ProbeResult::new(data))) })
}
}
struct CountingProbe {
counter: Arc<AtomicU32>,
}
impl CountingProbe {
fn new(counter: Arc<AtomicU32>) -> Self {
Self { counter }
}
}
impl PollingProbe for CountingProbe {
fn name(&self) -> &str {
"counting"
}
fn poll(&self) -> ProbeFuture<'_> {
let n = self.counter.fetch_add(1, Ordering::SeqCst);
Box::pin(async move { Ok(Some(ProbeResult::new(json!({"poll": n})))) })
}
}
struct EmptyProbe;
impl PollingProbe for EmptyProbe {
fn name(&self) -> &str {
"empty"
}
fn poll(&self) -> ProbeFuture<'_> {
Box::pin(async { Ok(None) })
}
}
struct ErrorProbe;
impl PollingProbe for ErrorProbe {
fn name(&self) -> &str {
"error"
}
fn poll(&self) -> ProbeFuture<'_> {
Box::pin(async { Err(ProbeError::Failed("connection refused".to_string())) })
}
}
fn make_trigger(probe: Box<dyn PollingProbe>, dedup: bool) -> PollingTrigger {
PollingTrigger::new(PollingTriggerConfig {
interval: Duration::from_millis(50),
probe,
workflow_name: "test-workflow".to_string(),
dedup,
})
}
#[tokio::test]
async fn polling_triggers_on_first_poll() {
let trigger = make_trigger(Box::new(FixedProbe::new(json!({"key": "val"}))), true);
let (sink, mut rx) = TriggerSink::channel(16);
let token = CancellationToken::new();
let token_clone = token.clone();
let handle = tokio::spawn(async move { trigger.start(sink, &token_clone).await });
let event = timeout(Duration::from_secs(2), rx.recv())
.await
.expect("timed out")
.expect("channel closed");
assert_eq!(event.workflow_name, "test-workflow");
assert_eq!(event.payload["key"], "val");
assert!(matches!(
event.trigger_kind,
TriggerKind::Polling { ref probe } if probe == "fixed"
));
token.cancel();
let _ = handle.await;
}
#[tokio::test]
async fn polling_dedup_skips_unchanged() {
let trigger = make_trigger(Box::new(FixedProbe::new(json!({"static": true}))), true);
let (sink, mut rx) = TriggerSink::channel(16);
let token = CancellationToken::new();
let token_clone = token.clone();
let handle = tokio::spawn(async move { trigger.start(sink, &token_clone).await });
let _event = timeout(Duration::from_secs(2), rx.recv())
.await
.expect("timed out")
.expect("channel closed");
tokio::time::sleep(Duration::from_millis(150)).await;
assert!(rx.try_recv().is_err());
token.cancel();
let _ = handle.await;
}
#[tokio::test]
async fn polling_no_dedup_fires_every_time() {
let trigger = make_trigger(Box::new(FixedProbe::new(json!({"static": true}))), false);
let (sink, mut rx) = TriggerSink::channel(16);
let token = CancellationToken::new();
let token_clone = token.clone();
let handle = tokio::spawn(async move { trigger.start(sink, &token_clone).await });
let _e1 = timeout(Duration::from_secs(2), rx.recv())
.await
.expect("timed out")
.expect("channel closed");
let _e2 = timeout(Duration::from_secs(2), rx.recv())
.await
.expect("timed out")
.expect("channel closed");
token.cancel();
let _ = handle.await;
}
#[tokio::test]
async fn polling_error_does_not_trigger() {
let trigger = make_trigger(Box::new(ErrorProbe), false);
let (sink, mut rx) = TriggerSink::channel(16);
let token = CancellationToken::new();
let token_clone = token.clone();
let handle = tokio::spawn(async move { trigger.start(sink, &token_clone).await });
tokio::time::sleep(Duration::from_millis(200)).await;
assert!(rx.try_recv().is_err());
token.cancel();
let _ = handle.await;
}
#[tokio::test]
async fn polling_graceful_shutdown() {
let trigger = make_trigger(Box::new(FixedProbe::new(json!(null))), false);
let (sink, _rx) = TriggerSink::channel(16);
let token = CancellationToken::new();
let token_clone = token.clone();
let handle = tokio::spawn(async move { trigger.start(sink, &token_clone).await });
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(!handle.is_finished());
token.cancel();
let result = timeout(Duration::from_secs(2), handle)
.await
.expect("timed out")
.expect("task panicked");
assert!(result.is_ok());
}
#[tokio::test]
async fn polling_dedup_fires_on_change() {
let counter = Arc::new(AtomicU32::new(0));
let trigger = make_trigger(Box::new(CountingProbe::new(counter)), true);
let (sink, mut rx) = TriggerSink::channel(16);
let token = CancellationToken::new();
let token_clone = token.clone();
let handle = tokio::spawn(async move { trigger.start(sink, &token_clone).await });
let _e1 = timeout(Duration::from_secs(2), rx.recv())
.await
.expect("timed out")
.expect("channel closed");
let _e2 = timeout(Duration::from_secs(2), rx.recv())
.await
.expect("timed out")
.expect("channel closed");
token.cancel();
let _ = handle.await;
}
#[tokio::test]
async fn polling_empty_probe_does_not_trigger() {
let trigger = make_trigger(Box::new(EmptyProbe), false);
let (sink, mut rx) = TriggerSink::channel(16);
let token = CancellationToken::new();
let token_clone = token.clone();
let handle = tokio::spawn(async move { trigger.start(sink, &token_clone).await });
tokio::time::sleep(Duration::from_millis(200)).await;
assert!(rx.try_recv().is_err());
token.cancel();
let _ = handle.await;
}
#[test]
fn probe_result_hash_deterministic() {
let r1 = ProbeResult::new(json!({"a": 1, "b": 2}));
let r2 = ProbeResult::new(json!({"a": 1, "b": 2}));
assert_eq!(r1.content_hash(), r2.content_hash());
}
#[test]
fn probe_result_hash_differs_for_different_data() {
let r1 = ProbeResult::new(json!({"a": 1}));
let r2 = ProbeResult::new(json!({"a": 2}));
assert_ne!(r1.content_hash(), r2.content_hash());
}
#[test]
fn probe_result_with_custom_hash() {
let r = ProbeResult::with_hash(json!(null), "custom-hash".to_string());
assert_eq!(r.content_hash(), "custom-hash");
}
#[test]
fn probe_error_display() {
let err = ProbeError::Failed("timeout".to_string());
assert_eq!(err.to_string(), "probe failed: timeout");
}
#[cfg(feature = "prometheus")]
#[tokio::test]
async fn polling_metrics_incremented() {
use metrics_exporter_prometheus::PrometheusBuilder;
let recorder = PrometheusBuilder::new().build_recorder();
let prom_handle = recorder.handle();
metrics::set_global_recorder(recorder).ok();
let trigger = make_trigger(Box::new(FixedProbe::new(json!({"m": 1}))), false);
let (sink, mut rx) = TriggerSink::channel(16);
let token = CancellationToken::new();
let token_clone = token.clone();
let handle = tokio::spawn(async move { trigger.start(sink, &token_clone).await });
let _ = timeout(Duration::from_secs(2), rx.recv()).await;
token.cancel();
let _ = handle.await;
let output = prom_handle.render();
assert!(
output.contains("ironflow_polling_trigger_total"),
"expected polling metric in: {output}"
);
}
}