rust_ef/transaction.rs
1//! Transaction handle abstraction.
2//!
3//! Decouples transaction capabilities from `DbContext` so callers can hold a
4//! typed handle (`Box<dyn ITransaction>`) returned by
5//! `DbContext::begin_transaction()`. The handle exposes `commit` / `rollback`
6//! (which consume it), savepoint operations, isolation level control, and a
7//! `connection()` accessor used internally by `save_changes()` to reuse the
8//! transaction's connection.
9//!
10//! ## Control modes
11//!
12//! - **Scoped** — `ctx.use_transaction(|ctx| Box::pin(async move { ... })).await?`
13//! or `ctx.transaction(...)`. Registers the handle as ambient; every
14//! statement issued through the context uses the transaction's connection.
15//! Commits on `Ok`, rolls back on `Err` / `TxOutcome::Rollback`.
16//! - **Manual** — `let txn = ctx.begin_transaction().await?; ... txn.commit().await?;`
17//! Returns a **standalone** handle. It is not registered as ambient, so
18//! queries and `save_changes()` do not join it; use it for raw SQL and
19//! explicit savepoint/isolation control. Use `ctx.transaction(...)` when the
20//! whole context should join the transaction.
21//!
22//! Any transaction handle that is dropped without `commit` / `rollback` is
23//! rolled back on a background task, matching the `Dispose`-rolls-back
24//! convention of EF Core / ADO.NET. The rollback is best effort: it requires a
25//! Tokio runtime to be available when the handle is dropped.
26
27use crate::error::{EFError, EFResult};
28use crate::provider::{IAsyncConnection, IsolationLevel};
29use crate::session::{Session, SessionConnection, SharedTxn};
30use std::future::Future;
31use std::pin::Pin;
32use std::sync::Arc;
33
34/// The outcome of a [`DbContext::transaction`](crate::db_context::DbContext::transaction)
35/// closure.
36///
37/// Unlike [`DbContext::use_transaction`](crate::db_context::DbContext::use_transaction),
38/// which commits on `Ok` and rolls back only on `Err`, this type lets a handler
39/// abort the database work and still return a successful business result.
40///
41/// ```rust,ignore
42/// let result = ctx.transaction(|ctx| Box::pin(async move {
43/// // ... database work ...
44/// if !meets_condition {
45/// return Ok(TxOutcome::Rollback(BizResult::Rejected));
46/// }
47/// Ok(TxOutcome::Commit(BizResult::Done))
48/// })).await?;
49/// ```
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum TxOutcome<T> {
52 /// Commit the transaction and return the value.
53 Commit(T),
54 /// Roll the transaction back, detach tracked entities, and return the value.
55 Rollback(T),
56}
57
58/// Transaction handle abstracting the Priority 2 connection-level transaction
59/// methods.
60///
61/// `commit` / `rollback` consume `self: Box<Self>` to make use-after-commit
62/// impossible at the type level. Savepoint and isolation methods take
63/// `&mut self` and return a boxed future borrowing the handle.
64///
65/// `Send + Sync` is required so that `Box<dyn ITransaction>` can be stored in
66/// `DbContext` (which DI registers as `Scoped`, requiring `Send + Sync`).
67pub trait ITransaction: Send + Sync {
68 /// Commits the transaction and consumes the handle.
69 fn commit(self: Box<Self>) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'static>>;
70
71 /// Rolls back the transaction and consumes the handle.
72 fn rollback(self: Box<Self>) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'static>>;
73
74 /// Creates a savepoint within the current transaction.
75 fn create_point<'a>(
76 &'a mut self,
77 name: &'a str,
78 ) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'a>>;
79
80 /// Releases (commits) a previously created savepoint.
81 fn release_point<'a>(
82 &'a mut self,
83 name: &'a str,
84 ) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'a>>;
85
86 /// Rolls back to the named savepoint, preserving the outer transaction.
87 fn rollback_point<'a>(
88 &'a mut self,
89 name: &'a str,
90 ) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'a>>;
91
92 /// Sets the isolation level of the current transaction.
93 fn set_isolation<'a>(
94 &'a mut self,
95 level: IsolationLevel,
96 ) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'a>>;
97
98 /// Exposes the underlying connection so `save_changes()` can reuse the
99 /// transaction's connection.
100 fn connection(&mut self) -> &mut (dyn IAsyncConnection + Send);
101
102 /// Whether this transaction is scoped to the `DbContext`.
103 ///
104 /// Scoped transactions (`use_transaction` / `transaction`) route queries
105 /// and bulk DML through the transaction connection and get per-save
106 /// savepoints. A manual [`DbContext::begin_transaction`](crate::db_context::DbContext::begin_transaction)
107 /// handle is standalone and keeps the legacy behavior.
108 fn is_scoped(&self) -> bool {
109 false
110 }
111}
112
113/// Where the transaction's connection lives.
114enum TxnState {
115 /// A connection owned outright by the handle (standalone
116 /// `DbTransaction::new`).
117 Owned(Option<Box<dyn IAsyncConnection>>),
118 /// A connection shared with the `DbContext` session, so queries and bulk
119 /// DML inside the transaction observe the same connection.
120 Shared {
121 shared: Arc<SharedTxn>,
122 facade: SessionConnection,
123 },
124}
125
126/// Default `ITransaction` implementation.
127///
128/// In `Owned` mode the connection is held in `Option` so `commit` / `rollback`
129/// can take it out by consuming the handle. In `Shared` mode the connection
130/// lives in the session slot and is taken out on commit / rollback.
131pub struct DbTransaction {
132 state: Option<TxnState>,
133 /// When set, commit / rollback / drop clears the context's active
134 /// transaction so subsequent statements fall back to the pool.
135 session: Option<Arc<Session>>,
136}
137
138impl DbTransaction {
139 pub fn new(conn: Box<dyn IAsyncConnection>) -> Self {
140 Self {
141 state: Some(TxnState::Owned(Some(conn))),
142 session: None,
143 }
144 }
145
146 pub(crate) fn new_shared(
147 shared: Arc<SharedTxn>,
148 facade: SessionConnection,
149 session: Option<Arc<Session>>,
150 ) -> Self {
151 Self {
152 state: Some(TxnState::Shared { shared, facade }),
153 session,
154 }
155 }
156}
157
158impl ITransaction for DbTransaction {
159 fn commit(self: Box<Self>) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'static>> {
160 Box::pin(async move {
161 let mut this = self;
162 let Some(state) = this.state.take() else {
163 return Err(EFError::transaction("transaction already consumed"));
164 };
165 let result = match state {
166 TxnState::Owned(conn) => match conn {
167 Some(mut conn) => conn.commit_transaction().await,
168 None => Err(EFError::transaction("transaction already consumed")),
169 },
170 TxnState::Shared { shared, .. } => match shared.take().await {
171 Ok(mut conn) => conn.commit_transaction().await,
172 Err(e) => Err(e),
173 },
174 };
175 if let Some(session) = this.session.as_ref() {
176 session.clear().await;
177 }
178 result
179 })
180 }
181
182 fn rollback(self: Box<Self>) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'static>> {
183 Box::pin(async move {
184 let mut this = self;
185 let Some(state) = this.state.take() else {
186 return Err(EFError::transaction("transaction already consumed"));
187 };
188 let result = match state {
189 TxnState::Owned(conn) => match conn {
190 Some(mut conn) => conn.rollback_transaction().await,
191 None => Err(EFError::transaction("transaction already consumed")),
192 },
193 TxnState::Shared { shared, .. } => match shared.take().await {
194 Ok(mut conn) => conn.rollback_transaction().await,
195 Err(e) => Err(e),
196 },
197 };
198 if let Some(session) = this.session.as_ref() {
199 session.clear().await;
200 }
201 result
202 })
203 }
204
205 fn create_point<'a>(
206 &'a mut self,
207 name: &'a str,
208 ) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'a>> {
209 Box::pin(async move {
210 let state = self
211 .state
212 .as_mut()
213 .ok_or_else(|| EFError::transaction("transaction already consumed"))?;
214 match state {
215 TxnState::Owned(conn) => {
216 conn.as_mut()
217 .ok_or_else(|| EFError::transaction("transaction consumed"))?
218 .create_savepoint(name)
219 .await
220 }
221 TxnState::Shared { facade, .. } => facade.create_savepoint(name).await,
222 }
223 })
224 }
225
226 fn release_point<'a>(
227 &'a mut self,
228 name: &'a str,
229 ) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'a>> {
230 Box::pin(async move {
231 let state = self
232 .state
233 .as_mut()
234 .ok_or_else(|| EFError::transaction("transaction already consumed"))?;
235 match state {
236 TxnState::Owned(conn) => {
237 conn.as_mut()
238 .ok_or_else(|| EFError::transaction("transaction consumed"))?
239 .release_savepoint(name)
240 .await
241 }
242 TxnState::Shared { facade, .. } => facade.release_savepoint(name).await,
243 }
244 })
245 }
246
247 fn rollback_point<'a>(
248 &'a mut self,
249 name: &'a str,
250 ) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'a>> {
251 Box::pin(async move {
252 let state = self
253 .state
254 .as_mut()
255 .ok_or_else(|| EFError::transaction("transaction already consumed"))?;
256 match state {
257 TxnState::Owned(conn) => {
258 conn.as_mut()
259 .ok_or_else(|| EFError::transaction("transaction consumed"))?
260 .rollback_to_savepoint(name)
261 .await
262 }
263 TxnState::Shared { facade, .. } => facade.rollback_to_savepoint(name).await,
264 }
265 })
266 }
267
268 fn set_isolation<'a>(
269 &'a mut self,
270 level: IsolationLevel,
271 ) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'a>> {
272 Box::pin(async move {
273 let state = self
274 .state
275 .as_mut()
276 .ok_or_else(|| EFError::transaction("transaction already consumed"))?;
277 match state {
278 TxnState::Owned(conn) => {
279 conn.as_mut()
280 .ok_or_else(|| EFError::transaction("transaction consumed"))?
281 .set_transaction_isolation(level)
282 .await
283 }
284 TxnState::Shared { facade, .. } => facade.set_transaction_isolation(level).await,
285 }
286 })
287 }
288
289 fn connection(&mut self) -> &mut (dyn IAsyncConnection + Send) {
290 match self.state.as_mut().expect("transaction already consumed") {
291 TxnState::Owned(conn) => conn
292 .as_mut()
293 .expect("transaction already consumed")
294 .as_mut(),
295 TxnState::Shared { facade, .. } => facade,
296 }
297 }
298
299 fn is_scoped(&self) -> bool {
300 matches!(&self.state, Some(TxnState::Shared { .. }))
301 }
302}
303
304impl Drop for DbTransaction {
305 fn drop(&mut self) {
306 let state = self.state.take();
307 let session = self.session.take();
308 if let Some(session) = &session {
309 // Detach before the background rollback so new statements do not
310 // join a transaction that is about to be rolled back. This also
311 // covers the cancellation window where commit/rollback already
312 // took the connection out of the handle.
313 session.clear_now();
314 }
315 let Some(state) = state else {
316 return;
317 };
318 let Ok(handle) = tokio::runtime::Handle::try_current() else {
319 return;
320 };
321 handle.spawn(async move {
322 match state {
323 TxnState::Owned(Some(mut conn)) => {
324 let _ = conn.rollback_transaction().await;
325 }
326 TxnState::Owned(None) => {}
327 TxnState::Shared { shared, .. } => {
328 if let Ok(mut conn) = shared.take().await {
329 let _ = conn.rollback_transaction().await;
330 }
331 }
332 }
333 if let Some(session) = session {
334 session.clear().await;
335 }
336 });
337 }
338}