Skip to main content

rust_ef/
transaction.rs

1//! Transaction handle abstraction.
2//!
3//! `DbContext::begin_transaction()` returns a context-scoped
4//! [`ITransaction`]. The handle exposes `commit` / `rollback` (which consume
5//! it), savepoint operations, isolation control, and a `connection()` accessor
6//! used internally by `save_changes()`.
7//!
8//! ## Control modes
9//!
10//! - **Manual** — `let txn = ctx.begin_transaction().await?; ... txn.commit().await?;`
11//!   The transaction is registered on the context: queries, bulk DML, raw SQL
12//!   and `save_changes()` all use its connection. Commit or rollback explicitly.
13//! - **Closure** — `ctx.use_transaction(|ctx| Box::pin(async move { ... })).await?`.
14//!   Commits on `Ok`, rolls back on `Err`.
15//!
16//! Any handle dropped without `commit` / `rollback` is rolled back on a
17//! background task, matching EF Core / ADO.NET `Dispose` semantics. The
18//! runtime is captured when the handle is created, so dropping it outside a
19//! Tokio context can still schedule the rollback. The rollback is best effort:
20//! it cannot run if the runtime is already shut down, and `panic = "abort"`
21//! skips `Drop` entirely.
22
23use crate::error::{EFError, EFResult};
24use crate::provider::{IAsyncConnection, IsolationLevel};
25use crate::session::{Session, SessionConnection, SharedTxn};
26use std::future::Future;
27use std::pin::Pin;
28use std::sync::Arc;
29
30/// Transaction handle abstracting the connection-level transaction methods.
31///
32/// `commit` / `rollback` consume `self: Box<Self>` so use-after-commit is
33/// impossible at the type level. Savepoint and isolation methods borrow the
34/// handle.
35pub trait ITransaction: Send + Sync {
36    /// Commits the transaction and consumes the handle.
37    fn commit(self: Box<Self>) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'static>>;
38
39    /// Rolls back the transaction and consumes the handle.
40    fn rollback(self: Box<Self>) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'static>>;
41
42    /// Creates a savepoint within the current transaction.
43    fn create_point<'a>(
44        &'a mut self,
45        name: &'a str,
46    ) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'a>>;
47
48    /// Releases (commits) a previously created savepoint.
49    fn release_point<'a>(
50        &'a mut self,
51        name: &'a str,
52    ) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'a>>;
53
54    /// Rolls back to the named savepoint, preserving the outer transaction.
55    fn rollback_point<'a>(
56        &'a mut self,
57        name: &'a str,
58    ) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'a>>;
59
60    /// Sets the isolation level of the current transaction.
61    fn set_isolation<'a>(
62        &'a mut self,
63        level: IsolationLevel,
64    ) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'a>>;
65
66    /// Exposes the underlying connection so `save_changes()` can reuse the
67    /// transaction's connection.
68    fn connection(&mut self) -> &mut (dyn IAsyncConnection + Send);
69}
70
71/// A connection that is discarded if the owning future is cancelled before it
72/// can be returned to the pool. `disarm` transfers the connection back.
73pub(crate) struct ConnectionGuard {
74    conn: Option<Box<dyn IAsyncConnection>>,
75}
76
77impl ConnectionGuard {
78    pub(crate) fn new(conn: Box<dyn IAsyncConnection>) -> Self {
79        Self { conn: Some(conn) }
80    }
81
82    pub(crate) fn as_mut(&mut self) -> &mut dyn IAsyncConnection {
83        self.conn
84            .as_mut()
85            .expect("connection guard already disarmed")
86            .as_mut()
87    }
88
89    /// Returns the connection for normal pool release.
90    pub(crate) fn disarm(mut self) -> Box<dyn IAsyncConnection> {
91        self.conn.take().expect("connection guard already disarmed")
92    }
93}
94
95impl Drop for ConnectionGuard {
96    fn drop(&mut self) {
97        if let Some(conn) = self.conn.take() {
98            // A cancelled commit/rollback or an unfinished managed save must
99            // not return an open transaction to the pool.
100            conn.discard();
101        }
102    }
103}
104
105/// A connection shared with the `DbContext` session.
106struct TxnState {
107    shared: Arc<SharedTxn>,
108    facade: SessionConnection,
109}
110
111/// Default `ITransaction` implementation for context-scoped transactions.
112pub struct DbTransaction {
113    state: Option<TxnState>,
114    /// Commit / rollback / drop clears the context's active transaction so
115    /// subsequent statements fall back to the pool.
116    session: Arc<Session>,
117    /// Runtime captured at begin time, so dropping the handle outside a Tokio
118    /// context can still schedule the rollback.
119    runtime: Option<tokio::runtime::Handle>,
120}
121
122impl DbTransaction {
123    pub(crate) fn new_shared(
124        shared: Arc<SharedTxn>,
125        facade: SessionConnection,
126        session: Arc<Session>,
127    ) -> Self {
128        Self {
129            state: Some(TxnState { shared, facade }),
130            session,
131            runtime: tokio::runtime::Handle::try_current().ok(),
132        }
133    }
134}
135
136impl ITransaction for DbTransaction {
137    fn commit(self: Box<Self>) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'static>> {
138        Box::pin(async move {
139            let mut this = self;
140            let Some(state) = this.state.take() else {
141                return Err(EFError::transaction("transaction already consumed"));
142            };
143            let result = match state.shared.take().await {
144                Ok(conn) => {
145                    let mut guard = ConnectionGuard::new(conn);
146                    match guard.as_mut().commit_transaction().await {
147                        Ok(()) => {
148                            drop(guard.disarm());
149                            Ok(())
150                        }
151                        Err(e) => {
152                            // Best-effort cleanup if COMMIT failed; the
153                            // connection may be in an unknown state either way,
154                            // so the guard discards it instead of recycling.
155                            let _ = guard.as_mut().rollback_transaction().await;
156                            Err(e)
157                        }
158                    }
159                }
160                Err(e) => Err(e),
161            };
162            this.session.clear().await;
163            result
164        })
165    }
166
167    fn rollback(self: Box<Self>) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'static>> {
168        Box::pin(async move {
169            let mut this = self;
170            let Some(state) = this.state.take() else {
171                return Err(EFError::transaction("transaction already consumed"));
172            };
173            let result = match state.shared.take().await {
174                Ok(conn) => {
175                    let mut guard = ConnectionGuard::new(conn);
176                    match guard.as_mut().rollback_transaction().await {
177                        Ok(()) => {
178                            drop(guard.disarm());
179                            Ok(())
180                        }
181                        Err(e) => Err(e),
182                    }
183                }
184                Err(e) => Err(e),
185            };
186            this.session.clear().await;
187            result
188        })
189    }
190
191    fn create_point<'a>(
192        &'a mut self,
193        name: &'a str,
194    ) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'a>> {
195        Box::pin(async move {
196            self.state
197                .as_mut()
198                .ok_or_else(|| EFError::transaction("transaction already consumed"))?
199                .facade
200                .create_savepoint(name)
201                .await
202        })
203    }
204
205    fn release_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            self.state
211                .as_mut()
212                .ok_or_else(|| EFError::transaction("transaction already consumed"))?
213                .facade
214                .release_savepoint(name)
215                .await
216        })
217    }
218
219    fn rollback_point<'a>(
220        &'a mut self,
221        name: &'a str,
222    ) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'a>> {
223        Box::pin(async move {
224            self.state
225                .as_mut()
226                .ok_or_else(|| EFError::transaction("transaction already consumed"))?
227                .facade
228                .rollback_to_savepoint(name)
229                .await
230        })
231    }
232
233    fn set_isolation<'a>(
234        &'a mut self,
235        level: IsolationLevel,
236    ) -> Pin<Box<dyn Future<Output = EFResult<()>> + Send + 'a>> {
237        Box::pin(async move {
238            self.state
239                .as_mut()
240                .ok_or_else(|| EFError::transaction("transaction already consumed"))?
241                .facade
242                .set_transaction_isolation(level)
243                .await
244        })
245    }
246
247    fn connection(&mut self) -> &mut (dyn IAsyncConnection + Send) {
248        &mut self
249            .state
250            .as_mut()
251            .expect("transaction already consumed")
252            .facade
253    }
254}
255
256impl Drop for DbTransaction {
257    fn drop(&mut self) {
258        let state = self.state.take();
259        self.session.clear_now();
260        let Some(state) = state else {
261            return;
262        };
263
264        // Fast path: no statement is using the connection. Closing it is a
265        // synchronous, reliable rollback (the database aborts on disconnect).
266        if let Some(conn) = state.shared.try_take() {
267            conn.discard();
268            return;
269        }
270
271        // The connection is busy (a statement borrowed it). Schedule rollback
272        // and close; `SharedTxn::drop` is the final fallback if the runtime is
273        // already gone.
274        let handle = self
275            .runtime
276            .clone()
277            .or_else(|| tokio::runtime::Handle::try_current().ok());
278        let Some(handle) = handle else {
279            return;
280        };
281        let session = Arc::clone(&self.session);
282        handle.spawn(async move {
283            if let Ok(conn) = state.shared.take().await {
284                let mut guard = ConnectionGuard::new(conn);
285                let _ = guard.as_mut().rollback_transaction().await;
286                // Guard drop discards the connection.
287            }
288            session.clear().await;
289        });
290    }
291}