Skip to content
Select theme

Transactions with MULTI and EXEC

Step 8 of 10. Step 7 ended at the tag mini-ch07 with pub/sub working: channels, mailboxes, fan-out. This step starts from that tag and ends at mini-ch08. A connection can now MULTI, queue as many commands as it likes, and run the whole queue with EXEC — one array of replies back, in queue order. DISCARD throws the queue away. Nothing runs until EXEC says so, which the tests prove by reading the store from a second connection while the first one is mid- transaction.

Captured on redis-server 7.2.4, raw bytes, before any of the Rust below was written:

Event Reply
MULTI +OK\r\n
SET a 1 while a transaction is open +QUEUED\r\n — nothing runs
GET a likewise +QUEUED\r\n
EXEC after those two *2\r\n+OK\r\n$1\r\n1\r\n — one array, queue order
EXEC with nothing queued *0\r\n — an empty array, not an error
EXEC with no transaction open -ERR EXEC without MULTI\r\n
DISCARD with no transaction open -ERR DISCARD without MULTI\r\n
MULTI inside MULTI -ERR MULTI calls can not be nested\r\n
BADCMD while queued -ERR unknown command 'BADCMD', with args beginning with: \r\n — refused now, not queued
GET with no argument while queued -ERR wrong number of arguments for 'get' command\r\n — also refused now
EXEC after either error above -EXECABORT Transaction discarded because of previous errors.\r\n
EXEC x — an argument where none belongs -EXECABORT Transaction discarded because of: wrong number of arguments for 'exec' command\r\n
LPUSH a x queued against a string key, then EXEC +QUEUED, and the WRONGTYPE error arrives as one element of the array, not an abort

Three details are worth pinning down before designing anything. Errors split in two: a command that cannot parse — unknown name, wrong arity — is refused the moment it arrives, and it poisons the transaction so EXEC aborts; a command that parses but fails at run time — WRONGTYPE — is queued like any other and becomes one element of EXEC’s array. The other connection still sees nothing until EXEC runs. And EXEC’s own refusal is routed through the transaction machinery: give EXEC an argument and the real server answers the EXECABORT shape wrapping the arity message, not a plain error. The same thing happens from a subscribed connection — MULTI there gets the ordinary guard message from step 7, but EXEC gets -EXECABORT Transaction discarded because of: Can't execute 'exec': .... The clone reproduces all of it, including that asymmetry.

One more capture, because it decides a design point: SUBSCRIBE sent while a transaction is open is queued like any other command, and the confirmation frames come out of EXEC’s array. The queue holds frames, not store commands — pub/sub included.

A transaction is not server state. Two connections can each have one open, and closing a connection throws its queue away — exactly the lifetime a subscription has. So the transaction lives in the connection task, next to the Sub from step 7, and nothing is shared:

src/main.rs — the state
/// One connection's open transaction: the frames queued since `MULTI`,
/// and whether any of them failed at queue time — which turns `EXEC`
/// into an abort instead of a run. It lives in the connection task,
/// next to the connection's subscriptions: a transaction, like a
/// subscription, belongs to one socket.
struct Transaction {
queued: Vec<Frame>,
dirty: bool,
}

The queue holds Frames, parsed on arrival, so EXEC does not re-read anything — and a frame that failed to parse never enters it. It only sets dirty.

Parsing: three commands, one odd arity rule

Section titled “Parsing: three commands, one odd arity rule”

The new variants sit at the end of the enum. Their arity handling is uniform except for EXEC, whose refusal the real server wraps in the abort message:

src/main.rs — the new variants and parsing
Multi,
Exec,
Discard,
src/main.rs — parsing
// No arguments for any of the three. Captured on
// redis-server 7.2.4: `MULTI x` and `DISCARD x` answer the
// wrong-arity error — but `EXEC x` wraps its refusal in the
// transaction abort message, because the real server routes
// EXEC's argument check through the transaction machinery.
// The clone reproduces that shape.
b"MULTI" => match args {
[] => Ok(Command::Multi),
_ => Err(wrong_args(name)),
},
b"EXEC" => match args {
[] => Ok(Command::Exec),
_ => Err(exec_arity_abort()),
},
b"DISCARD" => match args {
[] => Ok(Command::Discard),
_ => Err(wrong_args(name)),
},
src/main.rs — the two EXECABORT replies
/// `EXEC` with an argument. The real server checks EXEC's arity inside
/// the transaction machinery, so the refusal arrives wrapped in the
/// abort message instead of as a plain wrong-arity error. Captured on
/// redis-server 7.2.4.
fn exec_arity_abort() -> Frame {
Frame::Error(
"EXECABORT Transaction discarded because of: \
wrong number of arguments for 'exec' command"
.to_string(),
)
}
/// The reply to `EXEC` after a queue-time error. Captured verbatim on
/// redis-server 7.2.4 — note the full stop, which the arity variant of
/// the message does not carry.
fn exec_aborted() -> Frame {
Frame::Error(
"EXECABORT Transaction discarded because of previous errors."
.to_string(),
)
}

One function decides which of the two modes a connection is in. Without a transaction, MULTI opens one and everything else runs now; with one open, every frame — errors included — goes to the queue machinery:

src/main.rs — queue_or_execute
/// One frame from a connection that is not subscribed. With no
/// transaction open, `MULTI` opens one and `EXEC`/`DISCARD` are refused
/// for running without one; everything else runs against the store now.
fn queue_or_execute(
db: &Db,
hub: &Hub,
me: &mut Sub,
tx: &mut Option<Transaction>,
frame: Frame,
) -> Frame {
if tx.is_some() {
return transaction_step(db, hub, me, tx, frame);
}
match Command::from_frame(&frame) {
Ok(Command::Multi) => {
*tx = Some(Transaction {
queued: Vec::new(),
dirty: false,
});
Frame::Simple("OK".to_string())
}
Ok(Command::Exec) => Frame::Error("ERR EXEC without MULTI".to_string()),
Ok(Command::Discard) => {
Frame::Error("ERR DISCARD without MULTI".to_string())
}
_ => execute(db, &frame),
}
}
src/main.rs — transaction_step
/// A frame arriving while this connection's transaction is open.
/// Everything except the three control commands is queued and answered
/// `+QUEUED` — and nothing runs until `EXEC`. A frame that fails to
/// parse is refused on the spot and marks the transaction dirty, which
/// turns `EXEC` into an abort. All captured on redis-server 7.2.4.
fn transaction_step(
db: &Db,
hub: &Hub,
me: &mut Sub,
tx: &mut Option<Transaction>,
frame: Frame,
) -> Frame {
let transaction = tx.as_mut().expect("the transaction is open");
match Command::from_frame(&frame) {
Err(reply) => {
transaction.dirty = true;
reply
}
Ok(Command::Multi) => {
Frame::Error("ERR MULTI calls can not be nested".to_string())
}
Ok(Command::Discard) => {
*tx = None;
Frame::Simple("OK".to_string())
}
Ok(Command::Exec) => {
let transaction = tx.take().expect("the transaction is open");
if transaction.dirty {
return exec_aborted();
}
// Every queued frame runs through the same dispatch a fresh
// one would: pub/sub first, then the store. The replies come
// back in one array, in queue order — and a command that
// fails at run time is one element of that array, not an
// abort.
let mut replies = Vec::with_capacity(transaction.queued.len());
for frame in &transaction.queued {
let pushed = execute_pubsub(hub, me, frame);
if pushed.is_empty() {
replies.push(execute(db, frame));
} else {
replies.extend(pushed);
}
}
Frame::Array(replies)
}
Ok(_) => {
transaction.queued.push(frame);
Frame::Simple("QUEUED".to_string())
}
}
}

The queue-time error is the first Err arm, and it is the whole reason the routing in serve matters: Command::from_frame fails with an error reply for an unknown command or a wrong arity, and that reply must be produced inside the queue machinery, or the transaction never learns it happened.

Step 7’s loop answered pub/sub first, then the store. A transaction changes the order: while one is open, it owns the connection’s input, and even frames that fail to parse must pass through transaction_step:

src/main.rs — serve, the frame arm
Some(frame) => {
let mut out = Vec::new();
if tx.is_some() {
// While a transaction is open it owns this
// connection's input — every frame, including
// ones that fail to parse, passes through the
// queue machinery. A queue-time error answered
// out of band would never mark the transaction
// dirty, and EXEC would run instead of abort.
queue_or_execute(&db, &hub, &mut me, &mut tx, frame)
.encode(&mut out);
} else {
// Pub/sub answers first: a subscribed connection
// never reaches the transaction machinery, and
// the real server refuses MULTI there like every
// store command. Captured on redis-server 7.2.4.
let replies = execute_pubsub(&hub, &mut me, &frame);
if replies.is_empty() {
queue_or_execute(&db, &hub, &mut me, &mut tx, frame)
.encode(&mut out);
} else {
for reply in replies {
reply.encode(&mut out);
}
}
}
conn.stream.write_all(&out).await?;
}

A subscribed connection never opens a transaction — its MULTI is refused by the step 7 guard before the queue machinery is consulted — so the two states cannot mix. Except once: EXEC from a subscribed connection is refused through the transaction machinery, the same wrap-the- message shape as EXEC x. One arm in execute_pubsub reproduces it, and the guard message moves into a helper both replies share:

src/main.rs — the EXEC asymmetry, in execute_pubsub
// EXEC from a subscribed connection is refused through the
// transaction machinery, like every other EXEC refusal: the
// real server answers an EXECABORT carrying the context error,
// not the plain guard message. Captured on redis-server 7.2.4.
Command::Exec if !me.channels.is_empty() => vec![Frame::Error(format!(
"EXECABORT Transaction discarded because of: {}",
context_error(frame)
))],
src/main.rs — the shared helper
/// The reply to a store command sent from a subscribed connection.
fn cannot_execute(frame: &Frame) -> Frame {
Frame::Error(format!("ERR {}", context_error(frame)))
}
/// The subscribe-mode error without its `ERR` prefix — the real server
/// embeds this exact text inside an EXECABORT when EXEC is refused in
/// subscribe mode. Captured on redis-server 7.2.4.
fn context_error(frame: &Frame) -> String {
let name = match frame {
Frame::Array(parts) => match parts.first() {
Some(Frame::Bulk(name)) => String::from_utf8_lossy(name).to_lowercase(),
_ => String::new(),
},
_ => String::new(),
};
format!(
"Can't execute '{name}': only (P|S)SUBSCRIBE / (P|S)UNSUBSCRIBE / \
PING / QUIT / RESET are allowed in this context"
)
}

The miniature below keeps only the queue machinery and a HashMap store — no sockets, no pub/sub — so the shape is easy to see on its own. It walks one scripted session and prints what the server would reply:

> multi
OK
> set a 1
QUEUED
> get a
QUEUED
> exec
1) OK
2) 1
> exec
ERR EXEC without MULTI
> discard
ERR DISCARD without MULTI
> multi
OK
> multi
ERR MULTI calls can not be nested
> discard
OK
> multi
OK
> badcmd
ERR unknown command 'badcmd'
> exec
EXECABORT Transaction discarded because of previous errors.
> multi
OK
> set b 2
QUEUED
> discard
OK
> get b
(nil)

badcmd is refused the moment it arrives — and EXEC still does not run. That is the dirty flag doing its one job: the error reply and the abort are two consequences of the same failed parse.

The binary hardcodes 127.0.0.1:6379, which the real redis-server owns, so the same binary was rebuilt on a scratch port and run from an empty directory. One connection queues a SET, an LPUSH and a GET, then runs them:

$ printf 'multi\nset a 1\nlpush list x\nget a\nexec\n' | redis-cli -p 7783
OK
QUEUED
QUEUED
QUEUED
OK
1
1

EXEC’s reply is the array on the wire: OK for the SET, 1 for the LPUSH, 1 for the GET — piped output shows bare values, so those last two lines are the list’s length and the string. A second session sends a command the server cannot parse while the transaction is open, then checks the connection is back to normal afterwards:

$ printf 'multi\nbadcmd x\nexec\nping\n' | redis-cli -p 7783
OK
ERR unknown command 'badcmd', with args beginning with: 'x'
EXECABORT Transaction discarded because of previous errors.
PONG

The server’s own stdout is one line, as it has been since step 4:

listening on 127.0.0.1:7783

Eighty-eight, up from seventy-four. The fourteen new ones pin the wire shapes and the bookkeeping: queue-then-exec into one array, the queued SET invisible to a second connection until EXEC, EXEC/DISCARD without MULTI, an empty transaction answering an empty array, nested MULTI, a queue-time unknown command and a wrong arity each poisoning the transaction into an EXECABORT, a run-time WRONGTYPE staying one element of the array, DISCARD emptying the queue and the store, MULTI x answering the plain arity error, EXEC x answering the abort shape, and both MULTI and EXEC refused from a subscribed connection — each with the message the real server uses.

$ cargo test
Compiling mini-redis v0.1.0 (/Users/rango/www/hackingwithrust/code/mini-redis)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.69s
Running unittests src/main.rs (/Volumes/RustBuild/tracks/debug/deps/mini_redis-d1b449e02a3de456)
running 88 tests
test tests::a_frame_in_two_pieces_is_incomplete_not_invalid ... ok
test tests::a_deadline_that_has_arrived_expires_the_key_on_read ... ok
test tests::a_deadline_survives_the_round_trip ... ok
test tests::a_key_with_no_deadline_answers_minus_one ... ok
test tests::a_key_expired_while_down_is_dropped_at_load ... ok
test tests::a_non_positive_ttl_deletes_the_key ... ok
test tests::a_departed_connection_is_pruned_on_the_next_publish ... ok
test tests::a_non_positive_ttl_on_a_missing_key_answers_zero ... ok
test tests::a_snapshot_with_the_wrong_magic_is_an_error ... ok
test tests::a_snapshot_round_trips_every_value_kind ... ok
test tests::a_store_command_is_untouched_while_not_subscribed ... ok
test tests::a_subscribed_connection_cannot_run_store_commands ... ok
test tests::a_subscription_is_confirmed_with_the_channel_and_the_count ... ok
test tests::a_truncated_snapshot_is_an_error ... ok
test tests::an_empty_array_is_not_a_command ... ok
test tests::an_expire_time_past_the_cutoff_is_refused ... ok
test tests::an_expired_key_is_not_written ... ok
test tests::a_buffered_second_frame_needs_no_read ... ok
test tests::an_unknown_command_caps_its_argument_list_at_128_bytes ... ok
test tests::an_unknown_command_lists_its_arguments ... ok
test tests::an_unsupported_version_is_an_error ... ok
test tests::bytes_that_are_not_resp_are_invalid ... ok
test tests::del_counts_only_the_keys_that_existed ... ok
test tests::a_protocol_error_ends_the_connection_without_a_reply ... ok
test tests::downtime_shrinks_the_ttl_the_way_it_does_on_the_real_server ... ok
test tests::echo_round_trips_through_the_codec ... ok
test tests::a_nested_multi_is_refused ... ok
test tests::echo_with_the_wrong_number_of_arguments_is_refused ... ok
test tests::error_messages_quote_the_command_name_as_it_was_sent ... ok
test tests::every_argument_must_be_a_bulk_string ... ok
test tests::every_frame_type_round_trips ... ok
test tests::an_empty_transaction_execs_to_an_empty_array ... ok
test tests::discard_without_multi_is_refused ... ok
test tests::expire_on_a_missing_key_answers_zero ... ok
test tests::expire_reads_its_argument_the_way_the_real_server_does ... ok
test tests::expire_replaces_an_earlier_deadline ... ok
test tests::a_half_sent_command_does_not_block_another_connection ... ok
test tests::expire_then_ttl_reports_the_seconds_left ... ok
test tests::expire_with_the_wrong_number_of_arguments_is_refused ... ok
test tests::an_unknown_command_at_queue_time_aborts_exec ... ok
test tests::expire_works_on_a_list_key ... ok
test tests::a_runtime_error_inside_exec_is_one_element_not_an_abort ... ok
test tests::get_on_a_missing_key_answers_null ... ok
test tests::get_on_a_list_is_a_wrongtype_error ... ok
test tests::lpush_needs_at_least_one_value ... ok
test tests::load_snapshot_answers_none_when_the_file_is_missing ... ok
test tests::lpush_on_a_string_key_is_a_wrongtype_error ... ok
test tests::lpush_on_an_expired_key_starts_a_new_list ... ok
test tests::an_argument_less_unsubscribe_leaves_every_channel ... ok
test tests::exec_with_an_argument_answers_the_arity_abort ... ok
test tests::lpush_prepends_each_value_in_order ... ok
test tests::lrange_on_a_missing_key_answers_an_empty_array ... ok
test tests::lrange_on_a_string_key_is_a_wrongtype_error ... ok
test tests::lrange_reads_a_slice_the_way_the_real_server_clamps_it ... ok
test tests::lrange_with_a_non_integer_index_is_refused ... ok
test tests::a_message_fans_out_to_every_subscriber ... ok
test tests::a_transaction_queues_and_execs_into_one_array ... ok
test tests::exec_without_multi_is_refused ... ok
test tests::exec_while_subscribed_answers_the_execabort_wrapper ... ok
test tests::one_decode_consumes_exactly_one_frame ... ok
test tests::ping_while_subscribed_answers_the_two_part_pong ... ok
test tests::ping_with_an_argument_echoes_it ... ok
test tests::publish_reaches_the_mailbox_with_channel_and_payload ... ok
test tests::save_with_an_argument_is_refused ... ok
test tests::set_clears_the_deadline ... ok
test tests::set_replaces_a_list_without_an_error ... ok
test tests::discard_empties_the_queue_and_closes_the_transaction ... ok
test tests::set_then_get_round_trips ... ok
test tests::subscribe_with_no_arguments_is_refused ... ok
test tests::the_exact_bytes_the_real_redis_cli_sends_for_ping ... ok
test tests::subscribing_twice_does_not_double_the_delivery ... ok
test tests::save_answers_ok_and_writes_the_file ... ok
test tests::the_sweeper_removes_only_the_keys_whose_deadline_has_passed ... ok
test tests::multi_with_an_argument_is_the_wrong_arity_error ... ok
test tests::save_then_load_round_trips_through_a_file ... ok
test tests::ping_over_a_real_socket ... ok
test tests::ttl_on_a_missing_key_answers_minus_two ... ok
test tests::ttl_rounds_to_the_nearest_second ... ok
test tests::ttl_with_the_wrong_number_of_arguments_is_refused ... ok
test tests::multi_is_refused_while_subscribed ... ok
test tests::unsubscribe_removes_only_the_channel_named ... ok
test tests::pipelined_commands_are_all_answered ... ok
test tests::publish_counts_what_it_delivered ... ok
test tests::queued_commands_do_not_touch_the_store_until_exec ... ok
test tests::two_connections_share_one_store ... ok
test tests::wrong_arity_at_queue_time_aborts_exec_too ... ok
test tests::a_frame_split_across_two_reads_is_reassembled ... ok
test tests::the_sweeper_task_reclaims_an_expired_key_on_its_own ... ok
test result: ok. 88 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.11s

The order the tests print in varies between runs; the set does not. The Running line names this machine’s shared build cache; elsewhere it reads target/debug/deps/mini_redis-….

  • WATCH, UNWATCH and check-and-set. The real server’s optimistic locking: watch keys, run the transaction only if none of them changed, retry otherwise. That needs a version or dirty marker per key and a retry loop in the client; the clone’s transactions are pure batching, which is what MULTI/EXEC are without WATCH.
  • Isolation. Other connections can read and write between MULTI and EXEC — the queued SET in the tests is invisible to the second connection only because it has not run yet. The real server behaves the same way: a transaction is atomic to others when it runs, but the queueing phase is not a lock.
  • QUIT and RESET in subscribe mode. Still missing, as noted in step 7; a subscribed connection still ends only by hanging up.
  • Nested-transaction edge cases beyond the refusal. The real server treats a MULTI that follows a queued EXEC differently in some flow-control corners; the clone refuses nesting and moves on, which is the path every client actually takes.

Step 9, Testing a network server, points a real client at the server from an integration test. It is not written yet, so it gets no link — the track overview carries the full list as articles land.

  • Pub/sub messaging — step 7, the tag this step starts from, and the guard whose message EXEC’s refusal wraps.
  • Handling concurrent connections — step 4, where the one-task-per-connection shape that makes per-connection queues natural was built.
  • Async — the select! loop this step’s routing change lives in.