Skip to main content

lfsx_server/
locks.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use time::OffsetDateTime;
6use time::format_description::well_known::Rfc3339;
7use tokio::fs;
8use tokio::io::AsyncWriteExt;
9
10use crate::error::Error;
11use crate::namespace::Namespace;
12use crate::storage::s3::S3Store;
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct Lock {
16    pub id: String,
17    pub path: String,
18    pub locked_at: String,
19    pub owner: Owner,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct Owner {
24    pub name: String,
25}
26
27// Locks live wherever the objects do. On a volume that is a file per lock; in a
28// bucket it is a key per lock, and the reason is the same one the bucket exists
29// for: two replicas sharing storage have to agree on who holds what. A lock
30// store on local disk behind a bucket means each replica has its own answer,
31// and an artist told the scene is theirs while another replica hands it to
32// somebody else is worse than no locking at all.
33pub struct LockStore(Backend);
34
35enum Backend {
36    Local { root: PathBuf },
37    Bucket(Box<S3Store>),
38}
39
40impl LockStore {
41    pub fn local(root: impl Into<PathBuf>) -> Self {
42        Self(Backend::Local { root: root.into() })
43    }
44
45    pub fn bucket(bucket: S3Store) -> Self {
46        Self(Backend::Bucket(Box::new(bucket)))
47    }
48
49    pub fn id_of(path: &str) -> String {
50        hex::encode(Sha256::digest(path.as_bytes()))[..32].to_owned()
51    }
52
53    // The same layout in both, so an operator reading a bucket sees what they
54    // would see on the volume.
55    fn prefix(ns: &Namespace) -> String {
56        format!(".locks/{}/{}/", ns.org(), ns.repo())
57    }
58
59    fn key_of(ns: &Namespace, id: &str) -> String {
60        format!("{}{id}.json", Self::prefix(ns))
61    }
62
63    pub async fn create(&self, ns: &Namespace, path: &str, owner: &str) -> Result<Lock, Error> {
64        if path.is_empty() {
65            return Err(Error::MalformedLockPath);
66        }
67
68        let lock = Lock {
69            id: Self::id_of(path),
70            path: path.to_owned(),
71            locked_at: OffsetDateTime::now_utc()
72                .format(&Rfc3339)
73                .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned()),
74            owner: Owner {
75                name: owner.to_owned(),
76            },
77        };
78        let encoded = serde_json::to_vec(&lock)?;
79
80        let taken = match &self.0 {
81            Backend::Local { root } => {
82                Self::write_new(&Self::path_in(root, ns, &lock.id), &encoded).await?
83            }
84            Backend::Bucket(bucket) => {
85                bucket
86                    .put_if_absent(&Self::key_of(ns, &lock.id), encoded)
87                    .await?
88            }
89        };
90
91        if taken {
92            return Ok(lock);
93        }
94
95        // Whoever holds it is the useful half of the answer, and reading it back
96        // can race with a release. Naming the caller's own attempt is a worse
97        // answer than none, so a lock that vanished underfoot is reported as
98        // held by nobody in particular rather than by the caller.
99        match self.get(ns, &lock.id).await? {
100            Some(held) => Err(Error::LockHeld(Box::new(held))),
101            None => Err(Error::LockHeld(Box::new(lock))),
102        }
103    }
104
105    async fn write_new(path: &Path, encoded: &[u8]) -> Result<bool, Error> {
106        let parent = path.parent().expect("lock paths have a parent");
107        fs::create_dir_all(parent).await?;
108
109        match fs::OpenOptions::new()
110            .write(true)
111            .create_new(true)
112            .open(path)
113            .await
114        {
115            Ok(mut file) => {
116                file.write_all(encoded).await?;
117                file.sync_all().await?;
118                Ok(true)
119            }
120            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
121            Err(error) => Err(error.into()),
122        }
123    }
124
125    pub async fn get(&self, ns: &Namespace, id: &str) -> Result<Option<Lock>, Error> {
126        if !is_well_formed_id(id) {
127            return Ok(None);
128        }
129
130        let encoded = match &self.0 {
131            Backend::Local { root } => match fs::read(Self::path_in(root, ns, id)).await {
132                Ok(bytes) => Some(bytes),
133                Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
134                Err(error) => return Err(error.into()),
135            },
136            Backend::Bucket(bucket) => bucket.get_bytes(&Self::key_of(ns, id)).await?,
137        };
138
139        Ok(encoded.and_then(|bytes| serde_json::from_slice(&bytes).ok()))
140    }
141
142    pub async fn list(&self, ns: &Namespace) -> Result<Vec<Lock>, Error> {
143        let mut locks = match &self.0 {
144            Backend::Local { root } => Self::list_local(&Self::directory_in(root, ns)).await?,
145            Backend::Bucket(bucket) => Self::list_bucket(bucket, ns).await?,
146        };
147
148        locks.sort_by(|a: &Lock, b: &Lock| a.path.cmp(&b.path));
149        Ok(locks)
150    }
151
152    async fn list_local(directory: &Path) -> Result<Vec<Lock>, Error> {
153        let Ok(mut entries) = fs::read_dir(directory).await else {
154            return Ok(Vec::new());
155        };
156
157        let mut locks = Vec::new();
158        while let Some(entry) = entries.next_entry().await? {
159            if let Ok(bytes) = fs::read(entry.path()).await
160                && let Ok(lock) = serde_json::from_slice(&bytes)
161            {
162                locks.push(lock);
163            }
164        }
165
166        Ok(locks)
167    }
168
169    // A failure here is an error rather than an empty list. For a capacity
170    // figure, answering zero when the store cannot be reached is merely
171    // unhelpful; for locks it tells a client every file is free, which is the
172    // one answer that loses somebody's work.
173    async fn list_bucket(bucket: &S3Store, ns: &Namespace) -> Result<Vec<Lock>, Error> {
174        let mut locks = Vec::new();
175
176        for key in bucket.keys(&Self::prefix(ns)).await? {
177            if let Some(bytes) = bucket.get_bytes(&key).await?
178                && let Ok(lock) = serde_json::from_slice(&bytes)
179            {
180                locks.push(lock);
181            }
182        }
183
184        Ok(locks)
185    }
186
187    pub async fn remove(&self, ns: &Namespace, id: &str) -> Result<(), Error> {
188        if !is_well_formed_id(id) {
189            return Err(Error::LockNotFound);
190        }
191
192        let removed = match &self.0 {
193            Backend::Local { root } => match fs::remove_file(Self::path_in(root, ns, id)).await {
194                Ok(()) => true,
195                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
196                Err(error) => return Err(error.into()),
197            },
198            Backend::Bucket(bucket) => bucket.delete(&Self::key_of(ns, id)).await?,
199        };
200
201        removed.then_some(()).ok_or(Error::LockNotFound)
202    }
203
204    fn directory_in(root: &Path, ns: &Namespace) -> PathBuf {
205        root.join(".locks").join(ns.org()).join(ns.repo())
206    }
207
208    fn path_in(root: &Path, ns: &Namespace, id: &str) -> PathBuf {
209        Self::directory_in(root, ns).join(format!("{id}.json"))
210    }
211}
212
213fn is_well_formed_id(id: &str) -> bool {
214    id.len() == 32 && id.bytes().all(|b| b.is_ascii_hexdigit())
215}
216
217#[cfg(test)]
218mod tests;