faucet_cli/serve/triggers/
queue_depth.rs1use 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#[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 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; None
44 }
45 }
46}
47
48#[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 self.edge.armed = true;
107 self.edge.edge_ordinal -= 1;
108 }
109 Ok(outcome.committed())
110 }
111}
112
113pub 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 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 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 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)); assert_eq!(e.on_depth(9), None); assert_eq!(e.on_depth(6), None);
284 assert_eq!(e.on_depth(0), None); assert_eq!(e.on_depth(7), Some(2)); }
287}