rustis/client/transaction.rs
1use crate::{
2 ClientError, Error, ErrorKind, Result,
3 client::{BatchPreparedCommand, Client, PreparedCommand, command_traits::*},
4 resp::{Command, RespBatchDeserializer, RespResponse, RespView, cmd},
5};
6use bytes::Bytes;
7use serde::de::DeserializeOwned;
8use smallvec::SmallVec;
9
10/// Represents an on-going [`transaction`](https://redis.io/docs/manual/transactions/) on a specific client instance.
11pub struct Transaction {
12 client: Client,
13 commands: Vec<Command>,
14 forget_flags: SmallVec<[bool; 10]>,
15 retry_on_error: Option<bool>,
16}
17
18impl Transaction {
19 pub(crate) fn new(client: Client) -> Self {
20 Self {
21 client,
22 commands: vec![cmd("MULTI").into()],
23 forget_flags: SmallVec::new(),
24 retry_on_error: None,
25 }
26 }
27
28 /// Set a flag to override default `retry_on_error` behavior.
29 ///
30 /// See [Config::retry_on_error](crate::client::Config::retry_on_error)
31 pub fn retry_on_error(&mut self, retry_on_error: bool) {
32 self.retry_on_error = Some(retry_on_error);
33 }
34
35 /// Queue a command built with the generic API into the transaction.
36 ///
37 /// Built-in commands use
38 /// [`BatchPreparedCommand::queue`](crate::client::BatchPreparedCommand::queue)
39 /// instead: `transaction.get::<()>("k").queue()`. The names differ because the
40 /// calls do: this one takes a command, that one consumes a prepared command.
41 pub fn queue_command(&mut self, command: impl Into<Command>) {
42 self.commands.push(command.into());
43 self.forget_flags.push(false);
44 }
45
46 /// Queue a command built with the generic API into the transaction and
47 /// forget its response.
48 ///
49 /// See [`Self::queue_command`] for why the name differs from
50 /// [`BatchPreparedCommand::forget`](crate::client::BatchPreparedCommand::forget).
51 pub fn forget_command(&mut self, command: impl Into<Command>) {
52 self.commands.push(command.into());
53 self.forget_flags.push(true);
54 }
55
56 /// Execute the transaction by the sending the queued command
57 /// as a whole batch to the Redis server.
58 ///
59 /// # Return
60 /// It is the caller's responsibility to use the right type to cast the server response
61 /// to the right tuple or collection depending on which command has been
62 /// [queued](BatchPreparedCommand::queue) or [forgotten](BatchPreparedCommand::forget).
63 ///
64 /// The most generic type that can be requested as a result is `Vec<resp::Value>`
65 ///
66 /// # Example
67 /// ```
68 /// use rustis::{
69 /// client::{Client, Transaction, BatchPreparedCommand},
70 /// commands::StringCommands,
71 /// resp::{cmd, Value}, Result,
72 /// };
73 ///
74 /// #[tokio::main]
75 /// async fn main() -> Result<()> {
76 /// let client = Client::connect("127.0.0.1:6379").await?;
77 ///
78 /// let mut transaction = client.create_transaction();
79 ///
80 /// transaction.set("key1", "value1").forget();
81 /// transaction.set("key2", "value2").forget();
82 /// transaction.get::<()>("key1").queue();
83 /// let value: String = transaction.execute().await?;
84 ///
85 /// assert_eq!("value1", value);
86 ///
87 /// Ok(())
88 /// }
89 /// ```
90 #[expect(
91 clippy::arithmetic_side_effects,
92 reason = "`EXEC` was pushed just above, so the command count is at least 1."
93 )]
94 pub async fn execute<T: DeserializeOwned>(mut self) -> Result<T> {
95 if self.client.is_cluster() {
96 // Slots are no longer computed at command-build time; populate them
97 // here (caller thread, cluster only) before the cross-slot check
98 // reads them.
99 for command in &mut self.commands {
100 command.compute_slots();
101 }
102 Self::check_single_slot(&self.commands)?;
103 }
104
105 self.commands.push(cmd("EXEC").into());
106
107 let num_commands = self.commands.len();
108
109 // Unlike a pipeline, a transaction wants one name per command: the server
110 // refuses a command by name at queue time, and the queued phase below
111 // names each refusal. Taken here, from the commands, because a batch hands
112 // its replies back unnamed. `forget_flags` is offset by one against this
113 // list, `MULTI` occupying `commands[0]` and carrying no flag.
114 let command_names: Vec<Bytes> = self.commands.iter().map(Command::name_bytes).collect();
115
116 let results = self
117 .client
118 .internal_send_batch(self.commands, self.retry_on_error)
119 .await?;
120
121 // The reply the caller reads is EXEC's, whose elements are the queued
122 // commands' own replies. Which command an error inside it belongs to is
123 // only recoverable when exactly one command is awaited: with several,
124 // the batch deserializer reports on the tuple as a whole and does not
125 // say which element it stumbled on.
126 let awaited_command = {
127 let mut awaited = self
128 .forget_flags
129 .iter()
130 .enumerate()
131 .filter(|(_, forget)| !**forget);
132 match (awaited.next(), awaited.next()) {
133 // `commands` is MULTI, then the queued commands, then EXEC —
134 // hence the offset of one onto the queued commands.
135 (Some((i, _)), None) => command_names.get(i + 1).cloned(),
136 _ => None,
137 }
138 };
139
140 let mut iter = results.into_iter();
141
142 // MULTI + QUEUED commands. A server error here names the queued command
143 // it refused, which is the one the caller has to fix.
144 for name in command_names.iter().take(num_commands - 1) {
145 if let Some(response) = iter.next() {
146 response
147 .to::<()>()
148 .map_err(|e| e.with_command(name.clone()))?;
149 }
150 }
151
152 // EXEC. Its reply holds one element per queued command -- the same batch
153 // shape a pipeline hands back, read by the same deserializer.
154 let Some(result) = iter.next() else {
155 return Err(Error::from(ClientError::MissingTransactionReply));
156 };
157
158 match (
159 Self::deserialize_exec_reply(result, self.forget_flags),
160 awaited_command,
161 ) {
162 (Err(e), Some(command)) => Err(e.with_command(command)),
163 (result, _) => result,
164 }
165 }
166
167 /// Reads `EXEC`'s reply as the batch of the replies the caller kept.
168 ///
169 /// The elements are handed out as responses of their own -- a refcount bump
170 /// each, no byte copied and no value decoded -- so that the batch the caller
171 /// reads is the very one [`RespBatchDeserializer`] reads for a pipeline, and
172 /// a transaction retaining one reply answers `Vec<T>` and `T` the same way a
173 /// pipeline does.
174 fn deserialize_exec_reply<T: DeserializeOwned>(
175 result: RespResponse,
176 forget_flags: SmallVec<[bool; 10]>,
177 ) -> Result<T> {
178 // A nil `EXEC` is the server saying it dropped the transaction: a key a
179 // `WATCH` was holding changed under it.
180 if matches!(result.view()?, RespView::Null) {
181 return Err(Error::from(ErrorKind::Aborted));
182 }
183
184 let mut forget_flags = forget_flags.into_iter();
185 let replies = result
186 .into_collection_iter()?
187 // A forgotten reply is dropped unread, as in a pipeline: the caller
188 // said it does not want it, and that covers the errors it may carry.
189 // An element the flags do not cover is kept, so a reply longer than
190 // the transaction reads as the mismatch it is instead of vanishing.
191 .filter(|_| !forget_flags.next().unwrap_or(false))
192 .collect::<Result<Vec<RespResponse>>>()?;
193
194 let deserializer = RespBatchDeserializer::new(&replies);
195 T::deserialize(&deserializer)
196 }
197
198 /// Enforce Redis Cluster's own transaction constraint: every key must hash to
199 /// the same slot.
200 ///
201 /// In cluster mode each queued command is routed independently by its own key,
202 /// while MULTI is pinned to the node of the first key-bearing command and EXEC
203 /// follows that pin. A command whose slot belongs to another node is therefore
204 /// sent there *outside* any MULTI and executes immediately, and the queued-phase
205 /// check cannot notice: it accepts any non-error reply, so a direct command
206 /// result passes for `+QUEUED`. The outcome is a partially applied transaction
207 /// reported as a success. Refuse it before anything is sent.
208 fn check_single_slot(commands: &[Command]) -> Result<()> {
209 let mut slot: Option<u16> = None;
210
211 for command in commands {
212 for command_slot in command.slots() {
213 match slot {
214 None => slot = Some(command_slot),
215 Some(slot) if slot != command_slot => {
216 return Err(Error::from(ClientError::CrossSlot));
217 }
218 Some(_) => (),
219 }
220 }
221 }
222
223 Ok(())
224 }
225}
226
227impl<'a, R: DeserializeOwned> BatchPreparedCommand for PreparedCommand<'a, &'a mut Transaction, R> {
228 /// Queue a command into the transaction.
229 fn queue(self) {
230 self.executor.queue_command(self.command)
231 }
232
233 /// Queue a command into the transaction and forget its response.
234 fn forget(self) {
235 self.executor.forget_command(self.command)
236 }
237}
238
239impl_transaction_command_traits!(Transaction);