Skip to content

Commit c43439d

Browse files
authored
Add some tests and metadata to LockedFile (#4834)
# Description of Changes 1. This adds some basic tests of `LockedFile` (which would generally have also passed before these changes). 2. `LockedFile` can now have contents, which are added to the errors if we can't aquire the lock. The default metadata has the pid and a timestamp. I now see that there is a separate `Lockfile` that doesn't clean up locks on a crash, so that should probably also be cleaned up (but in a separate PR). # Expected complexity level and risk 1. # Testing This has unit tests.
1 parent 44f9581 commit c43439d

4 files changed

Lines changed: 206 additions & 24 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/core/src/error.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ pub enum DatabaseError {
8282
}
8383

8484
impl From<LockError> for DatabaseError {
85-
fn from(LockError { path, source }: LockError) -> Self {
85+
fn from(LockError { path, source, .. }: LockError) -> Self {
8686
Self::DatabasedOpened(path, source.into())
8787
}
8888
}

crates/fs-utils/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ description = "Assorted utilities for filesystem operations used in SpacetimeDB"
88

99
[dependencies]
1010
anyhow.workspace = true
11+
log.workspace = true
12+
chrono = { workspace = true, features = ["alloc", "std"] }
1113
fs2.workspace = true
1214
hex.workspace = true
1315
rand.workspace = true

crates/fs-utils/src/lockfile.rs

Lines changed: 201 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -91,24 +91,42 @@ impl Drop for Lockfile {
9191
}
9292

9393
pub mod advisory {
94+
use chrono::{DateTime, Utc};
95+
use log;
9496
use std::{
97+
error::Error as StdError,
9598
fmt,
96-
fs::{self, File},
97-
io,
99+
fs::File,
100+
io::{self, Read, Seek, SeekFrom, Write},
98101
path::{Path, PathBuf},
102+
process,
103+
time::SystemTime,
99104
};
100105

101-
use fs2::FileExt as _;
102-
use thiserror::Error;
103-
104106
use crate::create_parent_dir;
107+
use fs2::FileExt as _;
105108

106-
#[derive(Debug, Error)]
107-
#[error("failed to lock {}", path.display())]
109+
#[derive(Debug)]
108110
pub struct LockError {
109111
pub path: PathBuf,
110-
#[source]
111112
pub source: io::Error,
113+
pub existing_contents: Option<String>,
114+
}
115+
116+
impl fmt::Display for LockError {
117+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118+
write!(f, "failed to lock {}", self.path.display())?;
119+
if let Some(contents) = &self.existing_contents {
120+
write!(f, " (existing contents: {:?})", contents)?;
121+
}
122+
Ok(())
123+
}
124+
}
125+
126+
impl StdError for LockError {
127+
fn source(&self) -> Option<&(dyn StdError + 'static)> {
128+
Some(&self.source)
129+
}
112130
}
113131

114132
/// A file locked with an exclusive, filesystem-level lock.
@@ -139,30 +157,96 @@ pub mod advisory {
139157
/// created.
140158
pub fn lock(path: impl AsRef<Path>) -> Result<Self, LockError> {
141159
let path = path.as_ref();
142-
Self::lock_inner(path).map_err(|source| LockError {
143-
path: path.into(),
144-
source,
145-
})
160+
Self::lock_inner(path)
146161
}
147162

148-
fn lock_inner(path: &Path) -> io::Result<Self> {
149-
create_parent_dir(path)?;
150-
let lock = File::create(path)?;
163+
/// Replace the lock file contents with `metadata` while holding the lock.
164+
pub fn write_metadata(&mut self, metadata: impl AsRef<[u8]>) -> io::Result<()> {
165+
self.lock.set_len(0)?;
166+
self.lock.seek(SeekFrom::Start(0))?;
167+
self.lock.write_all(metadata.as_ref())?;
168+
self.lock.sync_data()?;
169+
Ok(())
170+
}
171+
172+
fn lock_inner(path: &Path) -> Result<Self, LockError> {
173+
create_parent_dir(path).map_err(|source| LockError {
174+
path: path.to_path_buf(),
175+
source,
176+
existing_contents: None,
177+
})?;
178+
// This will create the file if it doesn't already exist.
179+
let mut lock = File::options()
180+
.read(true)
181+
.write(true)
182+
.create(true)
183+
.truncate(false)
184+
.open(path)
185+
.map_err(|source| LockError {
186+
path: path.to_path_buf(),
187+
source,
188+
existing_contents: None,
189+
})?;
151190
// TODO: Use `File::lock` (available since rust 1.89) instead?
152-
lock.try_lock_exclusive()?;
191+
if let Err(source) = lock.try_lock_exclusive() {
192+
let existing_contents = if source.kind() == io::ErrorKind::WouldBlock {
193+
Self::read_existing_contents(&mut lock).ok().flatten()
194+
} else {
195+
None
196+
};
197+
return Err(LockError {
198+
path: path.to_path_buf(),
199+
source,
200+
existing_contents,
201+
});
202+
}
203+
log::debug!("Acquired lock on {}", path.display());
204+
// Now that we own the lock, clear any content that may have been written by a previous holder.
205+
lock.set_len(0).map_err(|source| LockError {
206+
path: path.to_path_buf(),
207+
source,
208+
existing_contents: None,
209+
})?;
210+
lock.seek(SeekFrom::Start(0)).map_err(|source| LockError {
211+
path: path.to_path_buf(),
212+
source,
213+
existing_contents: None,
214+
})?;
153215

154-
Ok(Self {
216+
let mut locked = Self {
155217
path: path.to_path_buf(),
156218
lock,
157-
})
219+
};
220+
// Write the default metadata.
221+
locked
222+
.write_metadata(Self::default_metadata())
223+
.map_err(|source| LockError {
224+
path: path.to_path_buf(),
225+
source,
226+
existing_contents: None,
227+
})?;
228+
229+
Ok(locked)
158230
}
159231

160-
/// Release the lock and optionally remove the locked file.
161-
pub fn release(self, remove: bool) -> io::Result<()> {
162-
if remove {
163-
fs::remove_file(&self.path)?;
232+
fn read_existing_contents(lock: &mut File) -> io::Result<Option<String>> {
233+
lock.seek(SeekFrom::Start(0))?;
234+
let mut bytes = Vec::new();
235+
lock.read_to_end(&mut bytes)?;
236+
if bytes.is_empty() {
237+
return Ok(None);
164238
}
165-
Ok(())
239+
Ok(Some(String::from_utf8_lossy(&bytes).into_owned()))
240+
}
241+
242+
// Default contents of a lockfile, which has the pid and timestamp.
243+
fn default_metadata() -> String {
244+
let timestamp_ms = SystemTime::now()
245+
.duration_since(SystemTime::UNIX_EPOCH)
246+
.unwrap_or_default()
247+
.as_millis() as i64;
248+
let timestamp = DateTime::<Utc>::from_timestamp_millis(timestamp_ms).unwrap_or(DateTime::<Utc>::UNIX_EPOCH);
249+
format!("pid={};timestamp_utc={}", process::id(), timestamp.to_rfc3339())
166250
}
167251
}
168252

@@ -171,4 +255,98 @@ pub mod advisory {
171255
f.debug_struct("LockedFile").field("path", &self.path).finish()
172256
}
173257
}
258+
259+
impl Drop for LockedFile {
260+
fn drop(&mut self) {
261+
log::debug!("Released lock on {}", self.path.display());
262+
}
263+
}
264+
}
265+
266+
#[cfg(test)]
267+
mod tests {
268+
use std::{fs, io::ErrorKind};
269+
270+
use tempdir::TempDir;
271+
272+
use super::advisory::LockedFile;
273+
274+
#[test]
275+
fn lockedfile_can_create_a_file() {
276+
let tmp = TempDir::new("lockfile_test").unwrap();
277+
let path = tmp.path().join("db.lock");
278+
let _lock1 = LockedFile::lock(&path).unwrap();
279+
assert!(path.exists());
280+
let contents = fs::read_to_string(&path).unwrap();
281+
assert!(contents.contains(&format!("pid={}", std::process::id())));
282+
assert!(contents.contains("timestamp_utc="));
283+
}
284+
285+
#[test]
286+
fn lockedfile_can_create_a_directory_file() {
287+
let tmp = TempDir::new("lockfile_test").unwrap();
288+
let path = tmp.path().join("new_dir").join("db.lock");
289+
let _lock1 = LockedFile::lock(&path).unwrap();
290+
assert!(path.exists());
291+
}
292+
293+
#[test]
294+
fn only_one_exclusive_lock_can_be_held() {
295+
let tmp = TempDir::new("lockfile_test").unwrap();
296+
let path = tmp.path().join("db.lock");
297+
let _lock1 = LockedFile::lock(&path).unwrap();
298+
299+
assert!(LockedFile::lock(&path).is_err());
300+
}
301+
302+
#[test]
303+
fn lockedfile_can_handle_existing_file() {
304+
let tmp = TempDir::new("locked_file_test").unwrap();
305+
let path = tmp.path().join("db.lock");
306+
let original = b"existing lock metadata";
307+
fs::write(&path, original).unwrap();
308+
309+
let _lock = LockedFile::lock(&path).unwrap();
310+
311+
// Previous metadata should be replaced when we acquire the lock.
312+
let contents = fs::read_to_string(&path).unwrap();
313+
assert!(contents.contains(&format!("pid={}", std::process::id())));
314+
assert!(contents.contains("timestamp_utc="));
315+
}
316+
317+
#[test]
318+
fn lockedfile_can_store_metadata() {
319+
let tmp = TempDir::new("locked_file_test").unwrap();
320+
let path = tmp.path().join("db.lock");
321+
let mut lock = LockedFile::lock(&path).unwrap();
322+
323+
lock.write_metadata("pid=1234").unwrap();
324+
325+
assert_eq!(fs::read_to_string(&path).unwrap(), "pid=1234");
326+
}
327+
328+
#[test]
329+
fn lock_error_includes_existing_contents_when_already_locked() {
330+
let tmp = TempDir::new("locked_file_test").unwrap();
331+
let path = tmp.path().join("db.lock");
332+
let mut lock = LockedFile::lock(&path).unwrap();
333+
lock.write_metadata("pid=1234").unwrap();
334+
335+
let err = LockedFile::lock(&path).unwrap_err();
336+
assert_eq!(err.source.kind(), ErrorKind::WouldBlock);
337+
assert_eq!(err.existing_contents.as_deref(), Some("pid=1234"));
338+
assert!(err.to_string().contains("pid=1234"));
339+
}
340+
341+
#[test]
342+
fn dropping_unlocks_file() {
343+
let tmp = TempDir::new("locked_file_test").unwrap();
344+
let path = tmp.path().join("db.lock");
345+
let mut lock = LockedFile::lock(&path).unwrap();
346+
lock.write_metadata("pid=1234").unwrap();
347+
348+
drop(lock);
349+
350+
let _lock2 = LockedFile::lock(&path).unwrap();
351+
}
174352
}

0 commit comments

Comments
 (0)