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}