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.
What the real server puts on the wire
Section titled “What the real server puts on the wire”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.
One queue per connection
Section titled “One queue per connection”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:
/// 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:
Multi, Exec, Discard, // 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)), },/// `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(), )}Queue or execute
Section titled “Queue or execute”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:
/// 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), }}/// 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.
serve routes by transaction state
Section titled “serve routes by transaction state”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:
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:
// 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) ))],/// 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:
use std::collections::HashMap;
/// The replies the queue machinery answers with.enum Reply { Simple(&'static str), Bulk(String), Error(String), Array(Vec<Reply>),}
/// One command after parsing.enum Cmd { Set(String, String), Get(String), Multi, Exec, Discard,}
/// Parse one command line. `Err` carries the immediate reply: an/// unknown name or the wrong number of arguments is refused on the/// spot, the way the real server refuses them at queue time.fn parse(line: &str) -> Result<Cmd, String> { let parts: Vec<&str> = line.split_whitespace().collect(); match parts.as_slice() { ["set", key, value] => Ok(Cmd::Set(key.to_string(), value.to_string())), ["set"] => Err("ERR wrong number of arguments for 'set' command".into()), ["get", key] => Ok(Cmd::Get(key.to_string())), ["get"] => Err("ERR wrong number of arguments for 'get' command".into()), ["multi"] => Ok(Cmd::Multi), ["exec"] => Ok(Cmd::Exec), ["discard"] => Ok(Cmd::Discard), [name, ..] => Err(format!("ERR unknown command '{name}'")), [] => unreachable!("empty lines are skipped before parse"), }}
/// Run one command against the store.fn execute(db: &mut HashMap<String, String>, cmd: &Cmd) -> Reply { match cmd { Cmd::Set(key, value) => { db.insert(key.clone(), value.clone()); Reply::Simple("OK") } Cmd::Get(key) => match db.get(key) { Some(value) => Reply::Bulk(value.clone()), // redis-cli prints a null this way; the wire carries $-1. None => Reply::Bulk("(nil)".into()), }, // MULTI, EXEC and DISCARD are intercepted upstream, so a frame // that reaches this arm would mean a routing bug. Cmd::Multi | Cmd::Exec | Cmd::Discard => { Reply::Error("ERR internal: transaction command reached the store".into()) } }}
/// One connection's open transaction: everything queued since MULTI,/// and whether anything failed at queue time.struct Tx { queued: Vec<Cmd>, dirty: bool,}
/// With no transaction open, MULTI opens one and everything else runs/// now. With one open, every frame — errors included — goes to the/// queue machinery, because a queue-time error is what turns EXEC into/// an abort.fn queue_or_execute(db: &mut HashMap<String, String>, tx: &mut Option<Tx>, line: &str) -> Reply { match tx { Some(_) => transaction_step(db, tx, line), None => match parse(line) { Ok(Cmd::Multi) => { *tx = Some(Tx { queued: Vec::new(), dirty: false, }); Reply::Simple("OK") } Ok(Cmd::Exec) => Reply::Error("ERR EXEC without MULTI".into()), Ok(Cmd::Discard) => Reply::Error("ERR DISCARD without MULTI".into()), Ok(cmd) => execute(db, &cmd), Err(reply) => Reply::Error(reply), }, }}
/// One frame arriving while the connection's transaction is open.fn transaction_step(db: &mut HashMap<String, String>, tx: &mut Option<Tx>, line: &str) -> Reply { match parse(line) { Err(reply) => { tx.as_mut().expect("the transaction is open").dirty = true; Reply::Error(reply) } Ok(Cmd::Multi) => Reply::Error("ERR MULTI calls can not be nested".into()), Ok(Cmd::Discard) => { *tx = None; Reply::Simple("OK") } Ok(Cmd::Exec) => { let tx = tx.take().expect("the transaction is open"); if tx.dirty { return Reply::Error( "EXECABORT Transaction discarded because of previous errors.".into(), ); } let mut replies = Vec::with_capacity(tx.queued.len()); for cmd in &tx.queued { match execute(db, cmd) { Reply::Array(_) => unreachable!("no queued command answers an array"), reply => replies.push(reply), } } Reply::Array(replies) } Ok(cmd) => { tx.as_mut().expect("the transaction is open").queued.push(cmd); Reply::Simple("QUEUED") } }}
/// Print a reply the way redis-cli would show it.fn render(reply: &Reply) { match reply { Reply::Simple(text) => println!("{text}"), Reply::Bulk(text) => println!("{text}"), Reply::Error(text) => println!("{text}"), Reply::Array(items) => { if items.is_empty() { println!("(empty array)"); } for (i, item) in items.iter().enumerate() { let text = match item { Reply::Simple(s) => s.to_string(), Reply::Bulk(s) => s.clone(), Reply::Error(s) => s.clone(), Reply::Array(_) => "...".into(), }; println!("{}) {text}", i + 1); } } }}
fn main() { let mut db = HashMap::new(); let mut tx: Option<Tx> = None; let session = [ // Queue two commands and run them as one array. "multi", "set a 1", "get a", "exec", // Without an open transaction, both are refused. "exec", "discard", // A second MULTI inside one is refused; the queue stays open. "multi", "multi", // DISCARD closes it for real. "discard", // An unknown command is refused at queue time and poisons the // transaction: EXEC aborts instead of running. "multi", "badcmd", "exec", // DISCARD empties the queue; the queued SET never runs. "multi", "set b 2", "discard", "get b", ]; for line in session { println!("> {line}"); render(&queue_or_execute(&mut db, &mut tx, line)); }}> multiOK> set a 1QUEUED> get aQUEUED> exec1) OK2) 1> execERR EXEC without MULTI> discardERR DISCARD without MULTI> multiOK> multiERR MULTI calls can not be nested> discardOK> multiOK> badcmdERR unknown command 'badcmd'> execEXECABORT Transaction discarded because of previous errors.> multiOK> set b 2QUEUED> discardOK> 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.
Seeing it run
Section titled “Seeing it run”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 7783OKQUEUEDQUEUEDQUEUEDOK11EXEC’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 7783OKERR unknown command 'badcmd', with args beginning with: 'x'
EXECABORT Transaction discarded because of previous errors.
PONGThe server’s own stdout is one line, as it has been since step 4:
listening on 127.0.0.1:7783Eighty-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 teststest tests::a_frame_in_two_pieces_is_incomplete_not_invalid ... oktest tests::a_deadline_that_has_arrived_expires_the_key_on_read ... oktest tests::a_deadline_survives_the_round_trip ... oktest tests::a_key_with_no_deadline_answers_minus_one ... oktest tests::a_key_expired_while_down_is_dropped_at_load ... oktest tests::a_non_positive_ttl_deletes_the_key ... oktest tests::a_departed_connection_is_pruned_on_the_next_publish ... oktest tests::a_non_positive_ttl_on_a_missing_key_answers_zero ... oktest tests::a_snapshot_with_the_wrong_magic_is_an_error ... oktest tests::a_snapshot_round_trips_every_value_kind ... oktest tests::a_store_command_is_untouched_while_not_subscribed ... oktest tests::a_subscribed_connection_cannot_run_store_commands ... oktest tests::a_subscription_is_confirmed_with_the_channel_and_the_count ... oktest tests::a_truncated_snapshot_is_an_error ... oktest tests::an_empty_array_is_not_a_command ... oktest tests::an_expire_time_past_the_cutoff_is_refused ... oktest tests::an_expired_key_is_not_written ... oktest tests::a_buffered_second_frame_needs_no_read ... oktest tests::an_unknown_command_caps_its_argument_list_at_128_bytes ... oktest tests::an_unknown_command_lists_its_arguments ... oktest tests::an_unsupported_version_is_an_error ... oktest tests::bytes_that_are_not_resp_are_invalid ... oktest tests::del_counts_only_the_keys_that_existed ... oktest tests::a_protocol_error_ends_the_connection_without_a_reply ... oktest tests::downtime_shrinks_the_ttl_the_way_it_does_on_the_real_server ... oktest tests::echo_round_trips_through_the_codec ... oktest tests::a_nested_multi_is_refused ... oktest tests::echo_with_the_wrong_number_of_arguments_is_refused ... oktest tests::error_messages_quote_the_command_name_as_it_was_sent ... oktest tests::every_argument_must_be_a_bulk_string ... oktest tests::every_frame_type_round_trips ... oktest tests::an_empty_transaction_execs_to_an_empty_array ... oktest tests::discard_without_multi_is_refused ... oktest tests::expire_on_a_missing_key_answers_zero ... oktest tests::expire_reads_its_argument_the_way_the_real_server_does ... oktest tests::expire_replaces_an_earlier_deadline ... oktest tests::a_half_sent_command_does_not_block_another_connection ... oktest tests::expire_then_ttl_reports_the_seconds_left ... oktest tests::expire_with_the_wrong_number_of_arguments_is_refused ... oktest tests::an_unknown_command_at_queue_time_aborts_exec ... oktest tests::expire_works_on_a_list_key ... oktest tests::a_runtime_error_inside_exec_is_one_element_not_an_abort ... oktest tests::get_on_a_missing_key_answers_null ... oktest tests::get_on_a_list_is_a_wrongtype_error ... oktest tests::lpush_needs_at_least_one_value ... oktest tests::load_snapshot_answers_none_when_the_file_is_missing ... oktest tests::lpush_on_a_string_key_is_a_wrongtype_error ... oktest tests::lpush_on_an_expired_key_starts_a_new_list ... oktest tests::an_argument_less_unsubscribe_leaves_every_channel ... oktest tests::exec_with_an_argument_answers_the_arity_abort ... oktest tests::lpush_prepends_each_value_in_order ... oktest tests::lrange_on_a_missing_key_answers_an_empty_array ... oktest tests::lrange_on_a_string_key_is_a_wrongtype_error ... oktest tests::lrange_reads_a_slice_the_way_the_real_server_clamps_it ... oktest tests::lrange_with_a_non_integer_index_is_refused ... oktest tests::a_message_fans_out_to_every_subscriber ... oktest tests::a_transaction_queues_and_execs_into_one_array ... oktest tests::exec_without_multi_is_refused ... oktest tests::exec_while_subscribed_answers_the_execabort_wrapper ... oktest tests::one_decode_consumes_exactly_one_frame ... oktest tests::ping_while_subscribed_answers_the_two_part_pong ... oktest tests::ping_with_an_argument_echoes_it ... oktest tests::publish_reaches_the_mailbox_with_channel_and_payload ... oktest tests::save_with_an_argument_is_refused ... oktest tests::set_clears_the_deadline ... oktest tests::set_replaces_a_list_without_an_error ... oktest tests::discard_empties_the_queue_and_closes_the_transaction ... oktest tests::set_then_get_round_trips ... oktest tests::subscribe_with_no_arguments_is_refused ... oktest tests::the_exact_bytes_the_real_redis_cli_sends_for_ping ... oktest tests::subscribing_twice_does_not_double_the_delivery ... oktest tests::save_answers_ok_and_writes_the_file ... oktest tests::the_sweeper_removes_only_the_keys_whose_deadline_has_passed ... oktest tests::multi_with_an_argument_is_the_wrong_arity_error ... oktest tests::save_then_load_round_trips_through_a_file ... oktest tests::ping_over_a_real_socket ... oktest tests::ttl_on_a_missing_key_answers_minus_two ... oktest tests::ttl_rounds_to_the_nearest_second ... oktest tests::ttl_with_the_wrong_number_of_arguments_is_refused ... oktest tests::multi_is_refused_while_subscribed ... oktest tests::unsubscribe_removes_only_the_channel_named ... oktest tests::pipelined_commands_are_all_answered ... oktest tests::publish_counts_what_it_delivered ... oktest tests::queued_commands_do_not_touch_the_store_until_exec ... oktest tests::two_connections_share_one_store ... oktest tests::wrong_arity_at_queue_time_aborts_exec_too ... oktest tests::a_frame_split_across_two_reads_is_reassembled ... oktest 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.11sThe 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-….
What is deliberately not here
Section titled “What is deliberately not here”WATCH,UNWATCHand 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 whatMULTI/EXECare withoutWATCH.- Isolation. Other connections can read and write between
MULTIandEXEC— the queuedSETin 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. QUITandRESETin 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
MULTIthat follows a queuedEXECdifferently 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.
Related
Section titled “Related”- 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.