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::Keyspace;
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    // Whether the store can be relied on to let exactly one writer win. A
38    // filesystem always can: `create_new` either makes the file or does not. A
39    // bucket can only if it implements `If-None-Match: *`, and that is asked at
40    // startup rather than assumed, because a store which accepts the header and
41    // writes anyway tells both callers they took the lock.
42    conditional_writes: bool,
43}
44
45enum Backend {
46    Local { root: PathBuf },
47    Bucket(Box<Keyspace>),
48}
49
50impl LockStore {
51    pub fn local(root: impl Into<PathBuf>) -> Self {
52        Self::over(Backend::Local { root: root.into() })
53    }
54
55    pub fn bucket(keys: Keyspace) -> Self {
56        Self::over(Backend::Bucket(Box::new(keys)))
57    }
58
59    fn over(backend: Backend) -> Self {
60        Self {
61            backend,
62            max_age: None,
63            conditional_writes: true,
64        }
65    }
66
67    pub fn with_max_age(mut self, max_age: Option<Duration>) -> Self {
68        self.max_age = max_age;
69        self
70    }
71
72    // Set from what the store answered at startup. False turns taking a lock
73    // into a `501` rather than into a lock two people can hold, which is the
74    // only honest thing to do with a store that cannot arbitrate.
75    pub fn with_conditional_writes(mut self, supported: bool) -> Self {
76        self.conditional_writes = supported;
77        self
78    }
79
80    pub fn max_age(&self) -> Option<Duration> {
81        self.max_age
82    }
83
84    // How long a lock has gone untouched, once it is past the age an operator
85    // said was too long. None while it is still somebody's.
86    pub fn stale_for(&self, lock: &Lock) -> Option<Duration> {
87        stale_for(lock, self.max_age)
88    }
89
90    pub fn id_of(path: &str) -> String {
91        hex::encode(Sha256::digest(path.as_bytes()))[..32].to_owned()
92    }
93
94    // The same layout in both, so an operator reading a bucket sees what they
95    // would see on the volume.
96    fn prefix(ns: &Namespace) -> String {
97        format!(".locks/{}/{}/", ns.org(), ns.repo())
98    }
99
100    fn key_of(ns: &Namespace, id: &str) -> String {
101        format!("{}{id}.json", Self::prefix(ns))
102    }
103
104    pub async fn create(&self, ns: &Namespace, path: &str, owner: &str) -> Result<Lock, Error> {
105        if path.is_empty() {
106            return Err(Error::MalformedLockPath);
107        }
108
109        let lock = Lock {
110            id: Self::id_of(path),
111            path: path.to_owned(),
112            locked_at: OffsetDateTime::now_utc()
113                .format(&Rfc3339)
114                .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned()),
115            owner: Owner {
116                name: owner.to_owned(),
117            },
118        };
119        let encoded = serde_json::to_vec(&lock)?;
120
121        if self.take(ns, &lock, &encoded).await? {
122            return Ok(lock);
123        }
124
125        // Whoever holds it is the useful half of the answer, and reading it back
126        // can race with a release. Naming the caller's own attempt is a worse
127        // answer than none, so a lock that vanished underfoot is reported as
128        // held by nobody in particular rather than by the caller.
129        let Some(held) = self.get(ns, &lock.id).await? else {
130            return Err(Error::LockHeld(Box::new(lock)));
131        };
132
133        let Some(age) = self.stale_for(&held) else {
134            return Err(Error::LockHeld(Box::new(held)));
135        };
136
137        // Discarding it and taking it conditionally, rather than overwriting in
138        // place, is what stops two replicas both claiming one abandoned lock:
139        // whoever loses the create loses outright and is told who won. Somebody
140        // taking it fresh in the gap wins for the same reason.
141        self.discard(ns, &held.id).await?;
142
143        if !self.take(ns, &lock, &encoded).await? {
144            return match self.get(ns, &lock.id).await? {
145                Some(other) => Err(Error::LockHeld(Box::new(other))),
146                None => Err(Error::LockHeld(Box::new(lock))),
147            };
148        }
149
150        tracing::info!(
151            path = lock.path,
152            previous_owner = held.owner.name,
153            untouched_for_seconds = age.as_secs(),
154            new_owner = lock.owner.name,
155            "a lock nobody had touched was taken over"
156        );
157
158        Ok(lock)
159    }
160
161    async fn take(&self, ns: &Namespace, lock: &Lock, encoded: &[u8]) -> Result<bool, Error> {
162        match &self.backend {
163            Backend::Local { root } => {
164                Self::write_new(&Self::path_in(root, ns, &lock.id), encoded).await
165            }
166            // Refused rather than attempted. The write would succeed and the
167            // caller would be told the lock is theirs, which is the one answer
168            // this must never give when the store cannot say whether somebody
169            // else was told the same thing a moment earlier.
170            Backend::Bucket(_) if !self.conditional_writes => Err(Error::Unsupported(
171                "this object store does not refuse a conditional write, so a lock here could be \
172                 held by two people at once",
173            )),
174            Backend::Bucket(bucket) => {
175                bucket
176                    .put_if_absent(&Self::key_of(ns, &lock.id), encoded.to_vec())
177                    .await
178            }
179        }
180    }
181
182    // Removing something already gone is the normal case here: another replica
183    // may have discarded the same abandoned lock a moment earlier.
184    async fn discard(&self, ns: &Namespace, id: &str) -> Result<(), Error> {
185        match self.remove(ns, id).await {
186            Ok(()) | Err(Error::LockNotFound) => Ok(()),
187            Err(error) => Err(error),
188        }
189    }
190
191    async fn write_new(path: &Path, encoded: &[u8]) -> Result<bool, Error> {
192        let parent = path.parent().expect("lock paths have a parent");
193        fs::create_dir_all(parent).await?;
194
195        match fs::OpenOptions::new()
196            .write(true)
197            .create_new(true)
198            .open(path)
199            .await
200        {
201            Ok(mut file) => {
202                file.write_all(encoded).await?;
203                file.sync_all().await?;
204                Ok(true)
205            }
206            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
207            Err(error) => Err(error.into()),
208        }
209    }
210
211    pub async fn get(&self, ns: &Namespace, id: &str) -> Result<Option<Lock>, Error> {
212        if !is_well_formed_id(id) {
213            return Ok(None);
214        }
215
216        let encoded = match &self.backend {
217            Backend::Local { root } => match fs::read(Self::path_in(root, ns, id)).await {
218                Ok(bytes) => Some(bytes),
219                Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
220                Err(error) => return Err(error.into()),
221            },
222            Backend::Bucket(bucket) => bucket.get_bytes(&Self::key_of(ns, id)).await?,
223        };
224
225        Ok(encoded.and_then(|bytes| serde_json::from_slice(&bytes).ok()))
226    }
227
228    pub async fn list(&self, ns: &Namespace) -> Result<Vec<Lock>, Error> {
229        let mut locks = match &self.backend {
230            Backend::Local { root } => Self::list_local(&Self::directory_in(root, ns)).await?,
231            Backend::Bucket(bucket) => Self::list_bucket(bucket, ns).await?,
232        };
233
234        locks.sort_by(|a: &Lock, b: &Lock| a.path.cmp(&b.path));
235        Ok(locks)
236    }
237
238    async fn list_local(directory: &Path) -> Result<Vec<Lock>, Error> {
239        let Ok(mut entries) = fs::read_dir(directory).await else {
240            return Ok(Vec::new());
241        };
242
243        let mut locks = Vec::new();
244        while let Some(entry) = entries.next_entry().await? {
245            if let Ok(bytes) = fs::read(entry.path()).await
246                && let Ok(lock) = serde_json::from_slice(&bytes)
247            {
248                locks.push(lock);
249            }
250        }
251
252        Ok(locks)
253    }
254
255    // A failure here is an error rather than an empty list. For a capacity
256    // figure, answering zero when the store cannot be reached is merely
257    // unhelpful; for locks it tells a client every file is free, which is the
258    // one answer that loses somebody's work.
259    async fn list_bucket(bucket: &Keyspace, ns: &Namespace) -> Result<Vec<Lock>, Error> {
260        let mut locks = Vec::new();
261
262        for key in bucket.keys(&Self::prefix(ns)).await? {
263            if let Some(bytes) = bucket.get_bytes(&key).await?
264                && let Ok(lock) = serde_json::from_slice(&bytes)
265            {
266                locks.push(lock);
267            }
268        }
269
270        Ok(locks)
271    }
272
273    pub async fn remove(&self, ns: &Namespace, id: &str) -> Result<(), Error> {
274        if !is_well_formed_id(id) {
275            return Err(Error::LockNotFound);
276        }
277
278        let removed = match &self.backend {
279            Backend::Local { root } => match fs::remove_file(Self::path_in(root, ns, id)).await {
280                Ok(()) => true,
281                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
282                Err(error) => return Err(error.into()),
283            },
284            Backend::Bucket(bucket) => bucket.delete(&Self::key_of(ns, id)).await?,
285        };
286
287        removed.then_some(()).ok_or(Error::LockNotFound)
288    }
289
290    fn directory_in(root: &Path, ns: &Namespace) -> PathBuf {
291        root.join(".locks").join(ns.org()).join(ns.repo())
292    }
293
294    fn path_in(root: &Path, ns: &Namespace, id: &str) -> PathBuf {
295        Self::directory_in(root, ns).join(format!("{id}.json"))
296    }
297}
298
299// The clock runs from when the lock was taken, not from the last push to the
300// object it covers. Creation is the claim, and it is the one this server can
301// answer for without guessing which object a path maps to.
302pub fn stale_for(lock: &Lock, max_age: Option<Duration>) -> Option<Duration> {
303    let max_age = max_age?;
304    let taken = OffsetDateTime::parse(&lock.locked_at, &Rfc3339).ok()?;
305
306    // A negative age is a clock that moved, not a lock from the future.
307    let age = Duration::try_from(OffsetDateTime::now_utc() - taken).ok()?;
308
309    (age > max_age).then_some(age)
310}
311
312fn is_well_formed_id(id: &str) -> bool {
313    id.len() == 32 && id.bytes().all(|b| b.is_ascii_hexdigit())
314}
315
316#[cfg(test)]
317mod tests;