matrix_sdk_common/cross_process_lock.rs
1// Copyright 2023 The Matrix.org Foundation C.I.C.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! A cross-process lock implementation.
16//!
17//! This is a per-process lock that may be used only for very specific use
18//! cases, where multiple processes might concurrently write to the same
19//! database at the same time; this would invalidate store caches, so
20//! that should be done mindfully. Such a lock can be obtained multiple times by
21//! the same process, and it remains active as long as there's at least one user
22//! in a given process.
23//!
24//! The lock is implemented using time-based leases. The lock maintains the lock
25//! identifier (key), who's the current holder (value), and an expiration
26//! timestamp on the side; see also `CryptoStore::try_take_leased_lock` for more
27//! details.
28//!
29//! The lock is initially obtained for a certain period of time (namely, the
30//! duration of a lease, aka `LEASE_DURATION_MS`), and then a “heartbeat” task
31//! renews the lease to extend its duration, every so often (namely, every
32//! `EXTEND_LEASE_EVERY_MS`). Since the Tokio scheduler might be busy, the
33//! extension request should happen way more frequently than the duration of a
34//! lease, in case a deadline is missed. The current values have been chosen to
35//! reflect that, with a ratio of 1:10 as of 2023-06-23.
36//!
37//! Releasing the lock happens naturally, by not renewing a lease. It happens
38//! automatically after the duration of the last lease, at most.
39
40use std::{
41 error::Error,
42 future::Future,
43 sync::{
44 Arc, Weak,
45 atomic::{AtomicBool, AtomicU64, Ordering},
46 },
47 time::Duration,
48};
49
50use tokio::sync::Mutex;
51use tracing::{debug, error, instrument, trace, warn};
52
53use crate::{
54 SendOutsideWasm, SyncOutsideWasm,
55 executor::{AbortOnDrop, JoinHandleExt, spawn},
56 sleep::sleep,
57};
58
59/// A lock generation is an integer incremented each time the lock is taken by a
60/// different holder.
61///
62/// This is used to know if a lock has been dirtied.
63pub type CrossProcessLockGeneration = u64;
64
65/// A trait that represents any function which can be used to acquire the
66/// underlying lock of a [`CrossProcessLock`].
67///
68/// For example, this can be useful when writing a function which is
69/// parameterized to acquire the underlying lock through either
70/// [`CrossProcessLock::spin_lock`] or [`CrossProcessLock::try_lock_once`].
71pub trait AcquireCrossProcessLockFn<L>
72where
73 Self: AsyncFn(&CrossProcessLock<L>) -> AcquireCrossProcessLockResult<L::LockError>,
74 L: TryLock + Clone + SendOutsideWasm + 'static,
75{
76}
77
78impl<L, T> AcquireCrossProcessLockFn<L> for T
79where
80 T: AsyncFn(&CrossProcessLock<L>) -> AcquireCrossProcessLockResult<L::LockError>,
81 L: TryLock + Clone + SendOutsideWasm + 'static,
82{
83}
84
85/// A convenience type for the [`Result`] returned from calling or
86/// [`CrossProcessLock::try_lock_once`] or [`CrossProcessLock::spin_lock`].
87pub type AcquireCrossProcessLockResult<E> =
88 Result<Result<CrossProcessLockState, CrossProcessLockUnobtained>, E>;
89
90/// Trait used to try to take a lock. Foundation of [`CrossProcessLock`].
91pub trait TryLock {
92 #[cfg(not(target_family = "wasm"))]
93 type LockError: Error + Send + Sync;
94
95 #[cfg(target_family = "wasm")]
96 type LockError: Error;
97
98 /// Try to take a leased lock.
99 ///
100 /// This attempts to take a lock for the given lease duration.
101 ///
102 /// - If we already had the lease, this will extend the lease.
103 /// - If we didn't, but the previous lease has expired, we will obtain the
104 /// lock.
105 /// - If there was no previous lease, we will obtain the lock.
106 /// - Otherwise, we don't get the lock.
107 ///
108 /// Returns `Some(_)` to indicate the lock succeeded, `None` otherwise. The
109 /// cross-process lock generation must be compared to the generation before
110 /// the call to see if the lock has been dirtied: a different generation
111 /// means the lock has been dirtied, i.e. taken by a different holder in the
112 /// meantime.
113 fn try_lock(
114 &self,
115 lease_duration_ms: u32,
116 key: &str,
117 holder: &str,
118 ) -> impl Future<Output = Result<Option<CrossProcessLockGeneration>, Self::LockError>>
119 + SendOutsideWasm;
120}
121
122/// Small state machine to handle wait times.
123#[derive(Clone, Debug)]
124enum WaitingTime {
125 /// Some time to wait, in milliseconds.
126 Some(u32),
127 /// Stop waiting when seeing this value.
128 Stop,
129}
130
131/// A guard of a cross-process lock.
132///
133/// The lock will be automatically released a short period of time after all the
134/// guards have dropped.
135#[derive(Clone, Debug)]
136#[must_use = "If unused, the `CrossProcessLock` will unlock at the end of the lease"]
137pub struct CrossProcessLockGuard {
138 /// A clone of [`CrossProcessLock::inner`].
139 ///
140 /// The number of guards/holders is based on the `Weak::weak_count`.
141 ///
142 /// - Every time [`CrossProcessLockGuard`] is cloned, `Weak` is cloned, and
143 /// thus the number of holders of `CrossProcessLockInner` increases.
144 /// - Every time [`CrossProcessLockGuard`] is dropped, `Weak` is dropped,
145 /// and thus the number of holders of `CrossProcessLockInner` decreases.
146 inner: Weak<CrossProcessLockInner>,
147}
148
149impl CrossProcessLockGuard {
150 fn new(inner: &Arc<CrossProcessLockInner>) -> Self {
151 // Downgrading the strong pointer to a weak pointer to represent a new
152 // lock holder.
153 Self { inner: Arc::downgrade(inner) }
154 }
155
156 /// Determine whether the cross-process lock associated to this guard is
157 /// dirty.
158 ///
159 /// See [`CrossProcessLockState::Dirty`] to learn more about the semantics
160 /// of _dirty_.
161 pub fn is_dirty(&self) -> bool {
162 self.inner
163 .upgrade()
164 .map(|inner| inner.is_dirty())
165 // If it's not possible to upgrade the weak pointer, it means the
166 // lock _and_ the `renew_task` have been dropped. In this case,
167 // whether the lock is dirty or not doesn't make any difference.
168 .unwrap_or(false)
169 }
170
171 /// Clear the dirty state from the cross-process lock associated to this
172 /// guard.
173 ///
174 /// If the cross-process lock is dirtied, it will remain dirtied until this
175 /// method is called. This allows recovering from a dirty state and marking
176 /// that it has recovered.
177 pub fn clear_dirty(&self) {
178 // If it's not possible to upgrade the weak pointer, it means the lock
179 // _and_ the `renew_task` have been dropped. Marking the lock as
180 // non-dirty makes no particular sense, so we do nothing.
181 if let Some(inner) = self.inner.upgrade() {
182 inner.clear_dirty();
183 }
184 }
185
186 #[cfg(test)]
187 fn count_holders(inner: &Weak<CrossProcessLockInner>) -> usize {
188 Weak::weak_count(inner)
189 }
190}
191
192/// A cross-process lock implementation.
193///
194/// See the doc-comment of this module for more information.
195#[derive(Clone, Debug)]
196pub struct CrossProcessLock<L> {
197 /// The locker implementation.
198 ///
199 /// `L` is responsible for trying to take the lock, while
200 /// [`CrossProcessLock`] is responsible to make it cross-process, with the
201 /// retry mechanism, plus guard and so on.
202 locker: Arc<L>,
203
204 /// The inner data of the lock, shared with all the lock holders.
205 ///
206 /// The number of lock holders must be computed with
207 /// [`CrossProcessLock::count_holders`].
208 ///
209 /// If the number of lock holders is greater than 0, this means we've
210 /// already obtained this lock, in this process, and the store lock mustn't
211 /// be touched.
212 ///
213 /// When the number of holders is decreased to 0, then the lock must be
214 /// released in the store.
215 // Notes about the `Arc`/`Weak` usage:
216 //
217 // - We want to track the number of holders, i.e. the number of guards. To achieve that, we
218 // could use a thread-safe counter, or hijack `Arc` and `Weak` which provide two thread-safe
219 // counters: strong count and weak count.
220 // - `CrossProcessLock` holds an `Arc` (this field).
221 // - `renew_task` holds an `Arc` (a clone of this field).
222 // - `CrossProcessLockGuard` holds a `Weak` (it could use an `Arc`, but a `Weak` is fine in
223 // this context and offers a unique counter for guards!).
224 // - Counting holders = counting the number of `Weak` pointers.
225 // - It is safe to upgrade the `Weak` pointer to an `Arc` (to get information about dirtiness)
226 // in a guard because the `renew_task` holds a clone of the `Arc` and will not exit until all
227 // guards have been dropped.
228 // - It is always possible to create a `Weak` pointer (i) either from `CrossProcessLock` by
229 // using `Arc::downgrade`, (ii) or from `CrossProcessLockGuard` by cloning it.
230 inner: Arc<CrossProcessLockInner>,
231
232 /// The key used in the key/value mapping for the lock entry.
233 lock_key: String,
234
235 /// A mutex to control an attempt to take the lock, to prevent someone using
236 /// it in a re-entrant way.
237 locking_attempt: Arc<Mutex<()>>,
238
239 /// Backoff time, in milliseconds.
240 backoff: Arc<Mutex<WaitingTime>>,
241
242 /// The cross-process lock configuration.
243 config: CrossProcessLockConfig,
244}
245
246/// Inner data for [`CrossProcessLock`] and [`CrossProcessLockGuard`].
247#[derive(Debug)]
248struct CrossProcessLockInner {
249 /// Current renew task spawned by [`CrossProcessLock::try_lock_once`].
250 ///
251 /// It is not used directly by [`CrossProcessLockGuard`]. It is stored here
252 /// to ensure the task will drop once the lock and all the guards drop.
253 renew_task: Mutex<Option<AbortOnDrop<()>>>,
254
255 /// This lock generation.
256 generation: AtomicU64,
257
258 /// Whether the lock has been dirtied.
259 ///
260 /// See [`CrossProcessLockState::Dirty`] to learn more about the semantics
261 /// of _dirty_.
262 is_dirty: AtomicBool,
263}
264
265impl CrossProcessLockInner {
266 /// Determine whether the cross-process lock is dirty.
267 ///
268 /// See [`CrossProcessLockState::Dirty`] to learn more about the semantics
269 /// of _dirty_.
270 pub fn is_dirty(&self) -> bool {
271 self.is_dirty.load(Ordering::SeqCst)
272 }
273
274 /// Clear the dirty state from this cross-process lock.
275 ///
276 /// If the cross-process lock is dirtied, it will remain dirtied until this
277 /// method is called. This allows recovering from a dirty state and marking
278 /// that it has recovered.
279 pub fn clear_dirty(&self) {
280 self.is_dirty.store(false, Ordering::SeqCst);
281 }
282}
283
284/// Amount of time a lease of the lock should last, in milliseconds.
285pub const LEASE_DURATION_MS: u32 = 500;
286
287/// Period of time between two attempts to extend the lease. We'll re-request a
288/// lease for an entire duration of `LEASE_DURATION_MS` milliseconds, every
289/// `EXTEND_LEASE_EVERY_MS`, so this has to be an amount safely low compared to
290/// `LEASE_DURATION_MS`, to make sure that we can miss a deadline without
291/// compromising the lock.
292pub const EXTEND_LEASE_EVERY_MS: u64 = 50;
293
294/// Initial backoff, in milliseconds. This is the time we wait the first time,
295/// if taking the lock initially failed.
296const INITIAL_BACKOFF_MS: u32 = 10;
297
298/// Maximal backoff, in milliseconds. This is the maximum amount of time we'll
299/// wait for the lock, _between two attempts_.
300pub const MAX_BACKOFF_MS: u32 = 1000;
301
302/// Sentinel value representing the absence of a lock generation value.
303///
304/// When the lock is created, it has no generation. Once locked, it receives its
305/// first generation from [`TryLock::try_lock`]. Subsequent lockings may
306/// generate new lock generation. The generation is incremented by 1 every time.
307///
308/// The first generation is defined by [`FIRST_CROSS_PROCESS_LOCK_GENERATION`].
309pub const NO_CROSS_PROCESS_LOCK_GENERATION: CrossProcessLockGeneration = 0;
310
311/// Describe the first lock generation value (see
312/// [`CrossProcessLockGeneration`]).
313pub const FIRST_CROSS_PROCESS_LOCK_GENERATION: CrossProcessLockGeneration = 1;
314
315impl<L> CrossProcessLock<L>
316where
317 L: TryLock + Clone + SendOutsideWasm + SyncOutsideWasm + 'static,
318{
319 /// Create a new cross-process lock.
320 ///
321 /// # Parameters
322 ///
323 /// - `lock_key`: key in the key-value store to store the lock's state.
324 /// - `config`: the cross-process lock configuration to use, if it's
325 /// [`CrossProcessLockConfig::SingleProcess`], no actual lock will be
326 /// taken.
327 pub fn new(locker: L, lock_key: String, config: CrossProcessLockConfig) -> Self {
328 Self {
329 locker: Arc::new(locker),
330 lock_key,
331 locking_attempt: Arc::new(Mutex::new(())),
332 inner: Arc::new(CrossProcessLockInner {
333 renew_task: Default::default(),
334 generation: AtomicU64::new(NO_CROSS_PROCESS_LOCK_GENERATION),
335
336 is_dirty: AtomicBool::new(false),
337 }),
338 backoff: Arc::new(Mutex::new(WaitingTime::Some(INITIAL_BACKOFF_MS))),
339 config,
340 }
341 }
342
343 /// Count the number of holders.
344 ///
345 /// # Safety
346 ///
347 /// This method by itself is safe, but using it correctly requires extra
348 /// care. Another thread can change the weak count at any time, including
349 /// potentially between calling this method and acting on the result.
350 fn count_holders(inner: &Arc<CrossProcessLockInner>) -> usize {
351 Arc::weak_count(inner)
352 }
353
354 /// Determine whether the cross-process lock is dirty.
355 ///
356 /// See [`CrossProcessLockState::Dirty`] to learn more about the semantics
357 /// of _dirty_.
358 pub fn is_dirty(&self) -> bool {
359 self.inner.is_dirty()
360 }
361
362 /// Clear the dirty state from this cross-process lock.
363 ///
364 /// If the cross-process lock is dirtied, it will remain dirtied until this
365 /// method is called. This allows recovering from a dirty state and marking
366 /// that it has recovered.
367 pub fn clear_dirty(&self) {
368 self.inner.clear_dirty();
369 }
370
371 /// Try to lock once, returns whether the lock was obtained or not.
372 ///
373 /// The lock can be obtained but it can be dirty. In all cases, the renew
374 /// task will run in the background.
375 #[instrument(skip(self), fields(?self.lock_key, ?self.config, ?self.inner.generation))]
376 pub async fn try_lock_once(&self) -> AcquireCrossProcessLockResult<L::LockError> {
377 // If it's not `MultiProcess`, this behaves as a no-op
378 let CrossProcessLockConfig::MultiProcess { holder_name } = &self.config else {
379 let guard = CrossProcessLockGuard::new(&self.inner);
380 return Ok(Ok(CrossProcessLockState::Clean(guard)));
381 };
382
383 // Hold onto the locking attempt mutex for the entire lifetime of this
384 // function, to avoid multiple reentrant calls.
385 let mut _attempt = self.locking_attempt.lock().await;
386
387 // If there is at least one other holder, it means the lock has already
388 // been acquired, and we can safely generate a new guard.
389 if Self::count_holders(&self.inner) > 0 {
390 // Note: between the above “count” and the
391 // `CrossProcessLockGuard::new` below, another thread may decrement
392 // the number of holders. That's fine because that means the lock
393 // was taken by at least one thread, and after this call it will be
394 // taken by at least one thread.
395 //
396 // Because `locking_attempt` is acquired, the task cannot drop the
397 // lock while the “count” might change.
398 trace!("We already had the lock, incrementing holder count");
399
400 return Ok(Ok(CrossProcessLockState::Clean(CrossProcessLockGuard::new(&self.inner))));
401 }
402
403 if let Some(new_generation) =
404 self.locker.try_lock(LEASE_DURATION_MS, &self.lock_key, holder_name).await?
405 {
406 match self.inner.generation.swap(new_generation, Ordering::SeqCst) {
407 // If there was no lock generation, it means this is the first
408 // time the lock is obtained. It cannot be dirty.
409 NO_CROSS_PROCESS_LOCK_GENERATION => {
410 trace!(?new_generation, "Setting the lock generation for the first time");
411 }
412
413 // This was NOT the same generation, the lock has been dirtied!
414 previous_generation if previous_generation != new_generation => {
415 warn!(
416 ?previous_generation,
417 ?new_generation,
418 "The lock has been obtained, but it's been dirtied!"
419 );
420 self.inner.is_dirty.store(true, Ordering::SeqCst);
421 }
422
423 // This was the same generation, no problem.
424 _ => {
425 trace!("Same lock generation; no problem");
426 }
427 }
428
429 trace!("Lock obtained!");
430 } else {
431 trace!("Couldn't obtain the lock immediately.");
432 return Ok(Err(CrossProcessLockUnobtained::Busy));
433 }
434
435 trace!("Obtained the lock, spawning the lease extension task.");
436
437 // No lock was acquired before (either because it's the first time the
438 // lock is acquired, or because all previous guards have been dropped).
439 // We're going to spawn the task that will renew the lease.
440
441 let mut renew_task = self.inner.renew_task.lock().await;
442
443 // Cancel the previous task, if any. That's safe to do, because:
444 //
445 // - either the task was done,
446 // - or it was still running, but taking a lock in the database has to
447 // be an atomic operation running in a transaction.
448 drop(renew_task.take());
449
450 // Restart a new one.
451 *renew_task = Some(
452 spawn({
453 let locker = self.locker.clone();
454 let lock_key = self.lock_key.clone();
455 let locking_attempt = self.locking_attempt.clone();
456 let config = self.config.clone();
457
458 // By cloning `CrossProcessLockInner`, we ensure the task acts
459 // as a lock holder.
460 let inner = self.inner.clone();
461
462 async move {
463 let CrossProcessLockConfig::MultiProcess { holder_name } = config else {
464 return;
465 };
466
467 loop {
468 {
469 // First, check if there are still users of this
470 // lock.
471 //
472 // This is not racy, because:
473 //
474 // - the `locking_attempt` mutex makes sure we don't
475 // have unexpected interactions with the
476 // non-atomic sequence above in `try_lock_once`,
477 // - other holders will only decrease over time.
478
479 let _guard = locking_attempt.lock().await;
480
481 // There are no more holders. We can quit.
482 if Self::count_holders(&inner) == 0 {
483 trace!("exiting the lease extension loop");
484
485 // Cancel the lease with another 0ms lease. If
486 // we don't get the lock, that's (weird but)
487 // fine.
488 let fut = locker.try_lock(0, &lock_key, &holder_name);
489 let _ = fut.await;
490
491 // Exit the loop.
492 break;
493 }
494 }
495
496 sleep(Duration::from_millis(EXTEND_LEASE_EVERY_MS)).await;
497
498 match locker.try_lock(LEASE_DURATION_MS, &lock_key, &holder_name).await {
499 Ok(Some(_generation)) => {
500 // It's impossible that the generation can be
501 // different from the previous generation.
502 //
503 // As long as the task runs, the lock is
504 // renewed, so the generation remains the same.
505 // If the lock is not taken, it's because the
506 // lease has expired, which is represented by
507 // the `Ok(None)` value, and the task must stop.
508 }
509
510 Ok(None) => {
511 error!(
512 "Failed to renew the lock lease: the lock could not be obtained"
513 );
514
515 // Exit the loop.
516 break;
517 }
518
519 Err(err) => {
520 error!("Error when extending the lock lease: {err:#}");
521
522 // Exit the loop.
523 break;
524 }
525 }
526 }
527 }
528 })
529 .abort_on_drop(),
530 );
531
532 let guard = CrossProcessLockGuard::new(&self.inner);
533
534 Ok(Ok(if self.is_dirty() {
535 CrossProcessLockState::Dirty(guard)
536 } else {
537 CrossProcessLockState::Clean(guard)
538 }))
539 }
540
541 /// Attempt to take the lock, with exponential backoff if the lock has
542 /// already been taken before.
543 ///
544 /// The `max_backoff` parameter is the maximum time (in milliseconds) that
545 /// should be waited for, between two attempts. When that time is reached a
546 /// second time, the lock will stop attempting to get the lock and will
547 /// return a timeout error upon locking. If not provided, will wait for
548 /// [`MAX_BACKOFF_MS`].
549 #[instrument(skip(self), fields(?self.lock_key, ?self.config))]
550 pub async fn spin_lock(
551 &self,
552 max_backoff: Option<u32>,
553 ) -> AcquireCrossProcessLockResult<L::LockError> {
554 // If there is no holder, this behaves as a no-op
555 let max_backoff = max_backoff.unwrap_or(MAX_BACKOFF_MS);
556
557 // Note: reads/writes to the backoff are racy across threads in theory,
558 // but the lock in `try_lock_once` should sequentialize it all.
559
560 loop {
561 // If the cross-process lock config is not `MultiProcess`, this
562 // behaves as a no-op and we just return
563 let lock_result = self.try_lock_once().await?;
564
565 if lock_result.is_ok() {
566 if matches!(self.config, CrossProcessLockConfig::MultiProcess { .. }) {
567 // Reset backoff before returning, for the next attempt to
568 // lock.
569 *self.backoff.lock().await = WaitingTime::Some(INITIAL_BACKOFF_MS);
570 }
571
572 return Ok(lock_result);
573 }
574
575 // Exponential backoff! Multiply by 2 the time we've waited before,
576 // cap it to max_backoff.
577 let mut backoff = self.backoff.lock().await;
578
579 let wait = match &mut *backoff {
580 WaitingTime::Some(val) => {
581 let wait = *val;
582 *val = val.saturating_mul(2);
583 if *val >= max_backoff {
584 *backoff = WaitingTime::Stop;
585 }
586 wait
587 }
588 WaitingTime::Stop => {
589 // We've reached the maximum backoff, abandon.
590 return Ok(Err(CrossProcessLockUnobtained::TimedOut));
591 }
592 };
593
594 debug!("Waiting {wait} before re-attempting to take the lock");
595 sleep(Duration::from_millis(wait.into())).await;
596 }
597 }
598
599 /// Returns the value in the database that represents the holder's
600 /// identifier.
601 pub fn lock_holder(&self) -> Option<&str> {
602 self.config.holder_name()
603 }
604}
605
606/// Represent a successful result of a locking attempt, either by
607/// [`CrossProcessLock::try_lock_once`] or [`CrossProcessLock::spin_lock`].
608#[derive(Debug)]
609#[must_use = "If unused, the `CrossProcessLock` will unlock at the end of the lease"]
610pub enum CrossProcessLockState {
611 /// The lock has been obtained successfully, all good.
612 Clean(CrossProcessLockGuard),
613
614 /// The lock has been obtained successfully, but the lock is dirty!
615 ///
616 /// This holder has obtained this cross-process lock once, then another
617 /// holder has obtained this cross-process lock _before_ this holder
618 /// obtained it again. The lock is marked as dirty. It means the value
619 /// protected by the cross-process lock may need to be reloaded if
620 /// synchronisation is important.
621 ///
622 /// Until [`CrossProcessLock::clear_dirty`] is called,
623 /// [`CrossProcessLock::is_dirty`], [`CrossProcessLock::try_lock_once`] and
624 /// [`CrossProcessLock::spin_lock`] will report the lock as dirty. Put it
625 /// differently: dirty once, dirty forever, unless
626 /// [`CrossProcessLock::clear_dirty`] is called.
627 Dirty(CrossProcessLockGuard),
628}
629
630impl CrossProcessLockState {
631 /// Map this value into the inner [`CrossProcessLockGuard`].
632 pub fn into_guard(self) -> CrossProcessLockGuard {
633 match self {
634 Self::Clean(guard) | Self::Dirty(guard) => guard,
635 }
636 }
637
638 /// Map this [`CrossProcessLockState`] into a
639 /// [`MappedCrossProcessLockState`].
640 ///
641 /// This is helpful when one wants to create its own wrapper over
642 /// [`CrossProcessLockGuard`].
643 pub fn map<F, G>(self, mapper: F) -> MappedCrossProcessLockState<G>
644 where
645 F: FnOnce(CrossProcessLockGuard) -> G,
646 {
647 match self {
648 Self::Clean(guard) => MappedCrossProcessLockState::Clean(mapper(guard)),
649 Self::Dirty(guard) => MappedCrossProcessLockState::Dirty(mapper(guard)),
650 }
651 }
652}
653
654/// A mapped [`CrossProcessLockState`].
655///
656/// Created by [`CrossProcessLockState::map`].
657#[derive(Debug)]
658#[must_use = "If unused, the `CrossProcessLock` will unlock at the end of the lease"]
659pub enum MappedCrossProcessLockState<G> {
660 /// The equivalent of [`CrossProcessLockState::Clean`].
661 Clean(G),
662
663 /// The equivalent of [`CrossProcessLockState::Dirty`].
664 Dirty(G),
665}
666
667impl<G> MappedCrossProcessLockState<G> {
668 /// Return `Some(G)` if `Self` is [`Clean`][Self::Clean].
669 pub fn as_clean(&self) -> Option<&G> {
670 match self {
671 Self::Clean(guard) => Some(guard),
672 Self::Dirty(_) => None,
673 }
674 }
675}
676
677/// Represent an unsuccessful result of a lock attempt, either by
678/// [`CrossProcessLock::try_lock_once`] or [`CrossProcessLock::spin_lock`].
679#[derive(Clone, Debug, thiserror::Error)]
680pub enum CrossProcessLockUnobtained {
681 /// The lock couldn't be obtained immediately because it is busy, i.e. it is
682 /// held by another holder.
683 #[error(
684 "The lock couldn't be obtained immediately because it is busy, i.e. it is held by another holder"
685 )]
686 Busy,
687
688 /// The lock couldn't be obtained after several attempts: locking has timed
689 /// out.
690 #[error("The lock couldn't be obtained after several attempts: locking has timed out")]
691 TimedOut,
692}
693
694/// Union of [`CrossProcessLockUnobtained`] and [`TryLock::LockError`].
695#[derive(Clone, Debug, thiserror::Error)]
696pub enum CrossProcessLockError {
697 #[error(transparent)]
698 Unobtained(#[from] CrossProcessLockUnobtained),
699
700 #[error(transparent)]
701 #[cfg(not(target_family = "wasm"))]
702 TryLock(#[from] Arc<dyn Error + Send + Sync>),
703
704 #[error(transparent)]
705 #[cfg(target_family = "wasm")]
706 TryLock(#[from] Arc<dyn Error>),
707}
708
709/// The cross-process lock config to use for the various stores.
710#[derive(Clone, Debug)]
711pub enum CrossProcessLockConfig {
712 /// The stores will be used in multiple processes, the holder name for the
713 /// cross-process lock is the associated `String`.
714 MultiProcess {
715 /// The name of the holder of the cross-process lock.
716 holder_name: String,
717 },
718 /// The stores will be used in a single process, there is no need for a
719 /// cross-process lock.
720 SingleProcess,
721}
722
723impl CrossProcessLockConfig {
724 /// Helper for quickly creating a [`CrossProcessLockConfig::MultiProcess`]
725 /// variant.
726 pub fn multi_process(holder_name: impl Into<String>) -> Self {
727 Self::MultiProcess { holder_name: holder_name.into() }
728 }
729
730 /// The holder name for the cross-process lock. This is only relevant for
731 /// [`CrossProcessLockConfig::MultiProcess`] variants.
732 pub fn holder_name(&self) -> Option<&str> {
733 match self {
734 Self::MultiProcess { holder_name } => Some(holder_name),
735 Self::SingleProcess => None,
736 }
737 }
738}
739
740#[cfg(test)]
741#[cfg(not(target_family = "wasm"))] // These tests require tokio::time, which is not implemented on wasm.
742mod tests {
743 use std::{
744 collections::HashMap,
745 ops::Not,
746 sync::{Arc, RwLock},
747 time::Duration,
748 };
749
750 use assert_matches::assert_matches;
751 use matrix_sdk_test_macros::async_test;
752 use strass::assert_let;
753 use tokio::{spawn, task::yield_now, time::sleep};
754
755 use super::{
756 CrossProcessLockConfig, CrossProcessLockError, CrossProcessLockGeneration,
757 CrossProcessLockGuard, CrossProcessLockState, CrossProcessLockUnobtained, TryLock,
758 memory_store_helper::{Lease, try_take_leased_lock},
759 };
760
761 #[derive(Clone, Default)]
762 struct TestStore {
763 leases: Arc<RwLock<HashMap<String, Lease>>>,
764 }
765
766 impl TestStore {
767 fn try_take_leased_lock(
768 &self,
769 lease_duration_ms: u32,
770 key: &str,
771 holder: &str,
772 ) -> Option<CrossProcessLockGeneration> {
773 try_take_leased_lock(&mut self.leases.write().unwrap(), lease_duration_ms, key, holder)
774 }
775 }
776
777 #[derive(Debug, thiserror::Error)]
778 enum DummyError {}
779
780 impl From<DummyError> for CrossProcessLockError {
781 fn from(value: DummyError) -> Self {
782 Self::TryLock(Arc::new(value))
783 }
784 }
785
786 impl TryLock for TestStore {
787 type LockError = DummyError;
788
789 /// Try to take a lock using the given store.
790 async fn try_lock(
791 &self,
792 lease_duration_ms: u32,
793 key: &str,
794 holder: &str,
795 ) -> Result<Option<CrossProcessLockGeneration>, Self::LockError> {
796 Ok(self.try_take_leased_lock(lease_duration_ms, key, holder))
797 }
798 }
799
800 async fn release_lock(lock: CrossProcessLockGuard) {
801 drop(lock);
802 yield_now().await;
803 }
804
805 type TestResult = Result<(), CrossProcessLockError>;
806 type CrossProcessLock = super::CrossProcessLock<TestStore>;
807
808 #[async_test]
809 async fn test_simple_lock_unlock() -> TestResult {
810 let store = TestStore::default();
811 let lock = CrossProcessLock::new(
812 store,
813 "key".to_owned(),
814 CrossProcessLockConfig::multi_process("first"),
815 );
816
817 // The lock plain works when used with a single holder.
818 let guard = lock.try_lock_once().await?.expect("lock must be obtained successfully");
819 assert_let!(CrossProcessLockState::Clean(guard) = guard);
820 assert!(lock.is_dirty().not());
821
822 assert_eq!(CrossProcessLock::count_holders(&lock.inner), 1);
823 assert_eq!(CrossProcessLockGuard::count_holders(&guard.inner), 1);
824
825 let guard_clone = guard.clone();
826
827 assert_eq!(CrossProcessLock::count_holders(&lock.inner), 2);
828 assert_eq!(CrossProcessLockGuard::count_holders(&guard.inner), 2);
829 assert_eq!(CrossProcessLockGuard::count_holders(&guard_clone.inner), 2);
830
831 // Dropping a guard decreases the number of holders.
832 drop(guard_clone);
833 assert_eq!(CrossProcessLock::count_holders(&lock.inner), 1);
834 assert_eq!(CrossProcessLockGuard::count_holders(&guard.inner), 1);
835
836 // Releasing works.
837 release_lock(guard).await;
838 assert!(lock.is_dirty().not());
839
840 assert_eq!(CrossProcessLock::count_holders(&lock.inner), 0);
841
842 // Spin locking on the same lock always works, assuming no concurrent
843 // access.
844 let guard = lock.spin_lock(None).await?.expect("spin lock must be obtained successfully");
845 assert_let!(CrossProcessLockState::Clean(guard) = guard);
846 assert!(lock.is_dirty().not());
847
848 assert_eq!(CrossProcessLock::count_holders(&lock.inner), 1);
849
850 // Releasing still works.
851 release_lock(guard).await;
852 assert!(lock.is_dirty().not());
853
854 assert_eq!(CrossProcessLock::count_holders(&lock.inner), 0);
855
856 Ok(())
857 }
858
859 #[async_test]
860 async fn test_self_recovery() -> TestResult {
861 let store = TestStore::default();
862 let lock = CrossProcessLock::new(
863 store.clone(),
864 "key".to_owned(),
865 CrossProcessLockConfig::multi_process("first"),
866 );
867
868 // When a lock is obtained…
869 let guard = lock.try_lock_once().await?.expect("lock must be obtained successfully");
870 assert_let!(CrossProcessLockState::Clean(guard) = guard);
871 assert!(lock.is_dirty().not());
872 assert_eq!(CrossProcessLock::count_holders(&lock.inner), 1);
873 assert_eq!(CrossProcessLockGuard::count_holders(&guard.inner), 1);
874
875 // But then forgotten…
876 drop(lock);
877
878 // Let's ensure the guard keeps acting as a lock holder even if the lock
879 // has dropped.
880 assert_eq!(CrossProcessLockGuard::count_holders(&guard.inner), 1);
881
882 // Okay, enough fun, time to drop it.
883 release_lock(guard).await;
884
885 // And when rematerializing the lock with the same key/value…
886 let lock = CrossProcessLock::new(
887 store.clone(),
888 "key".to_owned(),
889 CrossProcessLockConfig::multi_process("first"),
890 );
891
892 // We still got it.
893 let guard =
894 lock.try_lock_once().await?.expect("lock (again) must be obtained successfully");
895 assert_let!(CrossProcessLockState::Clean(guard) = guard);
896 assert!(lock.is_dirty().not());
897 assert_eq!(CrossProcessLock::count_holders(&lock.inner), 1);
898 assert_eq!(CrossProcessLockGuard::count_holders(&guard.inner), 1);
899
900 Ok(())
901 }
902
903 #[async_test]
904 async fn test_multiple_holders_same_process() -> TestResult {
905 let store = TestStore::default();
906 let lock = CrossProcessLock::new(
907 store,
908 "key".to_owned(),
909 CrossProcessLockConfig::multi_process("first"),
910 );
911
912 // Taking the lock twice…
913 let guard1 = lock.try_lock_once().await?.expect("lock must be obtained successfully");
914 assert_let!(CrossProcessLockState::Clean(guard1) = guard1);
915
916 let guard2 = lock.try_lock_once().await?.expect("lock must be obtained successfully");
917 assert_let!(CrossProcessLockState::Clean(guard2) = guard2);
918
919 assert!(lock.is_dirty().not());
920 assert_eq!(CrossProcessLock::count_holders(&lock.inner), 2);
921 assert_eq!(CrossProcessLockGuard::count_holders(&guard1.inner), 2);
922 assert_eq!(CrossProcessLockGuard::count_holders(&guard2.inner), 2);
923
924 // … means we can release it twice.
925 release_lock(guard1).await;
926 assert_eq!(CrossProcessLock::count_holders(&lock.inner), 1);
927 assert_eq!(CrossProcessLockGuard::count_holders(&guard2.inner), 1);
928
929 release_lock(guard2).await;
930 assert_eq!(CrossProcessLock::count_holders(&lock.inner), 0);
931
932 assert!(lock.is_dirty().not());
933
934 Ok(())
935 }
936
937 #[async_test]
938 async fn test_multiple_processes() -> TestResult {
939 let store = TestStore::default();
940 let lock1 = CrossProcessLock::new(
941 store.clone(),
942 "key".to_owned(),
943 CrossProcessLockConfig::multi_process("first"),
944 );
945 let lock2 = CrossProcessLock::new(
946 store,
947 "key".to_owned(),
948 CrossProcessLockConfig::multi_process("second"),
949 );
950
951 // `lock1` acquires the lock.
952 let guard1 = lock1.try_lock_once().await?.expect("lock must be obtained successfully");
953 assert_let!(CrossProcessLockState::Clean(guard1) = guard1);
954 assert!(lock1.is_dirty().not());
955 assert_eq!(CrossProcessLock::count_holders(&lock1.inner), 1);
956 assert_eq!(CrossProcessLockGuard::count_holders(&guard1.inner), 1);
957
958 // `lock2` cannot acquire the lock.
959 let err = lock2.try_lock_once().await?.expect_err("lock must NOT be obtained");
960 assert_matches!(err, CrossProcessLockUnobtained::Busy);
961
962 // `lock2` is waiting in a task.
963 let lock2_clone = lock2.clone();
964 let task = spawn(async move { lock2_clone.spin_lock(Some(500)).await });
965
966 yield_now().await;
967
968 release_lock(guard1).await;
969 sleep(Duration::from_millis(super::EXTEND_LEASE_EVERY_MS * 2)).await;
970 assert_eq!(CrossProcessLock::count_holders(&lock1.inner), 0);
971
972 // Once `lock1` is released, `lock2` managed to obtain it.
973 let guard2 = task
974 .await
975 .expect("join handle is properly awaited")
976 .expect("lock is successfully attempted")
977 .expect("lock must be obtained successfully");
978 assert_let!(CrossProcessLockState::Clean(guard2) = guard2);
979
980 assert_eq!(CrossProcessLock::count_holders(&lock1.inner), 0);
981 assert_eq!(CrossProcessLock::count_holders(&lock2.inner), 1);
982 assert_eq!(CrossProcessLockGuard::count_holders(&guard2.inner), 1);
983
984 // `lock1` and `lock2` are both clean!
985 assert!(lock1.is_dirty().not());
986 assert!(lock2.is_dirty().not());
987
988 // Now if `lock1` tries to obtain the lock with a small timeout, it will
989 // fail.
990 assert_matches!(
991 lock1.spin_lock(Some(200)).await,
992 Ok(Err(CrossProcessLockUnobtained::TimedOut))
993 );
994
995 Ok(())
996 }
997
998 #[async_test]
999 async fn test_multiple_processes_up_to_dirty() -> TestResult {
1000 let store = TestStore::default();
1001 let lock1 = CrossProcessLock::new(
1002 store.clone(),
1003 "key".to_owned(),
1004 CrossProcessLockConfig::multi_process("first"),
1005 );
1006 let lock2 = CrossProcessLock::new(
1007 store,
1008 "key".to_owned(),
1009 CrossProcessLockConfig::multi_process("second"),
1010 );
1011
1012 // Obtain `lock1` once.
1013 {
1014 let guard = lock1.try_lock_once().await?.expect("lock must be obtained successfully");
1015 assert_matches!(guard, CrossProcessLockState::Clean(_));
1016 assert!(lock1.is_dirty().not());
1017 drop(guard);
1018
1019 yield_now().await;
1020 }
1021
1022 // Obtain `lock2` once.
1023 {
1024 let guard = lock2.try_lock_once().await?.expect("lock must be obtained successfully");
1025 assert_matches!(guard, CrossProcessLockState::Clean(_));
1026 assert!(lock1.is_dirty().not());
1027 drop(guard);
1028
1029 yield_now().await;
1030 }
1031
1032 for _ in 0..3 {
1033 // Obtain `lock1` once more. Now it's dirty because `lock2` has
1034 // acquired the lock meanwhile.
1035 {
1036 let guard =
1037 lock1.try_lock_once().await?.expect("lock must be obtained successfully");
1038 assert_matches!(guard, CrossProcessLockState::Dirty(_));
1039 assert!(lock1.is_dirty());
1040
1041 drop(guard);
1042 yield_now().await;
1043 }
1044
1045 // Obtain `lock1` once more! It still dirty because it has not been
1046 // marked as non-dirty.
1047 {
1048 let guard =
1049 lock1.try_lock_once().await?.expect("lock must be obtained successfully");
1050 assert_matches!(guard, CrossProcessLockState::Dirty(_));
1051 assert!(lock1.is_dirty());
1052 lock1.clear_dirty();
1053
1054 drop(guard);
1055 yield_now().await;
1056 }
1057
1058 // Obtain `lock1` once more. Now it's clear!
1059 {
1060 let guard =
1061 lock1.try_lock_once().await?.expect("lock must be obtained successfully");
1062 assert_matches!(guard, CrossProcessLockState::Clean(_));
1063 assert!(lock1.is_dirty().not());
1064
1065 drop(guard);
1066 yield_now().await;
1067 }
1068
1069 // Same dance with `lock2`!
1070 {
1071 let guard =
1072 lock2.try_lock_once().await?.expect("lock must be obtained successfully");
1073 assert_matches!(guard, CrossProcessLockState::Dirty(_));
1074 assert!(lock2.is_dirty());
1075 lock2.clear_dirty();
1076
1077 drop(guard);
1078 yield_now().await;
1079 }
1080 }
1081
1082 Ok(())
1083 }
1084}
1085
1086/// Some code that is shared by almost all `MemoryStore` implementations out
1087/// there.
1088pub mod memory_store_helper {
1089 use std::collections::{HashMap, hash_map::Entry};
1090
1091 use ruma::time::{Duration, Instant};
1092
1093 use super::{CrossProcessLockGeneration, FIRST_CROSS_PROCESS_LOCK_GENERATION};
1094
1095 #[derive(Debug)]
1096 pub struct Lease {
1097 holder: String,
1098 expiration: Instant,
1099 generation: CrossProcessLockGeneration,
1100 }
1101
1102 pub fn try_take_leased_lock(
1103 leases: &mut HashMap<String, Lease>,
1104 lease_duration_ms: u32,
1105 key: &str,
1106 holder: &str,
1107 ) -> Option<CrossProcessLockGeneration> {
1108 let now = Instant::now();
1109 let expiration = now + Duration::from_millis(lease_duration_ms.into());
1110
1111 match leases.entry(key.to_owned()) {
1112 // There is an existing holder.
1113 Entry::Occupied(mut entry) => {
1114 let Lease {
1115 holder: current_holder,
1116 expiration: current_expiration,
1117 generation: current_generation,
1118 } = entry.get_mut();
1119
1120 if current_holder == holder {
1121 // We had the lease before, extend it.
1122 *current_expiration = expiration;
1123
1124 Some(*current_generation)
1125 } else {
1126 // We didn't have it.
1127 if *current_expiration < now {
1128 // Steal it!
1129 *current_holder = holder.to_owned();
1130 *current_expiration = expiration;
1131 *current_generation += 1;
1132
1133 Some(*current_generation)
1134 } else {
1135 // We tried our best.
1136 None
1137 }
1138 }
1139 }
1140
1141 // There is no holder, easy.
1142 Entry::Vacant(entry) => {
1143 entry.insert(Lease {
1144 holder: holder.to_owned(),
1145 expiration: Instant::now() + Duration::from_millis(lease_duration_ms.into()),
1146 generation: FIRST_CROSS_PROCESS_LOCK_GENERATION,
1147 });
1148
1149 Some(FIRST_CROSS_PROCESS_LOCK_GENERATION)
1150 }
1151 }
1152 }
1153}