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, Persisted>(
147        &self,
148        persisted_records: Persisted,
149        merge: impl AsyncFnOnce(&Transaction, Persisted) -> CryptoKeystoreResult<Vec<Arc<E>>>,
150    ) -> CryptoKeystoreResult<Vec<Arc<E>>>
151    where
152        Persisted: IntoIterator<Item = Arc<E>>,
153    {
154        let guard = self.transaction.lock().await;
155        let Some(weak) = guard.as_ref() else {
156            return Ok(persisted_records.into_iter().collect());
157        };
158        let Some(tx) = weak.upgrade().await else {
159            return Ok(persisted_records.into_iter().collect());
160        };
161        merge(&tx, persisted_records).await
162    }
163}
164
165#[cfg(all(test, not(target_os = "unknown")))]
166mod tests {
167    use std::{future::Future, time::Duration};
168
169    use futures_lite::future;
170    use smol::Timer;
171
172    use crate::{CryptoKeystoreError, Database, entities::ConsumerData, traits::FetchFromDatabase as _};
173
174    const OUTER: &[u8] = b"written by the outer operation";
175    const NESTED: &[u8] = b"written by the nested operation";
176
177    /// How long [`without_deadlock`] waits before declaring a hang.
178    ///
179    /// Generous, because overshooting only costs time on an already-failing test, while
180    /// undershooting would make the suite flaky on a loaded CI machine.
181    const TIMEOUT: Duration = Duration::from_secs(10);
182
183    /// Distinguishes an operation failure from a keystore failure, so that the rollback test can
184    /// assert that the error coming back out is the one the operation produced.
185    #[derive(Debug, derive_more::From)]
186    enum TestError {
187        Keystore(CryptoKeystoreError),
188        Operation,
189    }
190
191    fn consumer_data(content: &[u8]) -> ConsumerData {
192        ConsumerData {
193            content: content.to_owned(),
194        }
195    }
196
197    /// Run `fut` to completion, panicking rather than hanging if it takes too long.
198    ///
199    /// Every interesting failure mode in these tests is a deadlock, which would otherwise stall
200    /// the whole test run instead of reporting which case broke.
201    async fn without_deadlock<T>(fut: impl Future<Output = T>) -> T {
202        future::or(async { Some(fut.await) }, async {
203            Timer::after(TIMEOUT).await;
204            None
205        })
206        .await
207        .expect("timed out; `ensure_transaction` deadlocked")
208    }
209
210    /// Meta-test: [`without_deadlock`] can actually observe a hang.
211    ///
212    /// The other tests in this module lean on that guard to turn a deadlock regression into a
213    /// named failure instead of a stalled test run. If the guard ever stopped firing — say because
214    /// [`Timer`] no longer gets driven under [`future::block_on`] — those tests would keep passing
215    /// while silently losing the property they exist to check, so it's worth pinning down.
216    ///
217    /// Ignored by default because it is slow and only tests test code. Run it with
218    /// `cargo test -p core-crypto-keystore --lib timeout_guard -- --ignored`.
219    #[test]
220    #[ignore = "takes as long as the timeout it is verifying"]
221    #[should_panic(expected = "deadlocked")]
222    fn timeout_guard_actually_fires() {
223        future::block_on(without_deadlock(async {
224            Timer::after(TIMEOUT * 3).await;
225        }));
226    }
227
228    /// With nothing in flight, `ensure_transaction` creates a transaction of its own and commits
229    /// it once the operation succeeds.
230    #[test]
231    fn creates_and_commits_when_nothing_is_in_flight() {
232        future::block_on(without_deadlock(async {
233            let store = Database::open_in_memory().unwrap();
234
235            store
236                .ensure_transaction(async |tx| tx.save(consumer_data(OUTER)).await, std::convert::identity)
237                .await
238                .unwrap();
239
240            // no transaction is in flight any more, so this can only be reading persisted data
241            let persisted = store.get_unique::<ConsumerData>().await.unwrap().unwrap();
242            assert_eq!(persisted.content, OUTER);
243        }));
244    }
245
246    /// When a transaction is already in flight, `ensure_transaction` borrows it and leaves the
247    /// commit to whoever owns it.
248    #[test]
249    fn borrows_an_in_flight_transaction_without_committing_it() {
250        future::block_on(without_deadlock(async {
251            let store = Database::open_in_memory().unwrap();
252            let owned = store.new_transaction().await.unwrap();
253
254            store
255                .ensure_transaction(async |tx| tx.save(consumer_data(OUTER)).await, std::convert::identity)
256                .await
257                .unwrap();
258
259            // the write landed in the in-flight transaction, so it is visible through the store ...
260            let staged = store.get_unique::<ConsumerData>().await.unwrap().unwrap();
261            assert_eq!(staged.content, OUTER);
262
263            // ... but `ensure_transaction` must not have committed it: dropping the owner rolls the
264            // write back, which would be impossible had it already been persisted.
265            drop(owned);
266            assert!(!store.exists::<ConsumerData>().await.unwrap());
267        }));
268    }
269
270    /// A transaction which `ensure_transaction` created itself is rolled back when the operation
271    /// fails, and the operation's own error is what comes back out.
272    #[test]
273    fn rolls_back_its_own_transaction_when_the_operation_fails() {
274        future::block_on(without_deadlock(async {
275            let store = Database::open_in_memory().unwrap();
276
277            let error = store
278                .ensure_transaction(
279                    async |tx| {
280                        tx.save(consumer_data(OUTER)).await?;
281                        Err::<(), _>(TestError::Operation)
282                    },
283                    TestError::Keystore,
284                )
285                .await
286                .unwrap_err();
287
288            match error {
289                TestError::Operation => {}
290                TestError::Keystore(err) => panic!("expected the operation's own error, got a keystore error: {err}"),
291            }
292            assert!(!store.exists::<ConsumerData>().await.unwrap());
293        }));
294    }
295
296    /// A nested `ensure_transaction` finds and reuses the transaction the outer call created.
297    ///
298    /// Regression test: an earlier implementation held the transaction mutex across the operation,
299    /// so the nested call deadlocked against the outer one.
300    #[test]
301    fn can_be_nested() {
302        future::block_on(without_deadlock(async {
303            let store = Database::open_in_memory().unwrap();
304
305            store
306                .ensure_transaction(
307                    async |tx| {
308                        tx.save(consumer_data(OUTER)).await?;
309                        store
310                            .ensure_transaction(
311                                async |nested| nested.save(consumer_data(NESTED)).await,
312                                std::convert::identity,
313                            )
314                            .await?;
315                        Ok(())
316                    },
317                    std::convert::identity,
318                )
319                .await
320                .unwrap();
321
322            // the nested call borrowed the outer transaction instead of creating its own, so both
323            // writes committed together; `ConsumerData` is unique, so the later write is the survivor
324            let persisted = store.get_unique::<ConsumerData>().await.unwrap().unwrap();
325            assert_eq!(persisted.content, NESTED);
326        }));
327    }
328}