Skip to main content

rullst_orm/pool/
telemetry.rs

1//! Transaction spans and outcomes for the global ORM facade.
2
3use futures::future::BoxFuture;
4
5use super::Orm;
6use super::savepoint::{self, ManagedSavepoint, SharedTransaction};
7use crate::post_commit::PostCommitScope;
8
9impl Orm {
10    /// Opens a transaction under a secret-free tracing span.
11    #[tracing::instrument(
12        name = "rullst.orm.transaction.begin",
13        target = "rullst_orm",
14        fields(orm.driver = tracing::field::Empty)
15    )]
16    pub async fn begin_transaction() -> Result<crate::db::Transaction<'static>, crate::Error> {
17        Self::record_driver();
18        let pool = Self::pool()?;
19        pool.begin().await.map_err(Into::into)
20    }
21
22    /// Executes a closure inside an isolated transaction and records its final
23    /// commit or rollback outcome without recording SQL, bindings, or errors.
24    ///
25    /// Called while another managed transaction is active on the same task,
26    /// the closure joins that transaction through a savepoint and receives the
27    /// same shared handle: an `Err` rolls back only the nested work, while a
28    /// success releases the savepoint so the work commits (or rolls back) with
29    /// the outer transaction. `after_commit` callbacks registered inside it
30    /// are promoted to the outer commit boundary on success and discarded on
31    /// failure. Do not hold the shared handle's lock across a nested call.
32    ///
33    /// Sibling nested calls (for example futures joined on one task) take
34    /// turns: each opens its savepoint only after the previous sibling's
35    /// savepoint was released or rolled back. A savepoint left open, for
36    /// example by a nested future cancelled while another operation held the
37    /// connection, makes the enclosing level fail closed: the managed
38    /// transaction then rolls back and returns an error instead of committing.
39    ///
40    /// The returned future is `Send` when `R` and `E` are, so the call can be
41    /// nested in another transaction closure or run in a spawned task.
42    #[tracing::instrument(
43        name = "rullst.orm.transaction",
44        target = "rullst_orm",
45        skip(f),
46        fields(
47            orm.driver = tracing::field::Empty,
48            orm.outcome = tracing::field::Empty
49        )
50    )]
51    pub async fn transaction<F, R, E>(f: F) -> Result<R, crate::Error>
52    where
53        F: FnOnce(
54                SharedTransaction,
55            )
56                -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<R, E>> + Send>>
57            + Send,
58        E: std::fmt::Display,
59    {
60        Self::record_driver();
61        crate::__transaction_access::ensure_allowed()?;
62        // Lock-holding steps run in non-generic boxed futures: a generic
63        // future that awaits a borrowed lock is rejected by rustc as "Send is
64        // not general enough", which would forbid nesting this call.
65        let nested = match crate::CURRENT_TX.try_with(Clone::clone) {
66            Ok(outer) => ManagedSavepoint::begin(outer.clone())
67                .await?
68                .map(|savepoint| (outer, savepoint)),
69            Err(_) => None,
70        };
71        let (transaction, savepoint) = match nested {
72            Some((outer, savepoint)) => (outer, Some(savepoint)),
73            None => {
74                let tx = Self::begin_transaction().await?;
75                (std::sync::Arc::new(tokio::sync::Mutex::new(Some(tx))), None)
76            }
77        };
78
79        let post_commit = PostCommitScope::new();
80        // Each closure runs as a level whose own nested calls take turns.
81        let closure = savepoint::run_level(transaction.clone(), f(transaction.clone()));
82        let result = match savepoint {
83            // The nested closure already runs inside the outer task scope.
84            Some(_) => post_commit.scope(closure).await,
85            None => {
86                post_commit
87                    .scope(crate::CURRENT_TX.scope(transaction.clone(), closure))
88                    .await
89            }
90        };
91        let (value, failure) = match result {
92            Ok(value) => (Some(value), None),
93            Err(error) => (None, Some(error.to_string())),
94        };
95        let finished = match savepoint {
96            Some(savepoint) => finish_savepoint(savepoint, post_commit, failure).await,
97            None => finish_transaction(transaction, post_commit, failure).await,
98        };
99        finished?;
100        value.ok_or_else(|| {
101            crate::Error::Internal("managed transaction finished without a value".to_string())
102        })
103    }
104
105    fn record_driver() {
106        if let Ok(driver) = Self::try_driver() {
107            tracing::Span::current().record("orm.driver", driver);
108        }
109    }
110}
111
112fn record_outcome(outcome: &'static str) {
113    tracing::Span::current().record("orm.outcome", outcome);
114}
115
116/// Commits (after a successful closure) or rolls back the owned transaction.
117fn finish_transaction(
118    transaction: SharedTransaction,
119    post_commit: PostCommitScope,
120    failure: Option<String>,
121) -> BoxFuture<'static, Result<(), crate::Error>> {
122    Box::pin(async move {
123        let owned = transaction.lock_owned().await.take();
124        let Some(mut owned) = owned else {
125            let (outcome, action) = if failure.is_some() {
126                ("rollback_ownership_missing", "rollback")
127            } else {
128                ("commit_ownership_missing", "commit")
129            };
130            record_outcome(outcome);
131            return Err(crate::Error::Internal(format!(
132                "managed transaction ownership was removed before automatic {action}"
133            )));
134        };
135        // SQLx commits or rolls back only the innermost open level, so a
136        // savepoint left open would return the connection to the pool inside
137        // the transaction. Its partial work is discarded, never committed.
138        let leaked = match savepoint::unwind_leaked(&mut owned).await {
139            Ok(leaked) => leaked,
140            Err(unwind_error) => {
141                // Dropping `owned` queues the remaining rollback, and the pool
142                // closes a connection that is still inside a transaction.
143                drop(owned);
144                record_outcome("rollback_failed");
145                let failure = failure.map_or_else(String::new, |error| format!("{error}; "));
146                return Err(crate::Error::DatabaseError(format!(
147                    "Transaction failed: {failure}rolling back a nested savepoint left open also failed: {unwind_error}",
148                )));
149            }
150        };
151        if let Some(error) = failure {
152            if let Err(rollback_error) = owned.rollback().await {
153                record_outcome("rollback_failed");
154                return Err(crate::Error::DatabaseError(format!(
155                    "Transaction failed: {error}; rollback also failed: {rollback_error}",
156                )));
157            }
158            record_outcome("rolled_back");
159            return Err(crate::Error::DatabaseError(format!(
160                "Transaction failed: {error}",
161            )));
162        }
163        if leaked {
164            if let Err(rollback_error) = owned.rollback().await {
165                record_outcome("rollback_failed");
166                return Err(crate::Error::DatabaseError(format!(
167                    "a nested savepoint was left open and the transaction rollback failed: {rollback_error}",
168                )));
169            }
170            record_outcome("rolled_back_unbalanced");
171            return Err(crate::Error::Internal(
172                "a nested transaction savepoint was left open, so the managed transaction was rolled back instead of committed"
173                    .to_string(),
174            ));
175        }
176        if let Err(error) = owned.commit().await {
177            record_outcome("commit_failed");
178            return Err(error.into());
179        }
180        if let Err(error) = post_commit.commit().await {
181            record_outcome("committed_post_commit_failed");
182            return Err(error);
183        }
184        record_outcome("committed");
185        Ok(())
186    })
187}
188
189/// Releases the savepoint and promotes its callbacks to the enclosing commit
190/// boundary, or rolls the savepoint back and discards them.
191fn finish_savepoint(
192    mut savepoint: ManagedSavepoint,
193    post_commit: PostCommitScope,
194    failure: Option<String>,
195) -> BoxFuture<'static, Result<(), crate::Error>> {
196    Box::pin(async move {
197        if let Some(error) = failure {
198            drop(post_commit);
199            if let Err(rollback_error) = savepoint.rollback().await {
200                record_outcome("savepoint_rollback_failed");
201                return Err(crate::Error::DatabaseError(format!(
202                    "Transaction failed: {error}; savepoint rollback also failed: {rollback_error}",
203                )));
204            }
205            record_outcome("savepoint_rolled_back");
206            return Err(crate::Error::DatabaseError(format!(
207                "Transaction failed: {error}",
208            )));
209        }
210        if let Err(error) = savepoint.release().await {
211            record_outcome("savepoint_release_failed");
212            return Err(error);
213        }
214        if let Err(error) = post_commit.promote_to_parent().await {
215            record_outcome("savepoint_released_post_commit_failed");
216            return Err(error);
217        }
218        record_outcome("savepoint_released");
219        Ok(())
220    })
221}