xref: /redis-3.2.3/src/redis-trib.rb (revision ace86cfa)
1#!/usr/bin/env ruby
2
3# TODO (temporary here, we'll move this into the Github issues once
4#       redis-trib initial implementation is completed).
5#
6# - Make sure that if the rehashing fails in the middle redis-trib will try
7#   to recover.
8# - When redis-trib performs a cluster check, if it detects a slot move in
9#   progress it should prompt the user to continue the move from where it
10#   stopped.
11# - Gracefully handle Ctrl+C in move_slot to prompt the user if really stop
12#   while rehashing, and performing the best cleanup possible if the user
13#   forces the quit.
14# - When doing "fix" set a global Fix to true, and prompt the user to
15#   fix the problem if automatically fixable every time there is something
16#   to fix. For instance:
17#   1) If there is a node that pretend to receive a slot, or to migrate a
18#      slot, but has no entries in that slot, fix it.
19#   2) If there is a node having keys in slots that are not owned by it
20#      fix this condition moving the entries in the same node.
21#   3) Perform more possibly slow tests about the state of the cluster.
22#   4) When aborted slot migration is detected, fix it.
23
24require 'rubygems'
25require 'redis'
26
27ClusterHashSlots = 16384
28
29def xputs(s)
30    case s[0..2]
31    when ">>>"
32        color="29;1"
33    when "[ER"
34        color="31;1"
35    when "[OK"
36        color="32"
37    when "[FA","***"
38        color="33"
39    else
40        color=nil
41    end
42
43    color = nil if ENV['TERM'] != "xterm"
44    print "\033[#{color}m" if color
45    print s
46    print "\033[0m" if color
47    print "\n"
48end
49
50class ClusterNode
51    def initialize(addr)
52        s = addr.split(":")
53        if s.length != 2
54            puts "Invalid node name #{addr}"
55            exit 1
56        end
57        @r = nil
58        @info = {}
59        @info[:host] = s[0]
60        @info[:port] = s[1]
61        @info[:slots] = {}
62        @info[:migrating] = {}
63        @info[:importing] = {}
64        @dirty = false # True if we need to flush slots info into node.
65        @friends = []
66    end
67
68    def friends
69        @friends
70    end
71
72    def slots
73        @info[:slots]
74    end
75
76    def has_flag?(flag)
77        @info[:flags].index(flag)
78    end
79
80    def to_s
81        "#{@info[:host]}:#{@info[:port]}"
82    end
83
84    def connect(o={})
85        return if @r
86        print "Connecting to node #{self}: "
87        STDOUT.flush
88        begin
89            @r = Redis.new(:host => @info[:host], :port => @info[:port])
90            @r.ping
91        rescue
92            xputs "[ERR] Sorry, can't connect to node #{self}"
93            exit 1 if o[:abort]
94            @r = nil
95        end
96        xputs "OK"
97    end
98
99    def assert_cluster
100        info = @r.info
101        if !info["cluster_enabled"] || info["cluster_enabled"].to_i == 0
102            xputs "[ERR] Node #{self} is not configured as a cluster node."
103            exit 1
104        end
105    end
106
107    def assert_empty
108        if !(@r.cluster("info").split("\r\n").index("cluster_known_nodes:1")) ||
109            (@r.info['db0'])
110            xputs "[ERR] Node #{self} is not empty. Either the node already knows other nodes (check with CLUSTER NODES) or contains some key in database 0."
111            exit 1
112        end
113    end
114
115    def load_info(o={})
116        self.connect
117        nodes = @r.cluster("nodes").split("\n")
118        nodes.each{|n|
119            # name addr flags role ping_sent ping_recv link_status slots
120            split = n.split
121            name,addr,flags,role,ping_sent,ping_recv,link_status = split[0..6]
122            slots = split[7..-1]
123            info = {
124                :name => name,
125                :addr => addr,
126                :flags => flags.split(","),
127                :role => role,
128                :ping_sent => ping_sent.to_i,
129                :ping_recv => ping_recv.to_i,
130                :link_status => link_status
131            }
132            if info[:flags].index("myself")
133                @info = @info.merge(info)
134                @info[:slots] = {}
135                slots.each{|s|
136                    if s[0..0] == '['
137                        if s.index("->-") # Migrating
138                            slot,dst = s[1..-1].split("->-")
139                            @info[:migrating][slot] = dst
140                        elsif s.index("-<-") # Importing
141                            slot,src = s[1..-1].split("-<-")
142                            @info[:importing][slot] = src
143                        end
144                    elsif s.index("-")
145                        start,stop = s.split("-")
146                        self.add_slots((start.to_i)..(stop.to_i))
147                    else
148                        self.add_slots((s.to_i)..(s.to_i))
149                    end
150                } if slots
151                @dirty = false
152                @r.cluster("info").split("\n").each{|e|
153                    k,v=e.split(":")
154                    k = k.to_sym
155                    v.chop!
156                    if k != :cluster_state
157                        @info[k] = v.to_i
158                    else
159                        @info[k] = v
160                    end
161                }
162            elsif o[:getfriends]
163                @friends << info
164            end
165        }
166    end
167
168    def add_slots(slots)
169        slots.each{|s|
170            @info[:slots][s] = :new
171        }
172        @dirty = true
173    end
174
175    def flush_node_config
176        return if !@dirty
177        new = []
178        @info[:slots].each{|s,val|
179            if val == :new
180                new << s
181                @info[:slots][s] = true
182            end
183        }
184        @r.cluster("addslots",*new)
185        @dirty = false
186    end
187
188    def info_string
189        # We want to display the hash slots assigned to this node
190        # as ranges, like in: "1-5,8-9,20-25,30"
191        #
192        # Note: this could be easily written without side effects,
193        # we use 'slots' just to split the computation into steps.
194
195        # First step: we want an increasing array of integers
196        # for instance: [1,2,3,4,5,8,9,20,21,22,23,24,25,30]
197        slots = @info[:slots].keys.sort
198
199        # As we want to aggregate adjacent slots we convert all the
200        # slot integers into ranges (with just one element)
201        # So we have something like [1..1,2..2, ... and so forth.
202        slots.map!{|x| x..x}
203
204        # Finally we group ranges with adjacent elements.
205        slots = slots.reduce([]) {|a,b|
206            if !a.empty? && b.first == (a[-1].last)+1
207                a[0..-2] + [(a[-1].first)..(b.last)]
208            else
209                a + [b]
210            end
211        }
212
213        # Now our task is easy, we just convert ranges with just one
214        # element into a number, and a real range into a start-end format.
215        # Finally we join the array using the comma as separator.
216        slots = slots.map{|x|
217            x.count == 1 ? x.first.to_s : "#{x.first}-#{x.last}"
218        }.join(",")
219
220        role = self.has_flag?("master") ? "M" : "S"
221        "#{role}: #{self.info[:name]} #{self.to_s}\n"+
222        "   slots:#{slots} (#{self.slots.length} slots) "+
223        "#{(self.info[:flags]-["myself"]).join(",")}"
224    end
225
226    # Return a single string representing nodes and associated slots.
227    # TODO: remove slaves from config when slaves will be handled
228    # by Redis Cluster.
229    def get_config_signature
230        config = []
231        @r.cluster("nodes").each_line{|l|
232            s = l.split
233            slots = s[7..-1].select {|x| x[0..0] != "["}
234            next if slots.length == 0
235            config << s[0]+":"+(slots.sort.join(","))
236        }
237        config.sort.join("|")
238    end
239
240    def info
241        @info
242    end
243
244    def is_dirty?
245        @dirty
246    end
247
248    def r
249        @r
250    end
251end
252
253class RedisTrib
254    def initialize
255        @nodes = []
256        @fix = false
257        @errors = []
258    end
259
260    def check_arity(req_args, num_args)
261        if ((req_args > 0 and num_args != req_args) ||
262           (req_args < 0 and num_args < req_args.abs))
263           xputs "[ERR] Wrong number of arguments for specified sub command"
264           exit 1
265        end
266    end
267
268    def add_node(node)
269        @nodes << node
270    end
271
272    def cluster_error(msg)
273        @errors << msg
274        xputs msg
275    end
276
277    def get_node_by_name(name)
278        @nodes.each{|n|
279            return n if n.info[:name] == name.downcase
280        }
281        return nil
282    end
283
284    def check_cluster
285        xputs ">>> Performing Cluster Check (using node #{@nodes[0]})"
286        show_nodes
287        check_config_consistency
288        check_open_slots
289        check_slots_coverage
290    end
291
292    # Merge slots of every known node. If the resulting slots are equal
293    # to ClusterHashSlots, then all slots are served.
294    def covered_slots
295        slots = {}
296        @nodes.each{|n|
297            slots = slots.merge(n.slots)
298        }
299        slots
300    end
301
302    def check_slots_coverage
303        xputs ">>> Check slots coverage..."
304        slots = covered_slots
305        if slots.length == ClusterHashSlots
306            xputs "[OK] All #{ClusterHashSlots} slots covered."
307        else
308            cluster_error \
309                "[ERR] Not all #{ClusterHashSlots} slots are covered by nodes."
310            fix_slots_coverage if @fix
311        end
312    end
313
314    def check_open_slots
315        xputs ">>> Check for open slots..."
316        open_slots = []
317        @nodes.each{|n|
318            if n.info[:migrating].size > 0
319                cluster_error \
320                    "[WARNING] Node #{n} has slots in migrating state."
321                open_slots += n.info[:migrating].keys
322            elsif n.info[:importing].size > 0
323                cluster_error \
324                    "[WARNING] Node #{n} has slots in importing state."
325                open_slots += n.info[:importing].keys
326            end
327        }
328        open_slots.uniq!
329        if open_slots.length > 0
330            xputs "[WARNING] The following slots are open: #{open_slots.join(",")}"
331        end
332        if @fix
333            open_slots.each{|slot| fix_open_slot slot}
334        end
335    end
336
337    def nodes_with_keys_in_slot(slot)
338        nodes = []
339        @nodes.each{|n|
340            nodes << n if n.r.cluster("getkeysinslot",slot,1).length > 0
341        }
342        nodes
343    end
344
345    def fix_slots_coverage
346        not_covered = (0...ClusterHashSlots).to_a - covered_slots.keys
347        xputs ">>> Fixing slots coverage..."
348        xputs "List of not covered slots: " + not_covered.join(",")
349
350        # For every slot, take action depending on the actual condition:
351        # 1) No node has keys for this slot.
352        # 2) A single node has keys for this slot.
353        # 3) Multiple nodes have keys for this slot.
354        slots = {}
355        not_covered.each{|slot|
356            nodes = nodes_with_keys_in_slot(slot)
357            slots[slot] = nodes
358            xputs "Slot #{slot} has keys in #{nodes.length} nodes: #{nodes.join}"
359        }
360
361        none = slots.select {|k,v| v.length == 0}
362        single = slots.select {|k,v| v.length == 1}
363        multi = slots.select {|k,v| v.length > 1}
364
365        # Handle case "1": keys in no node.
366        if none.length > 0
367            xputs "The folowing uncovered slots have no keys across the cluster:"
368            xputs none.keys.join(",")
369            yes_or_die "Fix these slots by covering with a random node?"
370            none.each{|slot,nodes|
371                node = @nodes.sample
372                xputs ">>> Covering slot #{slot} with #{node}"
373                node.r.cluster("addslots",slot)
374            }
375        end
376
377        # Handle case "2": keys only in one node.
378        if single.length > 0
379            xputs "The folowing uncovered slots have keys in just one node:"
380            puts single.keys.join(",")
381            yes_or_die "Fix these slots by covering with those nodes?"
382            single.each{|slot,nodes|
383                xputs ">>> Covering slot #{slot} with #{nodes[0]}"
384                nodes[0].r.cluster("addslots",slot)
385            }
386        end
387
388        # Handle case "3": keys in multiple nodes.
389        if multi.length > 0
390            xputs "The folowing uncovered slots have keys in multiple nodes:"
391            xputs multi.keys.join(",")
392            yes_or_die "Fix these slots by moving keys into a single node?"
393            multi.each{|slot,nodes|
394                xputs ">>> Covering slot #{slot} moving keys to #{nodes[0]}"
395                # TODO
396                # 1) Set all nodes as "MIGRATING" for this slot, so that we
397                # can access keys in the hash slot using ASKING.
398                # 2) Move everything to node[0]
399                # 3) Clear MIGRATING from nodes, and ADDSLOTS the slot to
400                # node[0].
401                raise "TODO: Work in progress"
402            }
403        end
404    end
405
406    # Slot 'slot' was found to be in importing or migrating state in one or
407    # more nodes. This function fixes this condition by migrating keys where
408    # it seems more sensible.
409    def fix_open_slot(slot)
410        migrating = []
411        importing = []
412        @nodes.each{|n|
413            next if n.has_flag? "slave"
414            if n.info[:migrating][slot]
415                migrating << n
416            elsif n.info[:importing][slot]
417                importing << n
418            elsif n.r.cluster("countkeysinslot",slot) > 0
419                xputs "*** Found keys about slot #{slot} in node #{n}!"
420            end
421        }
422        puts ">>> Fixing open slot #{slot}"
423        puts "Set as migrating in: #{migrating.join(",")}"
424        puts "Set as importing in: #{importing.join(",")}"
425
426        # Case 1: The slot is in migrating state in one slot, and in
427        #         importing state in 1 slot. That's trivial to address.
428        if migrating.length == 1 && importing.length == 1
429            move_slot(migrating[0],importing[0],slot,:verbose=>true)
430        else
431            xputs "[ERR] Sorry, Redis-trib can't fix this slot yet (work in progress)"
432        end
433    end
434
435    # Check if all the nodes agree about the cluster configuration
436    def check_config_consistency
437        if !is_config_consistent?
438            cluster_error "[ERR] Nodes don't agree about configuration!"
439        else
440            xputs "[OK] All nodes agree about slots configuration."
441        end
442    end
443
444    def is_config_consistent?
445        signatures=[]
446        @nodes.each{|n|
447            signatures << n.get_config_signature
448        }
449        return signatures.uniq.length == 1
450    end
451
452    def wait_cluster_join
453        print "Waiting for the cluster to join"
454        while !is_config_consistent?
455            print "."
456            STDOUT.flush
457            sleep 1
458        end
459        print "\n"
460    end
461
462    def alloc_slots
463        slots_per_node = ClusterHashSlots/@nodes.length
464        i = 0
465        @nodes.each{|n|
466            first = i*slots_per_node
467            last = first+slots_per_node-1
468            last = ClusterHashSlots-1 if i == @nodes.length-1
469            n.add_slots first..last
470            i += 1
471        }
472    end
473
474    def flush_nodes_config
475        @nodes.each{|n|
476            n.flush_node_config
477        }
478    end
479
480    def show_nodes
481        @nodes.each{|n|
482            xputs n.info_string
483        }
484    end
485
486    def join_cluster
487        # We use a brute force approach to make sure the node will meet
488        # each other, that is, sending CLUSTER MEET messages to all the nodes
489        # about the very same node.
490        # Thanks to gossip this information should propagate across all the
491        # cluster in a matter of seconds.
492        first = false
493        @nodes.each{|n|
494            if !first then first = n.info; next; end # Skip the first node
495            n.r.cluster("meet",first[:host],first[:port])
496        }
497    end
498
499    def yes_or_die(msg)
500        print "#{msg} (type 'yes' to accept): "
501        STDOUT.flush
502        if !(STDIN.gets.chomp.downcase == "yes")
503            xputs "*** Aborting..."
504            exit 1
505        end
506    end
507
508    def load_cluster_info_from_node(nodeaddr)
509        node = ClusterNode.new(nodeaddr)
510        node.connect(:abort => true)
511        node.assert_cluster
512        node.load_info(:getfriends => true)
513        add_node(node)
514        node.friends.each{|f|
515            next if f[:flags].index("noaddr") ||
516                    f[:flags].index("disconnected") ||
517                    f[:flags].index("fail")
518            fnode = ClusterNode.new(f[:addr])
519            fnode.connect()
520            fnode.load_info()
521            add_node(fnode)
522        }
523    end
524
525    # Given a list of source nodes return a "resharding plan"
526    # with what slots to move in order to move "numslots" slots to another
527    # instance.
528    def compute_reshard_table(sources,numslots)
529        moved = []
530        # Sort from bigger to smaller instance, for two reasons:
531        # 1) If we take less slots than instances it is better to start
532        #    getting from the biggest instances.
533        # 2) We take one slot more from the first instance in the case of not
534        #    perfect divisibility. Like we have 3 nodes and need to get 10
535        #    slots, we take 4 from the first, and 3 from the rest. So the
536        #    biggest is always the first.
537        sources = sources.sort{|a,b| b.slots.length <=> a.slots.length}
538        source_tot_slots = sources.inject(0) {|sum,source|
539            sum+source.slots.length
540        }
541        sources.each_with_index{|s,i|
542            # Every node will provide a number of slots proportional to the
543            # slots it has assigned.
544            n = (numslots.to_f/source_tot_slots*s.slots.length)
545            if i == 0
546                n = n.ceil
547            else
548                n = n.floor
549            end
550            s.slots.keys.sort[(0...n)].each{|slot|
551                if moved.length < numslots
552                    moved << {:source => s, :slot => slot}
553                end
554            }
555        }
556        return moved
557    end
558
559    def show_reshard_table(table)
560        table.each{|e|
561            puts "    Moving slot #{e[:slot]} from #{e[:source].info[:name]}"
562        }
563    end
564
565    def move_slot(source,target,slot,o={})
566        # We start marking the slot as importing in the destination node,
567        # and the slot as migrating in the target host. Note that the order of
568        # the operations is important, as otherwise a client may be redirected
569        # to the target node that does not yet know it is importing this slot.
570        print "Moving slot #{slot} from #{source} to #{target}: "; STDOUT.flush
571        target.r.cluster("setslot",slot,"importing",source.info[:name])
572        source.r.cluster("setslot",slot,"migrating",target.info[:name])
573        # Migrate all the keys from source to target using the MIGRATE command
574        while true
575            keys = source.r.cluster("getkeysinslot",slot,10)
576            break if keys.length == 0
577            keys.each{|key|
578                source.r.migrate(target.info[:host],target.info[:port],key,0,1000)
579                print "." if o[:verbose]
580                STDOUT.flush
581            }
582        end
583        puts
584        # Set the new node as the owner of the slot in all the known nodes.
585        @nodes.each{|n|
586            n.r.cluster("setslot",slot,"node",target.info[:name])
587        }
588    end
589
590    # redis-trib subcommands implementations
591
592    def check_cluster_cmd
593        load_cluster_info_from_node(ARGV[1])
594        check_cluster
595    end
596
597    def fix_cluster_cmd
598        @fix = true
599        load_cluster_info_from_node(ARGV[1])
600        check_cluster
601    end
602
603    def reshard_cluster_cmd
604        load_cluster_info_from_node(ARGV[1])
605        check_cluster
606        if @errors.length != 0
607            puts "*** Please fix your cluster problems before resharding"
608            exit 1
609        end
610        numslots = 0
611        while numslots <= 0 or numslots > ClusterHashSlots
612            print "How many slots do you want to move (from 1 to #{ClusterHashSlots})? "
613            numslots = STDIN.gets.to_i
614        end
615        target = nil
616        while not target
617            print "What is the receiving node ID? "
618            target = get_node_by_name(STDIN.gets.chop)
619            if !target || target.has_flag?("slave")
620                xputs "*** The specified node is not known or not a master, please retry."
621                target = nil
622            end
623        end
624        sources = []
625        xputs "Please enter all the source node IDs."
626        xputs "  Type 'all' to use all the nodes as source nodes for the hash slots."
627        xputs "  Type 'done' once you entered all the source nodes IDs."
628        while true
629            print "Source node ##{sources.length+1}:"
630            line = STDIN.gets.chop
631            src = get_node_by_name(line)
632            if line == "done"
633                if sources.length == 0
634                    puts "No source nodes given, operation aborted"
635                    exit 1
636                else
637                    break
638                end
639            elsif line == "all"
640                @nodes.each{|n|
641                    next if n.info[:name] == target.info[:name]
642                    next if n.has_flag?("slave")
643                    sources << n
644                }
645                break
646            elsif !src || src.has_flag?("slave")
647                xputs "*** The specified node is not known or is not a master, please retry."
648            elsif src.info[:name] == target.info[:name]
649                xputs "*** It is not possible to use the target node as source node."
650            else
651                sources << src
652            end
653        end
654        puts "\nReady to move #{numslots} slots."
655        puts "  Source nodes:"
656        sources.each{|s| puts "    "+s.info_string}
657        puts "  Destination node:"
658        puts "    #{target.info_string}"
659        reshard_table = compute_reshard_table(sources,numslots)
660        puts "  Resharding plan:"
661        show_reshard_table(reshard_table)
662        print "Do you want to proceed with the proposed reshard plan (yes/no)? "
663        yesno = STDIN.gets.chop
664        exit(1) if (yesno != "yes")
665        reshard_table.each{|e|
666            move_slot(e[:source],target,e[:slot],:verbose=>true)
667        }
668    end
669
670    def create_cluster_cmd
671        xputs ">>> Creating cluster"
672        ARGV[1..-1].each{|n|
673            node = ClusterNode.new(n)
674            node.connect(:abort => true)
675            node.assert_cluster
676            node.load_info
677            node.assert_empty
678            add_node(node)
679        }
680        xputs ">>> Performing hash slots allocation on #{@nodes.length} nodes..."
681        alloc_slots
682        show_nodes
683        yes_or_die "Can I set the above configuration?"
684        flush_nodes_config
685        xputs ">>> Nodes configuration updated"
686        xputs ">>> Sending CLUSTER MEET messages to join the cluster"
687        join_cluster
688        # Give one second for the join to start, in order to avoid that
689        # wait_cluster_join will find all the nodes agree about the config as
690        # they are still empty with unassigned slots.
691        sleep 1
692        wait_cluster_join
693        check_cluster
694    end
695
696    def addnode_cluster_cmd
697        xputs ">>> Adding node #{ARGV[1]} to cluster #{ARGV[2]}"
698
699        # Check the existing cluster
700        load_cluster_info_from_node(ARGV[2])
701        check_cluster
702
703        # Add the new node
704        new = ClusterNode.new(ARGV[1])
705        new.connect(:abort => true)
706        new.assert_cluster
707        new.load_info
708        new.assert_empty
709        first = @nodes.first.info
710
711        # Send CLUSTER MEET command to the new node
712        xputs ">>> Send CLUSTER MEET to node #{new} to make it join the cluster."
713        new.r.cluster("meet",first[:host],first[:port])
714    end
715
716    def help_cluster_cmd
717        show_help
718        exit 0
719    end
720end
721
722COMMANDS={
723    "create"  => ["create_cluster_cmd", -2, "host1:port1 ... hostN:portN"],
724    "check"   => ["check_cluster_cmd", 2, "host:port"],
725    "fix"     => ["fix_cluster_cmd", 2, "host:port"],
726    "reshard" => ["reshard_cluster_cmd", 2, "host:port"],
727    "addnode" => ["addnode_cluster_cmd", 3, "new_host:new_port existing_host:existing_port"],
728    "help"    => ["help_cluster_cmd", 1, "(show this help)"]
729}
730
731def show_help
732    puts "Usage: redis-trib <command> <arguments ...>"
733    puts
734    COMMANDS.each{|k,v|
735        puts "  #{k.ljust(10)} #{v[2]}"
736    }
737    puts
738end
739
740# Sanity check
741if ARGV.length == 0
742    show_help
743    exit 1
744end
745
746rt = RedisTrib.new
747cmd_spec = COMMANDS[ARGV[0].downcase]
748if !cmd_spec
749    puts "Unknown redis-trib subcommand '#{ARGV[0]}'"
750    exit 1
751end
752rt.check_arity(cmd_spec[1],ARGV.length)
753
754# Dispatch
755rt.send(cmd_spec[0])
756