spool/
local_disk.rs

1use crate::{Spool, SpoolEntry, SpoolId};
2use anyhow::Context;
3use async_trait::async_trait;
4use chrono::{DateTime, Utc};
5use flume::Sender;
6use std::fs::File;
7use std::io::Write;
8use std::os::fd::AsRawFd;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::time::Instant;
12use tempfile::NamedTempFile;
13use tokio::runtime::Handle;
14
15pub struct LocalDiskSpool {
16    path: PathBuf,
17    flush: bool,
18    _pid_file: File,
19    runtime: Handle,
20}
21
22impl LocalDiskSpool {
23    pub fn new(path: &Path, flush: bool, runtime: Handle) -> anyhow::Result<Self> {
24        let pid_file_path = path.join("lock");
25        let _pid_file = lock_pid_file(pid_file_path)?;
26
27        Self::create_dir_structure(path)?;
28
29        // Catch a split real/effective identity meeting an over-restrictive
30        // directory before it can manifest as confusing store failures.
31        dir_probe::probe_directory(path)
32            .with_context(|| format!("spool directory {} is not usable", path.display()))?;
33
34        Ok(Self {
35            path: path.to_path_buf(),
36            flush,
37            _pid_file,
38            runtime,
39        })
40    }
41
42    fn create_dir_structure(path: &Path) -> anyhow::Result<()> {
43        std::fs::create_dir_all(path.join("new"))?;
44        std::fs::create_dir_all(path.join("data"))?;
45        Ok(())
46    }
47
48    fn compute_path(&self, id: SpoolId) -> PathBuf {
49        id.compute_path(&self.path.join("data"))
50    }
51
52    fn cleanup_dirs(path: &Path) {
53        let new_dir = path.join("new");
54        for entry in jwalk::WalkDir::new(new_dir) {
55            if let Ok(entry) = entry {
56                if !entry.file_type().is_file() {
57                    continue;
58                }
59                let path = entry.path();
60                if let Err(err) = std::fs::remove_file(&path) {
61                    eprintln!("Failed to remove {path:?}: {err:#}");
62                }
63            }
64        }
65
66        let data_dir = path.join("data");
67        Self::cleanup_data(&data_dir);
68    }
69
70    fn cleanup_data(data_dir: &Path) {
71        for entry in jwalk::WalkDir::new(data_dir) {
72            if let Ok(entry) = entry {
73                if !entry.file_type().is_dir() {
74                    continue;
75                }
76                let path = entry.path();
77                // Speculatively try removing the directory; it will
78                // only succeed if it is empty. We don't need to check
79                // for that first, and we don't care if it fails.
80                std::fs::remove_dir(&path).ok();
81            }
82        }
83    }
84}
85
86#[async_trait]
87impl Spool for LocalDiskSpool {
88    async fn load(&self, id: SpoolId) -> anyhow::Result<Vec<u8>> {
89        let path = self.compute_path(id);
90        tokio::fs::read(&path)
91            .await
92            .with_context(|| format!("failed to load {id} from {path:?}"))
93    }
94
95    async fn remove(&self, id: SpoolId) -> anyhow::Result<()> {
96        let path = self.compute_path(id);
97        tokio::fs::remove_file(&path)
98            .await
99            .with_context(|| format!("failed to remove {id} from {path:?}"))
100    }
101
102    async fn store(
103        &self,
104        id: SpoolId,
105        data: Arc<Box<[u8]>>,
106        force_sync: bool,
107        _deadline: Option<Instant>,
108    ) -> anyhow::Result<()> {
109        let path = self.compute_path(id);
110        let new_dir = self.path.join("new");
111        let flush = force_sync || self.flush;
112        tokio::task::Builder::new()
113            .name("LocalDiskSpool store")
114            .spawn_blocking_on(
115                move || {
116                    let mut temp = NamedTempFile::new_in(new_dir).with_context(|| {
117                        format!("failed to create a temporary file to store {id}")
118                    })?;
119
120                    temp.write_all(&data)
121                        .with_context(|| format!("failed to write data for {id}"))?;
122
123                    if flush {
124                        temp.as_file_mut()
125                            .sync_data()
126                            .with_context(|| format!("failed to sync data for {id}"))?;
127                    }
128
129                    std::fs::create_dir_all(path.parent().unwrap()).with_context(|| {
130                        format!("failed to create dir structure for {id} {path:?}")
131                    })?;
132
133                    temp.persist(&path).with_context(|| {
134                        format!("failed to move temp file for {id} to {path:?}")
135                    })?;
136                    Ok(())
137                },
138                &self.runtime,
139            )?
140            .await?
141    }
142
143    fn enumerate(
144        &self,
145        sender: Sender<SpoolEntry>,
146        start_time: DateTime<Utc>,
147    ) -> anyhow::Result<()> {
148        let path = self.path.clone();
149        tokio::task::Builder::new()
150            .name("LocalDiskSpool enumerate")
151            .spawn_blocking_on(
152                move || -> anyhow::Result<()> {
153                    Self::cleanup_dirs(&path);
154
155                    for entry in jwalk::WalkDir::new(path.join("data")) {
156                        if let Ok(entry) = entry {
157                            if !entry.file_type().is_file() {
158                                continue;
159                            }
160                            let path = entry.path();
161                            if let Some(id) = SpoolId::from_path(&path) {
162                                if id.created() >= start_time {
163                                    // Entries created since we started must have
164                                    // landed there after we started and are thus
165                                    // not eligible for discovery via enumeration
166                                    continue;
167                                }
168                                match std::fs::read(&path) {
169                                    Ok(data) => sender
170                                        .send(SpoolEntry::Item { id, data })
171                                        .map_err(|err| {
172                                            anyhow::anyhow!("failed to send data for {id}: {err:#}")
173                                        })?,
174                                    Err(err) => sender
175                                        .send(SpoolEntry::Corrupt {
176                                            id,
177                                            error: format!("{err:#}"),
178                                        })
179                                        .map_err(|err| {
180                                            anyhow::anyhow!(
181                                                "failed to send SpoolEntry for {id}: {err:#}"
182                                            )
183                                        })?,
184                                };
185                            } else {
186                                eprintln!("{} is not a spool id", path.display());
187                            }
188                        }
189                    }
190                    anyhow::Result::Ok(())
191                },
192                &self.runtime,
193            )?;
194        Ok(())
195    }
196
197    async fn cleanup(&self) -> anyhow::Result<()> {
198        let data_dir = self.path.join("data");
199        Ok(tokio::task::Builder::new()
200            .name("LocalDiskSpool cleanup")
201            .spawn_blocking_on(
202                move || {
203                    Self::cleanup_data(&data_dir);
204                },
205                &self.runtime,
206            )?
207            .await?)
208    }
209
210    async fn shutdown(&self) -> anyhow::Result<()> {
211        Ok(())
212    }
213
214    async fn advise_low_memory(&self) -> anyhow::Result<isize> {
215        Ok(0)
216    }
217}
218
219/// Set the sticky bit on path.
220/// This prevents tmpwatch from removing the lock file.
221pub fn set_sticky_bit(path: &Path) {
222    #[cfg(unix)]
223    {
224        use std::os::unix::fs::PermissionsExt;
225        if let Ok(metadata) = path.metadata() {
226            let mut perms = metadata.permissions();
227            let mode = perms.mode();
228            perms.set_mode(mode | libc::S_ISVTX as u32);
229            let _ = std::fs::set_permissions(path, perms);
230        }
231    }
232
233    #[cfg(windows)]
234    {
235        let _ = path;
236    }
237}
238
239fn lock_pid_file(pid_file: PathBuf) -> anyhow::Result<std::fs::File> {
240    let pid_file_dir = pid_file
241        .parent()
242        .ok_or_else(|| anyhow::anyhow!("{} has no parent?", pid_file.display()))?;
243    std::fs::create_dir_all(pid_file_dir).with_context(|| {
244        format!(
245            "while creating directory structure: {}",
246            pid_file_dir.display()
247        )
248    })?;
249
250    #[allow(clippy::suspicious_open_options)]
251    let mut file = std::fs::OpenOptions::new()
252        .create(true)
253        .write(true)
254        .open(&pid_file)
255        .with_context(|| format!("opening pid file {}", pid_file.display()))?;
256    set_sticky_bit(&pid_file);
257    let res = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
258    if res != 0 {
259        let err = std::io::Error::last_os_error();
260
261        let owner = match std::fs::read_to_string(&pid_file) {
262            Ok(pid) => format!(". Owned by pid {}.", pid.trim()),
263            Err(_) => "".to_string(),
264        };
265
266        anyhow::bail!(
267            "unable to lock pid file {}: {}{owner}",
268            pid_file.display(),
269            err
270        );
271    }
272
273    unsafe { libc::ftruncate(file.as_raw_fd(), 0) };
274    writeln!(file, "{}", unsafe { libc::getpid() }).ok();
275
276    Ok(file)
277}
278
279#[cfg(test)]
280mod test {
281    use super::*;
282
283    #[tokio::test]
284    async fn basic_spool() -> anyhow::Result<()> {
285        let location = tempfile::tempdir()?;
286        let spool = LocalDiskSpool::new(location.path(), false, Handle::current())?;
287        let data_dir = location.path().join("data");
288
289        {
290            let id1 = SpoolId::new();
291            let id1_path = id1.compute_path(&data_dir).display().to_string();
292
293            // Can't load an entry that doesn't exist
294            assert_eq!(
295                format!("{:#}", spool.load(id1).await.unwrap_err()),
296                format!(
297                    "failed to load {id1} from \"{id1_path}\": \
298                    No such file or directory (os error 2)"
299                )
300            );
301        }
302
303        // Insert some entries
304        let mut ids = vec![];
305        for i in 0..100 {
306            let id = SpoolId::new();
307            spool
308                .store(
309                    id,
310                    Arc::new(format!("I am {i}").as_bytes().to_vec().into_boxed_slice()),
311                    false,
312                    None,
313                )
314                .await?;
315            ids.push(id);
316        }
317
318        // Verify that we can load those entries
319        for (i, &id) in ids.iter().enumerate() {
320            let data = spool.load(id).await?;
321            let text = String::from_utf8(data)?;
322            assert_eq!(text, format!("I am {i}"));
323        }
324
325        {
326            // Verify that we can enumerate them
327            let (tx, rx) = flume::bounded(32);
328            spool.enumerate(tx, Utc::now())?;
329            let mut count = 0;
330
331            while let Ok(item) = rx.recv_async().await {
332                match item {
333                    SpoolEntry::Item { id, data } => {
334                        let i = ids
335                            .iter()
336                            .position(|&item| item == id)
337                            .ok_or_else(|| anyhow::anyhow!("{id} not found in ids!"))?;
338
339                        let text = String::from_utf8(data)?;
340                        assert_eq!(text, format!("I am {i}"));
341
342                        spool.remove(id).await?;
343                        // Can't load an entry that we just removed
344                        let id_path = id.compute_path(&data_dir).display().to_string();
345                        assert_eq!(
346                            format!("{:#}", spool.load(id).await.unwrap_err()),
347                            format!(
348                                "failed to load {id} from \"{id_path}\": \
349                                No such file or directory (os error 2)"
350                            )
351                        );
352                        count += 1;
353                    }
354                    SpoolEntry::Corrupt { id, error } => {
355                        anyhow::bail!("Corrupt: {id}: {error}");
356                    }
357                }
358            }
359
360            assert_eq!(count, 100);
361        }
362
363        // Now that we've removed the files, try enumerating again.
364        // We expect to receive no entries.
365        // Do it a couple of times to verify that none of the cleanup
366        // stuff that happens in enumerate breaks the directory
367        // structure
368        for _ in 0..2 {
369            // Verify that we can enumerate them
370            let (tx, rx) = flume::bounded(32);
371            spool.enumerate(tx, Utc::now())?;
372            let mut unexpected = vec![];
373
374            while let Ok(item) = rx.recv_async().await {
375                match item {
376                    SpoolEntry::Item { id, .. } | SpoolEntry::Corrupt { id, .. } => {
377                        unexpected.push(id)
378                    }
379                }
380            }
381
382            assert_eq!(unexpected.len(), 0);
383        }
384
385        Ok(())
386    }
387}