Skip to main content

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);