jetpants 0.7.10 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -7,7 +7,8 @@ module Jetpants
7
7
  class DB
8
8
  # Create a MySQL user. If you omit parameters, the defaults from Jetpants'
9
9
  # configuration will be used instead. Does not automatically grant any
10
- # privileges; use DB#grant_privileges for that.
10
+ # privileges; use DB#grant_privileges for that. Intentionally cannot
11
+ # create a passwordless user.
11
12
  def create_user(username=false, password=false, skip_binlog=false)
12
13
  username ||= app_credentials[:user]
13
14
  password ||= app_credentials[:pass]
@@ -19,6 +20,11 @@ module Jetpants
19
20
  commands << "FLUSH PRIVILEGES"
20
21
  commands = commands.join '; '
21
22
  mysql_root_cmd commands, schema: true
23
+ Jetpants.mysql_grant_ips.each do |ip|
24
+ message = "Created user '#{username}'@'#{ip}'"
25
+ message += ' (only on this node - skipping binlog!)' if skip_binlog
26
+ output message
27
+ end
22
28
  end
23
29
 
24
30
  # Drops a user. Can optionally make this statement skip replication, if you
@@ -33,6 +39,11 @@ module Jetpants
33
39
  commands << "FLUSH PRIVILEGES"
34
40
  commands = commands.join '; '
35
41
  mysql_root_cmd commands, schema: true
42
+ Jetpants.mysql_grant_ips.each do |ip|
43
+ message = "Dropped user '#{username}'@'#{ip}'"
44
+ message += ' (only on this node - skipping binlog!)' if skip_binlog
45
+ output message
46
+ end
36
47
  end
37
48
 
38
49
  # Grants privileges to the given username for the specified database.
@@ -64,30 +75,68 @@ module Jetpants
64
75
  commands << "FLUSH PRIVILEGES"
65
76
  commands = commands.join '; '
66
77
  mysql_root_cmd commands, schema: true
78
+ Jetpants.mysql_grant_ips.each do |ip|
79
+ verb = (statement.downcase == 'revoke' ? 'Revoking' : 'Granting')
80
+ target_db = (database == '*' ? 'globally' : "on #{database}.*")
81
+ output "#{verb} privileges #{preposition.downcase} '#{username}'@'#{ip}' #{target_db}: #{privileges.downcase}"
82
+ end
67
83
  end
68
84
 
69
85
  # Disables access to a DB by the application user, and sets the DB to
70
86
  # read-only. Useful when decommissioning instances from a shard that's
71
- # been split.
87
+ # been split, or a former slave that's been permanently removed from the pool
72
88
  def revoke_all_access!
73
89
  user_name = app_credentials[:user]
74
90
  enable_read_only!
75
- output "Revoking access for user #{user_name}."
76
- output(drop_user(user_name, true)) # drop the user without replicating the drop statement to slaves
91
+ drop_user(user_name, true) # drop the user without replicating the drop statement to slaves
77
92
  end
78
93
 
79
94
  # Enables global read-only mode on the database.
80
95
  def enable_read_only!
81
- output "Enabling global read_only mode"
82
- mysql_root_cmd 'SET GLOBAL read_only = 1' unless read_only?
83
- read_only?
96
+ if read_only?
97
+ output "Node already has read_only mode enabled"
98
+ true
99
+ else
100
+ output "Enabling read_only mode"
101
+ mysql_root_cmd 'SET GLOBAL read_only = 1'
102
+ read_only?
103
+ end
84
104
  end
85
105
 
86
106
  # Disables global read-only mode on the database.
87
107
  def disable_read_only!
88
- output "Disabling global read_only mode"
89
- mysql_root_cmd 'SET GLOBAL read_only = 0' if read_only?
90
- not read_only?
108
+ if read_only?
109
+ output "Disabling read_only mode"
110
+ mysql_root_cmd 'SET GLOBAL read_only = 0'
111
+ not read_only?
112
+ else
113
+ output "Confirmed that read_only mode is already disabled"
114
+ true
115
+ end
116
+ end
117
+
118
+ # Generate and return a random string consisting of uppercase
119
+ # letters, lowercase letters, and digits.
120
+ def self.random_password(length=50)
121
+ chars = [('a'..'z'), ('A'..'Z'), (0..9)].map(&:to_a).flatten
122
+ (1..length).map{ chars[rand(chars.length)] }.join
123
+ end
124
+
125
+ # override Jetpants.mysql_grant_ips temporarily before executing a block
126
+ # then set Jetpants.mysql_grant_ips back to the original values
127
+ # eg. master.override_mysql_grant_ips(['10.10.10.10']) do
128
+ # #something
129
+ # end
130
+ def override_mysql_grant_ips(ips)
131
+ ip_holder = Jetpants.mysql_grant_ips
132
+ Jetpants.mysql_grant_ips = ips
133
+ begin
134
+ yield
135
+ rescue StandardError, Interrupt, IOError
136
+ Jetpants.mysql_grant_ips = ip_holder
137
+ raise
138
+ end
139
+ Jetpants.mysql_grant_ips = ip_holder
91
140
  end
92
141
 
93
142
  end
@@ -48,44 +48,67 @@ module Jetpants
48
48
  # Pauses replication
49
49
  def pause_replication
50
50
  raise "This DB object has no master" unless master
51
- return if @repl_paused
52
51
  output "Pausing replication from #{@master}."
53
- output mysql_root_cmd "STOP SLAVE"
54
- @repl_paused = true
52
+ if @repl_paused
53
+ output "Replication was already paused."
54
+ repl_binlog_coordinates(true)
55
+ else
56
+ output mysql_root_cmd "STOP SLAVE"
57
+ repl_binlog_coordinates(true)
58
+ @repl_paused = true
59
+ end
55
60
  end
56
61
  alias stop_replication pause_replication
57
62
 
58
63
  # Starts replication, or restarts replication after a pause
59
64
  def resume_replication
60
65
  raise "This DB object has no master" unless master
66
+ repl_binlog_coordinates(true)
61
67
  output "Resuming replication from #{@master}."
62
68
  output mysql_root_cmd "START SLAVE"
63
69
  @repl_paused = false
64
70
  end
65
71
  alias start_replication resume_replication
66
72
 
73
+ # Stops replication at the same coordinates on two nodes
74
+ def pause_replication_with(sibling)
75
+ [self, sibling].each &:pause_replication
76
+
77
+ # self and sibling at same coordinates: all done
78
+ return true if repl_binlog_coordinates == sibling.repl_binlog_coordinates
79
+
80
+ # self ahead of sibling: handle via recursion with roles swapped
81
+ return sibling.pause_replication_with(self) if ahead_of? sibling
82
+
83
+ # sibling ahead of self: catch up to sibling
84
+ sibling_coords = sibling.repl_binlog_coordinates
85
+ output "Resuming replication from #{@master} until (#{sibling_coords[0]}, #{sibling_coords[1]})."
86
+ output(mysql_root_cmd "START SLAVE UNTIL MASTER_LOG_FILE = '#{sibling_coords[0]}', MASTER_LOG_POS = #{sibling_coords[1]}")
87
+ sleep 1 while repl_binlog_coordinates != sibling_coords
88
+ true
89
+ end
90
+
67
91
  # Permanently disables replication. Clears out the SHOW SLAVE STATUS output
68
92
  # entirely in MySQL versions that permit this.
69
93
  def disable_replication!
70
- raise "This DB object has no master" unless master
94
+ stop_replication
71
95
  output "Disabling replication; this db is no longer a slave."
72
-
73
96
  ver = version_tuple
74
-
97
+
75
98
  # MySQL < 5.5: allows master_host='', which clears out SHOW SLAVE STATUS
76
99
  if ver[0] == 5 && ver[1] < 5
77
- output mysql_root_cmd "STOP SLAVE; CHANGE MASTER TO master_host=''; RESET SLAVE"
78
-
100
+ output mysql_root_cmd "CHANGE MASTER TO master_host=''; RESET SLAVE"
101
+
79
102
  # MySQL 5.5.16+: allows RESET SLAVE ALL, which clears out SHOW SLAVE STATUS
80
103
  elsif ver[0] >= 5 && (ver[0] > 5 || ver[1] >= 5) && (ver[0] > 5 || ver[1] > 5 || ver[2] >= 16)
81
- output mysql_root_cmd "STOP SLAVE; CHANGE MASTER TO master_user='test'; RESET SLAVE ALL"
82
-
104
+ output mysql_root_cmd "CHANGE MASTER TO master_user='test'; RESET SLAVE ALL"
105
+
83
106
  # Other versions: no safe way to clear out SHOW SLAVE STATUS. Still set master_user to 'test'
84
107
  # so that we know to ignore the slave status output.
85
108
  else
86
- output mysql_root_cmd "STOP SLAVE; CHANGE MASTER TO master_user='test'; RESET SLAVE"
109
+ output mysql_root_cmd "CHANGE MASTER TO master_user='test'; RESET SLAVE"
87
110
  end
88
-
111
+
89
112
  @master.slaves.delete(self) rescue nil
90
113
  @master = nil
91
114
  @repl_paused = nil
@@ -112,6 +135,7 @@ module Jetpants
112
135
  log_pos: pos,
113
136
  user: repl_user,
114
137
  password: repl_pass )
138
+ t.enable_read_only!
115
139
  end
116
140
  resume_replication if @master # should already have happened from the clone_to! restart anyway, but just to be explicit
117
141
  enable_monitoring
@@ -133,6 +157,7 @@ module Jetpants
133
157
  log_pos: pos,
134
158
  user: replication_credentials[:user],
135
159
  password: replication_credentials[:pass] )
160
+ t.enable_read_only!
136
161
  end
137
162
  resume_replication # should already have happened from the clone_to! restart anyway, but just to be explicit
138
163
  catch_up_to_master
@@ -240,18 +265,42 @@ module Jetpants
240
265
  user && pass ? {user: user, pass: pass} : Jetpants.replication_credentials
241
266
  end
242
267
 
243
- # Disables binary logging in my.cnf. Does not take effect until you restart
244
- # mysql.
268
+ # This method is no longer supported. It used to manipulate /etc/my.cnf directly, which was too brittle.
269
+ # You can achieve the same effect by passing parameters to DB#restart_mysql.
245
270
  def disable_binary_logging
246
- output "Disabling binary logging in MySQL configuration; will take effect at next restart"
247
- comment_out_ini(mysql_config_file, 'log-bin', 'log-slave-updates')
271
+ raise "DB#disable_binary_logging is no longer supported, please use DB#restart_mysql('--skip-log-bin', '--skip-log-slave-updates') instead"
248
272
  end
249
273
 
250
- # Re-enables binary logging in my.cnf after a prior call to disable_bin_log.
251
- # Does not take effect until you restart mysql.
274
+ # This method is no longer supported. It used to manipulate /etc/my.cnf directly, which was too brittle.
275
+ # You can achieve the same effect by passing (or NOT passing) parameters to DB#restart_mysql.
252
276
  def enable_binary_logging
253
- output "Re-enabling binary logging in MySQL configuration; will take effect at next restart"
254
- uncomment_out_ini(mysql_config_file, 'log-bin', 'log-slave-updates')
277
+ raise "DB#enable_binary_logging is no longer supported, please use DB#restart_mysql() instead"
278
+ end
279
+
280
+ # Return true if this node's replication progress is ahead of the provided
281
+ # node, or false otherwise. The nodes must be in the same pool for coordinates
282
+ # to be comparable. Does not work in hierarchical replication scenarios!
283
+ def ahead_of?(node)
284
+ my_pool = pool(true)
285
+ raise "Node #{node} is not in the same pool as #{self}" unless node.pool(true) == my_pool
286
+
287
+ my_coords = (my_pool.master == self ? binlog_coordinates : repl_binlog_coordinates)
288
+ node_coords = (my_pool.master == node ? node.binlog_coordinates : node.repl_binlog_coordinates)
289
+
290
+ # Same coordinates
291
+ if my_coords == node_coords
292
+ false
293
+
294
+ # Same logfile: simply compare position
295
+ elsif my_coords[0] == node_coords[0]
296
+ my_coords[1] > node_coords[1]
297
+
298
+ # Different logfile
299
+ else
300
+ my_logfile_num = my_coords[0].match(/^[a-zA-Z.0]+(\d+)$/)[1].to_i
301
+ node_logfile_num = node_coords[0].match(/^[a-zA-Z.0]+(\d+)$/)[1].to_i
302
+ my_logfile_num > node_logfile_num
303
+ end
255
304
  end
256
305
 
257
306
  end
@@ -9,28 +9,44 @@ module Jetpants
9
9
  # OK to use this if MySQL is already stopped; it's a no-op then.
10
10
  def stop_mysql
11
11
  output "Attempting to shutdown MySQL"
12
+ disconnect if @db
12
13
  output service(:stop, 'mysql')
13
14
  running = ssh_cmd "netstat -ln | grep #{@port} | wc -l"
14
15
  raise "[#{@ip}] Failed to shut down MySQL: Something is still listening on port #{@port}" unless running.chomp == '0'
16
+ @options = []
15
17
  @running = false
16
18
  end
17
19
 
18
20
  # Starts MySQL, and confirms that something is now listening on the port.
19
21
  # Raises an exception if MySQL is already running or if something else is
20
22
  # already running on its port.
21
- def start_mysql
22
- @repl_paused = false if @master
23
+ # Options should be supplied as positional method args, for example:
24
+ # start_mysql '--skip-networking', '--skip-grant-tables'
25
+ def start_mysql(*options)
26
+ if @master
27
+ @repl_paused = options.include?('--skip-slave-start')
28
+ end
23
29
  running = ssh_cmd "netstat -ln | grep #{@port} | wc -l"
24
30
  raise "[#{@ip}] Failed to start MySQL: Something is already listening on port #{@port}" unless running.chomp == '0'
25
- output "Attempting to start MySQL"
26
- output service(:start, 'mysql')
31
+ if options.size == 0
32
+ output "Attempting to start MySQL, no option overrides supplied"
33
+ else
34
+ output "Attempting to start MySQL with options #{options.join(' ')}"
35
+ end
36
+ output service(:start, 'mysql', options.join(' '))
37
+ @options = options
27
38
  confirm_listening
28
39
  @running = true
40
+ if role == :master && ! @options.include?('--skip-networking')
41
+ disable_read_only!
42
+ end
29
43
  end
30
44
 
31
45
  # Restarts MySQL.
32
- def restart_mysql
33
- @repl_paused = false if @master
46
+ def restart_mysql(*options)
47
+ if @master
48
+ @repl_paused = options.include?('--skip-slave-start')
49
+ end
34
50
 
35
51
  # Disconnect if we were previously connected
36
52
  user, schema = false, false
@@ -39,13 +55,21 @@ module Jetpants
39
55
  disconnect
40
56
  end
41
57
 
42
- output "Attempting to restart MySQL"
43
- output service(:restart, 'mysql')
58
+ if options.size == 0
59
+ output "Attempting to restart MySQL, no option overrides supplied"
60
+ else
61
+ output "Attempting to restart MySQL with options #{options.join(' ')}"
62
+ end
63
+ output service(:restart, 'mysql', options.join(' '))
64
+ @options = options
44
65
  confirm_listening
45
66
  @running = true
46
-
47
- # Reconnect if we were previously connected
48
- connect(user: user, schema: schema) if user || schema
67
+ unless @options.include?('--skip-networking')
68
+ disable_read_only! if role == :master
69
+
70
+ # Reconnect if we were previously connected
71
+ connect(user: user, schema: schema) if user || schema
72
+ end
49
73
  end
50
74
 
51
75
  # Has no built-in effect. Plugins can override it, and/or implement
@@ -60,7 +84,12 @@ module Jetpants
60
84
 
61
85
  # Confirms that a process is listening on the DB's port
62
86
  def confirm_listening(timeout=10)
63
- confirm_listening_on_port(@port, timeout)
87
+ if @options.include? '--skip-networking'
88
+ output 'Unable to confirm mysqld listening because server started with --skip-networking'
89
+ false
90
+ else
91
+ confirm_listening_on_port(@port, timeout)
92
+ end
64
93
  end
65
94
 
66
95
  # Returns the MySQL data directory for this instance. A plugin can override this
@@ -69,13 +98,6 @@ module Jetpants
69
98
  '/var/lib/mysql'
70
99
  end
71
100
 
72
- # Returns the MySQL server configuration file for this instance. A plugin can
73
- # override this if needed, especially if running multiple MySQL instances on
74
- # the same host.
75
- def mysql_config_file
76
- '/etc/my.cnf'
77
- end
78
-
79
101
  # Has no built-in effect. Plugins can override it, and/or implement
80
102
  # before_enable_monitoring and after_enable_monitoring callbacks.
81
103
  def enable_monitoring(*services)
@@ -105,6 +105,28 @@ module Jetpants
105
105
  sleep(interval)
106
106
  global_status[:Connections].to_i - conn_counter > threshold
107
107
  end
108
+
109
+ # Gets the max theads connected over a time period
110
+ def max_threads_running(tries=8, interval=1.0)
111
+ poll_status_value(:Threads_running,:max, tries, interval)
112
+ end
113
+
114
+ # Gets the max or avg for a mysql value
115
+ def poll_status_value(field, type=:max, tries=8, interval=1.0)
116
+ max = 0
117
+ sum = 0
118
+ tries.times do
119
+ value = global_status[field].to_i
120
+ max = value unless max > value
121
+ sum += value
122
+ sleep(interval)
123
+ end
124
+ if type == :max
125
+ max
126
+ elsif type == :avg
127
+ sum.to_f/tries.to_f
128
+ end
129
+ end
108
130
 
109
131
  # Confirms the binlog of this node has not moved during a duration
110
132
  # of [interval] seconds.
@@ -134,6 +156,27 @@ module Jetpants
134
156
  @host.hostname.start_with? 'backup'
135
157
  end
136
158
 
159
+ # Returns true if the node can be promoted to be the master of its pool,
160
+ # false otherwise (also false if node is ALREADY the master)
161
+ # Don't use this in hierarchical replication scenarios, result may be
162
+ # unexpected.
163
+ def promotable_to_master?(detect_version_mismatches=true)
164
+ # backup_slaves are non-promotable
165
+ return false if for_backups?
166
+
167
+ # already the master
168
+ p = pool(true)
169
+ return false if p.master == self
170
+
171
+ # ordinarily, cannot promote a slave that's running a higher version of
172
+ # MySQL than any other node in the pool.
173
+ if detect_version_mismatches
174
+ p.nodes.all? {|db| db == self || !db.available? || db.version_cmp(self) >= 0}
175
+ else
176
+ true
177
+ end
178
+ end
179
+
137
180
  # Returns a hash mapping global MySQL variables (as symbols)
138
181
  # to their values (as strings).
139
182
  def global_variables
@@ -155,15 +198,51 @@ module Jetpants
155
198
  # Returns an array of integers representing the version of the MySQL server.
156
199
  # For example, Percona Server 5.5.27-rel28.1-log would return [5, 5, 27]
157
200
  def version_tuple
158
- raise "Cannot determine version of a stopped MySQL instance" unless running?
159
- global_variables[:version].split('.', 3).map &:to_i
201
+ result = nil
202
+ if running?
203
+ # If the server is running, we can just query it
204
+ result = global_variables[:version].split('.', 3).map(&:to_i) rescue nil
205
+ end
206
+ if result.nil?
207
+ # Otherwise we need to parse the output of mysqld --version
208
+ output = ssh_cmd 'mysqld --version'
209
+ matches = output.downcase.match('ver\s*(\d+)\.(\d+)\.(\d+)')
210
+ raise "Unable to determine version for #{self}" unless matches
211
+ result = matches[1, 3].map(&:to_i)
212
+ end
213
+ result
214
+ end
215
+
216
+ # Return a string representing the version. The precision indicates how
217
+ # many major/minor version numbers to return.
218
+ # ie, on 5.5.29, normalized_version(3) returns '5.5.29',
219
+ # normalized_version(2) returns '5.5', and normalized_version(1) returns '5'
220
+ def normalized_version(precision=2)
221
+ raise "Invalid precision #{precision}" if precision < 1 || precision > 3
222
+ version_tuple[0, precision].join('.')
223
+ end
224
+
225
+ # Returns -1 if self is running a lower version than db; 1 if self is running
226
+ # a higher version; and 0 if running same version.
227
+ def version_cmp(db, precision=2)
228
+ raise "Invalid precision #{precision}" if precision < 1 || precision > 3
229
+ my_tuple = version_tuple[0, precision]
230
+ other_tuple = db.version_tuple[0, precision]
231
+ my_tuple.each_with_index do |subver, i|
232
+ return -1 if subver < other_tuple[i]
233
+ return 1 if subver > other_tuple[i]
234
+ end
235
+ 0
160
236
  end
161
237
 
162
238
  # Returns the Jetpants::Pool that this instance belongs to, if any.
163
239
  # Can optionally create an anonymous pool if no pool was found. This anonymous
164
240
  # pool intentionally has a blank sync_configuration implementation.
165
241
  def pool(create_if_missing=false)
166
- result = Jetpants.topology.pool(self) || Jetpants.topology.pool(master)
242
+ result = Jetpants.topology.pool(self)
243
+ if !result && master
244
+ result ||= Jetpants.topology.pool(master)
245
+ end
167
246
  if !result && create_if_missing
168
247
  pool_master = master || self
169
248
  result = Pool.new('anon_pool_' + pool_master.ip.tr('.', ''), pool_master)
@@ -178,10 +257,15 @@ module Jetpants
178
257
  # Note that we consider a node with no master and no slaves to be
179
258
  # a :master, since we can't determine if it had slaves but they're
180
259
  # just offline/dead, vs it being an orphaned machine.
260
+ #
261
+ # In hierarchical replication scenarios (such as the child shard
262
+ # masters in the middle of a shard split), we return :master if
263
+ # Jetpants.topology considers the node to be the master for a pool.
181
264
  def role
182
265
  p = pool
183
266
  case
184
- when !@master then :master
267
+ when !@master then :master # nodes that aren't slaves (including orphans)
268
+ when p.master == self then :master # nodes that the topology thinks are masters
185
269
  when for_backups? then :backup_slave
186
270
  when p && p.active_slave_weights[self] then :active_slave # if pool in topology, determine based on expected/ideal state
187
271
  when !p && !is_standby? then :active_slave # if pool missing from topology, determine based on actual state
@@ -194,10 +278,15 @@ module Jetpants
194
278
  # a metric gigabyte. This puts it on the same scale as the output to tools like
195
279
  # "du -h" and "df -h".
196
280
  def data_set_size(in_gb=false)
197
- bytes = dir_size("#{mysql_directory}/#{app_schema}")
281
+ bytes = dir_size("#{mysql_directory}/#{app_schema}") + dir_size("#{mysql_directory}/ibdata1")
198
282
  in_gb ? (bytes / 1073741824.0).round : bytes
199
283
  end
200
284
 
285
+ def mount_stats(mount=false)
286
+ mount ||= mysql_directory
287
+
288
+ host.mount_stats(mount)
289
+ end
201
290
 
202
291
  ###### Private methods #####################################################
203
292
 
@@ -230,9 +319,11 @@ module Jetpants
230
319
  else
231
320
  @master = self.class.new(status[:master_host], status[:master_port])
232
321
  if status[:slave_io_running] != status[:slave_sql_running]
233
- message = "One replication thread is stopped and the other is not"
234
- raise "#{self}: #{message}" if Jetpants.verify_replication
235
- output message
322
+ output "One replication thread is stopped and the other is not."
323
+ if Jetpants.verify_replication
324
+ output "You must repair this node manually, OR remove it from its pool permanently if it is unrecoverable."
325
+ raise "Fatal replication problem on #{self}"
326
+ end
236
327
  pause_replication
237
328
  else
238
329
  @repl_paused = (status[:slave_io_running].downcase == 'no')