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}