Skip to main content

kevy_client/
transaction.rs

1//! `MULTI` / `EXEC` / `DISCARD` — Redis transactions, with optional
2//! `WATCH`-driven optimistic concurrency.
3//!
4//! Wire flow (Remote): client sends `MULTI` → server `+OK`; client sends
5//! each queued command → server `+QUEUED`; client sends `EXEC` → server
6//! returns an array of `N` typed replies, one per queued command. When
7//! `WATCH` was issued on the same `Connection` before `MULTI` and any
8//! watched key was modified between `WATCH` and `EXEC`, the server
9//! returns `Nil` (RESP null array) and the transaction aborts.
10//!
11//! Embedded mode rejects [`Connection::multi`] / [`Connection::watch`]
12//! / [`Connection::unwatch`] with `io::ErrorKind::Unsupported`:
13//! kevy-embedded has no MULTI dispatcher, and single-Connection embed
14//! access is already sequential (the inner mutex serialises every op),
15//! so the locking guarantee transactions add doesn't exist as a
16//! separate concept. Call methods directly instead.
17//!
18//! ```no_run
19//! use kevy_client::Connection;
20//!
21//! let mut conn = Connection::connect("kevy://localhost:6379")?;
22//! conn.watch(&[b"counter"])?;
23//! let mut txn = conn.multi()?;
24//! txn.incr(b"counter")?
25//!    .set(b"a", b"1")?;
26//! match txn.exec_watched()? {
27//!     Some(replies) => assert_eq!(replies.len(), 2),
28//!     None => { /* watched key changed — retry */ }
29//! }
30//! # Ok::<(), kevy_client::KevyError>(())
31//! ```
32//!
33//! [`Transaction::exec`] returns the raw [`kevy_resp::Reply`] per queued
34//! command; [`Transaction::exec_typed`] returns a [`TransactionReplies`]
35//! cursor with typed extractors (`next_int`, `next_bulk`, …) instead.
36
37use crate::{KevyError, KevyResult};
38
39use kevy_resp::Reply;
40use kevy_resp_client::RespClient;
41
42use crate::{Connection, string, unexpected, vec2, vec3};
43
44/// One in-flight `MULTI` block over a `Remote` connection.
45///
46/// Drop without calling [`Self::exec`] / [`Self::exec_watched`] /
47/// [`Self::discard`] sends an implicit `DISCARD` so the underlying
48/// socket isn't left in MULTI mode.
49pub struct Transaction<'a> {
50    client: &'a mut RespClient,
51    /// `false` after `exec`/`exec_watched`/`discard` consumed the txn —
52    /// suppresses the implicit-DISCARD in Drop.
53    live: bool,
54}
55
56impl std::fmt::Debug for Transaction<'_> {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        f.debug_struct("Transaction").field("live", &self.live).finish_non_exhaustive()
59    }
60}
61
62impl Connection {
63    /// Start a `MULTI` block. Embedded backend returns
64    /// [`io::ErrorKind::Unsupported`].
65    pub fn multi(&mut self) -> KevyResult<Transaction<'_>> {
66        match self {
67            Self::Embedded(_) => Err(KevyError::Unsupported(
68                "MULTI/EXEC is not implemented for the embedded backend; \
69                 call Connection methods directly (each is atomic on its own lock)"
70                    .into(),
71            )),
72            Self::Remote(client) => match client.request(&[b"MULTI".to_vec()])? {
73                Reply::Simple(s) if s == b"OK" => Ok(Transaction { client, live: true }),
74                Reply::Error(e) => Err(KevyError::Protocol(string(e))),
75                other => Err(unexpected(other)),
76            },
77        }
78    }
79
80    /// `WATCH key [key ...]` — mark keys for optimistic concurrency.
81    /// The next [`multi`](Self::multi) on this connection will abort
82    /// (EXEC returns Nil) if any watched key was modified between
83    /// this call and EXEC. Remote-only.
84    ///
85    /// Per RESP spec, WATCH must be sent **before** MULTI. Repeated
86    /// `watch` calls accumulate — the abort triggers on any of the
87    /// watched keys changing.
88    pub fn watch(&mut self, keys: &[&[u8]]) -> KevyResult<()> {
89        if keys.is_empty() {
90            return Err(KevyError::InvalidInput("WATCH needs at least one key".into()));
91        }
92        match self {
93            Self::Embedded(_) => Err(KevyError::Unsupported(
94                "WATCH is a transaction primitive; embedded backend has no MULTI".into(),
95            )),
96            Self::Remote(c) => {
97                let mut args = Vec::with_capacity(keys.len() + 1);
98                args.push(b"WATCH".to_vec());
99                args.extend(keys.iter().map(|k| k.to_vec()));
100                match c.request(&args)? {
101                    Reply::Simple(s) if s == b"OK" => Ok(()),
102                    Reply::Error(e) => Err(KevyError::Protocol(string(e))),
103                    other => Err(unexpected(other)),
104                }
105            }
106        }
107    }
108
109    /// `UNWATCH` — drop every WATCH set on this connection without
110    /// running a transaction. Remote-only.
111    pub fn unwatch(&mut self) -> KevyResult<()> {
112        match self {
113            Self::Embedded(_) => Err(KevyError::Unsupported(
114                "UNWATCH is a transaction primitive; embedded backend has no MULTI".into(),
115            )),
116            Self::Remote(c) => match c.request(&[b"UNWATCH".to_vec()])? {
117                Reply::Simple(s) if s == b"OK" => Ok(()),
118                Reply::Error(e) => Err(KevyError::Protocol(string(e))),
119                other => Err(unexpected(other)),
120            },
121        }
122    }
123}
124
125impl Transaction<'_> {
126    /// Queue one command — verb + args as raw byte slices. The server
127    /// replies `+QUEUED` synchronously; errors propagate as `io::Error`.
128    pub fn queue(&mut self, parts: &[&[u8]]) -> KevyResult<()> {
129        if parts.is_empty() {
130            return Err(KevyError::InvalidInput("Transaction::queue needs at least a verb".into()));
131        }
132        let argv: Vec<Vec<u8>> = parts.iter().map(|p| p.to_vec()).collect();
133        self.queue_argv(argv)
134    }
135
136    /// `EXEC` — send EXEC, return the per-queued-command reply array.
137    /// Consumes the transaction handle.
138    ///
139    /// When a `WATCH` violation aborts the transaction the server
140    /// returns Nil; this method collapses that into an empty `Vec`
141    /// (legacy behaviour, retained for compat). For new code, prefer
142    /// [`exec_watched`](Self::exec_watched), which distinguishes
143    /// "aborted by WATCH" (returns `None`) from "successful empty
144    /// transaction" (returns `Some(vec![])`).
145    pub fn exec(mut self) -> KevyResult<Vec<Reply>> {
146        self.live = false;
147        match self.client.request(&[b"EXEC".to_vec()])? {
148            Reply::Array(items) => Ok(items),
149            Reply::Nil => Ok(Vec::new()),
150            Reply::Error(e) => Err(KevyError::Protocol(string(e))),
151            other => Err(unexpected(other)),
152        }
153    }
154
155    /// Like [`exec`](Self::exec) but returns `None` when a `WATCH`
156    /// violation aborts the transaction (RESP Nil reply to EXEC).
157    /// Use this when you've called [`Connection::watch`] and need to
158    /// distinguish an abort from a successfully-empty queue.
159    pub fn exec_watched(mut self) -> KevyResult<Option<Vec<Reply>>> {
160        self.live = false;
161        match self.client.request(&[b"EXEC".to_vec()])? {
162            Reply::Array(items) => Ok(Some(items)),
163            Reply::Nil => Ok(None),
164            Reply::Error(e) => Err(KevyError::Protocol(string(e))),
165            other => Err(unexpected(other)),
166        }
167    }
168
169    /// Like [`exec`](Self::exec) but returns a [`TransactionReplies`]
170    /// cursor with typed extractors (`next_int`, `next_bulk`, …) so
171    /// callers don't hand-match every `Reply` themselves. Aborts with
172    /// `io::ErrorKind::InvalidData` ("transaction aborted by WATCH") if
173    /// the server replied Nil; use [`exec_watched_typed`](Self::exec_watched_typed)
174    /// to distinguish abort from successfully-empty.
175    ///
176    /// Consumes the handle. The cursor remembers how many replies are
177    /// left ([`TransactionReplies::remaining`]) so callers can sanity-
178    /// check arity at the end of the read sequence.
179    pub fn exec_typed(mut self) -> KevyResult<TransactionReplies> {
180        self.live = false;
181        match self.client.request(&[b"EXEC".to_vec()])? {
182            Reply::Array(items) => Ok(TransactionReplies::new(items)),
183            Reply::Nil => Err(KevyError::Protocol("transaction aborted by WATCH".into())),
184            Reply::Error(e) => Err(KevyError::Protocol(string(e))),
185            other => Err(unexpected(other)),
186        }
187    }
188
189    /// Like [`exec_watched`](Self::exec_watched) but returns a typed
190    /// [`TransactionReplies`] cursor on commit; `None` on WATCH abort.
191    pub fn exec_watched_typed(mut self) -> KevyResult<Option<TransactionReplies>> {
192        self.live = false;
193        match self.client.request(&[b"EXEC".to_vec()])? {
194            Reply::Array(items) => Ok(Some(TransactionReplies::new(items))),
195            Reply::Nil => Ok(None),
196            Reply::Error(e) => Err(KevyError::Protocol(string(e))),
197            other => Err(unexpected(other)),
198        }
199    }
200
201    /// `DISCARD` — abandon the queued commands. Consumes the handle.
202    pub fn discard(mut self) -> KevyResult<()> {
203        self.live = false;
204        match self.client.request(&[b"DISCARD".to_vec()])? {
205            Reply::Simple(s) if s == b"OK" => Ok(()),
206            Reply::Error(e) => Err(KevyError::Protocol(string(e))),
207            other => Err(unexpected(other)),
208        }
209    }
210}
211
212// ─────────────────────────────────────────────────────────────────────────
213// Typed builders. Each mirrors the same-named Connection method's
214// argument shape; on EXEC the matching index in the returned Vec carries
215// the raw `Reply` (use `exec_typed` for cursor-based typed decode).
216//
217// All builders return `&mut Self` so they can chain:
218//     txn.set(k, v)?.incr(c)?.del(&[k2])?;
219// ─────────────────────────────────────────────────────────────────────────
220
221impl Transaction<'_> {
222    /// Queue `SET key value`.
223    pub fn set(&mut self, key: &[u8], value: &[u8]) -> KevyResult<&mut Self> {
224        self.queue_argv(vec3(b"SET", key, value))?;
225        Ok(self)
226    }
227
228    /// Queue `GET key`.
229    pub fn get(&mut self, key: &[u8]) -> KevyResult<&mut Self> {
230        self.queue_argv(vec2(b"GET", key))?;
231        Ok(self)
232    }
233
234    /// Queue `DEL key [key ...]`.
235    pub fn del(&mut self, keys: &[&[u8]]) -> KevyResult<&mut Self> {
236        if keys.is_empty() {
237            return Err(KevyError::InvalidInput("Transaction::del needs at least one key".into()));
238        }
239        let mut args = Vec::with_capacity(keys.len() + 1);
240        args.push(b"DEL".to_vec());
241        args.extend(keys.iter().map(|k| k.to_vec()));
242        self.queue_argv(args)?;
243        Ok(self)
244    }
245
246    /// Queue `EXISTS key [key ...]`.
247    pub fn exists(&mut self, keys: &[&[u8]]) -> KevyResult<&mut Self> {
248        if keys.is_empty() {
249            return Err(KevyError::InvalidInput(
250                "Transaction::exists needs at least one key".into(),
251            ));
252        }
253        let mut args = Vec::with_capacity(keys.len() + 1);
254        args.push(b"EXISTS".to_vec());
255        args.extend(keys.iter().map(|k| k.to_vec()));
256        self.queue_argv(args)?;
257        Ok(self)
258    }
259
260    /// Queue `INCR key`.
261    pub fn incr(&mut self, key: &[u8]) -> KevyResult<&mut Self> {
262        self.queue_argv(vec2(b"INCR", key))?;
263        Ok(self)
264    }
265
266    /// Queue `INCRBY key delta`.
267    pub fn incr_by(&mut self, key: &[u8], delta: i64) -> KevyResult<&mut Self> {
268        let args = vec![b"INCRBY".to_vec(), key.to_vec(), delta.to_string().into_bytes()];
269        self.queue_argv(args)?;
270        Ok(self)
271    }
272
273    /// Queue `MGET key [key ...]`.
274    pub fn mget(&mut self, keys: &[&[u8]]) -> KevyResult<&mut Self> {
275        if keys.is_empty() {
276            return Err(KevyError::InvalidInput("Transaction::mget needs at least one key".into()));
277        }
278        let mut args = Vec::with_capacity(keys.len() + 1);
279        args.push(b"MGET".to_vec());
280        args.extend(keys.iter().map(|k| k.to_vec()));
281        self.queue_argv(args)?;
282        Ok(self)
283    }
284
285    /// Queue `MSET key value [key value ...]`.
286    pub fn mset(&mut self, pairs: &[(&[u8], &[u8])]) -> KevyResult<&mut Self> {
287        if pairs.is_empty() {
288            return Err(KevyError::InvalidInput(
289                "Transaction::mset needs at least one (key, value) pair".into(),
290            ));
291        }
292        let mut args = Vec::with_capacity(pairs.len() * 2 + 1);
293        args.push(b"MSET".to_vec());
294        for (k, v) in pairs {
295            args.push(k.to_vec());
296            args.push(v.to_vec());
297        }
298        self.queue_argv(args)?;
299        Ok(self)
300    }
301
302    /// Send one already-materialised argv and parse the `+QUEUED` ack.
303    /// Shared back-end for `queue` + every typed builder.
304    fn queue_argv(&mut self, argv: Vec<Vec<u8>>) -> KevyResult<()> {
305        match self.client.request(&argv)? {
306            Reply::Simple(s) if s == b"QUEUED" => Ok(()),
307            Reply::Error(e) => Err(KevyError::Protocol(string(e))),
308            other => Err(unexpected(other)),
309        }
310    }
311}
312
313impl Drop for Transaction<'_> {
314    fn drop(&mut self) {
315        // Implicit DISCARD if the caller dropped the handle without
316        // exec/exec_watched/discard. Best-effort: ignore any error
317        // since we're in Drop.
318        if self.live {
319            let _ = self.client.request(&[b"DISCARD".to_vec()]);
320        }
321    }
322}
323
324// ─────────────────────────────────────────────────────────────────────────
325// Typed EXEC reply cursor. Sits between the existing raw
326// `Vec<Reply>` API and the maximalist typestate-tuple alternative —
327// callers consume queued replies in order via per-typed extractors:
328//
329//     let mut r = txn.exec_typed()?;
330//     let counter: i64       = r.next_int()?;        // INCR
331//     let prior:   Option<_> = r.next_bulk()?;       // GET
332//     let bulk_m:  Vec<_>    = r.next_array_of_bulks()?;  // MGET
333//     r.expect_empty()?;                              // arity gate
334//
335// Mismatch surfaces InvalidData with the actual variant in the message
336// so debugging doesn't require turning on RESP wire logging. The cursor
337// also exposes `raw()` as an escape hatch for verbs the typed helpers
338// don't cover (HGETALL → array of bulks; ZRANGE WITHSCORES → mixed
339// pairs; etc.).
340// ─────────────────────────────────────────────────────────────────────────
341
342/// Typed cursor over the per-queued-command replies of a successful
343/// `EXEC`. Produced by [`Transaction::exec_typed`] /
344/// [`Transaction::exec_watched_typed`]. Each `next_*` consumes one
345/// reply; if the variant doesn't match the extractor, an
346/// `io::ErrorKind::InvalidData` is returned and the cursor advances
347/// regardless (so a downstream `expect_empty` still works correctly).
348#[derive(Debug)]
349pub struct TransactionReplies {
350    iter: std::vec::IntoIter<Reply>,
351}
352
353impl TransactionReplies {
354    fn new(items: Vec<Reply>) -> Self {
355        Self { iter: items.into_iter() }
356    }
357
358    /// Number of replies still un-consumed.
359    pub fn remaining(&self) -> usize {
360        self.iter.len()
361    }
362
363    /// Error out if the cursor still has replies — useful at the end of
364    /// a typed read sequence to assert the queued-command count matched.
365    pub fn expect_empty(&mut self) -> KevyResult<()> {
366        let left = self.remaining();
367        if left == 0 {
368            Ok(())
369        } else {
370            Err(KevyError::Protocol(format!(
371                "transaction reply cursor has {left} un-consumed replies"
372            )))
373        }
374    }
375
376    /// Pop the next reply as a raw [`Reply`]. Escape hatch for verbs
377    /// the typed extractors don't cover.
378    pub fn raw(&mut self) -> KevyResult<Reply> {
379        self.iter.next().ok_or_else(|| KevyError::Protocol("exhausted".into()))
380    }
381
382    /// Expect `Reply::Simple(b"OK")` — `SET` / `MSET` ack.
383    pub fn next_ok(&mut self) -> KevyResult<()> {
384        match self.raw()? {
385            Reply::Simple(s) if s == b"OK" => Ok(()),
386            other => Err(mismatch("Simple(OK)", &other)),
387        }
388    }
389
390    /// Expect `Reply::Simple(b"OK")` OR `Reply::Nil` — `SET key v NX/XX`
391    /// returns Nil when the condition is not met.
392    pub fn next_ok_or_nil(&mut self) -> KevyResult<bool> {
393        match self.raw()? {
394            Reply::Simple(s) if s == b"OK" => Ok(true),
395            Reply::Nil => Ok(false),
396            other => Err(mismatch("Simple(OK) or Nil", &other)),
397        }
398    }
399
400    /// Expect `Reply::Int` — `INCR` / `DEL` / `EXISTS` / `INCRBY`.
401    pub fn next_int(&mut self) -> KevyResult<i64> {
402        match self.raw()? {
403            Reply::Int(n) => Ok(n),
404            other => Err(mismatch("Int", &other)),
405        }
406    }
407
408    /// Expect `Reply::Bulk` (or `Nil` → `None`) — `GET`.
409    pub fn next_bulk(&mut self) -> KevyResult<Option<Vec<u8>>> {
410        match self.raw()? {
411            Reply::Bulk(b) => Ok(Some(b)),
412            Reply::Nil => Ok(None),
413            other => Err(mismatch("Bulk or Nil", &other)),
414        }
415    }
416
417    /// Expect `Reply::Array` of `Bulk`/`Nil` entries — `MGET`. Returns
418    /// `Vec<Option<Vec<u8>>>` in request order.
419    pub fn next_array_of_bulks(&mut self) -> KevyResult<Vec<Option<Vec<u8>>>> {
420        let items = match self.raw()? {
421            Reply::Array(v) => v,
422            Reply::Nil => return Ok(Vec::new()),
423            other => return Err(mismatch("Array", &other)),
424        };
425        items
426            .into_iter()
427            .map(|r| match r {
428                Reply::Bulk(b) => Ok(Some(b)),
429                Reply::Nil => Ok(None),
430                other => Err(mismatch("Array element Bulk/Nil", &other)),
431            })
432            .collect()
433    }
434
435    /// Expect `Reply::Simple` (any payload) — for verbs whose ack isn't
436    /// `OK` (e.g. `PING` → `+PONG`).
437    pub fn next_simple(&mut self) -> KevyResult<Vec<u8>> {
438        match self.raw()? {
439            Reply::Simple(s) => Ok(s),
440            other => Err(mismatch("Simple", &other)),
441        }
442    }
443}
444
445fn mismatch(want: &str, got: &Reply) -> KevyError {
446    KevyError::Protocol(format!("transaction reply mismatch: expected {want}, got {got:?}"))
447}