Skip to main content

faucet_cli/serve/triggers/
queue_depth.rs

1//! `queue_depth` trigger: poll a queue's depth and fire edge-triggered (once per
2//! rising crossing of `threshold`, suppressed until it drains). The `Edge` is
3//! pure; `DepthProbe` is the IO seam (Redis/Kafka impls + a fake in tests).
4
5use super::context::TriggerEvent;
6use super::enqueue::{self};
7use super::spec::QueueSpec;
8use super::watcher::Watcher;
9use crate::serve::state::ServerState;
10use async_trait::async_trait;
11use std::time::Duration;
12
13/// Edge detector. Fires once when depth first reaches `threshold`; re-arms only
14/// after depth drops below `threshold`.
15#[derive(Debug)]
16pub struct Edge {
17    threshold: u64,
18    armed: bool,
19    edge_ordinal: u64,
20}
21
22impl Edge {
23    pub fn new(threshold: u64) -> Self {
24        Self {
25            threshold,
26            armed: true,
27            edge_ordinal: 0,
28        }
29    }
30
31    /// Feed a depth reading. Returns `Some(edge_ordinal)` if this reading is a
32    /// rising-edge fire, else `None`.
33    pub fn on_depth(&mut self, depth: u64) -> Option<u64> {
34        if depth >= self.threshold {
35            if self.armed {
36                self.armed = false;
37                self.edge_ordinal += 1;
38                return Some(self.edge_ordinal);
39            }
40            None
41        } else {
42            self.armed = true; // dropped below → re-arm
43            None
44        }
45    }
46}
47
48/// The IO seam: returns the current depth of the queue.
49#[async_trait]
50pub trait DepthProbe: Send + Sync {
51    async fn depth(&self) -> Result<u64, String>;
52    fn queue_label(&self) -> String;
53}
54
55pub struct QueueDepthWatcher {
56    name: String,
57    probe: Box<dyn DepthProbe>,
58    edge: Edge,
59    poll: Duration,
60    compiled: std::sync::Arc<super::compiled::CompiledTrigger>,
61}
62
63impl QueueDepthWatcher {
64    pub fn new(
65        compiled: std::sync::Arc<super::compiled::CompiledTrigger>,
66        probe: Box<dyn DepthProbe>,
67        threshold: u64,
68        poll: Duration,
69    ) -> Self {
70        Self {
71            name: compiled.name().to_string(),
72            probe,
73            edge: Edge::new(threshold),
74            poll,
75            compiled,
76        }
77    }
78}
79
80#[async_trait]
81impl Watcher for QueueDepthWatcher {
82    fn name(&self) -> &str {
83        &self.name
84    }
85    fn kind(&self) -> &'static str {
86        "queue_depth"
87    }
88    fn poll_interval(&self) -> Duration {
89        self.poll
90    }
91
92    async fn poll(&mut self, state: &ServerState) -> Result<bool, String> {
93        let depth = self.probe.depth().await?;
94        let Some(edge) = self.edge.on_depth(depth) else {
95            return Ok(false);
96        };
97        let event = TriggerEvent::QueueDepth {
98            queue: self.probe.queue_label(),
99            depth,
100            edge,
101        };
102        let fired_at = chrono::Utc::now().to_rfc3339();
103        let outcome = enqueue::fire(state, &self.compiled, event, &fired_at).await;
104        if !outcome.committed() {
105            // Dropped/error: re-arm so the next poll retries the same edge.
106            self.edge.armed = true;
107            self.edge.edge_ordinal -= 1;
108        }
109        Ok(outcome.committed())
110    }
111}
112
113/// Build the depth probe for a queue spec (feature-gated backends).
114pub fn build_probe(queue: &QueueSpec) -> Result<Box<dyn DepthProbe>, String> {
115    match queue {
116        #[cfg(feature = "triggers-redis")]
117        QueueSpec::Redis { url, key, kind } => Ok(Box::new(redis_probe::RedisProbe::new(
118            url.clone(),
119            key.clone(),
120            *kind,
121        ))),
122        #[cfg(not(feature = "triggers-redis"))]
123        QueueSpec::Redis { .. } => {
124            Err("queue_depth redis requires the `triggers-redis` feature".into())
125        }
126        #[cfg(feature = "triggers-kafka")]
127        QueueSpec::Kafka {
128            brokers,
129            topic,
130            group,
131        } => Ok(Box::new(kafka_probe::KafkaProbe::new(
132            brokers.clone(),
133            topic.clone(),
134            group.clone(),
135        ))),
136        #[cfg(not(feature = "triggers-kafka"))]
137        QueueSpec::Kafka { .. } => {
138            Err("queue_depth kafka requires the `triggers-kafka` feature".into())
139        }
140    }
141}
142
143#[cfg(feature = "triggers-redis")]
144mod redis_probe {
145    use super::DepthProbe;
146    use crate::serve::triggers::spec::RedisQueueKind;
147    use async_trait::async_trait;
148    use redis::AsyncCommands;
149
150    pub struct RedisProbe {
151        url: String,
152        key: String,
153        kind: RedisQueueKind,
154    }
155    impl RedisProbe {
156        pub fn new(url: String, key: String, kind: RedisQueueKind) -> Self {
157            Self { url, key, kind }
158        }
159    }
160    #[async_trait]
161    impl DepthProbe for RedisProbe {
162        async fn depth(&self) -> Result<u64, String> {
163            // A fresh connection per poll is intentional: depth polling is low-frequency
164            // (poll_interval_secs, default 30s), so a pooled/cached client isn't worth it.
165            let client = redis::Client::open(self.url.as_str())
166                .map_err(|e| format!("invalid Redis URL: {e}"))?;
167            let mut conn = client
168                .get_multiplexed_async_connection()
169                .await
170                .map_err(|e| format!("Redis connect: {e}"))?;
171            let n: i64 = match self.kind {
172                RedisQueueKind::List => conn
173                    .llen(&self.key)
174                    .await
175                    .map_err(|e| format!("LLEN: {e}"))?,
176                RedisQueueKind::Stream => conn
177                    .xlen(&self.key)
178                    .await
179                    .map_err(|e| format!("XLEN: {e}"))?,
180            };
181            Ok(n.max(0) as u64)
182        }
183        fn queue_label(&self) -> String {
184            self.key.clone()
185        }
186    }
187}
188
189#[cfg(feature = "triggers-kafka")]
190mod kafka_probe {
191    use super::DepthProbe;
192    use async_trait::async_trait;
193    use rdkafka::consumer::{BaseConsumer, Consumer};
194    use rdkafka::{ClientConfig, Offset, TopicPartitionList};
195    use std::time::Duration;
196
197    pub struct KafkaProbe {
198        brokers: String,
199        topic: String,
200        group: String,
201    }
202    impl KafkaProbe {
203        pub fn new(brokers: String, topic: String, group: String) -> Self {
204            Self {
205                brokers,
206                topic,
207                group,
208            }
209        }
210    }
211    #[async_trait]
212    impl DepthProbe for KafkaProbe {
213        async fn depth(&self) -> Result<u64, String> {
214            // rdkafka is sync; run on a blocking thread.
215            let brokers = self.brokers.clone();
216            let topic = self.topic.clone();
217            let group = self.group.clone();
218            tokio::task::spawn_blocking(move || -> Result<u64, String> {
219                let consumer: BaseConsumer = ClientConfig::new()
220                    .set("bootstrap.servers", &brokers)
221                    .set("group.id", &group)
222                    .set("enable.auto.commit", "false")
223                    .create()
224                    .map_err(|e| format!("kafka consumer: {e}"))?;
225                let meta = consumer
226                    .fetch_metadata(Some(&topic), Duration::from_secs(10))
227                    .map_err(|e| format!("kafka metadata: {e}"))?;
228                let parts: Vec<i32> = meta
229                    .topics()
230                    .iter()
231                    .find(|t| t.name() == topic)
232                    .map(|t| t.partitions().iter().map(|p| p.id()).collect())
233                    .unwrap_or_default();
234                if parts.is_empty() {
235                    return Err(format!(
236                        "kafka topic '{topic}' has no partitions in metadata (check the topic name / broker permissions)"
237                    ));
238                }
239                // Sum (high watermark - committed) across partitions = consumer lag.
240                let mut tpl = TopicPartitionList::new();
241                for p in &parts {
242                    tpl.add_partition(&topic, *p);
243                }
244                let committed = consumer
245                    .committed_offsets(tpl, Duration::from_secs(10))
246                    .map_err(|e| format!("kafka committed: {e}"))?;
247                let mut lag: i64 = 0;
248                for p in &parts {
249                    let (_low, high) = consumer
250                        .fetch_watermarks(&topic, *p, Duration::from_secs(10))
251                        .map_err(|e| format!("kafka watermarks: {e}"))?;
252                    let committed_off = committed
253                        .find_partition(&topic, *p)
254                        .map(|e| e.offset())
255                        .unwrap_or(Offset::Invalid);
256                    let c = match committed_off {
257                        Offset::Offset(n) => n,
258                        _ => 0,
259                    };
260                    lag += (high - c).max(0);
261                }
262                Ok(lag.max(0) as u64)
263            })
264            .await
265            .map_err(|e| format!("kafka probe join: {e}"))?
266        }
267        fn queue_label(&self) -> String {
268            self.topic.clone()
269        }
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn edge_fires_once_then_suppresses_until_drain() {
279        let mut e = Edge::new(5);
280        assert_eq!(e.on_depth(0), None);
281        assert_eq!(e.on_depth(5), Some(1)); // rising edge
282        assert_eq!(e.on_depth(9), None); // still high → suppressed
283        assert_eq!(e.on_depth(6), None);
284        assert_eq!(e.on_depth(0), None); // drained → re-arm
285        assert_eq!(e.on_depth(7), Some(2)); // next rising edge → new ordinal
286    }
287}