Skip to main content

datui_lib/home/
locality.rs

1//! Where a dataset physically lives, and what that implies about opening it (2 GB on
2//! tmpfs and on hotel-wifi NFS are the same row and a thousandfold apart). Derived from
3//! `/proc/self/mountinfo`, a kernel-generated local read that cannot block on a share
4//! that stopped answering.
5
6use std::path::Path;
7use std::sync::{Arc, Mutex};
8use std::time::{Duration, Instant};
9
10/// Filesystems whose reads cross a network: grouped by behavior (a read can stall while
11/// the far end is unreachable, uninterruptibly on `hard` NFS), not protocol.
12pub const NETWORK_FILESYSTEMS: &[&str] = &[
13    "nfs",
14    "nfs4",
15    "cifs",
16    "smb3",
17    "smbfs",
18    "afs",
19    "9p",
20    "ceph",
21    "glusterfs",
22    "fuse.sshfs",
23    "fuse.rclone",
24    "fuse.s3fs",
25    "fuse.davfs",
26    "davfs",
27    "ftpfs",
28    // An automount point that has not been triggered yet blocks on first access,
29    // which is exactly what the marker is warning about. Once it triggers, the real
30    // filesystem shadows it in the mount table and is judged on its own merits.
31    "autofs",
32];
33
34/// Filesystems backed by RAM. Reads from these are free, which is worth saying when
35/// every other row on screen is not.
36const MEMORY_FILESYSTEMS: &[&str] = &["tmpfs", "ramfs", "devtmpfs"];
37
38/// How a dataset is reached, in the terms that predict what opening it costs.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Locality {
41    /// A disk on this machine.
42    Local,
43    /// RAM. Reading is as fast as it gets.
44    Memory,
45    /// Reads cross a network and can stall.
46    Network,
47    /// An object store, reached by URL rather than by path.
48    Object,
49    /// Nothing in the mount table covered it.
50    Unknown,
51}
52
53impl Locality {
54    /// The locality a [`Source::fstype`] implies. Rows cache the filesystem name, and
55    /// reclassifying it is free, unlike rereading `/proc` on the draw thread. Object-store
56    /// schemes come first: they never appear in the mount table.
57    pub fn of_fstype(fstype: &str) -> Locality {
58        match fstype {
59            "s3" | "s3a" | "gs" | "gcs" | "az" | "cloud" | "http" | "https" => Locality::Object,
60            "" | "unknown" => Locality::Unknown,
61            other => classify(other),
62        }
63    }
64}
65
66/// Where something lives and what the kernel calls it.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct Source {
69    /// The filesystem type, or the URL scheme for an object store: `nfs4`, `ext4`,
70    /// `fuse.sshfs`, `tmpfs`, `s3`, `gs`.
71    pub fstype: String,
72    pub locality: Locality,
73}
74
75impl Source {
76    /// A short label for the interface. The filesystem's own name is the most
77    /// informative thing available and costs one word: `nfs4` and `fuse.sshfs` fail
78    /// in different ways, and neither behaves like `tmpfs`.
79    pub fn label(&self) -> &str {
80        &self.fstype
81    }
82
83    pub fn network(&self) -> bool {
84        self.locality == Locality::Network
85    }
86
87    /// Classify a filesystem name on its own, for a source recorded earlier rather
88    /// than resolved from a path just now.
89    pub fn from_fstype(fstype: &str) -> Self {
90        if let Some(scheme) = ["s3", "gs", "http", "https", "az", "hdfs"]
91            .into_iter()
92            .find(|s| *s == fstype)
93        {
94            return Self {
95                fstype: scheme.to_string(),
96                locality: Locality::Object,
97            };
98        }
99        Self {
100            locality: classify(fstype),
101            fstype: fstype.to_string(),
102        }
103    }
104}
105
106/// How long a mount-table read is reused by [`Mounts::cached`]: mounts change on a human
107/// timescale and answers are hints, so half a second is unnoticeable yet covers a
108/// listing's burst of per-row questions.
109const MOUNTS_TTL: Duration = Duration::from_millis(500);
110
111/// The last read of the mount table, and when it was taken.
112static CACHED_MOUNTS: Mutex<Option<(Instant, Arc<Mounts>)>> = Mutex::new(None);
113
114/// The mount table, parsed once; resolving paths against it is string work, so a whole
115/// listing needs one read.
116#[derive(Debug, Clone, Default)]
117pub struct Mounts {
118    /// (mount point, filesystem type), in the order the kernel listed them.
119    entries: Vec<(String, String)>,
120}
121
122impl Mounts {
123    /// Read the current mount table. An unreadable one yields an empty table, which
124    /// reports everything as unknown rather than as wrong.
125    pub fn current() -> Self {
126        std::fs::read_to_string("/proc/self/mountinfo")
127            .map(|s| Self::parse(&s))
128            .unwrap_or_default()
129    }
130
131    /// The mount table as of at most `MOUNTS_TTL` ago, shared: reading `/proc` is most of
132    /// an answer's cost, and five thousand rows asked it per row per frame.
133    pub fn cached() -> Arc<Self> {
134        let now = Instant::now();
135        // Read under the lock rather than around it: two threads arriving on a cold
136        // cache would otherwise both read `/proc`, and the loser's read would replace
137        // a table just as good as its own.
138        let mut slot = CACHED_MOUNTS
139            .lock()
140            .unwrap_or_else(std::sync::PoisonError::into_inner);
141        if let Some((read_at, mounts)) = slot.as_ref()
142            && now.duration_since(*read_at) < MOUNTS_TTL
143        {
144            return Arc::clone(mounts);
145        }
146        let mounts = Arc::new(Self::current());
147        *slot = Some((now, Arc::clone(&mounts)));
148        mounts
149    }
150
151    pub fn parse(mountinfo: &str) -> Self {
152        let mut entries = Vec::new();
153        for line in mountinfo.lines() {
154            // Fields before the separator end with the mount point at index 4; the
155            // filesystem type is the first field after it.
156            let Some((before, after)) = line.split_once(" - ") else {
157                continue;
158            };
159            let Some(point) = before.split_whitespace().nth(4) else {
160                continue;
161            };
162            let Some(fstype) = after.split_whitespace().next() else {
163                continue;
164            };
165            entries.push((point.to_string(), fstype.to_string()));
166        }
167        Self { entries }
168    }
169
170    /// The filesystem covering `path`: the deepest mount, and at one point the last listed
171    /// (a later mount shadows an earlier one, as NFS automounted over its autofs entry).
172    pub fn fstype_for(&self, path: &Path) -> Option<&str> {
173        // Mount points are absolute: join a relative path to the working directory (string
174        // work, unlike canonicalizing, which would touch the filesystem).
175        let joined;
176        // `has_root`, not `is_absolute`: on Windows `/mnt/nas/data` is "relative", and joining
177        // the cwd would break it.
178        let path = if path.has_root() {
179            path
180        } else {
181            match std::env::current_dir() {
182                Ok(cwd) => {
183                    joined = cwd.join(path);
184                    &joined
185                }
186                Err(_) => path,
187            }
188        };
189
190        let mut best: Option<(usize, &str)> = None;
191        for (point, fstype) in &self.entries {
192            if !path.starts_with(point) {
193                continue;
194            }
195            let len = point.len();
196            if best.is_none_or(|(n, _)| len >= n) {
197                best = Some((len, fstype));
198            }
199        }
200        best.map(|(_, f)| f)
201    }
202
203    /// How `path` is reached.
204    pub fn describe(&self, path: &Path) -> Source {
205        if let Some(scheme) = object_scheme(path) {
206            return Source {
207                fstype: scheme,
208                locality: Locality::Object,
209            };
210        }
211        match self.fstype_for(path) {
212            Some(fstype) => Source {
213                locality: classify(fstype),
214                fstype: fstype.to_string(),
215            },
216            None => Source {
217                fstype: "unknown".to_string(),
218                locality: Locality::Unknown,
219            },
220        }
221    }
222
223    pub fn is_network(&self, path: &Path) -> bool {
224        self.describe(path).network()
225    }
226}
227
228fn classify(fstype: &str) -> Locality {
229    if NETWORK_FILESYSTEMS.contains(&fstype) {
230        Locality::Network
231    } else if MEMORY_FILESYSTEMS.contains(&fstype) {
232        Locality::Memory
233    } else {
234        Locality::Local
235    }
236}
237
238/// The URL scheme of an object-store path, if any: never in the mount table, never
239/// walked or measured, only listed from history.
240pub fn object_scheme(path: &Path) -> Option<String> {
241    match crate::cloud::source::input_source(path) {
242        crate::cloud::source::InputSource::Local(_) => None,
243        crate::cloud::source::InputSource::S3(_) => Some("s3".to_string()),
244        crate::cloud::source::InputSource::Gcs(_) => Some("gs".to_string()),
245        crate::cloud::source::InputSource::Azure(_) => Some("az".to_string()),
246        crate::cloud::source::InputSource::Http(_) => Some("http".to_string()),
247    }
248}
249
250#[cfg(test)]
251mod locality_of_fstype_tests {
252    use super::*;
253
254    #[test]
255    fn object_store_schemes_are_not_local_disks() {
256        // The case the round trip exists for. These never appear in the mount table, so
257        // a classifier that only knew filesystems would call every one of them a disk
258        // on this machine -- and the row would then claim a cloud object is local.
259        for scheme in ["s3", "s3a", "gs", "gcs", "http", "https"] {
260            assert_eq!(
261                Locality::of_fstype(scheme),
262                Locality::Object,
263                "{scheme} should be an object store"
264            );
265        }
266    }
267
268    #[test]
269    fn network_filesystems_are_network() {
270        for fstype in ["nfs", "nfs4", "cifs", "smb3"] {
271            assert_eq!(
272                Locality::of_fstype(fstype),
273                Locality::Network,
274                "{fstype} should be network"
275            );
276        }
277    }
278
279    #[test]
280    fn memory_filesystems_are_memory() {
281        assert_eq!(Locality::of_fstype("tmpfs"), Locality::Memory);
282    }
283
284    #[test]
285    fn ordinary_filesystems_are_local() {
286        for fstype in ["ext4", "btrfs", "xfs", "apfs", "ntfs"] {
287            assert_eq!(
288                Locality::of_fstype(fstype),
289                Locality::Local,
290                "{fstype} should be local"
291            );
292        }
293    }
294
295    #[test]
296    fn nothing_known_is_not_guessed_at() {
297        assert_eq!(Locality::of_fstype(""), Locality::Unknown);
298        assert_eq!(Locality::of_fstype("unknown"), Locality::Unknown);
299    }
300
301    /// What `describe` reports and what `of_fstype` makes of it have to agree, or a row
302    /// is classified one way for the detail pane and another for its marker.
303    #[test]
304    fn it_agrees_with_describe() {
305        let mounts = Mounts::parse("");
306        for path in ["s3://bucket/key.parquet", "gs://bucket/key.parquet"] {
307            let source = mounts.describe(std::path::Path::new(path));
308            assert_eq!(source.locality, Locality::of_fstype(&source.fstype));
309        }
310    }
311}