Skip to main content

lfsx_server/
locks.rs

1use std::path::{Path, PathBuf};
2use std::time::Duration;
3
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use time::OffsetDateTime;
7use time::format_description::well_known::Rfc3339;
8use tokio::fs;
9use tokio::io::AsyncWriteExt;
10
11use crate::error::Error;
12use crate::namespace::Namespace;
13use crate::storage::s3::S3Store;
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct Lock {
17    pub id: String,
18    pub path: String,
19    pub locked_at: String,
20    pub owner: Owner,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct Owner {
25    pub name: String,
26}
27
28// Locks live wherever the objects do. On a volume that is a file per lock; in a
29// bucket it is a key per lock, and the reason is the same one the bucket exists
30// for: two replicas sharing storage have to agree on who holds what. A lock
31// store on local disk behind a bucket means each replica has its own answer,
32// and an artist told the scene is theirs while another replica hands it to
33// somebody else is worse than no locking at all.
34pub struct LockStore {
35    backend: Backend,
36    max_age: Option<Duration>,
37}
38
39enum Backend {
40    Local { root: PathBuf },
41    Bucket(Box<S3Store>),
42}
43
44impl LockStore {
45    pub fn local(root: impl Into<PathBuf>) -> Self {
46        Self::over(Backend::Local { root: root.into() })
47    }
48
49    pub fn bucket(bucket: S3Store) -> Self {
50        Self::over(Backend::Bucket(Box::new(bucket)))
51    }
52
53    fn over(backend: Backend) -> Self {
54        Self {
55            backend,
56            max_age: None,
57        }
58    }
59
60    pub fn with_max_age(mut self, max_age: Option<Duration>) -> Self {
61        self.max_age = max_age;
62        self
63    }
64
65    pub fn max_age(&self) -> Option<Duration> {
66        self.max_age
67    }
68
69    // How long a lock has gone untouched, once it is past the age an operator
70    // said was too long. None while it is still somebody's.
71    pub fn stale_for(&self, lock: &Lock) -> Option<Duration> {
72        stale_for(lock, self.max_age)
73    }
74
75    pub fn id_of(path: &str) -> String {
76        hex::encode(Sha256::digest(path.as_bytes()))[..32].to_owned()
77    }
78
79    // The same layout in both, so an operator reading a bucket sees what they
80    // would see on the volume.
81    fn prefix(ns: &Namespace) -> String {
82        format!(".locks/{}/{}/", ns.org(), ns.repo())
83    }
84
85    fn key_of(ns: &Namespace, id: &str) -> String {
86        format!("{}{id}.json", Self::prefix(ns))
87    }
88
89    pub async fn create(&self, ns: &Namespace, path: &str, owner: &str) -> Result<Lock, Error> {
90        if path.is_empty() {
91            return Err(Error::MalformedLockPath);
92        }
93
94        let lock = Lock {
95            id: Self::id_of(path),
96            path: path.to_owned(),
97            locked_at: OffsetDateTime::now_utc()
98                .format(&Rfc3339)
99                .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned()),
100            owner: Owner {
101                name: owner.to_owned(),
102            },
103        };
104        let encoded = serde_json::to_vec(&lock)?;
105
106        if self.take(ns, &lock, &encoded).await? {
107            return Ok(lock);
108        }
109
110        // Whoever holds it is the useful half of the answer, and reading it back
111        // can race with a release. Naming the caller's own attempt is a worse
112        // answer than none, so a lock that vanished underfoot is reported as
113        // held by nobody in particular rather than by the caller.
114        let Some(held) = self.get(ns, &lock.id).await? else {
115            return Err(Error::LockHeld(Box::new(lock)));
116        };
117
118        let Some(age) = self.stale_for(&held) else {
119            return Err(Error::LockHeld(Box::new(held)));
120        };
121
122        // Discarding it and taking it conditionally, rather than overwriting in
123        // place, is what stops two replicas both claiming one abandoned lock:
124        // whoever loses the create loses outright and is told who won. Somebody
125        // taking it fresh in the gap wins for the same reason.
126        self.discard(ns, &held.id).await?;
127
128        if !self.take(ns, &lock, &encoded).await? {
129            return match self.get(ns, &lock.id).await? {
130                Some(other) => Err(Error::LockHeld(Box::new(other))),
131                None => Err(Error::LockHeld(Box::new(lock))),
132            };
133        }
134
135        tracing::info!(
136            path = lock.path,
137            previous_owner = held.owner.name,
138            untouched_for_seconds = age.as_secs(),
139            new_owner = lock.owner.name,
140            "a lock nobody had touched was taken over"
141        );
142
143        Ok(lock)
144    }
145
146    async fn take(&self, ns: &Namespace, lock: &Lock, encoded: &[u8]) -> Result<bool, Error> {
147        match &self.backend {
148            Backend::Local { root } => {
149                Self::write_new(&Self::path_in(root, ns, &lock.id), encoded).await
150            }
151            Backend::Bucket(bucket) => {
152                bucket
153                    .put_if_absent(&Self::key_of(ns, &lock.id), encoded.to_vec())
154                    .await
155            }
156        }
157    }
158
159    // Removing something already gone is the normal case here: another replica
160    // may have discarded the same abandoned lock a moment earlier.
161    async fn discard(&self, ns: &Namespace, id: &str) -> Result<(), Error> {
162        match self.remove(ns, id).await {
163            Ok(()) | Err(Error::LockNotFound) => Ok(()),
164            Err(error) => Err(error),
165        }
166    }
167
168    async fn write_new(path: &Path, encoded: &[u8]) -> Result<bool, Error> {
169        let parent = path.parent().expect("lock paths have a parent");
170        fs::create_dir_all(parent).await?;
171
172        match fs::OpenOptions::new()
173            .write(true)
174            .create_new(true)
175            .open(path)
176            .await
177        {
178            Ok(mut file) => {
179                file.write_all(encoded).await?;
180                file.sync_all().await?;
181                Ok(true)
182            }
183            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
184            Err(error) => Err(error.into()),
185        }
186    }
187
188    pub async fn get(&self, ns: &Namespace, id: &str) -> Result<Option<Lock>, Error> {
189        if !is_well_formed_id(id) {
190            return Ok(None);
191        }
192
193        let encoded = match &self.backend {
194            Backend::Local { root } => match fs::read(Self::path_in(root, ns, id)).await {
195                Ok(bytes) => Some(bytes),
196                Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
197                Err(error) => return Err(error.into()),
198            },
199            Backend::Bucket(bucket) => bucket.get_bytes(&Self::key_of(ns, id)).await?,
200        };
201
202        Ok(encoded.and_then(|bytes| serde_json::from_slice(&bytes).ok()))
203    }
204
205    pub async fn list(&self, ns: &Namespace) -> Result<Vec<Lock>, Error> {
206        let mut locks = match &self.backend {
207            Backend::Local { root } => Self::list_local(&Self::directory_in(root, ns)).await?,
208            Backend::Bucket(bucket) => Self::list_bucket(bucket, ns).await?,
209        };
210
211        locks.sort_by(|a: &Lock, b: &Lock| a.path.cmp(&b.path));
212        Ok(locks)
213    }
214
215    async fn list_local(directory: &Path) -> Result<Vec<Lock>, Error> {
216        let Ok(mut entries) = fs::read_dir(directory).await else {
217            return Ok(Vec::new());
218        };
219
220        let mut locks = Vec::new();
221        while let Some(entry) = entries.next_entry().await? {
222            if let Ok(bytes) = fs::read(entry.path()).await
223                && let Ok(lock) = serde_json::from_slice(&bytes)
224            {
225                locks.push(lock);
226            }
227        }
228
229        Ok(locks)
230    }
231
232    // A failure here is an error rather than an empty list. For a capacity
233    // figure, answering zero when the store cannot be reached is merely
234    // unhelpful; for locks it tells a client every file is free, which is the
235    // one answer that loses somebody's work.
236    async fn list_bucket(bucket: &S3Store, ns: &Namespace) -> Result<Vec<Lock>, Error> {
237        let mut locks = Vec::new();
238
239        for key in bucket.keys(&Self::prefix(ns)).await? {
240            if let Some(bytes) = bucket.get_bytes(&key).await?
241                && let Ok(lock) = serde_json::from_slice(&bytes)
242            {
243                locks.push(lock);
244            }
245        }
246
247        Ok(locks)
248    }
249
250    pub async fn remove(&self, ns: &Namespace, id: &str) -> Result<(), Error> {
251        if !is_well_formed_id(id) {
252            return Err(Error::LockNotFound);
253        }
254
255        let removed = match &self.backend {
256            Backend::Local { root } => match fs::remove_file(Self::path_in(root, ns, id)).await {
257                Ok(()) => true,
258                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
259                Err(error) => return Err(error.into()),
260            },
261            Backend::Bucket(bucket) => bucket.delete(&Self::key_of(ns, id)).await?,
262        };
263
264        removed.then_some(()).ok_or(Error::LockNotFound)
265    }
266
267    fn directory_in(root: &Path, ns: &Namespace) -> PathBuf {
268        root.join(".locks").join(ns.org()).join(ns.repo())
269    }
270
271    fn path_in(root: &Path, ns: &Namespace, id: &str) -> PathBuf {
272        Self::directory_in(root, ns).join(format!("{id}.json"))
273    }
274}
275
276// The clock runs from when the lock was taken, not from the last push to the
277// object it covers. Creation is the claim, and it is the one this server can
278// answer for without guessing which object a path maps to.
279pub fn stale_for(lock: &Lock, max_age: Option<Duration>) -> Option<Duration> {
280    let max_age = max_age?;
281    let taken = OffsetDateTime::parse(&lock.locked_at, &Rfc3339).ok()?;
282
283    // A negative age is a clock that moved, not a lock from the future.
284    let age = Duration::try_from(OffsetDateTime::now_utc() - taken).ok()?;
285
286    (age > max_age).then_some(age)
287}
288
289fn is_well_formed_id(id: &str) -> bool {
290    id.len() == 32 && id.bytes().all(|b| b.is_ascii_hexdigit())
291}
292
293#[cfg(test)]
294mod tests;