Skip to main content

rustfs_io_core/
io_profile.rs

1// Copyright 2024 RustFS Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! I/O profile helpers for adaptive scheduling.
16
17use std::collections::VecDeque;
18use std::str::FromStr;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub enum StorageMedia {
22    Nvme,
23    Ssd,
24    Hdd,
25    Unknown,
26}
27
28impl StorageMedia {
29    pub fn as_str(&self) -> &'static str {
30        match self {
31            Self::Nvme => "nvme",
32            Self::Ssd => "ssd",
33            Self::Hdd => "hdd",
34            Self::Unknown => "unknown",
35        }
36    }
37}
38
39impl FromStr for StorageMedia {
40    type Err = ();
41
42    fn from_str(value: &str) -> Result<Self, Self::Err> {
43        match value.trim().to_ascii_lowercase().as_str() {
44            "nvme" => Ok(Self::Nvme),
45            "ssd" => Ok(Self::Ssd),
46            "hdd" => Ok(Self::Hdd),
47            "unknown" => Ok(Self::Unknown),
48            _ => Err(()),
49        }
50    }
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
54pub enum AccessPattern {
55    Sequential,
56    Random,
57    Mixed,
58    Unknown,
59}
60
61impl AccessPattern {
62    pub fn as_str(&self) -> &'static str {
63        match self {
64            Self::Sequential => "sequential",
65            Self::Random => "random",
66            Self::Mixed => "mixed",
67            Self::Unknown => "unknown",
68        }
69    }
70
71    /// Check if this is a sequential access pattern.
72    pub fn is_sequential(&self) -> bool {
73        matches!(self, Self::Sequential)
74    }
75
76    /// Check if this is a random access pattern.
77    pub fn is_random(&self) -> bool {
78        matches!(self, Self::Random)
79    }
80
81    /// Check if this is a mixed access pattern.
82    pub fn is_mixed(&self) -> bool {
83        matches!(self, Self::Mixed)
84    }
85
86    /// Check if this pattern is unknown.
87    pub fn is_unknown(&self) -> bool {
88        matches!(self, Self::Unknown)
89    }
90}
91
92#[derive(Debug, Clone, Copy, PartialEq)]
93pub struct StorageProfile {
94    pub media: StorageMedia,
95    pub buffer_cap: usize,
96    pub sequential_boost_multiplier: f64,
97    pub random_penalty_multiplier: f64,
98    pub prefers_readahead: bool,
99}
100
101impl StorageProfile {
102    pub fn for_media(media: StorageMedia, nvme_buffer_cap: usize, ssd_buffer_cap: usize, hdd_buffer_cap: usize) -> Self {
103        match media {
104            StorageMedia::Nvme => Self {
105                media,
106                buffer_cap: nvme_buffer_cap,
107                sequential_boost_multiplier: 1.35,
108                random_penalty_multiplier: 0.9,
109                prefers_readahead: true,
110            },
111            StorageMedia::Ssd => Self {
112                media,
113                buffer_cap: ssd_buffer_cap,
114                sequential_boost_multiplier: 1.2,
115                random_penalty_multiplier: 0.8,
116                prefers_readahead: true,
117            },
118            StorageMedia::Hdd => Self {
119                media,
120                buffer_cap: hdd_buffer_cap,
121                sequential_boost_multiplier: 1.1,
122                random_penalty_multiplier: 0.65,
123                prefers_readahead: false,
124            },
125            StorageMedia::Unknown => Self {
126                media,
127                buffer_cap: ssd_buffer_cap,
128                sequential_boost_multiplier: 1.0,
129                random_penalty_multiplier: 0.8,
130                prefers_readahead: true,
131            },
132        }
133    }
134}
135
136#[derive(Debug, Clone)]
137pub struct IoPatternDetector {
138    history_size: usize,
139    sequential_step_tolerance_bytes: u64,
140    history: VecDeque<(u64, u64)>,
141}
142
143impl IoPatternDetector {
144    pub fn new(history_size: usize, sequential_step_tolerance_bytes: u64) -> Self {
145        Self {
146            history_size: history_size.max(2),
147            sequential_step_tolerance_bytes,
148            history: VecDeque::with_capacity(history_size.max(2)),
149        }
150    }
151
152    pub fn record(&mut self, offset: u64, len: u64) {
153        if self.history.len() == self.history_size {
154            self.history.pop_front();
155        }
156        self.history.push_back((offset, len));
157    }
158
159    pub fn current_pattern(&self) -> AccessPattern {
160        if self.history.len() < 2 {
161            return AccessPattern::Unknown;
162        }
163
164        let history = self.history.iter().copied().collect::<Vec<_>>();
165        let mut sequential = 0usize;
166        let mut random = 0usize;
167
168        for window in history.windows(2) {
169            let (prev_offset, prev_len) = window[0];
170            let (curr_offset, _) = window[1];
171            let prev_end = prev_offset.saturating_add(prev_len);
172            if curr_offset.abs_diff(prev_end) <= self.sequential_step_tolerance_bytes {
173                sequential += 1;
174            } else {
175                random += 1;
176            }
177        }
178
179        match (sequential, random) {
180            (0, 0) => AccessPattern::Unknown,
181            (_, 0) => AccessPattern::Sequential,
182            (0, _) => AccessPattern::Random,
183            _ => AccessPattern::Mixed,
184        }
185    }
186}
187
188pub fn detect_storage_media(storage_detection_enabled: bool, storage_media_override: &str) -> StorageMedia {
189    if let Ok(media) = StorageMedia::from_str(storage_media_override) {
190        return media;
191    }
192
193    if !storage_detection_enabled {
194        return StorageMedia::Unknown;
195    }
196
197    // Try platform-specific detection
198    #[cfg(target_os = "linux")]
199    {
200        if let Ok(media) = detect_linux_storage_media()
201            && media != StorageMedia::Unknown
202        {
203            return media;
204        }
205    }
206
207    #[cfg(target_os = "macos")]
208    {
209        if let Ok(media) = detect_macos_storage_media()
210            && media != StorageMedia::Unknown
211        {
212            return media;
213        }
214    }
215
216    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
217    {
218        if let Ok(media) = detect_platform_storage_media()
219            && media != StorageMedia::Unknown
220        {
221            return media;
222        }
223    }
224
225    StorageMedia::Unknown
226}
227
228#[cfg(target_os = "linux")]
229fn detect_linux_storage_media() -> Result<StorageMedia, std::io::Error> {
230    use std::path::Path;
231
232    // Try to detect NVMe devices first
233    if Path::new("/sys/class/nvme").exists() {
234        // Check if there are any NVMe devices
235        if let Ok(entries) = std::fs::read_dir("/sys/class/nvme") {
236            for entry in entries.flatten() {
237                let name = entry.file_name();
238                let name_str = name.to_string_lossy();
239                if name_str.starts_with("nvme") {
240                    return Ok(StorageMedia::Nvme);
241                }
242            }
243        }
244    }
245
246    // Check rotational flag for common block devices (sda, sdb, etc.)
247    for device in &["sda", "sdb", "nvme0n1", "vda"] {
248        let rotational_path = format!("/sys/block/{}/queue/rotational", device);
249        if let Ok(content) = std::fs::read_to_string(&rotational_path) {
250            let rotational = content.trim().parse::<u32>().unwrap_or(1);
251            if rotational == 0 {
252                // Non-rotating = SSD/NVMe
253                // If device name starts with "nvme", it's NVMe
254                if device.starts_with("nvme") {
255                    return Ok(StorageMedia::Nvme);
256                }
257                return Ok(StorageMedia::Ssd);
258            } else {
259                // Rotating = HDD
260                return Ok(StorageMedia::Hdd);
261            }
262        }
263    }
264
265    Ok(StorageMedia::Unknown)
266}
267
268#[cfg(target_os = "macos")]
269fn detect_macos_storage_media() -> Result<StorageMedia, std::io::Error> {
270    use std::process::Command;
271
272    // Use diskutil to get disk information
273    let output = Command::new("diskutil").args(["info", "/"]).output()?;
274
275    if !output.status.success() {
276        return Ok(StorageMedia::Unknown);
277    }
278
279    let info = String::from_utf8_lossy(&output.stdout);
280
281    // Check for NVMe
282    if info.contains("NVMe") || info.contains("nvme") {
283        return Ok(StorageMedia::Nvme);
284    }
285
286    // Check for SSD indicators
287    if info.contains("Solid State") || info.contains("SSD") || info.contains("Solid-State") {
288        return Ok(StorageMedia::Ssd);
289    }
290
291    // Check for HDD/rotational indicators
292    // Note: macOS typically doesn't explicitly say "HDD", so we assume HDD if not SSD/NVMe
293    // when detection is enabled
294    if info.contains("Rotational") || info.contains("HDD") {
295        return Ok(StorageMedia::Hdd);
296    }
297
298    // Default to SSD for modern Macs (most are SSD-based)
299    // This is a reasonable default for macOS systems
300    Ok(StorageMedia::Ssd)
301}
302
303#[cfg(not(any(target_os = "linux", target_os = "macos")))]
304fn detect_platform_storage_media() -> Result<StorageMedia, std::io::Error> {
305    Ok(StorageMedia::Unknown)
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn test_storage_media_override() {
314        // Override should always take precedence
315        assert_eq!(detect_storage_media(true, "nvme"), StorageMedia::Nvme);
316        assert_eq!(detect_storage_media(false, "ssd"), StorageMedia::Ssd);
317        assert_eq!(detect_storage_media(true, "hdd"), StorageMedia::Hdd);
318        assert_eq!(detect_storage_media(false, "unknown"), StorageMedia::Unknown);
319    }
320
321    #[test]
322    fn test_storage_media_from_str() {
323        assert_eq!(StorageMedia::from_str("nvme"), Ok(StorageMedia::Nvme));
324        assert_eq!(StorageMedia::from_str("NVMe"), Ok(StorageMedia::Nvme));
325        assert_eq!(StorageMedia::from_str("ssd"), Ok(StorageMedia::Ssd));
326        assert_eq!(StorageMedia::from_str("SSD"), Ok(StorageMedia::Ssd));
327        assert_eq!(StorageMedia::from_str("hdd"), Ok(StorageMedia::Hdd));
328        assert_eq!(StorageMedia::from_str("HDD"), Ok(StorageMedia::Hdd));
329        assert_eq!(StorageMedia::from_str("unknown"), Ok(StorageMedia::Unknown));
330        assert_eq!(StorageMedia::from_str("invalid"), Err(()));
331        assert_eq!(StorageMedia::from_str(""), Err(()));
332    }
333
334    #[test]
335    fn test_storage_media_as_str() {
336        assert_eq!(StorageMedia::Nvme.as_str(), "nvme");
337        assert_eq!(StorageMedia::Ssd.as_str(), "ssd");
338        assert_eq!(StorageMedia::Hdd.as_str(), "hdd");
339        assert_eq!(StorageMedia::Unknown.as_str(), "unknown");
340    }
341
342    #[test]
343    fn test_storage_detection_disabled() {
344        // When detection is disabled and no override, should return Unknown
345        assert_eq!(detect_storage_media(false, ""), StorageMedia::Unknown);
346    }
347
348    #[test]
349    fn test_pattern_detector_sequential() {
350        let mut detector = IoPatternDetector::new(4, 1024);
351        detector.record(0, 4096);
352        detector.record(4096, 4096);
353        detector.record(8192, 4096);
354        assert_eq!(detector.current_pattern(), AccessPattern::Sequential);
355    }
356
357    #[test]
358    fn test_pattern_detector_random() {
359        let mut detector = IoPatternDetector::new(4, 1024);
360        detector.record(0, 4096);
361        detector.record(65536, 4096);
362        detector.record(4096, 4096);
363        assert_eq!(detector.current_pattern(), AccessPattern::Random);
364    }
365
366    #[test]
367    fn test_pattern_detector_mixed() {
368        let mut detector = IoPatternDetector::new(10, 1024);
369        detector.record(0, 4096); // Sequential to 4096
370        detector.record(4096, 4096); // Sequential to 8192
371        detector.record(65536, 4096); // Random jump
372        detector.record(98304, 4096); // Sequential from random position
373        assert_eq!(detector.current_pattern(), AccessPattern::Mixed);
374    }
375
376    #[test]
377    fn test_pattern_detector_insufficient_history() {
378        let detector = IoPatternDetector::new(10, 1024);
379        // No records yet
380        assert_eq!(detector.current_pattern(), AccessPattern::Unknown);
381
382        // Only one record
383        let mut detector = IoPatternDetector::new(10, 1024);
384        detector.record(0, 4096);
385        assert_eq!(detector.current_pattern(), AccessPattern::Unknown);
386    }
387
388    #[test]
389    fn test_access_pattern_helpers() {
390        assert!(AccessPattern::Sequential.is_sequential());
391        assert!(!AccessPattern::Sequential.is_random());
392        assert!(!AccessPattern::Sequential.is_mixed());
393        assert!(!AccessPattern::Sequential.is_unknown());
394
395        assert!(AccessPattern::Random.is_random());
396        assert!(!AccessPattern::Random.is_sequential());
397
398        assert!(AccessPattern::Mixed.is_mixed());
399        assert!(!AccessPattern::Mixed.is_sequential());
400        assert!(!AccessPattern::Mixed.is_random());
401
402        assert!(AccessPattern::Unknown.is_unknown());
403        assert!(!AccessPattern::Unknown.is_sequential());
404    }
405
406    #[test]
407    fn test_storage_profile_for_media() {
408        let nvme_cap = 2 * 1024 * 1024;
409        let ssd_cap = 1024 * 1024;
410        let hdd_cap = 512 * 1024;
411
412        let nvme_profile = StorageProfile::for_media(StorageMedia::Nvme, nvme_cap, ssd_cap, hdd_cap);
413        assert_eq!(nvme_profile.media, StorageMedia::Nvme);
414        assert_eq!(nvme_profile.buffer_cap, nvme_cap);
415        assert_eq!(nvme_profile.sequential_boost_multiplier, 1.35);
416        assert_eq!(nvme_profile.random_penalty_multiplier, 0.9);
417        assert!(nvme_profile.prefers_readahead);
418
419        let ssd_profile = StorageProfile::for_media(StorageMedia::Ssd, nvme_cap, ssd_cap, hdd_cap);
420        assert_eq!(ssd_profile.media, StorageMedia::Ssd);
421        assert_eq!(ssd_profile.buffer_cap, ssd_cap);
422        assert_eq!(ssd_profile.sequential_boost_multiplier, 1.2);
423        assert_eq!(ssd_profile.random_penalty_multiplier, 0.8);
424
425        let hdd_profile = StorageProfile::for_media(StorageMedia::Hdd, nvme_cap, ssd_cap, hdd_cap);
426        assert_eq!(hdd_profile.media, StorageMedia::Hdd);
427        assert_eq!(hdd_profile.buffer_cap, hdd_cap);
428        assert_eq!(hdd_profile.sequential_boost_multiplier, 1.1);
429        assert_eq!(hdd_profile.random_penalty_multiplier, 0.65);
430        assert!(!hdd_profile.prefers_readahead);
431
432        let unknown_profile = StorageProfile::for_media(StorageMedia::Unknown, nvme_cap, ssd_cap, hdd_cap);
433        assert_eq!(unknown_profile.media, StorageMedia::Unknown);
434        // Unknown media uses SSD cap
435        assert_eq!(unknown_profile.buffer_cap, ssd_cap);
436        assert_eq!(unknown_profile.sequential_boost_multiplier, 1.0);
437    }
438
439    // What platform probing returns depends on the machine, so these pin the two
440    // rules that do not: the override wins over probing, and probing that is
441    // switched off reports Unknown rather than guessing (rustfs/backlog#1836).
442    #[test]
443    fn storage_media_override_wins_over_platform_detection() {
444        for (override_value, expected) in [
445            ("nvme", StorageMedia::Nvme),
446            ("ssd", StorageMedia::Ssd),
447            ("hdd", StorageMedia::Hdd),
448        ] {
449            assert_eq!(detect_storage_media(true, override_value), expected);
450            assert_eq!(
451                detect_storage_media(false, override_value),
452                expected,
453                "an override must be honoured even with detection disabled"
454            );
455        }
456    }
457
458    #[test]
459    fn disabled_detection_reports_unknown_instead_of_guessing() {
460        assert_eq!(detect_storage_media(false, ""), StorageMedia::Unknown);
461        assert_eq!(
462            detect_storage_media(false, "not-a-medium"),
463            StorageMedia::Unknown,
464            "an unparseable override falls through to the disabled path"
465        );
466    }
467
468    #[test]
469    fn enabled_detection_returns_a_medium_for_this_platform() {
470        // Whatever this machine reports, it must be one of the known variants and
471        // it must be stable across calls — a probe that flapped would make the
472        // scheduler's profile depend on when it asked.
473        let first = detect_storage_media(true, "");
474        assert!(matches!(
475            first,
476            StorageMedia::Nvme | StorageMedia::Ssd | StorageMedia::Hdd | StorageMedia::Unknown
477        ));
478        assert_eq!(detect_storage_media(true, ""), first);
479    }
480}