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