Skip to content

Commit 93fa69a

Browse files
committed
Follow redirects in transactions
Closes #321.
1 parent e771af7 commit 93fa69a

2 files changed

Lines changed: 178 additions & 1 deletion

File tree

lib/redix/cluster.ex

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,13 @@ defmodule Redix.Cluster do
421421
Transactions always run on the slot's primary; passing `route:` anything other
422422
than `:primary` raises an `ArgumentError`.
423423
424+
Like `command/3` and `pipeline/3`, a "transaction" follows `MOVED`/`ASK`
425+
redirections: since all commands target one slot, a redirect means the whole
426+
transaction belongs on another node, so the entire `MULTI`/`EXEC` is re-run
427+
there (bounded by the internal redirection limit). A `MOVED` also triggers a
428+
reactive topology refresh, so a transaction-only workload self-heals after a
429+
failover instead of failing until the next periodic refresh.
430+
424431
## Options
425432
426433
* `:timeout` - request timeout in milliseconds. Defaults to `5_000`.
@@ -468,7 +475,14 @@ defmodule Redix.Cluster do
468475
[slot] ->
469476
case Manager.get_connection(slot_table, registry, slot) do
470477
{:ok, conn} ->
471-
Redix.transaction_pipeline(conn, commands, opts)
478+
execute_transaction(
479+
cluster,
480+
conn,
481+
commands,
482+
opts,
483+
@max_redirections,
484+
_asking? = false
485+
)
472486

473487
:error ->
474488
{:error, %Redix.ConnectionError{reason: :closed}}
@@ -499,6 +513,91 @@ defmodule Redix.Cluster do
499513
end
500514
end
501515

516+
## Transaction implementation
517+
518+
# Runs a MULTI/EXEC transaction on `conn`, following a MOVED/ASK redirection to
519+
# its target. A cluster transaction is pinned to a single slot, so a redirect on
520+
# any queued command means the *whole* transaction belongs on one other node —
521+
# we re-run the entire MULTI/EXEC there, bounded by the same @max_redirections
522+
# budget command/3 and pipeline/3 use. This is what lets a transaction-only
523+
# workload self-heal after a failover instead of failing until the next periodic
524+
# topology refresh (issue #321). We issue the pipeline directly rather than via
525+
# Redix.transaction_pipeline/3 because that only inspects the EXEC reply, while a
526+
# redirect surfaces in the (otherwise hidden) queueing replies.
527+
defp execute_transaction(cluster, conn, commands, opts, remaining, asking?) do
528+
# ASKING must precede MULTI: Redis preserves the ASKING flag for the whole
529+
# transaction (it only clears it outside a MULTI), so one ASKING covers every
530+
# queued command.
531+
prefix = if asking?, do: [["ASKING"], ["MULTI"]], else: [["MULTI"]]
532+
533+
case Redix.pipeline(conn, prefix ++ commands ++ [["EXEC"]], opts) do
534+
{:ok, responses} ->
535+
# Replies line up as [prefix..., queueing replies..., EXEC reply]. A
536+
# wrong-slot command is rejected with MOVED/ASK at *queue* time (which
537+
# aborts EXEC with EXECABORT), so the redirection lives among the queueing
538+
# replies, not in the EXEC reply.
539+
queue_responses = responses |> Enum.drop(length(prefix)) |> Enum.drop(-1)
540+
541+
case Enum.find_value(queue_responses, &parse_redirection/1) do
542+
{type, slot, host, port} ->
543+
follow_transaction_redirect(
544+
cluster,
545+
type,
546+
slot,
547+
host,
548+
port,
549+
commands,
550+
opts,
551+
remaining
552+
)
553+
554+
nil ->
555+
case List.last(responses) do
556+
%Redix.Error{} = error -> {:error, error}
557+
other -> {:ok, other}
558+
end
559+
end
560+
561+
{:error, _reason} = error ->
562+
error
563+
end
564+
end
565+
566+
defp follow_transaction_redirect(_cluster, _type, _slot, _host, _port, _commands, _opts, 0) do
567+
{:error, %Redix.ConnectionError{reason: :too_many_redirections}}
568+
end
569+
570+
defp follow_transaction_redirect(cluster, type, slot, host, port, commands, opts, remaining) do
571+
# MOVED means the slot's ownership changed (typically a failover); trigger the
572+
# same reactive refresh command/3 does so future routing is corrected. ASK is a
573+
# transient per-request migration hint and signals no topology change.
574+
if type == :moved, do: Manager.refresh_topology(manager_name(cluster))
575+
576+
:telemetry.execute([:redix, :cluster, :redirection], %{}, %{
577+
cluster: cluster,
578+
type: type,
579+
slot: slot,
580+
target_address: "#{host}:#{port}"
581+
})
582+
583+
# The target may not be in the Registry yet (mid-resharding for MOVED, a
584+
# brand-new node serving zero slots for ASK), so connect on demand — the
585+
# redirect address is authoritative. Mirrors handle_moved_redirect/6.
586+
conn =
587+
case Manager.get_connection_by_node(registry_name(cluster), {host, port}) do
588+
{:ok, conn} -> {:ok, conn}
589+
:error -> Manager.connect_to_node(manager_name(cluster), {host, port})
590+
end
591+
592+
case conn do
593+
{:ok, conn} ->
594+
execute_transaction(cluster, conn, commands, opts, remaining - 1, type == :ask)
595+
596+
{:error, _reason} ->
597+
{:error, %Redix.ConnectionError{reason: :closed}}
598+
end
599+
end
600+
502601
## Pipeline implementation
503602

504603
defp execute_pipeline(_cluster, [] = _commands, _opts) do

test/redix/cluster/redirection_test.exs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,84 @@ defmodule Redix.Cluster.RedirectionTest do
176176
end
177177
end
178178

179+
# Reproduces issue #321: a transaction queued on a stale primary (e.g. right
180+
# after a failover) is rejected with MOVED at queue time, aborting EXEC with
181+
# EXECABORT. Since a cluster transaction targets a single slot, the whole
182+
# MULTI/EXEC is re-run at the redirect target.
183+
test "follows a MOVED redirect for a transaction by re-running it at the target", %{
184+
cluster: cluster
185+
} do
186+
slot = Hash.hash_slot("x")
187+
188+
node_b =
189+
start_node(cluster, fn
190+
["MULTI"] -> "+OK\r\n"
191+
["SET", _, _] -> "+QUEUED\r\n"
192+
["EXEC"] -> "*1\r\n+OK\r\n"
193+
end)
194+
195+
node_a =
196+
start_node(cluster, fn
197+
["MULTI"] -> "+OK\r\n"
198+
["SET", _, _] -> "-MOVED #{slot} #{node_b}\r\n"
199+
["EXEC"] -> "-EXECABORT Transaction discarded because of previous errors.\r\n"
200+
end)
201+
202+
route_slot(cluster, slot, node_a)
203+
204+
assert Redix.Cluster.transaction_pipeline(cluster, [["SET", "x", "1"]]) == {:ok, ["OK"]}
205+
end
206+
207+
test "follows an ASK redirect for a transaction with an ASKING-prefixed re-run", %{
208+
cluster: cluster
209+
} do
210+
slot = Hash.hash_slot("x")
211+
212+
# The importing node accepts a single ASKING that flags the whole MULTI.
213+
node_b =
214+
start_node(cluster, fn
215+
["ASKING"] -> "+OK\r\n"
216+
["MULTI"] -> "+OK\r\n"
217+
["SET", _, _] -> "+QUEUED\r\n"
218+
["EXEC"] -> "*1\r\n+OK\r\n"
219+
end)
220+
221+
node_a =
222+
start_node(cluster, fn
223+
["MULTI"] -> "+OK\r\n"
224+
["SET", _, _] -> "-ASK #{slot} #{node_b}\r\n"
225+
["EXEC"] -> "-EXECABORT Transaction discarded because of previous errors.\r\n"
226+
end)
227+
228+
route_slot(cluster, slot, node_a)
229+
230+
assert Redix.Cluster.transaction_pipeline(cluster, [["SET", "x", "1"]]) == {:ok, ["OK"]}
231+
end
232+
233+
test "bounds an endless MOVED redirection loop for a transaction", %{cluster: cluster} do
234+
slot = Hash.hash_slot("x")
235+
236+
{listen_a, node_a} = listen(cluster)
237+
{listen_b, node_b} = listen(cluster)
238+
239+
serve(listen_a, fn
240+
["MULTI"] -> "+OK\r\n"
241+
["SET", _, _] -> "-MOVED #{slot} #{node_b}\r\n"
242+
["EXEC"] -> "-EXECABORT Transaction discarded because of previous errors.\r\n"
243+
end)
244+
245+
serve(listen_b, fn
246+
["MULTI"] -> "+OK\r\n"
247+
["SET", _, _] -> "-MOVED #{slot} #{node_a}\r\n"
248+
["EXEC"] -> "-EXECABORT Transaction discarded because of previous errors.\r\n"
249+
end)
250+
251+
route_slot(cluster, slot, node_a)
252+
253+
assert Redix.Cluster.transaction_pipeline(cluster, [["SET", "x", "1"]]) ==
254+
{:error, %Redix.ConnectionError{reason: :too_many_redirections}}
255+
end
256+
179257
## Helpers
180258

181259
defp route_slot(cluster, slot, node_id) do

0 commit comments

Comments
 (0)