sqlx_cloudflare_do/transaction.rs
1use std::cell::RefCell;
2use std::fmt::{self, Debug, Formatter};
3use std::future::{self, Future};
4use std::rc::Rc;
5
6use sqlx_core::error::Error;
7use sqlx_core::sql_str::SqlStr;
8use sqlx_core::transaction::TransactionManager;
9use worker::send::SendFuture;
10
11use crate::error::unsupported;
12use crate::{Do, DoConnection};
13
14const NO_TRANSACTIONS: &str = "Durable Object storage cannot begin a transaction here; \
15 run the statements inside `DoConnection::transaction`, which takes them as a callback";
16
17impl DoConnection {
18 /// Runs `callback` in a transaction: it commits if the callback returns
19 /// `Ok`, and rolls back every write the callback made if it returns
20 /// `Err`.
21 ///
22 /// A real interactive transaction, unlike a batch: the callback reads its
23 /// own writes, and one statement can use what the one before it returned.
24 /// It runs inside `Storage::transaction`, and the callback's value -- or
25 /// its error, as it returned it -- is what this resolves to.
26 ///
27 /// ```no_run
28 /// # async fn create(conn: &sqlx_cloudflare_do::DoConnection, email: String) -> Result<i64, sqlx::Error> {
29 /// let id = conn
30 /// .transaction(move |tx| async move {
31 /// let id: i64 = sqlx::query_scalar("INSERT INTO users (email) VALUES (?) RETURNING id")
32 /// .bind(email)
33 /// .fetch_one(&tx)
34 /// .await?;
35 /// sqlx::query("INSERT INTO posts (user_id, title) VALUES (?, 'hello')")
36 /// .bind(id)
37 /// .execute(&tx)
38 /// .await?;
39 /// Ok::<_, sqlx::Error>(id)
40 /// })
41 /// .await?;
42 /// # Ok(id)
43 /// # }
44 /// ```
45 ///
46 /// # The callback owns what it uses
47 ///
48 /// `Storage::transaction` takes a `'static` callback, so it cannot borrow
49 /// the connection or anything else from the caller: move what it needs
50 /// in, and run its queries through the [`DoTransaction`] it is handed.
51 /// That is also why this is a callback and not sqlx's `begin()`, which
52 /// borrows the connection -- `begin()` still fails.
53 ///
54 /// # It holds the whole object
55 ///
56 /// The Durable Object delivers no other request until the transaction
57 /// ends -- even while the callback awaits something that is not storage.
58 /// That is what keeps other requests' writes out of it, and why it should
59 /// be short: an awaited `fetch()` inside it stalls every caller of the
60 /// object, and a request to the object itself would never be answered.
61 ///
62 /// # Errors
63 ///
64 /// The callback's own error, after rolling back; or, converted into `E`,
65 /// an error from storage when the transaction could not commit.
66 pub fn transaction<F, Fut, T, E>(
67 &self,
68 callback: F,
69 ) -> impl Future<Output = Result<T, E>> + Send + '_
70 where
71 F: FnOnce(DoTransaction) -> Fut + 'static,
72 Fut: Future<Output = Result<T, E>> + 'static,
73 T: 'static,
74 E: From<Error> + 'static,
75 {
76 let tx = DoTransaction {
77 sql: self.sql.clone(),
78 };
79
80 // `Storage::transaction` and the callback's future hold JavaScript
81 // handles, so neither is `Send`; `SendFuture` asserts it, soundly,
82 // because a Durable Object runs on one thread.
83 SendFuture::new(async move {
84 // The callback reports through this rather than its return
85 // value, which `worker` flattens to a string: the caller gets its
86 // value, or its error as it returned it.
87 let outcome = Rc::new(RefCell::new(None));
88 let slot = Rc::clone(&outcome);
89
90 let committed = self
91 .storage
92 .transaction(move |_| async move {
93 let result = callback(tx).await;
94 let failed = result.is_err();
95 *slot.borrow_mut() = Some(result);
96
97 if failed {
98 // Any error will do: rejecting is what rolls back.
99 Err(worker::Error::RustError("the callback failed".into()))
100 } else {
101 Ok(())
102 }
103 })
104 .await;
105
106 let outcome = outcome.borrow_mut().take();
107 match (committed, outcome) {
108 (_, Some(Err(error))) => Err(error),
109 (Ok(()), Some(Ok(value))) => Ok(value),
110 (Err(error), _) => Err(E::from(Error::Protocol(format!(
111 "the Durable Object storage transaction failed: {error}"
112 )))),
113 (Ok(()), None) => Err(E::from(Error::Protocol(
114 "the Durable Object storage transaction never ran its callback".into(),
115 ))),
116 }
117 })
118 }
119}
120
121/// An open transaction: the executor [`DoConnection::transaction`] hands its
122/// callback.
123///
124/// `&DoTransaction` and `&mut DoTransaction` are sqlx executors, like a
125/// connection's references, and every query run through one is part of the
126/// transaction.
127pub struct DoTransaction {
128 pub(crate) sql: worker::SqlStorage,
129}
130
131impl Debug for DoTransaction {
132 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
133 f.debug_struct("DoTransaction").finish_non_exhaustive()
134 }
135}
136
137/// Refuses `begin()`.
138///
139/// `sql.exec` rejects `BEGIN` and `SAVEPOINT` outright: the only transactions
140/// Durable Object storage offers wrap a callback, and sqlx's `begin` and
141/// `commit` are two separate calls with the caller's code in between.
142/// Buffering writes until `commit` would look like a transaction and not be
143/// one -- reads inside it would see the database as it was before -- which is
144/// worse than an error. So `conn.begin()` compiles and fails at runtime, and
145/// transactions go through [`DoConnection::transaction`].
146#[derive(Debug)]
147pub struct DoTransactionManager;
148
149impl TransactionManager for DoTransactionManager {
150 type Database = Do;
151
152 fn begin(
153 _conn: &mut DoConnection,
154 _statement: Option<SqlStr>,
155 ) -> impl Future<Output = Result<(), Error>> + Send + '_ {
156 future::ready(Err(unsupported(NO_TRANSACTIONS)))
157 }
158
159 // Unreachable in practice: `begin` never succeeds, so there is never a
160 // transaction to end. They still answer rather than panic.
161 fn commit(_conn: &mut DoConnection) -> impl Future<Output = Result<(), Error>> + Send + '_ {
162 future::ready(Err(unsupported(NO_TRANSACTIONS)))
163 }
164
165 fn rollback(_conn: &mut DoConnection) -> impl Future<Output = Result<(), Error>> + Send + '_ {
166 future::ready(Err(unsupported(NO_TRANSACTIONS)))
167 }
168
169 // `Transaction` calls this from `Drop` when `begin` failed -- it cannot
170 // tell a failed begin from an abandoned transaction -- so it must be a
171 // quiet no-op, not an error.
172 fn start_rollback(_conn: &mut DoConnection) {}
173
174 fn get_transaction_depth(_conn: &DoConnection) -> usize {
175 0
176 }
177}