jetpants 0.7.10 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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')