Skip to main content

core_crypto_keystore/connection/
transaction.rs

1//! The methods in this module handle keystore transactions.
2//!
3//! Keystore transactions are "fake", in-memory persistence of database operations over time.
4//! They're required because actual [`rusqlite::Transaction`] is `!Send + !Sync`, and we need
5//! `Send` at a minimum in order to keep the transaction around and manipulate it concurrently
6//! from various tasks.
7
8use std::sync::Arc;
9
10use crate::{
11    CryptoKeystoreError, CryptoKeystoreResult, Database, UniqueArc, transaction::Transaction,
12    unique_arc::ArcWithReadGuard,
13};
14
15/// These impls control the keystore transaction lifecycle.
16impl Database {
17    /// Waits for the current transaction to be committed or rolled back, then starts a new one.
18    pub async fn new_transaction(self: &Arc<Self>) -> CryptoKeystoreResult<UniqueArc<Transaction>> {
19        let semaphore = self.transaction_semaphore.acquire_arc().await;
20        Transaction::new(semaphore, self.clone()).await
21    }
22
23    /// Start a new transaction if no other transaction is currently in progress.
24    ///
25    /// If a transaction is currently in progress, this will produce a `TransactionInProgress` error.
26    pub async fn try_new_immediate_transaction(self: &Arc<Self>) -> CryptoKeystoreResult<UniqueArc<Transaction>> {
27        let semaphore = self
28            .transaction_semaphore
29            .try_acquire_arc()
30            .ok_or(CryptoKeystoreError::TransactionInProgress)?;
31        Transaction::new(semaphore, self.clone()).await
32    }
33
34    /// Do an operation on a new keystore transaction on this database.
35    ///
36    /// This is a convenience method abstracting over the transaction lifecycle;
37    /// it creates a new transaction (including waiting for any existing transaction to finish),
38    /// then performs its operation.
39    ///
40    /// If the operation succeeds, the transaction is committed.
41    /// Otherwise, it is rolled back.
42    pub async fn transactionally<R>(
43        self: &Arc<Self>,
44        operation: impl AsyncFnOnce(&Transaction) -> CryptoKeystoreResult<R>,
45    ) -> CryptoKeystoreResult<R> {
46        let semaphore = self.transaction_semaphore.acquire_arc().await;
47        let transaction = Transaction::new(semaphore, self.clone()).await?;
48
49        let result = operation(&transaction).await;
50        if result.is_ok() {
51            transaction.commit().await?;
52        }
53        // otherwise implicit rollback on tx drop
54
55        result
56    }
57
58    /// Do an operation on an existing keystore transaction.
59    ///
60    /// This does not create, commit, or abort an existing transaction; it just provides a standardized
61    /// helper to acquire it while creating appropriate errors.
62    pub(crate) async fn with_transaction<R>(
63        &self,
64        operation: impl AsyncFnOnce(&Transaction) -> CryptoKeystoreResult<R>,
65    ) -> CryptoKeystoreResult<R> {
66        let guard = self.transaction.lock().await;
67        let transaction = guard
68            .as_ref()
69            .ok_or(CryptoKeystoreError::MutatingOperationWithoutTransaction)?
70            .upgrade()
71            .await
72            .ok_or(CryptoKeystoreError::MutatingOperationWithoutTransaction)?;
73
74        operation(&transaction).await
75    }
76
77    /// Ensure a transaction exists, passing it to the operation.
78    ///
79    /// Ideally this method wouldn't exist; in most cases, [`Self::transactionally`]
80    /// or `Self::with_transaction` (crate-public) are the simpler picks. Every usage of this is a step
81    /// away from the long-term goal of separating transactions from the database entirely.
82    /// At present, this is designed for the FFI version of `PkiEnvironment`, which cannot
83    /// natively know whether a CC transaction is currently in-progress or not.
84    ///
85    /// If a transaction is already in progress, perform the operation and pass on its result
86    /// without affecting the transaction lifecycle at all, whether or not the operation succeeded.
87    ///
88    /// NOTE: if the transaction was already in progress, its owner's `.commit()` will block until
89    /// the operation here has completed.
90    ///
91    /// If a transaction was _not_ already in progress, create one and then perform the operation.
92    /// If the operation succeeded, commit the transaction; otherwise, let it rollback by drop.
93    /// Then return the result.
94    ///
95    /// If the operation succeeded but the commit failed, the commit error masks the operation's
96    /// success.
97    ///
98    /// Because the operation and therefore this overall function can return an arbitrary error type,
99    /// and there is not necessarily a direct relation between [`CryptoKeystoreError`] and `E`,
100    /// this function requires an explicit mapping function to be provided.
101    ///
102    /// NOTE: Because of TOCTOU, there is an interval between when we check if an existing
103    /// transaction exists, and when we create our own. If a separate process creates a transaction
104    /// during that interval, then this function must wait for that external transaction to
105    /// complete before it can acquire the semaphore. This might be surprising, but isn't
106    /// worth putting in the effort to change; it's not strictly a bug.
107    pub async fn ensure_transaction<T, E>(
108        self: &Arc<Self>,
109        operation: impl AsyncFnOnce(&Transaction) -> Result<T, E>,
110        map_err: impl Fn(CryptoKeystoreError) -> E,
111    ) -> Result<T, E> {
112        #[derive(derive_more::Deref, derive_more::From)]
113        enum TxHandle {
114            /// Someone else created this transaction and we have a guard over it
115            Borrowed(#[deref(forward)] ArcWithReadGuard<Transaction>),
116            /// We created this transaction and need to commit it ourselves
117            Owned(#[deref(forward)] UniqueArc<Transaction>),
118        }
119
120        // don't keep the lock alive, to prevent deadlocks
121        let weak = (*self.transaction.lock().await).clone();
122        // still waiting on `Option::async_map`, `Option::async_and_then`
123        let existing = match weak {
124            Some(weak) => weak.upgrade_without_type_erasure().await,
125            None => None,
126        };
127        let handle: TxHandle = match existing {
128            Some(existing) => existing.into(),
129            None => self.new_transaction().await.map_err(&map_err)?.into(),
130        };
131
132        let result = operation(&handle).await;
133
134        if let TxHandle::Owned(tx) = handle
135            && result.is_ok()
136        {
137            tx.commit().await.map_err(&map_err)?;
138        }
139
140        result
141    }
142
143    /// Merge database records with the active transaction's view of them.
144    ///
145    /// If no transaction is in progress, the database records are returned unchanged.
146    pub(super) async fn merge_with_transaction<E>(
147        &self,
148        persisted_records: Vec<E>,
149        merge: impl AsyncFnOnce(&Transaction, Vec<E>) -> CryptoKeystoreResult<Vec<E>>,
150    ) -> CryptoKeystoreResult<Vec<E>> {
151        let guard = self.transaction.lock().await;
152        let Some(weak) = guard.as_ref() else {
153            return Ok(persisted_records);
154        };
155        let Some(tx) = weak.upgrade().await else {
156            return Ok(persisted_records);
157        };
158        merge(&tx, persisted_records).await
159    }
160}
161
162#[cfg(all(test, not(target_os = "unknown")))]
163mod tests {
164    use std::{future::Future, time::Duration};
165
166    use futures_lite::future;
167    use smol::Timer;
168
169    use crate::{CryptoKeystoreError, Database, entities::ConsumerData, traits::FetchFromDatabase as _};
170
171    const OUTER: &[u8] = b"written by the outer operation";
172    const NESTED: &[u8] = b"written by the nested operation";
173
174    /// How long [`without_deadlock`] waits before declaring a hang.
175    ///
176    /// Generous, because overshooting only costs time on an already-failing test, while
177    /// undershooting would make the suite flaky on a loaded CI machine.
178    const TIMEOUT: Duration = Duration::from_secs(10);
179
180    /// Distinguishes an operation failure from a keystore failure, so that the rollback test can
181    /// assert that the error coming back out is the one the operation produced.
182    #[derive(Debug, derive_more::From)]
183    enum TestError {
184        Keystore(CryptoKeystoreError),
185        Operation,
186    }
187
188    fn consumer_data(content: &[u8]) -> ConsumerData {
189        ConsumerData {
190            content: content.to_owned(),
191        }
192    }
193
194    /// Run `fut` to completion, panicking rather than hanging if it takes too long.
195    ///
196    /// Every interesting failure mode in these tests is a deadlock, which would otherwise stall
197    /// the whole test run instead of reporting which case broke.
198    async fn without_deadlock<T>(fut: impl Future<Output = T>) -> T {
199        future::or(async { Some(fut.await) }, async {
200            Timer::after(TIMEOUT).await;
201            None
202        })
203        .await
204        .expect("timed out; `ensure_transaction` deadlocked")
205    }
206
207    /// Meta-test: [`without_deadlock`] can actually observe a hang.
208    ///
209    /// The other tests in this module lean on that guard to turn a deadlock regression into a
210    /// named failure instead of a stalled test run. If the guard ever stopped firing — say because
211    /// [`Timer`] no longer gets driven under [`future::block_on`] — those tests would keep passing
212    /// while silently losing the property they exist to check, so it's worth pinning down.
213    ///
214    /// Ignored by default because it is slow and only tests test code. Run it with
215    /// `cargo test -p core-crypto-keystore --lib timeout_guard -- --ignored`.
216    #[test]
217    #[ignore = "takes as long as the timeout it is verifying"]
218    #[should_panic(expected = "deadlocked")]
219    fn timeout_guard_actually_fires() {
220        future::block_on(without_deadlock(async {
221            Timer::after(TIMEOUT * 3).await;
222        }));
223    }
224
225    /// With nothing in flight, `ensure_transaction` creates a transaction of its own and commits
226    /// it once the operation succeeds.
227    #[test]
228    fn creates_and_commits_when_nothing_is_in_flight() {
229        future::block_on(without_deadlock(async {
230            let store = Database::open_in_memory().unwrap();
231
232            store
233                .ensure_transaction(async |tx| tx.save(consumer_data(OUTER)).await, std::convert::identity)
234                .await
235                .unwrap();
236
237            // no transaction is in flight any more, so this can only be reading persisted data
238            let persisted = store.get_unique::<ConsumerData>().await.unwrap().unwrap();
239            assert_eq!(persisted.content, OUTER);
240        }));
241    }
242
243    /// When a transaction is already in flight, `ensure_transaction` borrows it and leaves the
244    /// commit to whoever owns it.
245    #[test]
246    fn borrows_an_in_flight_transaction_without_committing_it() {
247        future::block_on(without_deadlock(async {
248            let store = Database::open_in_memory().unwrap();
249            let owned = store.new_transaction().await.unwrap();
250
251            store
252                .ensure_transaction(async |tx| tx.save(consumer_data(OUTER)).await, std::convert::identity)
253                .await
254                .unwrap();
255
256            // the write landed in the in-flight transaction, so it is visible through the store ...
257            let staged = store.get_unique::<ConsumerData>().await.unwrap().unwrap();
258            assert_eq!(staged.content, OUTER);
259
260            // ... but `ensure_transaction` must not have committed it: dropping the owner rolls the
261            // write back, which would be impossible had it already been persisted.
262            drop(owned);
263            assert!(!store.exists::<ConsumerData>().await.unwrap());
264        }));
265    }
266
267    /// A transaction which `ensure_transaction` created itself is rolled back when the operation
268    /// fails, and the operation's own error is what comes back out.
269    #[test]
270    fn rolls_back_its_own_transaction_when_the_operation_fails() {
271        future::block_on(without_deadlock(async {
272            let store = Database::open_in_memory().unwrap();
273
274            let error = store
275                .ensure_transaction(
276                    async |tx| {
277                        tx.save(consumer_data(OUTER)).await?;
278                        Err::<(), _>(TestError::Operation)
279                    },
280                    TestError::Keystore,
281                )
282                .await
283                .unwrap_err();
284
285            match error {
286                TestError::Operation => {}
287                TestError::Keystore(err) => panic!("expected the operation's own error, got a keystore error: {err}"),
288            }
289            assert!(!store.exists::<ConsumerData>().await.unwrap());
290        }));
291    }
292
293    /// A nested `ensure_transaction` finds and reuses the transaction the outer call created.
294    ///
295    /// Regression test: an earlier implementation held the transaction mutex across the operation,
296    /// so the nested call deadlocked against the outer one.
297    #[test]
298    fn can_be_nested() {
299        future::block_on(without_deadlock(async {
300            let store = Database::open_in_memory().unwrap();
301
302            store
303                .ensure_transaction(
304                    async |tx| {
305                        tx.save(consumer_data(OUTER)).await?;
306                        store
307                            .ensure_transaction(
308                                async |nested| nested.save(consumer_data(NESTED)).await,
309                                std::convert::identity,
310                            )
311                            .await?;
312                        Ok(())
313                    },
314                    std::convert::identity,
315                )
316                .await
317                .unwrap();
318
319            // the nested call borrowed the outer transaction instead of creating its own, so both
320            // writes committed together; `ConsumerData` is unique, so the later write is the survivor
321            let persisted = store.get_unique::<ConsumerData>().await.unwrap().unwrap();
322            assert_eq!(persisted.content, NESTED);
323        }));
324    }
325}