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}