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