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 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 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 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
219pub 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 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 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 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 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 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 for _ in 0..2 {
369 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}