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