dragonfly-client-util 1.4.1

Utility library for the dragonfly client
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
/*
 *     Copyright 2026 The Dragonfly Authors
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

use dragonfly_client_core::Result;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};
use tokio::sync::Mutex;
use tracing::debug;

/// Represents the disk statistics for a specific path.
#[derive(Debug, Clone, Default)]
pub struct DiskStats {
    /// The total disk space in bytes.
    pub total: u64,

    /// The free disk space available in bytes.
    pub free: u64,

    /// The used disk space in bytes.
    pub usage: u64,

    /// The percentage of disk space used (0.0 to 100.0).
    pub used_percent: f64,
}

/// Represents the disk I/O statistics for a specific process.
#[derive(Debug, Clone, Default)]
pub struct ProcessDiskStats {
    /// The write bandwidth of the process in bytes per second.
    pub write_bandwidth: u64,

    /// The read bandwidth of the process in bytes per second.
    pub read_bandwidth: u64,
}

/// Represents disk I/O statistics from cgroup (Linux containers/resource limits).
#[derive(Debug, Clone, Default)]
pub struct CgroupDiskStats {
    /// The write bandwidth of the cgroup at the block-device level in bytes per second.
    pub write_bandwidth: u64,

    /// The read bandwidth of the cgroup at the block-device level in bytes per second.
    pub read_bandwidth: u64,
}

/// Represents a disk interface for monitoring disk statistics.
#[derive(Debug, Clone, Default)]
pub struct Disk {
    // Mutex to protect concurrent access to disk statistics.
    mutex: Arc<Mutex<()>>,
}

/// Implementation of disk monitoring functionality.
///
/// Provides methods to retrieve disk statistics at three different levels:
/// - Path-level: Total, free and used space of the filesystem.
/// - Process-level: I/O bandwidth of a specific process.
/// - Cgroup-level: Block-device I/O bandwidth of the cgroup (Linux only).
impl Disk {
    /// Default interval for refreshing disk statistics.
    const DEFAULT_DISK_REFRESH_INTERVAL: Duration = Duration::from_secs(1);

    /// Creates a new Disk instance.
    ///
    /// # Returns
    /// A new Disk instance with an initialized mutex for thread-safe access.
    pub fn new() -> Self {
        Self {
            mutex: Arc::new(Mutex::new(())),
        }
    }

    /// Retrieves the disk statistics for the specified path.
    ///
    /// This method queries the filesystem to obtain total space, available space,
    /// used space, and the percentage of disk usage.
    ///
    /// # Arguments
    /// * `path` - The filesystem path to query for disk statistics.
    ///
    /// # Returns
    /// Result<DiskStats> containing disk space information if successful,
    /// or an error if the operation fails.
    pub fn get_stats(&self, path: &Path) -> Result<DiskStats> {
        let stats = fs2::statvfs(path)?;
        let total_space = stats.total_space();
        let available_space = stats.available_space();
        let usage_space = total_space - available_space;
        let used_percent = (usage_space as f64 / (total_space) as f64) * 100.0;

        debug!(
            "disk total space: {} bytes, available space: {} bytes, usage space: {} bytes, used percent: {}%",
            total_space, available_space, usage_space, used_percent
        );

        Ok(DiskStats {
            total: total_space,
            free: available_space,
            usage: usage_space,
            used_percent: used_percent.clamp(0.0, 100.0),
        })
    }

    /// Retrieves the disk I/O statistics for the process with the given PID.
    ///
    /// This method measures the process's /proc I/O counters over a time
    /// interval (DEFAULT_DISK_REFRESH_INTERVAL) to calculate read and write
    /// bandwidth. Writes are accounted at page-dirtying time rather than at
    /// the block device.
    ///
    /// # Arguments
    /// * `pid` - The process ID to monitor for disk I/O statistics.
    ///
    /// # Returns
    /// ProcessDiskStats containing read and write bandwidth information in bytes per second.
    pub async fn get_process_stats(&self, pid: u32) -> ProcessDiskStats {
        // Lock the mutex to ensure exclusive access to disk stats.
        let _guard = self.mutex.lock().await;

        let mut sys = System::new();
        sys.refresh_processes_specifics(
            ProcessesToUpdate::Some(&[Pid::from_u32(pid)]),
            false,
            ProcessRefreshKind::nothing().with_disk_usage(),
        );

        // Sleep to calculate the disk traffic difference over
        // the DEFAULT_DISK_REFRESH_INTERVAL.
        tokio::time::sleep(Self::DEFAULT_DISK_REFRESH_INTERVAL).await;

        sys.refresh_processes_specifics(
            ProcessesToUpdate::Some(&[Pid::from_u32(pid)]),
            false,
            ProcessRefreshKind::nothing().with_disk_usage(),
        );

        let disk_usage = sys.process(Pid::from_u32(pid)).unwrap().disk_usage();
        let write_bandwidth =
            disk_usage.written_bytes / Self::DEFAULT_DISK_REFRESH_INTERVAL.as_secs();
        let read_bandwidth = disk_usage.read_bytes / Self::DEFAULT_DISK_REFRESH_INTERVAL.as_secs();

        debug!(
            "process {} disk write bandwidth: {} bytes/s, read bandwidth: {} bytes/s",
            pid, write_bandwidth, read_bandwidth
        );

        ProcessDiskStats {
            write_bandwidth,
            read_bandwidth,
        }
    }

    /// Retrieves disk I/O statistics from the cgroup (Linux only).
    ///
    /// This method measures the cumulative block-device I/O counters of the
    /// cgroup the process belongs to over a time interval
    /// (DEFAULT_DISK_REFRESH_INTERVAL) to calculate device-level read and
    /// write bandwidth. cgroup v2 supports writeback attribution, so flushed
    /// page cache writes are charged to the cgroup that dirtied the pages,
    /// while cgroup v1 only accounts direct and synchronous I/O.
    ///
    /// # Arguments
    /// * `pid` - Process ID used to determine which cgroup to query.
    ///
    /// # Returns
    /// Some(CgroupDiskStats) if cgroup I/O statistics are available and accessible,
    /// None otherwise or on non-Linux platforms.
    #[allow(unused_variables)]
    pub async fn get_cgroup_stats(&self, pid: u32) -> Option<CgroupDiskStats> {
        #[cfg(target_os = "linux")]
        {
            // Lock the mutex to ensure exclusive access to disk stats.
            let _guard = self.mutex.lock().await;

            // Take a baseline of the cumulative block-device I/O counters.
            let (baseline_read_bytes, baseline_written_bytes) = Self::get_cgroup_io_counters(pid)?;

            // Sleep to calculate the disk traffic difference over
            // the DEFAULT_DISK_REFRESH_INTERVAL.
            tokio::time::sleep(Self::DEFAULT_DISK_REFRESH_INTERVAL).await;

            let (read_bytes, written_bytes) = Self::get_cgroup_io_counters(pid)?;

            // Calculate the write bandwidth in bytes per second.
            let write_bandwidth = (written_bytes.saturating_sub(baseline_written_bytes) as f64
                / Self::DEFAULT_DISK_REFRESH_INTERVAL.as_secs_f64())
            .round() as u64;

            // Calculate the read bandwidth in bytes per second.
            let read_bandwidth = (read_bytes.saturating_sub(baseline_read_bytes) as f64
                / Self::DEFAULT_DISK_REFRESH_INTERVAL.as_secs_f64())
            .round() as u64;

            debug!(
                "process {} cgroup disk write bandwidth: {} bytes/s, read bandwidth: {} bytes/s",
                pid, write_bandwidth, read_bandwidth
            );

            Some(CgroupDiskStats {
                write_bandwidth,
                read_bandwidth,
            })
        }

        #[cfg(not(target_os = "linux"))]
        None
    }

    /// Retrieves the cumulative bytes read from and written to the block
    /// devices by the cgroup the process belongs to.
    ///
    /// # Arguments
    /// * `pid` - The process ID used to determine which cgroup to read.
    ///
    /// # Returns
    /// Some((read_bytes, written_bytes)) summed across all block devices if
    /// the cgroup I/O statistics are accessible, None otherwise.
    #[cfg(target_os = "linux")]
    fn get_cgroup_io_counters(pid: u32) -> Option<(u64, u64)> {
        use cgroups_rs::fs::hierarchies;

        if hierarchies::auto().v2() {
            Self::get_cgroup_v2_io_counters(pid)
        } else {
            Self::get_cgroup_v1_io_counters(pid)
        }
    }

    /// Retrieves the cumulative block-device I/O counters from the cgroup v2
    /// io.stat file.
    ///
    /// # Arguments
    /// * `pid` - The process ID used to determine which cgroup to read.
    ///
    /// # Returns
    /// Some((read_bytes, written_bytes)) summed across all block devices,
    /// None if the io.stat file is unavailable.
    #[cfg(target_os = "linux")]
    fn get_cgroup_v2_io_counters(pid: u32) -> Option<(u64, u64)> {
        use crate::cgroups::get_cgroup_v2_path_by_pid;
        use tracing::error;

        let path = match get_cgroup_v2_path_by_pid(pid) {
            Ok(path) => path.join("io.stat"),
            Err(err) => {
                error!("failed to get cgroup v2 path for pid {}: {}", pid, err);
                return None;
            }
        };

        match std::fs::read_to_string(&path) {
            Ok(content) => Some(Self::parse_cgroup_v2_io_stat(&content)),
            Err(err) => {
                error!("failed to read {}: {}", path.display(), err);
                None
            }
        }
    }

    /// Parses the content of a cgroup v2 io.stat file and sums the read and
    /// written bytes across all block devices.
    ///
    /// Each line is `MAJ:MIN rbytes=N wbytes=N rios=N wios=N dbytes=N dios=N`.
    ///
    /// # Arguments
    /// * `content` - The content of the io.stat file.
    ///
    /// # Returns
    /// (read_bytes, written_bytes) summed across all block devices.
    #[cfg(target_os = "linux")]
    fn parse_cgroup_v2_io_stat(content: &str) -> (u64, u64) {
        let (mut read_bytes, mut written_bytes) = (0u64, 0u64);
        for part in content.split_whitespace() {
            if let Some(value) = part.strip_prefix("rbytes=") {
                read_bytes = read_bytes.saturating_add(value.parse().unwrap_or(0));
            } else if let Some(value) = part.strip_prefix("wbytes=") {
                written_bytes = written_bytes.saturating_add(value.parse().unwrap_or(0));
            }
        }

        (read_bytes, written_bytes)
    }

    /// Retrieves the cumulative block-device I/O counters from the cgroup v1
    /// blkio controller.
    ///
    /// cgroup v1 charges page cache writeback to the root cgroup, so only
    /// direct and synchronous I/O are accounted here.
    ///
    /// # Arguments
    /// * `pid` - The process ID used to determine which cgroup to read.
    ///
    /// # Returns
    /// Some((read_bytes, written_bytes)) summed across all block devices,
    /// None if the blkio controller is unavailable.
    #[cfg(target_os = "linux")]
    fn get_cgroup_v1_io_counters(pid: u32) -> Option<(u64, u64)> {
        use crate::cgroups::get_cgroup_by_pid;
        use cgroups_rs::fs::blkio::BlkIoController;
        use tracing::error;

        let cgroup = match get_cgroup_by_pid(pid) {
            Ok(cgroup) => cgroup,
            Err(err) => {
                error!("failed to get cgroup for pid {}: {}", pid, err);
                return None;
            }
        };

        let Some(blkio_controller) = cgroup.controller_of::<BlkIoController>() else {
            error!("no blkio controller found for pid {}", pid);
            return None;
        };

        // blkio.throttle.io_service_bytes is maintained by any I/O scheduler,
        // while blkio.io_service_bytes is only maintained by CFQ.
        let blkio = blkio_controller.blkio();
        let io_service_bytes = if !blkio.throttle.io_service_bytes.is_empty() {
            blkio.throttle.io_service_bytes
        } else {
            blkio.io_service_bytes
        };

        Some(Self::parse_cgroup_v1_io_stat(&io_service_bytes))
    }

    /// Parses the cgroup v1 blkio IoService entries and sums the read and
    /// written bytes across all block devices.
    ///
    /// # Arguments
    /// * `io_service_bytes` - The per-device IoService entries from
    ///   blkio.throttle.io_service_bytes or blkio.io_service_bytes.
    ///
    /// # Returns
    /// (read_bytes, written_bytes) summed across all block devices.
    #[cfg(target_os = "linux")]
    fn parse_cgroup_v1_io_stat(
        io_service_bytes: &[cgroups_rs::fs::blkio::IoService],
    ) -> (u64, u64) {
        io_service_bytes
            .iter()
            .fold((0u64, 0u64), |(read_bytes, written_bytes), io_service| {
                (
                    read_bytes.saturating_add(io_service.read),
                    written_bytes.saturating_add(io_service.write),
                )
            })
    }
}

#[cfg(all(test, target_os = "linux"))]
mod tests {
    use super::*;

    #[test]
    fn test_parse_cgroup_v2_io_stat() {
        let content = "8:0 rbytes=90430464 wbytes=299008000 rios=8950 wios=1252 dbytes=50331648 dios=3021\n253:0 rbytes=1459200 wbytes=314773504 rios=192 wios=353 dbytes=0 dios=0";
        assert_eq!(
            Disk::parse_cgroup_v2_io_stat(content),
            (90430464 + 1459200, 299008000 + 314773504)
        );

        assert_eq!(Disk::parse_cgroup_v2_io_stat(""), (0, 0));
    }

    #[test]
    fn test_parse_cgroup_v1_io_stat() {
        use cgroups_rs::fs::blkio::IoService;

        let io_service_bytes = vec![
            IoService {
                major: 8,
                minor: 0,
                read: 90430464,
                write: 299008000,
                ..Default::default()
            },
            IoService {
                major: 253,
                minor: 0,
                read: 1459200,
                write: 314773504,
                ..Default::default()
            },
        ];
        assert_eq!(
            Disk::parse_cgroup_v1_io_stat(&io_service_bytes),
            (90430464 + 1459200, 299008000 + 314773504)
        );

        assert_eq!(Disk::parse_cgroup_v1_io_stat(&[]), (0, 0));
    }
}