Skip to main content

sidekiq/
stats.rs

1use crate::RedisPool;
2use rand::RngCore;
3use serde::Serialize;
4use std::sync::atomic::{AtomicUsize, Ordering};
5use std::sync::Arc;
6
7#[derive(Clone)]
8pub struct Counter {
9    count: Arc<AtomicUsize>,
10}
11
12impl Counter {
13    #[must_use]
14    pub fn new(n: usize) -> Self {
15        Self {
16            count: Arc::new(AtomicUsize::new(n)),
17        }
18    }
19
20    #[must_use]
21    pub fn value(&self) -> usize {
22        self.count.load(Ordering::SeqCst)
23    }
24
25    pub fn decrby(&self, n: usize) {
26        self.count.fetch_sub(n, Ordering::SeqCst);
27    }
28
29    pub fn incrby(&self, n: usize) {
30        self.count.fetch_add(n, Ordering::SeqCst);
31    }
32}
33
34struct ProcessStats {
35    rtt_us: String,
36    quiet: String,
37    busy: usize,
38    beat: f64,
39    concurrency: usize,
40    info: ProcessInfo,
41    rss: String,
42}
43
44#[derive(Serialize)]
45struct ProcessInfo {
46    hostname: String,
47    identity: String,
48    started_at: f64,
49    pid: u32,
50    tag: String,
51    concurrency: usize,
52    queues: Vec<String>,
53    labels: Vec<String>,
54    version: String,
55    embedded: bool,
56}
57
58pub struct StatsPublisher {
59    hostname: String,
60    identity: String,
61    queues: Vec<String>,
62    started_at: chrono::DateTime<chrono::Utc>,
63    busy_jobs: Counter,
64    concurrency: usize,
65}
66
67fn generate_identity(hostname: &String) -> String {
68    let pid = std::process::id();
69    let mut bytes = [0u8; 12];
70    rand::rng().fill_bytes(&mut bytes);
71    let nonce = hex::encode(bytes);
72
73    format!("{hostname}:{pid}:{nonce}")
74}
75
76/// A per-worker "thread id", used as the field key in the `<identity>:work`
77/// hash that backs the Sidekiq web "Busy" page. Mirrors Ruby's `Sidekiq.tid`
78/// (a short, process-unique token); generated once per worker so the same
79/// slot is reused as that worker churns through jobs.
80pub(crate) fn generate_tid() -> String {
81    let mut bytes = [0u8; 6];
82    rand::rng().fill_bytes(&mut bytes);
83    hex::encode(bytes)
84}
85
86impl StatsPublisher {
87    #[must_use]
88    pub fn new(
89        hostname: String,
90        queues: Vec<String>,
91        busy_jobs: Counter,
92        concurrency: usize,
93    ) -> Self {
94        let identity = generate_identity(&hostname);
95        let started_at = chrono::Utc::now();
96
97        Self {
98            hostname,
99            identity,
100            queues,
101            started_at,
102            busy_jobs,
103            concurrency,
104        }
105    }
106
107    // 127.0.0.1:6379> hkeys "yolo_app:DESKTOP-UMSV21A:107068:5075431aeb06"
108    // 1) "rtt_us"
109    // 2) "quiet"
110    // 3) "busy"
111    // 4) "beat"
112    // 5) "info"
113    // 6) "rss"
114    // 127.0.0.1:6379> hget "yolo_app:DESKTOP-UMSV21A:107068:5075431aeb06" info
115    // "{\"hostname\":\"DESKTOP-UMSV21A\",\"started_at\":1658082501.5606177,\"pid\":107068,\"tag\":\"\",\"concurrency\":10,\"queues\":[\"ruby:v1_statistics\",\"ruby:v2_statistics\"],\"labels\":[],\"identity\":\"DESKTOP-UMSV21A:107068:5075431aeb06\"}"
116    // 127.0.0.1:6379> hget "yolo_app:DESKTOP-UMSV21A:107068:5075431aeb06" irss
117    // (nil)
118    pub async fn publish_stats(&self, redis: RedisPool) -> Result<(), Box<dyn std::error::Error>> {
119        let stats = self.create_process_stats().await?;
120        let mut conn = redis.get().await?;
121        let _: () = conn
122            .cmd_with_key("HSET", self.identity.clone())
123            .arg("info")
124            .arg(serde_json::to_string(&stats.info)?)
125            .arg("concurrency")
126            .arg(stats.concurrency)
127            .arg("busy")
128            .arg(stats.busy)
129            .arg("beat")
130            .arg(stats.beat)
131            .arg("rtt_us")
132            .arg(stats.rtt_us)
133            .arg("quiet")
134            .arg(stats.quiet)
135            .arg("rss")
136            .arg(stats.rss)
137            .query_async::<()>(conn.unnamespaced_borrow_mut())
138            .await?;
139
140        conn.expire(self.identity.clone(), 60).await?;
141
142        conn.sadd("processes".to_string(), self.identity.clone())
143            .await?;
144
145        // Keep the WorkSet hash (written per-job in `Processor::process_one_tick_once`)
146        // on the same 60s heartbeat TTL, so a crashed process's in-flight entries
147        // self-expire and long-running jobs stay visible while the process is alive.
148        // EXPIRE on a missing key (no jobs in flight) is a harmless no-op.
149        conn.expire(format!("{}:work", self.identity), 60).await?;
150
151        Ok(())
152    }
153
154    /// Remove this process from the `processes` set and delete the heartbeat hash
155    /// and the WorkSet (`<identity>:work`).
156    ///
157    /// Mirrors Ruby Sidekiq's `Launcher#clear_heartbeat`, which pipelines:
158    ///   `SREM processes [identity]`
159    ///   `UNLINK identity:work`
160    ///
161    /// Without this, stale entries accumulate in the `processes` set until the
162    /// heartbeat hash's 60s TTL expires — but the set membership has no TTL and
163    /// never self-cleans.
164    pub(crate) async fn deregister(&self, redis: RedisPool) -> crate::Result<()> {
165        let mut conn = redis.get().await?;
166        conn.srem_and_unlink(
167            "processes".to_string(),
168            self.identity.clone(),
169            self.identity.clone(),
170        )
171        .await?;
172        conn.unlink(format!("{}:work", self.identity)).await?;
173        Ok(())
174    }
175
176    pub(crate) fn identity(&self) -> &str {
177        &self.identity
178    }
179
180    async fn create_process_stats(&self) -> Result<ProcessStats, Box<dyn std::error::Error>> {
181        let rss_in_kb = format!("{}", get_rss_kb());
182
183        Ok(ProcessStats {
184            rtt_us: "0".into(),
185            busy: self.busy_jobs.value(),
186            quiet: "false".into(),
187            rss: rss_in_kb,
188            concurrency: self.concurrency,
189            beat: chrono::Utc::now().timestamp_millis() as f64 / 1000.0,
190            info: ProcessInfo {
191                concurrency: self.concurrency,
192                hostname: self.hostname.clone(),
193                identity: self.identity.clone(),
194                queues: self.queues.clone(),
195                started_at: self.started_at.timestamp_millis() as f64 / 1000.0,
196                pid: std::process::id(),
197                labels: vec![],
198                tag: String::new(),
199                version: env!("CARGO_PKG_VERSION").to_string(),
200                embedded: false,
201            },
202        })
203    }
204}
205
206/// Get RSS (resident set size) in kilobytes for the current process.
207#[cfg(target_os = "macos")]
208#[allow(deprecated)] // mach_task_self is deprecated in libc, but works fine
209fn get_rss_kb() -> u64 {
210    use std::mem;
211    unsafe {
212        let mut info: libc::mach_task_basic_info_data_t = mem::zeroed();
213        let mut count = (mem::size_of::<libc::mach_task_basic_info_data_t>()
214            / mem::size_of::<libc::natural_t>())
215            as libc::mach_msg_type_number_t;
216        let ret = libc::task_info(
217            libc::mach_task_self(),
218            libc::MACH_TASK_BASIC_INFO,
219            &mut info as *mut _ as libc::task_info_t,
220            &mut count,
221        );
222        if ret == libc::KERN_SUCCESS {
223            info.resident_size as u64 / 1024
224        } else {
225            0
226        }
227    }
228}
229
230#[cfg(target_os = "linux")]
231fn get_rss_kb() -> u64 {
232    std::fs::read_to_string("/proc/self/statm")
233        .ok()
234        .and_then(|s| s.split_whitespace().nth(1)?.parse::<u64>().ok())
235        .map(|pages| pages * 4) // page size is typically 4KB
236        .unwrap_or(0)
237}
238
239#[cfg(not(any(target_os = "macos", target_os = "linux")))]
240fn get_rss_kb() -> u64 {
241    0
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use bb8::Pool;
248    use crate::RedisConnectionManager;
249
250    async fn test_pool() -> crate::RedisPool {
251        let manager = RedisConnectionManager::new("redis://127.0.0.1/").unwrap();
252        Pool::builder().build(manager).await.unwrap()
253    }
254
255    async fn sismember(redis: &RedisPool, set: &str, member: &str) -> bool {
256        let mut conn = redis.get().await.unwrap();
257        redis::cmd("SISMEMBER")
258            .arg(set)
259            .arg(member)
260            .query_async::<i64>(conn.unnamespaced_borrow_mut())
261            .await
262            .unwrap_or(0)
263            == 1
264    }
265
266    async fn exists(redis: &RedisPool, key: &str) -> bool {
267        let mut conn = redis.get().await.unwrap();
268        redis::cmd("EXISTS")
269            .arg(key)
270            .query_async::<i64>(conn.unnamespaced_borrow_mut())
271            .await
272            .unwrap_or(0)
273            > 0
274    }
275
276    fn new_publisher() -> StatsPublisher {
277        StatsPublisher::new(
278            "testhost".to_string(),
279            vec!["default".to_string()],
280            Counter::new(0),
281            1,
282        )
283    }
284
285    #[tokio::test]
286    async fn deregister_removes_identity_from_processes_set() {
287        let redis = test_pool().await;
288        let p = new_publisher();
289
290        p.publish_stats(redis.clone()).await.unwrap();
291        assert!(
292            sismember(&redis, "processes", p.identity()).await,
293            "should be in set after publish_stats"
294        );
295
296        p.deregister(redis.clone()).await.unwrap();
297        assert!(
298            !sismember(&redis, "processes", p.identity()).await,
299            "should be removed from set after deregister"
300        );
301    }
302
303    #[tokio::test]
304    async fn deregister_deletes_heartbeat_hash() {
305        let redis = test_pool().await;
306        let p = new_publisher();
307
308        p.publish_stats(redis.clone()).await.unwrap();
309        assert!(exists(&redis, p.identity()).await, "heartbeat hash should exist");
310
311        p.deregister(redis.clone()).await.unwrap();
312        assert!(
313            !exists(&redis, p.identity()).await,
314            "heartbeat hash should be deleted after deregister"
315        );
316    }
317
318    #[tokio::test]
319    async fn deregister_does_not_affect_sibling_process() {
320        let redis = test_pool().await;
321        let p1 = new_publisher();
322        let p2 = new_publisher(); // distinct nonce → distinct identity
323
324        p1.publish_stats(redis.clone()).await.unwrap();
325        p2.publish_stats(redis.clone()).await.unwrap();
326
327        p1.deregister(redis.clone()).await.unwrap();
328
329        assert!(
330            sismember(&redis, "processes", p2.identity()).await,
331            "sibling process must remain registered after p1 deregisters"
332        );
333
334        p2.deregister(redis.clone()).await.unwrap();
335    }
336
337    #[tokio::test]
338    async fn deregister_is_idempotent() {
339        let redis = test_pool().await;
340        let p = new_publisher();
341
342        p.publish_stats(redis.clone()).await.unwrap();
343        p.deregister(redis.clone()).await.unwrap();
344        // Second call must not error (SREM on missing member is a no-op in Redis)
345        p.deregister(redis.clone()).await.unwrap();
346    }
347}