@@ -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
0 commit comments