mutineer 1.5.0 → 1.7.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +473 -0
- data/README.md +352 -26
- data/lib/mutineer/child_coverage.rb +53 -0
- data/lib/mutineer/cli.rb +184 -24
- data/lib/mutineer/concern_blocks.rb +80 -0
- data/lib/mutineer/config.rb +148 -8
- data/lib/mutineer/coverage_map.rb +541 -92
- data/lib/mutineer/daemon_backend.rb +227 -86
- data/lib/mutineer/daemon_client.rb +72 -9
- data/lib/mutineer/daemon_server.rb +229 -51
- data/lib/mutineer/external_backend.rb +12 -1
- data/lib/mutineer/file_swap.rb +1 -1
- data/lib/mutineer/init.rb +124 -0
- data/lib/mutineer/isolation.rb +15 -2
- data/lib/mutineer/job_plan.rb +458 -0
- data/lib/mutineer/migrate.rb +705 -0
- data/lib/mutineer/minitest_integration.rb +48 -1
- data/lib/mutineer/mutator_registry.rb +6 -2
- data/lib/mutineer/mutators/base.rb +14 -7
- data/lib/mutineer/mutators/call_to_receiver.rb +135 -0
- data/lib/mutineer/mutators/chain_link.rb +1 -1
- data/lib/mutineer/orphan_guard.rb +100 -0
- data/lib/mutineer/project.rb +411 -21
- data/lib/mutineer/rails_run_env.rb +73 -0
- data/lib/mutineer/rails_worker_db.rb +1477 -48
- data/lib/mutineer/redaction.rb +98 -0
- data/lib/mutineer/reporter.rb +83 -12
- data/lib/mutineer/result.rb +49 -8
- data/lib/mutineer/runner.rb +66 -290
- data/lib/mutineer/statement_lines.rb +54 -7
- data/lib/mutineer/subject.rb +24 -2
- data/lib/mutineer/test_runners/rspec.rb +54 -3
- data/lib/mutineer/test_runners.rb +3 -2
- data/lib/mutineer/triage.rb +111 -0
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer.rb +5 -0
- metadata +11 -1
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
3
6
|
module Mutineer
|
|
4
7
|
# Per-worker database isolation for the daemon path.
|
|
5
8
|
#
|
|
@@ -13,17 +16,87 @@ module Mutineer
|
|
|
13
16
|
# Isolation model: each parallel worker gets its OWN database so concurrent
|
|
14
17
|
# forks cannot clobber each other's transactional fixtures. {after_fork} runs
|
|
15
18
|
# inside a freshly-forked child and points that child's connection at the
|
|
16
|
-
# worker's database BEFORE any test loads
|
|
17
|
-
# repopulate that isolated database per test.
|
|
19
|
+
# worker's database BEFORE any test loads.
|
|
18
20
|
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
21
|
+
# SQLite copies the base file on a slot's first use ({after_fork} with
|
|
22
|
+
# `seed: true`). Postgres and MySQL copy once, before any worker daemon starts
|
|
23
|
+
# ({provision}). A later fork only connects. Both servers roll back a killed
|
|
24
|
+
# fork's open transaction, so the SQLite re-seed after `error` or `timeout` is
|
|
25
|
+
# skipped. Rows a test commits outside a transaction stay in the slot.
|
|
22
26
|
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
27
|
+
# MySQL has no template database. Each slot is created empty, the app schema
|
|
28
|
+
# is loaded with ActiveRecord::Tasks::DatabaseTasks, and rows are copied
|
|
29
|
+
# table by table. `mysql2` and `trilogy` are accepted. Any other adapter
|
|
30
|
+
# raises NotImplementedError rather than routing at a database that does not exist.
|
|
26
31
|
module RailsWorkerDb
|
|
32
|
+
# Postgres cuts an unquoted identifier at this many bytes.
|
|
33
|
+
POSTGRES_NAME_LIMIT = 63
|
|
34
|
+
# MySQL rejects a database name longer than this many bytes. The shortening
|
|
35
|
+
# rule matches Postgres. A byte limit stays inside the 64-character limit.
|
|
36
|
+
MYSQL_NAME_LIMIT = 64
|
|
37
|
+
# How many worker daemons can hold a lock at once. MySQL GET_LOCK is
|
|
38
|
+
# exclusive, so each daemon takes one name from this pool. A separate guard
|
|
39
|
+
# name covers the handoff and is not one of these slots.
|
|
40
|
+
MYSQL_LOCK_SLOTS = 256
|
|
41
|
+
# Actions information_schema may report for a foreign key. Anything else is
|
|
42
|
+
# rejected so a bad row cannot change the ALTER TABLE statement.
|
|
43
|
+
MYSQL_REFERENTIAL_ACTIONS = ["CASCADE", "SET NULL", "SET DEFAULT", "RESTRICT", "NO ACTION"].freeze
|
|
44
|
+
# TLS and timeout keys from the app's MySQL config that reach the
|
|
45
|
+
# maintenance client unchanged. ActiveRecord passes them to the client the
|
|
46
|
+
# same way. The first group is mysql2's, the second trilogy's. Each client
|
|
47
|
+
# ignores the other's keys.
|
|
48
|
+
MYSQL_CLIENT_KEYS = %i[
|
|
49
|
+
connect_timeout read_timeout write_timeout
|
|
50
|
+
ssl_mode sslkey sslcert sslca sslcapath sslcipher sslverify get_server_public_key
|
|
51
|
+
ssl ssl_ca ssl_capath ssl_cert ssl_key ssl_cipher ssl_crl ssl_crlpath
|
|
52
|
+
tls_ciphersuites tls_min_version tls_max_version
|
|
53
|
+
].freeze
|
|
54
|
+
# Infix that keeps a worker database off Rails' own `<base>-<n>` names.
|
|
55
|
+
WORKER_NAME_MARK = "-mutineer-"
|
|
56
|
+
# Database we connect to while the test database is a template. Advisory
|
|
57
|
+
# locks are per database, so every run must take them here, not on a slot.
|
|
58
|
+
MAINTENANCE_DATABASE = "postgres"
|
|
59
|
+
# Server catalogs a worker copy must never drop, even if a name bug
|
|
60
|
+
# produced one of them.
|
|
61
|
+
RESERVED_DATABASES = %w[postgres template0 template1].freeze
|
|
62
|
+
# Rails keys copied onto the maintenance `PG::Connection`. `database` is
|
|
63
|
+
# not here: that session always uses {MAINTENANCE_DATABASE}. `username` is
|
|
64
|
+
# Rails' spelling of libpq `user`. Anything else (adapter, variables,
|
|
65
|
+
# schema_search_path) is a Rails setting and would make libpq reject the
|
|
66
|
+
# connection.
|
|
67
|
+
LIBPQ_CONNECTION_KEYS = {
|
|
68
|
+
host: :host,
|
|
69
|
+
hostaddr: :hostaddr,
|
|
70
|
+
port: :port,
|
|
71
|
+
user: :user,
|
|
72
|
+
username: :user,
|
|
73
|
+
password: :password,
|
|
74
|
+
service: :service,
|
|
75
|
+
sslmode: :sslmode,
|
|
76
|
+
sslcert: :sslcert,
|
|
77
|
+
sslkey: :sslkey,
|
|
78
|
+
sslrootcert: :sslrootcert,
|
|
79
|
+
sslcrl: :sslcrl,
|
|
80
|
+
sslcompression: :sslcompression,
|
|
81
|
+
ssl_min_protocol_version: :ssl_min_protocol_version,
|
|
82
|
+
ssl_max_protocol_version: :ssl_max_protocol_version,
|
|
83
|
+
connect_timeout: :connect_timeout,
|
|
84
|
+
client_encoding: :client_encoding,
|
|
85
|
+
options: :options,
|
|
86
|
+
application_name: :application_name,
|
|
87
|
+
fallback_application_name: :fallback_application_name,
|
|
88
|
+
keepalives: :keepalives,
|
|
89
|
+
keepalives_idle: :keepalives_idle,
|
|
90
|
+
keepalives_interval: :keepalives_interval,
|
|
91
|
+
keepalives_count: :keepalives_count,
|
|
92
|
+
channel_binding: :channel_binding,
|
|
93
|
+
gssencmode: :gssencmode,
|
|
94
|
+
krbsrvname: :krbsrvname,
|
|
95
|
+
gsslib: :gsslib,
|
|
96
|
+
requiressl: :requiressl,
|
|
97
|
+
target_session_attrs: :target_session_attrs
|
|
98
|
+
}.freeze
|
|
99
|
+
|
|
27
100
|
# True when the app has ActiveRecord loaded. The only condition under which
|
|
28
101
|
# any other method here may touch AR. Never triggers an autoload/require of
|
|
29
102
|
# AR itself.
|
|
@@ -33,10 +106,9 @@ module Mutineer
|
|
|
33
106
|
defined?(ActiveRecord::Base) ? true : false
|
|
34
107
|
end
|
|
35
108
|
|
|
36
|
-
# Derive a per-worker
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
# `storage/test-1.sqlite3`.
|
|
109
|
+
# Derive a per-worker SQLite path by inserting `-<worker>` before the
|
|
110
|
+
# extension. Pure string transform (no AR). `storage/test.sqlite3`, worker
|
|
111
|
+
# 1 -> `storage/test-1.sqlite3`.
|
|
40
112
|
#
|
|
41
113
|
# @param database [String] the base database path.
|
|
42
114
|
# @param worker [Integer] the worker slot index (0..N-1).
|
|
@@ -46,71 +118,347 @@ module Mutineer
|
|
|
46
118
|
"#{database.delete_suffix(ext)}-#{worker}#{ext}"
|
|
47
119
|
end
|
|
48
120
|
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
121
|
+
# Worker database name: `<base>-mutineer-<slot>`. When that is longer than
|
|
122
|
+
# `limit` bytes, the base is shortened on a character boundary and a hash
|
|
123
|
+
# of the full base is added so two slots, and two long bases that share a
|
|
124
|
+
# prefix, stay distinct.
|
|
125
|
+
#
|
|
126
|
+
# @param base [String] the test database name.
|
|
127
|
+
# @param slot [Integer] the worker slot index.
|
|
128
|
+
# @param limit [Integer] maximum name length in bytes.
|
|
129
|
+
# @return [String] a name of at most `limit` bytes, or the hash suffix
|
|
130
|
+
# alone when that suffix itself exceeds the limit.
|
|
131
|
+
def self.worker_database_name(base, slot, limit)
|
|
132
|
+
suffix = "#{WORKER_NAME_MARK}#{slot}"
|
|
133
|
+
full = "#{base}#{suffix}"
|
|
134
|
+
return full if full.bytesize <= limit
|
|
135
|
+
|
|
136
|
+
hash = Digest::SHA256.hexdigest(base)[0, 8]
|
|
137
|
+
tail = "-#{hash}#{suffix}"
|
|
138
|
+
room = limit - tail.bytesize
|
|
139
|
+
"#{utf8_prefix(base, room)}#{tail}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Postgres worker database name. See {worker_database_name}.
|
|
143
|
+
#
|
|
144
|
+
# @param base [String] the test database name.
|
|
145
|
+
# @param slot [Integer] the worker slot index.
|
|
146
|
+
# @return [String] a name of at most {POSTGRES_NAME_LIMIT} bytes.
|
|
147
|
+
def self.postgres_worker_database(base, slot)
|
|
148
|
+
worker_database_name(base, slot, POSTGRES_NAME_LIMIT)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# MySQL worker database name. See {worker_database_name}.
|
|
152
|
+
#
|
|
153
|
+
# @param base [String] the test database name.
|
|
154
|
+
# @param slot [Integer] the worker slot index.
|
|
155
|
+
# @return [String] a name of at most {MYSQL_NAME_LIMIT} bytes.
|
|
156
|
+
def self.mysql_worker_database(base, slot)
|
|
157
|
+
worker_database_name(base, slot, MYSQL_NAME_LIMIT)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Leading characters of `text` that fit in `max_bytes`. Stops before a
|
|
161
|
+
# character that would cross the limit, so a multibyte name stays valid
|
|
162
|
+
# text. PostgreSQL and MySQL count identifier length in bytes.
|
|
163
|
+
#
|
|
164
|
+
# @param text [String]
|
|
165
|
+
# @param max_bytes [Integer]
|
|
166
|
+
# @return [String]
|
|
167
|
+
def self.utf8_prefix(text, max_bytes)
|
|
168
|
+
return "" if max_bytes <= 0
|
|
169
|
+
|
|
170
|
+
source = text.to_s
|
|
171
|
+
return source if source.bytesize <= max_bytes
|
|
172
|
+
|
|
173
|
+
kept = +""
|
|
174
|
+
source.each_char do |char|
|
|
175
|
+
break if kept.bytesize + char.bytesize > max_bytes
|
|
176
|
+
|
|
177
|
+
kept << char
|
|
178
|
+
end
|
|
179
|
+
kept
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# True when `name` is one of this run's exact worker database names.
|
|
183
|
+
# Equality only: a SQL `LIKE` would treat `_` in the base as a wildcard,
|
|
184
|
+
# so `my_app_test-mutineer-1` must not match base `myXappXtest`.
|
|
185
|
+
# `limit` defaults to the Postgres limit so existing callers stay valid.
|
|
186
|
+
#
|
|
187
|
+
# @param base [String] the test database name.
|
|
188
|
+
# @param name [String] a database name we might drop.
|
|
189
|
+
# @param slots [Integer] how many worker slots this run owns.
|
|
190
|
+
# @param limit [Integer] name limit in bytes. {POSTGRES_NAME_LIMIT} or {MYSQL_NAME_LIMIT}.
|
|
191
|
+
# @return [Boolean]
|
|
192
|
+
def self.owned_database?(base, name, slots:, limit: POSTGRES_NAME_LIMIT)
|
|
193
|
+
(0...slots).any? { |slot| worker_database_name(base, slot, limit) == name }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# True when `name` is a database this tool may drop. It must be the exact
|
|
197
|
+
# name {postgres_worker_database} would build for `base` and the slot
|
|
198
|
+
# written at the end of `name`. The source database, a server catalog, and
|
|
199
|
+
# any other name are refused.
|
|
200
|
+
#
|
|
201
|
+
# @param base [String] the test database name.
|
|
202
|
+
# @param name [String] a database name we might drop.
|
|
203
|
+
# @return [Boolean]
|
|
204
|
+
def self.worker_database_name?(base, name)
|
|
205
|
+
return false if base.nil? || name.nil?
|
|
206
|
+
|
|
207
|
+
text = name.to_s
|
|
208
|
+
source = base.to_s
|
|
209
|
+
return false if text.empty? || text == source
|
|
210
|
+
return false if RESERVED_DATABASES.include?(text)
|
|
211
|
+
|
|
212
|
+
slot = text[/#{Regexp.escape(WORKER_NAME_MARK)}(\d+)\z/, 1]
|
|
213
|
+
return false unless slot
|
|
214
|
+
|
|
215
|
+
postgres_worker_database(source, Integer(slot, 10)) == text
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Build the AR connection config for one worker from the app's current
|
|
219
|
+
# test config. SQLite, Postgres, and MySQL (mysql2 or trilogy) get a
|
|
220
|
+
# per-worker database name. Anything else raises.
|
|
53
221
|
#
|
|
54
222
|
# @param worker [Integer] the worker slot index.
|
|
55
223
|
# @return [Hash] a symbol-keyed AR configuration hash for the worker database.
|
|
56
|
-
# @raise [NotImplementedError] when the
|
|
224
|
+
# @raise [NotImplementedError] when the adapter has no worker database yet, or the database is in-memory.
|
|
57
225
|
def self.worker_db_config(worker)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
# Config shaping (per_worker_config) is already adapter-general: it derives
|
|
61
|
-
# correct SQLite and Postgres worker-DB names. What is gated is runtime
|
|
62
|
-
# provisioning: SQLite files are created on connect, but Postgres needs an
|
|
63
|
-
# explicit `CREATE DATABASE` per worker. Until that lands, refuse non-SQLite
|
|
64
|
-
# loudly rather than route to a database that does not exist.
|
|
65
|
-
unless adapter.start_with?("sqlite")
|
|
66
|
-
raise NotImplementedError,
|
|
67
|
-
"worker-DB isolation currently provisions SQLite only (got adapter #{adapter.inspect}); " \
|
|
68
|
-
"Postgres per-worker provisioning is not yet supported. Use a SQLite test DB with --daemon, or drop --daemon to run serially."
|
|
69
|
-
end
|
|
226
|
+
per_worker_config(routing_config_hash, worker)
|
|
227
|
+
end
|
|
70
228
|
|
|
71
|
-
|
|
229
|
+
# Config used to name a worker database. MySQL schema load points
|
|
230
|
+
# ActiveRecord at the last slot. Coverage forks must keep the original test
|
|
231
|
+
# database, or the next name is nested and was never created.
|
|
232
|
+
#
|
|
233
|
+
# @return [Hash]
|
|
234
|
+
def self.routing_config_hash
|
|
235
|
+
@mysql_base_config || current_config_hash
|
|
72
236
|
end
|
|
73
237
|
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
|
|
79
|
-
|
|
238
|
+
# Remember the test database config before schema load switches the connection.
|
|
239
|
+
#
|
|
240
|
+
# @param config_hash [Hash]
|
|
241
|
+
# @return [void]
|
|
242
|
+
def self.remember_mysql_base_config(config_hash)
|
|
243
|
+
@mysql_base_config = config_hash.transform_keys(&:to_sym)
|
|
244
|
+
nil
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Drop the remembered MySQL test config. The next route reads ActiveRecord again.
|
|
248
|
+
#
|
|
249
|
+
# @return [void]
|
|
250
|
+
def self.forget_mysql_base_config
|
|
251
|
+
@mysql_base_config = nil
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Pure config shaping (no AR): swap in the worker database name.
|
|
255
|
+
# SQLite keeps {worker_database_path}. Postgres uses {postgres_worker_database}.
|
|
256
|
+
# MySQL (`mysql2` or `trilogy`) uses {mysql_worker_database}.
|
|
80
257
|
#
|
|
81
258
|
# @param config_hash [Hash] a connection config hash (symbol or string keys).
|
|
82
259
|
# @param worker [Integer] the worker slot index.
|
|
83
260
|
# @return [Hash] the per-worker config (symbol keys), database swapped.
|
|
84
|
-
# @raise [NotImplementedError] for an in-memory
|
|
261
|
+
# @raise [NotImplementedError] for an in-memory database, an empty name, or an adapter other than SQLite, Postgres, or MySQL.
|
|
85
262
|
def self.per_worker_config(config_hash, worker)
|
|
86
263
|
hash = config_hash.transform_keys(&:to_sym)
|
|
264
|
+
adapter = hash[:adapter].to_s
|
|
87
265
|
database = hash[:database].to_s
|
|
266
|
+
if database.empty? && postgres_adapter?(adapter)
|
|
267
|
+
database = database_name_from_url(hash[:url])
|
|
268
|
+
end
|
|
88
269
|
if database.empty? || database == ":memory:"
|
|
89
270
|
raise NotImplementedError,
|
|
90
|
-
"worker-DB isolation needs a file
|
|
271
|
+
"worker-DB isolation needs a file or named database (got #{database.inspect})."
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
name =
|
|
275
|
+
if sqlite_adapter?(adapter)
|
|
276
|
+
worker_database_path(database, worker)
|
|
277
|
+
elsif postgres_adapter?(adapter)
|
|
278
|
+
postgres_worker_database(database, worker)
|
|
279
|
+
elsif mysql_adapter?(adapter)
|
|
280
|
+
mysql_worker_database(database, worker)
|
|
281
|
+
else
|
|
282
|
+
raise NotImplementedError,
|
|
283
|
+
"worker-DB isolation does not support adapter #{adapter.inspect} yet."
|
|
284
|
+
end
|
|
285
|
+
hash.merge(database: name)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Create each worker database before any worker daemon starts.
|
|
289
|
+
# SQLite is a no-op: the file is copied on first use. Postgres drops and
|
|
290
|
+
# recreates each exact slot name from the test database. MySQL creates each
|
|
291
|
+
# slot, loads the schema, and copies rows. The caller must be the only
|
|
292
|
+
# daemon connected to that test database.
|
|
293
|
+
#
|
|
294
|
+
# @param slots [Integer] how many worker slots to create (0..slots-1).
|
|
295
|
+
# @return [void]
|
|
296
|
+
# @raise [NotImplementedError] for an adapter other than SQLite, Postgres, or MySQL.
|
|
297
|
+
# @raise [RuntimeError] when the copy cannot be made, or another run holds the lock.
|
|
298
|
+
def self.provision(slots)
|
|
299
|
+
return unless available?
|
|
300
|
+
|
|
301
|
+
count = Integer(slots)
|
|
302
|
+
return if count < 1
|
|
303
|
+
return if sqlite_adapter?(current_adapter)
|
|
304
|
+
|
|
305
|
+
if postgres_adapter?(current_adapter)
|
|
306
|
+
provision_postgres(count)
|
|
307
|
+
return
|
|
308
|
+
end
|
|
309
|
+
if mysql_adapter?(current_adapter)
|
|
310
|
+
provision_mysql(count)
|
|
311
|
+
return
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
raise NotImplementedError,
|
|
315
|
+
"worker-DB isolation does not support adapter #{current_adapter.inspect} yet."
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Take the shared run lock and drop this process's connection to the test
|
|
319
|
+
# database. Worker daemons call this at boot. They do not create databases.
|
|
320
|
+
# SQLite has no lock here. The file copy still happens in {after_fork}.
|
|
321
|
+
#
|
|
322
|
+
# @return [void]
|
|
323
|
+
# @raise [RuntimeError] when another run holds the exclusive lock.
|
|
324
|
+
def self.prepare_worker!
|
|
325
|
+
return unless available?
|
|
326
|
+
return if sqlite_adapter?(current_adapter)
|
|
327
|
+
|
|
328
|
+
if mysql_adapter?(current_adapter)
|
|
329
|
+
prepare_mysql_worker!
|
|
330
|
+
return
|
|
331
|
+
end
|
|
332
|
+
return unless postgres_adapter?(current_adapter)
|
|
333
|
+
|
|
334
|
+
hash = current_config_hash
|
|
335
|
+
base = source_database_name!(hash)
|
|
336
|
+
conn = open_maintenance_connection(hash, base)
|
|
337
|
+
unless try_lock(conn, base, shared: true)
|
|
338
|
+
conn.close
|
|
339
|
+
raise "another Mutineer run is using #{base}"
|
|
91
340
|
end
|
|
92
341
|
|
|
93
|
-
|
|
342
|
+
@lock_connection = conn
|
|
343
|
+
disconnect_app!
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Point this process at one worker database. Postgres and MySQL only connect.
|
|
347
|
+
# The slot was copied at {provision}.
|
|
348
|
+
#
|
|
349
|
+
# @param slot [Integer] the worker slot index.
|
|
350
|
+
# @return [void]
|
|
351
|
+
def self.connect(slot)
|
|
352
|
+
ActiveRecord::Base.establish_connection(worker_db_config(slot))
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# How many database configs the test environment declares. One is normal.
|
|
356
|
+
# More than one means the run must use a single worker.
|
|
357
|
+
#
|
|
358
|
+
# @return [Integer]
|
|
359
|
+
def self.database_count
|
|
360
|
+
return 1 unless available?
|
|
361
|
+
|
|
362
|
+
env = ENV["RAILS_ENV"].to_s
|
|
363
|
+
env = "test" if env.empty?
|
|
364
|
+
list = ActiveRecord::Base.configurations.configs_for(env_name: env)
|
|
365
|
+
count = list.size
|
|
366
|
+
count < 1 ? 1 : count
|
|
367
|
+
rescue StandardError
|
|
368
|
+
1
|
|
94
369
|
end
|
|
95
370
|
|
|
96
371
|
# Child-side (after fork): route this process's ActiveRecord at the worker's
|
|
97
372
|
# own database and confirm it is reachable, so a routing failure reads as
|
|
98
|
-
# `error`
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
# file.
|
|
373
|
+
# `error` rather than a false verdict.
|
|
374
|
+
#
|
|
375
|
+
# SQLite with `seed: true` copies the base file first, then loads `schema.rb`
|
|
376
|
+
# only when the copy differs from that file. Postgres and MySQL ignore `seed`
|
|
377
|
+
# and the schema path: the slot was copied at {provision}, and loading
|
|
378
|
+
# `schema.rb` again would drop those rows.
|
|
102
379
|
#
|
|
103
380
|
# @param worker [Integer] the worker slot index.
|
|
104
381
|
# @param schema_path [String, nil] absolute path to `db/schema.rb`, or nil to skip.
|
|
382
|
+
# @param seed [Boolean] copy the base SQLite file into the worker file first.
|
|
105
383
|
# @return [void]
|
|
106
|
-
def self.after_fork(worker, schema_path = nil)
|
|
384
|
+
def self.after_fork(worker, schema_path = nil, seed: false)
|
|
107
385
|
return unless available?
|
|
108
386
|
|
|
109
|
-
|
|
110
|
-
|
|
387
|
+
forget_lock_connection_after_fork!
|
|
388
|
+
if postgres_adapter?(current_adapter) || mysql_adapter?(current_adapter)
|
|
389
|
+
connect(worker)
|
|
390
|
+
verify_connection!
|
|
391
|
+
return
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
config = worker_db_config(worker)
|
|
395
|
+
base = ActiveRecord::Base.connection.select_value("SELECT file FROM pragma_database_list WHERE name = 'main'") if seed
|
|
396
|
+
ActiveRecord::Base.establish_connection(config)
|
|
397
|
+
seed_from(base) if seed
|
|
398
|
+
load_schema(schema_path) if schema_path && !schema_current?(schema_path)
|
|
111
399
|
verify_connection!
|
|
112
400
|
end
|
|
113
401
|
|
|
402
|
+
# Copy the base test database file into the worker database this fork is
|
|
403
|
+
# now connected to, with the SQLite online backup API: one consistent
|
|
404
|
+
# snapshot of the committed data (WAL included) that replaces whatever the
|
|
405
|
+
# worker file held (an earlier run, or a copy a timeout interrupted). Both
|
|
406
|
+
# ends are files SQLite itself opened, so no path rules are re-derived here.
|
|
407
|
+
#
|
|
408
|
+
# @param base_file [String] the base database file, from `pragma_database_list`.
|
|
409
|
+
# @return [void]
|
|
410
|
+
def self.seed_from(base_file)
|
|
411
|
+
codes = SQLite3::Constants::ErrorCode
|
|
412
|
+
source = SQLite3::Database.new(base_file, readonly: true)
|
|
413
|
+
source.busy_timeout = 5000
|
|
414
|
+
backup = SQLite3::Backup.new(ActiveRecord::Base.connection.raw_connection, "main", source, "main")
|
|
415
|
+
# Another worker's process can hold a lock on the base file for a moment;
|
|
416
|
+
# BUSY/LOCKED steps are retried for up to 5 seconds, then the copy fails
|
|
417
|
+
# with a message that names the database (scored `error`).
|
|
418
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
|
|
419
|
+
until (status = backup.step(-1)) == codes::DONE
|
|
420
|
+
retry_ok = [codes::BUSY, codes::LOCKED].include?(status) && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
421
|
+
raise "copying #{base_file} into the worker database failed (SQLite code #{status})" unless retry_ok
|
|
422
|
+
|
|
423
|
+
sleep 0.01
|
|
424
|
+
end
|
|
425
|
+
ensure
|
|
426
|
+
backup&.finish
|
|
427
|
+
source&.close
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# True when the current database already holds the schema that `schema.rb`
|
|
431
|
+
# declares: its newest `schema_migrations` row matches the declared version,
|
|
432
|
+
# and the `schema_sha1` Rails stores in `ar_internal_metadata` matches the
|
|
433
|
+
# file, which catches an edited schema with the same version. No stored
|
|
434
|
+
# checksum counts as out of date, as in Rails' own `schema_up_to_date?`.
|
|
435
|
+
#
|
|
436
|
+
# @param schema_path [String] absolute path to `db/schema.rb`.
|
|
437
|
+
# @return [Boolean]
|
|
438
|
+
def self.schema_current?(schema_path)
|
|
439
|
+
text = File.read(schema_path)
|
|
440
|
+
version = schema_file_version(text)
|
|
441
|
+
conn = ActiveRecord::Base.connection
|
|
442
|
+
base = ActiveRecord::Base
|
|
443
|
+
migrations = "#{base.table_name_prefix}#{base.schema_migrations_table_name}#{base.table_name_suffix}"
|
|
444
|
+
metadata = "#{base.table_name_prefix}#{base.internal_metadata_table_name}#{base.table_name_suffix}"
|
|
445
|
+
return false unless version && conn.table_exists?(migrations)
|
|
446
|
+
return false unless conn.select_values("SELECT version FROM #{conn.quote_table_name(migrations)}").map(&:to_i).max == version
|
|
447
|
+
return false unless conn.table_exists?(metadata)
|
|
448
|
+
|
|
449
|
+
conn.select_value("SELECT value FROM #{conn.quote_table_name(metadata)} WHERE key = 'schema_sha1'") ==
|
|
450
|
+
Digest::SHA1.hexdigest(text)
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# The version a `schema.rb` declares in `define(version: ...)`, or nil. Pure
|
|
454
|
+
# string parse (no AR) so it is unit-testable in the zero-dep suite.
|
|
455
|
+
#
|
|
456
|
+
# @param text [String] the `schema.rb` source.
|
|
457
|
+
# @return [Integer, nil]
|
|
458
|
+
def self.schema_file_version(text)
|
|
459
|
+
text[/define\(version:\s*([\d_]+)/, 1]&.delete("_")&.to_i
|
|
460
|
+
end
|
|
461
|
+
|
|
114
462
|
# Load a Rails `schema.rb` into the current connection with output silenced
|
|
115
463
|
# (fork child stdout is already File::NULL; this is belt-and-braces).
|
|
116
464
|
#
|
|
@@ -127,12 +475,1093 @@ module Mutineer
|
|
|
127
475
|
end
|
|
128
476
|
|
|
129
477
|
# Force a round-trip to the freshly-routed connection so a broken route fails
|
|
130
|
-
# HERE (
|
|
131
|
-
# (
|
|
478
|
+
# HERE (then `error`) instead of later masquerading as a test failure
|
|
479
|
+
# (then a false `killed`).
|
|
132
480
|
#
|
|
133
481
|
# @return [void]
|
|
134
482
|
def self.verify_connection!
|
|
135
483
|
ActiveRecord::Base.connection.execute("SELECT 1")
|
|
136
484
|
end
|
|
485
|
+
|
|
486
|
+
# Symbol-keyed connection hash for the database the app booted. Does not
|
|
487
|
+
# open a new connection.
|
|
488
|
+
#
|
|
489
|
+
# @return [Hash]
|
|
490
|
+
def self.current_config_hash
|
|
491
|
+
ActiveRecord::Base.connection_db_config.configuration_hash.transform_keys(&:to_sym)
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# Adapter name from {current_config_hash}.
|
|
495
|
+
#
|
|
496
|
+
# @return [String]
|
|
497
|
+
def self.current_adapter
|
|
498
|
+
current_config_hash[:adapter].to_s
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# @param adapter [String]
|
|
502
|
+
# @return [Boolean]
|
|
503
|
+
def self.sqlite_adapter?(adapter)
|
|
504
|
+
adapter.start_with?("sqlite")
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# @param adapter [String]
|
|
508
|
+
# @return [Boolean]
|
|
509
|
+
def self.postgres_adapter?(adapter)
|
|
510
|
+
adapter == "postgres" || adapter.start_with?("postgres")
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# True for the mysql2 and trilogy adapters. The old `mysql` gem is not included.
|
|
514
|
+
#
|
|
515
|
+
# @param adapter [String]
|
|
516
|
+
# @return [Boolean]
|
|
517
|
+
def self.mysql_adapter?(adapter)
|
|
518
|
+
adapter == "mysql2" || adapter == "trilogy"
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# Drop and recreate slots `0...count` from the booted test database.
|
|
522
|
+
# Holds the exclusive advisory lock only for the copy, then a shared lock
|
|
523
|
+
# for the rest of this process so a second run cannot drop those databases.
|
|
524
|
+
#
|
|
525
|
+
# @param count [Integer]
|
|
526
|
+
# @return [void]
|
|
527
|
+
def self.provision_postgres(count)
|
|
528
|
+
hash = current_config_hash
|
|
529
|
+
base = source_database_name!(hash)
|
|
530
|
+
conn = open_maintenance_connection(hash, base)
|
|
531
|
+
unless try_lock(conn, base, shared: false)
|
|
532
|
+
conn.close
|
|
533
|
+
raise "another Mutineer run is using #{base}"
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
begin
|
|
537
|
+
disconnect_app!
|
|
538
|
+
count.times do |slot|
|
|
539
|
+
name = postgres_worker_database(base, slot)
|
|
540
|
+
recreate_from_template(conn, name, base)
|
|
541
|
+
end
|
|
542
|
+
rescue StandardError => e
|
|
543
|
+
unlock(conn, base)
|
|
544
|
+
conn.close
|
|
545
|
+
raise provision_wrapped?(e) ? e : provision_failure(base, base, e.message)
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
unlock(conn, base)
|
|
549
|
+
unless try_lock(conn, base, shared: true)
|
|
550
|
+
conn.close
|
|
551
|
+
raise "another Mutineer run is using #{base}"
|
|
552
|
+
end
|
|
553
|
+
@lock_connection = conn
|
|
554
|
+
warn "[mutineer] provisioned #{count} postgres worker database(s) from #{base}"
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
# Open a session on {MAINTENANCE_DATABASE} with the app's Postgres credentials.
|
|
558
|
+
# Uses the `pg` client the app already loaded. The gem does not depend on it.
|
|
559
|
+
# Endpoint options from the Rails config, including `service` and `url`, are
|
|
560
|
+
# kept. Only the database name changes, to {MAINTENANCE_DATABASE}.
|
|
561
|
+
#
|
|
562
|
+
# @param config_hash [Hash]
|
|
563
|
+
# @param base [String] test database name, named in the error if this fails.
|
|
564
|
+
# @return [PG::Connection]
|
|
565
|
+
def self.open_maintenance_connection(config_hash, base)
|
|
566
|
+
unless defined?(PG::Connection)
|
|
567
|
+
raise "could not provision #{base}: the app's pg client is not loaded"
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
PG::Connection.open(maintenance_connection_options(config_hash))
|
|
571
|
+
rescue StandardError => e
|
|
572
|
+
raise e if e.message.start_with?("could not provision")
|
|
573
|
+
|
|
574
|
+
raise "could not provision #{base}: #{e.message}"
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
# libpq options for the maintenance session. A Rails `url:` fills anything
|
|
578
|
+
# the hash does not set itself. Explicit keys win. `dbname` is always
|
|
579
|
+
# {MAINTENANCE_DATABASE}, never the test database and never a name taken
|
|
580
|
+
# from the URL path.
|
|
581
|
+
#
|
|
582
|
+
# @param config_hash [Hash] symbol or string keys, as Rails stores them.
|
|
583
|
+
# @return [Hash] symbol keys for `PG::Connection.open`.
|
|
584
|
+
# @raise [RuntimeError] when `url:` is present and is not a Postgres URL.
|
|
585
|
+
def self.maintenance_connection_options(config_hash)
|
|
586
|
+
hash = config_hash.transform_keys(&:to_sym)
|
|
587
|
+
opts = options_from_database_url(hash[:url])
|
|
588
|
+
opts.delete(:database)
|
|
589
|
+
LIBPQ_CONNECTION_KEYS.each do |config_key, pg_key|
|
|
590
|
+
next unless hash.key?(config_key)
|
|
591
|
+
|
|
592
|
+
value = hash[config_key]
|
|
593
|
+
next if blank_option?(value)
|
|
594
|
+
|
|
595
|
+
opts[pg_key] = value
|
|
596
|
+
end
|
|
597
|
+
opts[:dbname] = MAINTENANCE_DATABASE
|
|
598
|
+
opts
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# The test database this run copies from. `database:` wins. A Postgres URL
|
|
602
|
+
# supplies the name when the hash has none. A `service:` entry does not
|
|
603
|
+
# carry the name, so that shape fails here instead of guessing.
|
|
604
|
+
#
|
|
605
|
+
# @param config_hash [Hash]
|
|
606
|
+
# @return [String]
|
|
607
|
+
# @raise [RuntimeError] when no database name is available.
|
|
608
|
+
def self.source_database_name!(config_hash)
|
|
609
|
+
hash = config_hash.transform_keys(&:to_sym)
|
|
610
|
+
name = hash[:database].to_s
|
|
611
|
+
name = database_name_from_url(hash[:url]) if name.empty?
|
|
612
|
+
return name unless name.empty?
|
|
613
|
+
|
|
614
|
+
raise "could not provision: the test database name is missing. " \
|
|
615
|
+
"Set database: or use a URL that includes the database name."
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
# Database name from a postgres URL path, or "" when `url` is blank.
|
|
619
|
+
#
|
|
620
|
+
# @param url [String, nil]
|
|
621
|
+
# @return [String]
|
|
622
|
+
# @raise [RuntimeError] when `url` is present and is not a Postgres URL.
|
|
623
|
+
def self.database_name_from_url(url)
|
|
624
|
+
options_from_database_url(url)[:database].to_s
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
# Host, credentials, database name, and query options from a Rails `url:`.
|
|
628
|
+
# Blank when `url` is blank. The database is returned under `:database`
|
|
629
|
+
# so callers can separate it from the maintenance `dbname`.
|
|
630
|
+
#
|
|
631
|
+
# @param url [String, nil]
|
|
632
|
+
# @return [Hash]
|
|
633
|
+
# @raise [RuntimeError] when `url` is present and is not a Postgres URL.
|
|
634
|
+
def self.options_from_database_url(url)
|
|
635
|
+
return {} if blank_option?(url)
|
|
636
|
+
|
|
637
|
+
uri = URI.parse(url.to_s)
|
|
638
|
+
scheme = uri.scheme.to_s.downcase
|
|
639
|
+
unless scheme == "postgres" || scheme == "postgresql"
|
|
640
|
+
raise "could not provision: the database URL must start with postgres:// or postgresql://"
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
opts = {}
|
|
644
|
+
host = uri.host
|
|
645
|
+
opts[:host] = host if host && !host.empty?
|
|
646
|
+
opts[:port] = uri.port if uri.port
|
|
647
|
+
user = decode_uri_component(uri.user)
|
|
648
|
+
opts[:user] = user if user && !user.empty?
|
|
649
|
+
password = decode_uri_component(uri.password)
|
|
650
|
+
opts[:password] = password if password && !password.empty?
|
|
651
|
+
database = uri.path.to_s.sub(%r{\A/}, "")
|
|
652
|
+
database = decode_uri_component(database) unless database.empty?
|
|
653
|
+
opts[:database] = database if database && !database.empty?
|
|
654
|
+
URI.decode_www_form(uri.query.to_s).each do |key, value|
|
|
655
|
+
pg_key = LIBPQ_CONNECTION_KEYS[key.to_sym]
|
|
656
|
+
next unless pg_key
|
|
657
|
+
next if blank_option?(value)
|
|
658
|
+
|
|
659
|
+
opts[pg_key] = value
|
|
660
|
+
end
|
|
661
|
+
opts
|
|
662
|
+
rescue URI::InvalidURIError, URI::InvalidComponentError, ArgumentError
|
|
663
|
+
# The parser's message repeats the URL, password included. `cause: nil`
|
|
664
|
+
# keeps Ruby from printing that original error under this one.
|
|
665
|
+
raise "could not provision: the database URL is not valid. " \
|
|
666
|
+
"The URL is not shown because it can contain a password. " \
|
|
667
|
+
"Check each part of DATABASE_URL or url: in config/database.yml. " \
|
|
668
|
+
"Percent-encode the user name, password and database name. Encode each " \
|
|
669
|
+
"character that is not an ASCII letter, a digit, -, ., _ or ~. " \
|
|
670
|
+
"For example, write % as %25, @ as %40 and a space as %20.", cause: nil
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
# Percent-decode one URL component. Nil when `text` is blank.
|
|
674
|
+
#
|
|
675
|
+
# @param text [String, nil]
|
|
676
|
+
# @return [String, nil]
|
|
677
|
+
def self.decode_uri_component(text)
|
|
678
|
+
return if blank_option?(text)
|
|
679
|
+
|
|
680
|
+
# `unescape` keeps a literal `+`. The form decoder would turn it into a space.
|
|
681
|
+
# The daemon runs on the app's Ruby, which may be older than 3.4.
|
|
682
|
+
URI::DEFAULT_PARSER.unescape(text.to_s)
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
# True for nil and empty strings. Numbers and false are present values.
|
|
686
|
+
#
|
|
687
|
+
# @param value [Object]
|
|
688
|
+
# @return [Boolean]
|
|
689
|
+
def self.blank_option?(value)
|
|
690
|
+
value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
# @param conn [PG::Connection]
|
|
694
|
+
# @param base [String]
|
|
695
|
+
# @param shared [Boolean] shared lock when true, exclusive when false.
|
|
696
|
+
# @return [Boolean]
|
|
697
|
+
def self.try_lock(conn, base, shared:)
|
|
698
|
+
k1, k2 = lock_keys(base)
|
|
699
|
+
sql = shared ? "SELECT pg_try_advisory_lock_shared($1, $2)" : "SELECT pg_try_advisory_lock($1, $2)"
|
|
700
|
+
conn.exec_params(sql, [k1, k2]).getvalue(0, 0) == "t"
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
# Release one advisory lock this session holds for `base`.
|
|
704
|
+
#
|
|
705
|
+
# @param conn [PG::Connection]
|
|
706
|
+
# @param base [String]
|
|
707
|
+
# @return [void]
|
|
708
|
+
def self.unlock(conn, base)
|
|
709
|
+
k1, k2 = lock_keys(base)
|
|
710
|
+
conn.exec_params("SELECT pg_advisory_unlock($1, $2)", [k1, k2])
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
# Two signed 32-bit keys for `pg_try_advisory_lock(int, int)`, from the base name.
|
|
714
|
+
#
|
|
715
|
+
# @param base [String]
|
|
716
|
+
# @return [Array(Integer, Integer)]
|
|
717
|
+
def self.lock_keys(base)
|
|
718
|
+
Digest::SHA256.digest("mutineer-run:#{base}").unpack("l>l>")
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
# Replace `name` with a template copy of `base`. Terminates other sessions
|
|
722
|
+
# on the slot only. A session on `base` is left alone and fails the copy.
|
|
723
|
+
#
|
|
724
|
+
# @param conn [PG::Connection]
|
|
725
|
+
# @param name [String] exact worker database name.
|
|
726
|
+
# @param base [String] test database used as the template.
|
|
727
|
+
# @return [void]
|
|
728
|
+
def self.recreate_from_template(conn, name, base)
|
|
729
|
+
unless worker_database_name?(base, name)
|
|
730
|
+
raise "refusing to drop #{name}"
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
conn.exec("SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = #{conn.escape_literal(name)} AND pid <> pg_backend_pid()")
|
|
734
|
+
conn.exec("DROP DATABASE IF EXISTS #{quote_ident(name)}")
|
|
735
|
+
conn.exec("CREATE DATABASE #{quote_ident(name)} TEMPLATE #{quote_ident(base)}")
|
|
736
|
+
rescue StandardError => e
|
|
737
|
+
raise e if e.message.start_with?("refusing to drop")
|
|
738
|
+
|
|
739
|
+
raise provision_failure(base, name, e.message)
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
# Close every connection this process holds through ActiveRecord, so the
|
|
743
|
+
# test database can be used as a Postgres template and a MySQL slot can be
|
|
744
|
+
# dropped.
|
|
745
|
+
#
|
|
746
|
+
# @return [void]
|
|
747
|
+
def self.disconnect_app!
|
|
748
|
+
handler = ActiveRecord::Base.connection_handler
|
|
749
|
+
if handler.respond_to?(:clear_all_connections!)
|
|
750
|
+
handler.clear_all_connections!
|
|
751
|
+
else
|
|
752
|
+
ActiveRecord::Base.connection_pool.disconnect!
|
|
753
|
+
end
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
# The child inherited the parent's lock session. Drop the Ruby wrapper
|
|
757
|
+
# without sending a disconnect, so the parent's locks stay held.
|
|
758
|
+
# Clearing the held-name list stops the child from sending RELEASE_LOCK
|
|
759
|
+
# on that shared session.
|
|
760
|
+
#
|
|
761
|
+
# @return [void]
|
|
762
|
+
def self.forget_lock_connection_after_fork!
|
|
763
|
+
@mysql_held_locks = []
|
|
764
|
+
conn = @lock_connection
|
|
765
|
+
@lock_connection = nil
|
|
766
|
+
return unless conn
|
|
767
|
+
|
|
768
|
+
detach_inherited_connection(conn)
|
|
769
|
+
ObjectSpace.undefine_finalizer(conn)
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
# Make a fork child's copy of the lock connection safe to free. The
|
|
773
|
+
# client's free function runs at the child's next GC. Left as it is, that
|
|
774
|
+
# sends QUIT (mysql2) or Terminate (pg) on the socket the parent shares,
|
|
775
|
+
# so the server ends the parent's session and every lock it holds.
|
|
776
|
+
# These are the steps ActiveRecord's adapters take in `discard!`.
|
|
777
|
+
#
|
|
778
|
+
# @param conn [Mysql2::Client, Trilogy, PG::Connection]
|
|
779
|
+
# @return [void]
|
|
780
|
+
def self.detach_inherited_connection(conn)
|
|
781
|
+
if conn.respond_to?(:automatic_close=)
|
|
782
|
+
conn.automatic_close = false # mysql2: GC drops only this process's fd
|
|
783
|
+
elsif conn.respond_to?(:discard!)
|
|
784
|
+
conn.discard! # trilogy: closes only this process's fd
|
|
785
|
+
elsif conn.respond_to?(:socket_io)
|
|
786
|
+
conn.socket_io.reopen(IO::NULL) # pg: Terminate goes to /dev/null
|
|
787
|
+
end
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
# Quote a database name for a Postgres identifier. `CREATE DATABASE` cannot
|
|
791
|
+
# take a bound parameter for the name.
|
|
792
|
+
#
|
|
793
|
+
# @param name [String]
|
|
794
|
+
# @return [String]
|
|
795
|
+
def self.quote_ident(name)
|
|
796
|
+
raise ArgumentError, "database name is empty" if name.nil? || name.empty?
|
|
797
|
+
|
|
798
|
+
%("#{name.gsub('"', '""')}")
|
|
799
|
+
end
|
|
800
|
+
|
|
801
|
+
# Failure text for a copy that did not happen. Names the database and the
|
|
802
|
+
# server's cause. A busy template also says to close other sessions.
|
|
803
|
+
#
|
|
804
|
+
# @param base [String]
|
|
805
|
+
# @param name [String]
|
|
806
|
+
# @param cause [String]
|
|
807
|
+
# @return [String]
|
|
808
|
+
def self.provision_failure(base, name, cause)
|
|
809
|
+
message = "could not provision #{name} from #{base}: #{cause}"
|
|
810
|
+
message += ". Close other sessions on #{base}." if cause.match?(/other users|being accessed/i)
|
|
811
|
+
message
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
# Create MySQL slots `0...count`, load the app schema into each, and copy
|
|
815
|
+
# rows from the test database. Holds the guard lock and every pool name
|
|
816
|
+
# while copying, then keeps the guard lock. Pool names are released without
|
|
817
|
+
# a gap so another run cannot drop the new databases. Worker daemons then
|
|
818
|
+
# take one pool name each.
|
|
819
|
+
#
|
|
820
|
+
# @param count [Integer]
|
|
821
|
+
# @return [void]
|
|
822
|
+
# @raise [RuntimeError] when `count` exceeds {MYSQL_LOCK_SLOTS}, or another run holds the lock.
|
|
823
|
+
def self.provision_mysql(count)
|
|
824
|
+
if count > MYSQL_LOCK_SLOTS
|
|
825
|
+
raise "MySQL worker databases support at most #{MYSQL_LOCK_SLOTS} workers"
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
hash = routing_config_hash
|
|
829
|
+
remember_mysql_base_config(hash)
|
|
830
|
+
base = hash[:database].to_s
|
|
831
|
+
conn = open_mysql_connection(hash, base)
|
|
832
|
+
unless try_mysql_lock(conn, base, shared: false)
|
|
833
|
+
conn.close
|
|
834
|
+
raise "another Mutineer run is using #{base}"
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
begin
|
|
838
|
+
disconnect_app!
|
|
839
|
+
count.times do |slot|
|
|
840
|
+
name = mysql_worker_database(base, slot)
|
|
841
|
+
begin
|
|
842
|
+
recreate_mysql_database(conn, name, base)
|
|
843
|
+
load_mysql_schema(hash, name, base)
|
|
844
|
+
copy_mysql_rows(conn, base, name)
|
|
845
|
+
rescue StandardError => e
|
|
846
|
+
raise e if provision_wrapped?(e)
|
|
847
|
+
|
|
848
|
+
raise provision_failure(base, name, e.message)
|
|
849
|
+
end
|
|
850
|
+
end
|
|
851
|
+
raise "another Mutineer run is using #{base}" unless downgrade_mysql_lock_to_shared(conn, base)
|
|
852
|
+
rescue StandardError => e
|
|
853
|
+
release_held_mysql_locks(conn)
|
|
854
|
+
conn.close
|
|
855
|
+
raise e if provision_wrapped?(e)
|
|
856
|
+
|
|
857
|
+
raise provision_failure(base, base, e.message)
|
|
858
|
+
end
|
|
859
|
+
|
|
860
|
+
@lock_connection = conn
|
|
861
|
+
warn "[mutineer] provisioned #{count} mysql worker database(s) from #{base}"
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
# Take one shared GET_LOCK name and drop this process's app connection.
|
|
865
|
+
#
|
|
866
|
+
# @return [void]
|
|
867
|
+
def self.prepare_mysql_worker!
|
|
868
|
+
hash = current_config_hash
|
|
869
|
+
base = hash[:database].to_s
|
|
870
|
+
conn = open_mysql_connection(hash, base)
|
|
871
|
+
unless try_mysql_lock(conn, base, shared: true)
|
|
872
|
+
conn.close
|
|
873
|
+
raise "another Mutineer run is using #{base}"
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
@lock_connection = conn
|
|
877
|
+
disconnect_app!
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
# Open a session with the app's MySQL client. It starts with no default
|
|
881
|
+
# database, so it is not tied to the test database while it drops and
|
|
882
|
+
# creates slots. {restore_mysql_triggers} later selects a slot. Every
|
|
883
|
+
# other statement names its database. The gem does not depend on the client.
|
|
884
|
+
#
|
|
885
|
+
# @param config_hash [Hash]
|
|
886
|
+
# @param base [String] test database name, named in the error if this fails.
|
|
887
|
+
# @return [Mysql2::Client, Trilogy]
|
|
888
|
+
def self.open_mysql_connection(config_hash, base)
|
|
889
|
+
adapter = config_hash[:adapter].to_s
|
|
890
|
+
opts = mysql_client_options(config_hash)
|
|
891
|
+
if adapter == "trilogy"
|
|
892
|
+
raise "could not provision #{base}: the app's trilogy client is not loaded" unless defined?(Trilogy)
|
|
893
|
+
|
|
894
|
+
# Rails' adapter turns a `ssl_mode` name into the constant trilogy wants.
|
|
895
|
+
if defined?(ActiveRecord::ConnectionAdapters::TrilogyAdapter)
|
|
896
|
+
ActiveRecord::ConnectionAdapters::TrilogyAdapter.new_client(opts)
|
|
897
|
+
else
|
|
898
|
+
Trilogy.new(opts)
|
|
899
|
+
end
|
|
900
|
+
else
|
|
901
|
+
raise "could not provision #{base}: the app's mysql2 client is not loaded" unless defined?(Mysql2::Client)
|
|
902
|
+
|
|
903
|
+
Mysql2::Client.new(opts)
|
|
904
|
+
end
|
|
905
|
+
rescue StandardError => e
|
|
906
|
+
raise e if e.message.start_with?("could not provision ")
|
|
907
|
+
|
|
908
|
+
raise "could not provision #{base}: #{e.message}"
|
|
909
|
+
end
|
|
910
|
+
|
|
911
|
+
# Connection options shared by mysql2 and trilogy. The database key is left
|
|
912
|
+
# out so the session is not tied to the test database. A socket wins over
|
|
913
|
+
# host: trilogy ignores `socket` when `host` is set, and would then use its
|
|
914
|
+
# compiled-in default instead of the app's socket. TLS and timeout keys in
|
|
915
|
+
# {MYSQL_CLIENT_KEYS} are kept.
|
|
916
|
+
#
|
|
917
|
+
# @param config_hash [Hash]
|
|
918
|
+
# @return [Hash]
|
|
919
|
+
def self.mysql_client_options(config_hash)
|
|
920
|
+
opts = {}
|
|
921
|
+
socket = config_hash[:socket].to_s
|
|
922
|
+
if socket.empty?
|
|
923
|
+
host = config_hash[:host].to_s
|
|
924
|
+
opts[:host] = host unless host.empty?
|
|
925
|
+
port = config_hash[:port]
|
|
926
|
+
opts[:port] = Integer(port) if port && !port.to_s.empty?
|
|
927
|
+
else
|
|
928
|
+
opts[:socket] = socket
|
|
929
|
+
end
|
|
930
|
+
user = config_hash[:username] || config_hash[:user]
|
|
931
|
+
opts[:username] = user.to_s if user && !user.to_s.empty?
|
|
932
|
+
password = config_hash[:password]
|
|
933
|
+
opts[:password] = password.to_s unless password.nil?
|
|
934
|
+
MYSQL_CLIENT_KEYS.each { |key| opts[key] = config_hash[key] unless config_hash[key].nil? }
|
|
935
|
+
# `:encoding` is an ActiveRecord key. mysql2 and trilogy do not share it.
|
|
936
|
+
# Row copy runs in the server, so the client charset does not change it.
|
|
937
|
+
opts
|
|
938
|
+
end
|
|
939
|
+
|
|
940
|
+
# Take one pool name, or the guard plus every pool name when `shared` is
|
|
941
|
+
# false. A miss releases any names this call already took.
|
|
942
|
+
#
|
|
943
|
+
# @param conn [Mysql2::Client, Trilogy]
|
|
944
|
+
# @param base [String]
|
|
945
|
+
# @param shared [Boolean]
|
|
946
|
+
# @return [Boolean]
|
|
947
|
+
def self.try_mysql_lock(conn, base, shared:)
|
|
948
|
+
return try_mysql_shared_lock(conn, base) if shared
|
|
949
|
+
|
|
950
|
+
try_mysql_exclusive_lock(conn, base)
|
|
951
|
+
end
|
|
952
|
+
|
|
953
|
+
# Take the guard, then every pool name. The guard stays held across the
|
|
954
|
+
# later downgrade so another run cannot start a copy in that gap.
|
|
955
|
+
#
|
|
956
|
+
# @param conn [Mysql2::Client, Trilogy]
|
|
957
|
+
# @param base [String]
|
|
958
|
+
# @return [Boolean]
|
|
959
|
+
def self.try_mysql_exclusive_lock(conn, base)
|
|
960
|
+
guard = mysql_guard_lock_name(base)
|
|
961
|
+
return false unless mysql_get_lock(conn, guard)
|
|
962
|
+
|
|
963
|
+
held = [guard]
|
|
964
|
+
mysql_lock_names(base).each do |name|
|
|
965
|
+
if mysql_get_lock(conn, name)
|
|
966
|
+
held << name
|
|
967
|
+
else
|
|
968
|
+
held.each { |got| mysql_release_lock(conn, got) }
|
|
969
|
+
@mysql_held_locks = []
|
|
970
|
+
return false
|
|
971
|
+
end
|
|
972
|
+
end
|
|
973
|
+
@mysql_held_locks = held
|
|
974
|
+
true
|
|
975
|
+
end
|
|
976
|
+
|
|
977
|
+
# Take the first free pool name. Does not take the guard, so worker daemons
|
|
978
|
+
# can connect while the provisioning process still holds that guard.
|
|
979
|
+
#
|
|
980
|
+
# @param conn [Mysql2::Client, Trilogy]
|
|
981
|
+
# @param base [String]
|
|
982
|
+
# @return [Boolean]
|
|
983
|
+
def self.try_mysql_shared_lock(conn, base)
|
|
984
|
+
mysql_lock_names(base).each do |name|
|
|
985
|
+
next unless mysql_get_lock(conn, name)
|
|
986
|
+
|
|
987
|
+
@mysql_held_locks = [name]
|
|
988
|
+
return true
|
|
989
|
+
end
|
|
990
|
+
@mysql_held_locks = []
|
|
991
|
+
false
|
|
992
|
+
end
|
|
993
|
+
|
|
994
|
+
# Release every pool name and keep the guard. The guard is never released
|
|
995
|
+
# first, so this process always holds a lock the next copy needs.
|
|
996
|
+
#
|
|
997
|
+
# @param conn [Mysql2::Client, Trilogy]
|
|
998
|
+
# @param base [String]
|
|
999
|
+
# @return [Boolean] false when this session does not hold the guard.
|
|
1000
|
+
def self.downgrade_mysql_lock_to_shared(conn, base)
|
|
1001
|
+
guard = mysql_guard_lock_name(base)
|
|
1002
|
+
held = Array(@mysql_held_locks)
|
|
1003
|
+
return false unless held.include?(guard)
|
|
1004
|
+
|
|
1005
|
+
(held - [guard]).each { |name| mysql_release_lock(conn, name) }
|
|
1006
|
+
@mysql_held_locks = [guard]
|
|
1007
|
+
true
|
|
1008
|
+
end
|
|
1009
|
+
|
|
1010
|
+
# Release the GET_LOCK names this session took for the current run.
|
|
1011
|
+
#
|
|
1012
|
+
# @param conn [Mysql2::Client, Trilogy]
|
|
1013
|
+
# @return [void]
|
|
1014
|
+
def self.release_held_mysql_locks(conn)
|
|
1015
|
+
Array(@mysql_held_locks).each { |name| mysql_release_lock(conn, name) }
|
|
1016
|
+
@mysql_held_locks = []
|
|
1017
|
+
nil
|
|
1018
|
+
end
|
|
1019
|
+
|
|
1020
|
+
# One GET_LOCK name per worker daemon. The digest keeps each name inside 64
|
|
1021
|
+
# characters for any base database name.
|
|
1022
|
+
#
|
|
1023
|
+
# @param base [String]
|
|
1024
|
+
# @return [Array<String>]
|
|
1025
|
+
def self.mysql_lock_names(base)
|
|
1026
|
+
digest = mysql_lock_digest(base)
|
|
1027
|
+
(0...MYSQL_LOCK_SLOTS).map { |slot| "m:#{digest}:#{slot}" }
|
|
1028
|
+
end
|
|
1029
|
+
|
|
1030
|
+
# Lock held for the whole copy and until worker daemons hold pool names.
|
|
1031
|
+
# Not part of {mysql_lock_names}, so it does not reduce the worker count.
|
|
1032
|
+
#
|
|
1033
|
+
# @param base [String]
|
|
1034
|
+
# @return [String]
|
|
1035
|
+
def self.mysql_guard_lock_name(base)
|
|
1036
|
+
"m:#{mysql_lock_digest(base)}:g"
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
# Shared prefix for the guard and the pool. 16 hex characters keep the full
|
|
1040
|
+
# lock name inside MySQL's 64-character limit.
|
|
1041
|
+
#
|
|
1042
|
+
# @param base [String]
|
|
1043
|
+
# @return [String]
|
|
1044
|
+
def self.mysql_lock_digest(base)
|
|
1045
|
+
Digest::SHA256.hexdigest("mutineer-run:#{base}")[0, 16]
|
|
1046
|
+
end
|
|
1047
|
+
|
|
1048
|
+
# @param conn [Mysql2::Client, Trilogy]
|
|
1049
|
+
# @param name [String]
|
|
1050
|
+
# @return [Boolean]
|
|
1051
|
+
def self.mysql_get_lock(conn, name)
|
|
1052
|
+
rows = mysql_rows(conn, "SELECT GET_LOCK(#{mysql_quote_string(conn, name)}, 0) AS got")
|
|
1053
|
+
rows.dig(0, "got").to_i == 1
|
|
1054
|
+
end
|
|
1055
|
+
|
|
1056
|
+
# @param conn [Mysql2::Client, Trilogy]
|
|
1057
|
+
# @param name [String]
|
|
1058
|
+
# @return [void]
|
|
1059
|
+
def self.mysql_release_lock(conn, name)
|
|
1060
|
+
mysql_exec(conn, "SELECT RELEASE_LOCK(#{mysql_quote_string(conn, name)})")
|
|
1061
|
+
end
|
|
1062
|
+
|
|
1063
|
+
# Drop and create `name`. Refuses any name that is not one of `base`'s
|
|
1064
|
+
# worker database names, so a name bug can never drop the test database.
|
|
1065
|
+
#
|
|
1066
|
+
# @param conn [Mysql2::Client, Trilogy]
|
|
1067
|
+
# @param name [String]
|
|
1068
|
+
# @param base [String] the test database name.
|
|
1069
|
+
# @return [void]
|
|
1070
|
+
def self.recreate_mysql_database(conn, name, base)
|
|
1071
|
+
raise "refusing to drop #{name}" unless owned_database?(base, name, slots: MYSQL_LOCK_SLOTS, limit: MYSQL_NAME_LIMIT)
|
|
1072
|
+
|
|
1073
|
+
quoted = quote_mysql_ident(name)
|
|
1074
|
+
charset, collation = mysql_database_defaults(conn, base)
|
|
1075
|
+
mysql_exec(conn, "DROP DATABASE IF EXISTS #{quoted}")
|
|
1076
|
+
mysql_exec(conn, "CREATE DATABASE #{quoted} CHARACTER SET #{charset} COLLATE #{collation}")
|
|
1077
|
+
end
|
|
1078
|
+
|
|
1079
|
+
# The test database's default character set and collation. A table the
|
|
1080
|
+
# schema creates without its own collation then compares text the same way
|
|
1081
|
+
# in each worker database as in the test database.
|
|
1082
|
+
#
|
|
1083
|
+
# @param conn [Mysql2::Client, Trilogy]
|
|
1084
|
+
# @param base [String] the test database name.
|
|
1085
|
+
# @return [Array(String, String)] the character set and the collation.
|
|
1086
|
+
# @raise [RuntimeError] when the test database is missing or reports an unexpected name.
|
|
1087
|
+
def self.mysql_database_defaults(conn, base)
|
|
1088
|
+
row = mysql_rows(conn, "SELECT DEFAULT_CHARACTER_SET_NAME AS charset, DEFAULT_COLLATION_NAME AS collation " \
|
|
1089
|
+
"FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = #{mysql_quote_string(conn, base)}").first
|
|
1090
|
+
values = row && [row["charset"].to_s, row["collation"].to_s]
|
|
1091
|
+
raise "cannot read the character set of #{base}" unless values&.all? { |value| value.match?(/\A\w+\z/) }
|
|
1092
|
+
|
|
1093
|
+
values
|
|
1094
|
+
end
|
|
1095
|
+
|
|
1096
|
+
# Load `schema.rb` or `structure.sql` into the empty slot with the app's
|
|
1097
|
+
# DatabaseTasks. A missing file leaves the slot empty so the row copy can
|
|
1098
|
+
# still clone tables that exist only on the test database.
|
|
1099
|
+
#
|
|
1100
|
+
# @param base_hash [Hash] symbol-keyed config for the test database.
|
|
1101
|
+
# @param name [String] worker database name.
|
|
1102
|
+
# @param base [String] test database name, used in the failure text.
|
|
1103
|
+
# @return [void]
|
|
1104
|
+
def self.load_mysql_schema(base_hash, name, base)
|
|
1105
|
+
return unless defined?(ActiveRecord::Tasks::DatabaseTasks)
|
|
1106
|
+
|
|
1107
|
+
switched = false
|
|
1108
|
+
failure = nil
|
|
1109
|
+
begin
|
|
1110
|
+
slot_hash = base_hash.merge(database: name)
|
|
1111
|
+
ActiveRecord::Base.establish_connection(slot_hash)
|
|
1112
|
+
switched = true
|
|
1113
|
+
db_config = mysql_db_config(slot_hash)
|
|
1114
|
+
format = current_schema_format
|
|
1115
|
+
file = mysql_schema_file(db_config, format)
|
|
1116
|
+
ActiveRecord::Tasks::DatabaseTasks.load_schema(db_config, format, file) if file
|
|
1117
|
+
rescue StandardError => e
|
|
1118
|
+
failure = e
|
|
1119
|
+
ensure
|
|
1120
|
+
if switched
|
|
1121
|
+
begin
|
|
1122
|
+
ActiveRecord::Base.establish_connection(base_hash)
|
|
1123
|
+
rescue StandardError => e
|
|
1124
|
+
failure ||= e
|
|
1125
|
+
end
|
|
1126
|
+
end
|
|
1127
|
+
end
|
|
1128
|
+
return unless failure
|
|
1129
|
+
raise failure if provision_wrapped?(failure)
|
|
1130
|
+
|
|
1131
|
+
raise provision_failure(base, name, failure.message)
|
|
1132
|
+
end
|
|
1133
|
+
|
|
1134
|
+
# A database config whose name is the slot, so DatabaseTasks loads into that
|
|
1135
|
+
# database and not the test database.
|
|
1136
|
+
#
|
|
1137
|
+
# @param slot_hash [Hash]
|
|
1138
|
+
# @return [ActiveRecord::DatabaseConfigurations::HashConfig]
|
|
1139
|
+
def self.mysql_db_config(slot_hash)
|
|
1140
|
+
env = ENV["RAILS_ENV"].to_s
|
|
1141
|
+
env = "test" if env.empty?
|
|
1142
|
+
ActiveRecord::DatabaseConfigurations::HashConfig.new(env, "primary", slot_hash)
|
|
1143
|
+
end
|
|
1144
|
+
|
|
1145
|
+
# Schema format the booted app is using.
|
|
1146
|
+
#
|
|
1147
|
+
# @return [Symbol]
|
|
1148
|
+
def self.current_schema_format
|
|
1149
|
+
if ActiveRecord.respond_to?(:schema_format)
|
|
1150
|
+
ActiveRecord.schema_format
|
|
1151
|
+
else
|
|
1152
|
+
ActiveRecord::Base.schema_format
|
|
1153
|
+
end
|
|
1154
|
+
end
|
|
1155
|
+
|
|
1156
|
+
# Absolute path of the schema file for `format`, or nil when it is absent.
|
|
1157
|
+
# DatabaseTasks aborts the process when the file is missing, so the caller
|
|
1158
|
+
# skips the load instead.
|
|
1159
|
+
#
|
|
1160
|
+
# @param db_config [ActiveRecord::DatabaseConfigurations::HashConfig]
|
|
1161
|
+
# @param format [Symbol]
|
|
1162
|
+
# @return [String, nil]
|
|
1163
|
+
def self.mysql_schema_file(db_config, format)
|
|
1164
|
+
path = ActiveRecord::Tasks::DatabaseTasks.schema_dump_path(db_config, format)
|
|
1165
|
+
path if path && File.exist?(path)
|
|
1166
|
+
end
|
|
1167
|
+
|
|
1168
|
+
# Copy every base table from `base` into `dest`. Foreign key checks are off.
|
|
1169
|
+
# Generated columns are left out of the INSERT list. A table the schema load
|
|
1170
|
+
# did not create is cloned with CREATE TABLE ... LIKE, then its foreign keys
|
|
1171
|
+
# are added. LIKE does not copy them. Each table's AUTO_INCREMENT is set
|
|
1172
|
+
# from the base after the rows, so a deleted high id is not reused.
|
|
1173
|
+
# Triggers the schema load created are dropped for the copy and created
|
|
1174
|
+
# again after it, so the copied rows match the base.
|
|
1175
|
+
#
|
|
1176
|
+
# @param conn [Mysql2::Client, Trilogy]
|
|
1177
|
+
# @param base [String]
|
|
1178
|
+
# @param dest [String]
|
|
1179
|
+
# @return [void]
|
|
1180
|
+
def self.copy_mysql_rows(conn, base, dest)
|
|
1181
|
+
mysql_exec(conn, "SET FOREIGN_KEY_CHECKS = 0")
|
|
1182
|
+
# MySQL 8 caches information_schema.TABLES values, AUTO_INCREMENT among
|
|
1183
|
+
# them, for a day by default. The copy reads them live.
|
|
1184
|
+
begin
|
|
1185
|
+
mysql_exec(conn, "SET SESSION information_schema_stats_expiry = 0")
|
|
1186
|
+
rescue StandardError
|
|
1187
|
+
nil # MariaDB has no such cache or variable.
|
|
1188
|
+
end
|
|
1189
|
+
triggers = drop_mysql_triggers(conn, dest)
|
|
1190
|
+
tables = mysql_rows(conn, <<~SQL)
|
|
1191
|
+
SELECT TABLE_NAME AS table_name
|
|
1192
|
+
FROM information_schema.TABLES
|
|
1193
|
+
WHERE TABLE_SCHEMA = #{mysql_quote_string(conn, base)}
|
|
1194
|
+
AND TABLE_TYPE = 'BASE TABLE'
|
|
1195
|
+
SQL
|
|
1196
|
+
names = []
|
|
1197
|
+
cloned = []
|
|
1198
|
+
tables.each do |row|
|
|
1199
|
+
table = row["table_name"].to_s
|
|
1200
|
+
next if table.empty?
|
|
1201
|
+
|
|
1202
|
+
names << table
|
|
1203
|
+
cloned << table if ensure_mysql_table(conn, base, dest, table)
|
|
1204
|
+
columns = copyable_mysql_columns(conn, base, table)
|
|
1205
|
+
next if columns.empty?
|
|
1206
|
+
|
|
1207
|
+
dest_table = "#{quote_mysql_ident(dest)}.#{quote_mysql_ident(table)}"
|
|
1208
|
+
base_table = "#{quote_mysql_ident(base)}.#{quote_mysql_ident(table)}"
|
|
1209
|
+
listed = columns.map { |column| quote_mysql_ident(column) }.join(", ")
|
|
1210
|
+
mysql_exec(conn, "DELETE FROM #{dest_table}")
|
|
1211
|
+
mysql_exec(conn, "INSERT INTO #{dest_table} (#{listed}) SELECT #{listed} FROM #{base_table}")
|
|
1212
|
+
end
|
|
1213
|
+
cloned.each { |table| copy_mysql_foreign_keys(conn, base, dest, table) }
|
|
1214
|
+
names.each { |table| copy_mysql_auto_increment(conn, base, dest, table) }
|
|
1215
|
+
restore_mysql_triggers(conn, dest, triggers)
|
|
1216
|
+
ensure
|
|
1217
|
+
begin
|
|
1218
|
+
mysql_exec(conn, "SET FOREIGN_KEY_CHECKS = 1")
|
|
1219
|
+
rescue StandardError
|
|
1220
|
+
nil
|
|
1221
|
+
end
|
|
1222
|
+
end
|
|
1223
|
+
|
|
1224
|
+
# Drop every trigger on `dest` and return what {restore_mysql_triggers}
|
|
1225
|
+
# needs to create each one again. Triggers on one table event come back in
|
|
1226
|
+
# ACTION_ORDER. MySQL 8's CREATE text has no FOLLOWS or PRECEDES, so
|
|
1227
|
+
# creating them in that order keeps their order.
|
|
1228
|
+
#
|
|
1229
|
+
# The CREATE text is read as utf8mb4 with an empty sql_mode. mysql2 0.5.6
|
|
1230
|
+
# and older open utf8mb3, which turns a 4-byte character into "?". Under
|
|
1231
|
+
# ANSI_QUOTES, MySQL quotes the names with double quotes, which the
|
|
1232
|
+
# trigger's own sql_mode may not read. Backticks read in every sql_mode.
|
|
1233
|
+
# The session stays on utf8mb4, so {restore_mysql_triggers} sends the
|
|
1234
|
+
# text back unchanged.
|
|
1235
|
+
#
|
|
1236
|
+
# @param conn [Mysql2::Client, Trilogy]
|
|
1237
|
+
# @param dest [String]
|
|
1238
|
+
# @return [Array<Hash>] `:name`, `:sql`, `:sql_mode` and `:collation` for each trigger.
|
|
1239
|
+
# @raise [RuntimeError] when SHOW CREATE TRIGGER returns no row for a trigger.
|
|
1240
|
+
def self.drop_mysql_triggers(conn, dest)
|
|
1241
|
+
rows = mysql_rows(conn, <<~SQL)
|
|
1242
|
+
SELECT TRIGGER_NAME AS trigger_name
|
|
1243
|
+
FROM information_schema.TRIGGERS
|
|
1244
|
+
WHERE TRIGGER_SCHEMA = #{mysql_quote_string(conn, dest)}
|
|
1245
|
+
ORDER BY EVENT_OBJECT_TABLE, ACTION_TIMING, EVENT_MANIPULATION, ACTION_ORDER
|
|
1246
|
+
SQL
|
|
1247
|
+
return [] if rows.empty?
|
|
1248
|
+
|
|
1249
|
+
sql_mode = mysql_rows(conn, "SELECT @@SESSION.sql_mode AS sql_mode").first["sql_mode"]
|
|
1250
|
+
mysql_exec(conn, "SET NAMES utf8mb4")
|
|
1251
|
+
mysql_exec(conn, "SET SESSION sql_mode = ''")
|
|
1252
|
+
# Limit: the text comes back as utf8mb4. In a trigger saved with another
|
|
1253
|
+
# client charset, a _binary or _latin1 literal that holds non-ASCII bytes
|
|
1254
|
+
# changes bytes on replay.
|
|
1255
|
+
triggers = rows.map do |row|
|
|
1256
|
+
name = row["trigger_name"].to_s
|
|
1257
|
+
shown = mysql_rows(conn, "SHOW CREATE TRIGGER #{quote_mysql_ident(dest)}.#{quote_mysql_ident(name)}").first
|
|
1258
|
+
raise "cannot read trigger #{name} on #{dest}" unless shown
|
|
1259
|
+
|
|
1260
|
+
{ name: name, sql: shown["sql original statement"].to_s, sql_mode: shown["sql_mode"].to_s,
|
|
1261
|
+
collation: shown["collation_connection"].to_s }
|
|
1262
|
+
end
|
|
1263
|
+
# Every definition is saved before the first drop.
|
|
1264
|
+
triggers.each { |trigger| mysql_exec(conn, "DROP TRIGGER #{quote_mysql_ident(dest)}.#{quote_mysql_ident(trigger[:name])}") }
|
|
1265
|
+
mysql_exec(conn, "SET SESSION sql_mode = #{mysql_quote_string(conn, sql_mode)}")
|
|
1266
|
+
triggers
|
|
1267
|
+
end
|
|
1268
|
+
|
|
1269
|
+
# Create the triggers {drop_mysql_triggers} dropped. Each one is created
|
|
1270
|
+
# with its own sql_mode and connection collation, which decide how MySQL
|
|
1271
|
+
# reads its body. The CREATE text names no database, so this session
|
|
1272
|
+
# switches to `dest`. The sql_mode and collation go back to their values at
|
|
1273
|
+
# the start of this call. The character set stays utf8mb4. A failed create
|
|
1274
|
+
# names the trigger, for example when the DEFINER account needs SET_USER_ID.
|
|
1275
|
+
#
|
|
1276
|
+
# @param conn [Mysql2::Client, Trilogy]
|
|
1277
|
+
# @param dest [String]
|
|
1278
|
+
# @param triggers [Array<Hash>]
|
|
1279
|
+
# @return [void]
|
|
1280
|
+
# @raise [RuntimeError] when MySQL refuses to create a trigger.
|
|
1281
|
+
def self.restore_mysql_triggers(conn, dest, triggers)
|
|
1282
|
+
return if triggers.empty?
|
|
1283
|
+
|
|
1284
|
+
saved = mysql_rows(conn, "SELECT @@SESSION.sql_mode AS sql_mode, @@SESSION.collation_connection AS collation").first
|
|
1285
|
+
mysql_exec(conn, "USE #{quote_mysql_ident(dest)}")
|
|
1286
|
+
triggers.each do |trigger|
|
|
1287
|
+
mysql_exec(conn, mysql_session_modes_sql(conn, trigger[:sql_mode], trigger[:collation]))
|
|
1288
|
+
mysql_exec(conn, trigger[:sql])
|
|
1289
|
+
rescue StandardError => e
|
|
1290
|
+
raise "cannot recreate trigger #{trigger[:name]} on #{dest}: #{e.message}"
|
|
1291
|
+
end
|
|
1292
|
+
mysql_exec(conn, mysql_session_modes_sql(conn, saved["sql_mode"], saved["collation"]))
|
|
1293
|
+
end
|
|
1294
|
+
|
|
1295
|
+
# SET statement for the session's sql_mode and connection collation.
|
|
1296
|
+
#
|
|
1297
|
+
# @param conn [Mysql2::Client, Trilogy]
|
|
1298
|
+
# @param sql_mode [String]
|
|
1299
|
+
# @param collation [String]
|
|
1300
|
+
# @return [String]
|
|
1301
|
+
def self.mysql_session_modes_sql(conn, sql_mode, collation)
|
|
1302
|
+
"SET SESSION sql_mode = #{mysql_quote_string(conn, sql_mode)}, " \
|
|
1303
|
+
"collation_connection = #{mysql_quote_string(conn, collation)}"
|
|
1304
|
+
end
|
|
1305
|
+
|
|
1306
|
+
# Create `table` on `dest` when the schema load did not. LIKE copies columns,
|
|
1307
|
+
# including generated columns, and does not copy foreign keys. The caller
|
|
1308
|
+
# adds those keys after every missing table exists.
|
|
1309
|
+
#
|
|
1310
|
+
# @param conn [Mysql2::Client, Trilogy]
|
|
1311
|
+
# @param base [String]
|
|
1312
|
+
# @param dest [String]
|
|
1313
|
+
# @param table [String]
|
|
1314
|
+
# @return [Boolean] true when this call created the table.
|
|
1315
|
+
def self.ensure_mysql_table(conn, base, dest, table)
|
|
1316
|
+
rows = mysql_rows(conn, <<~SQL)
|
|
1317
|
+
SELECT TABLE_NAME AS table_name
|
|
1318
|
+
FROM information_schema.TABLES
|
|
1319
|
+
WHERE TABLE_SCHEMA = #{mysql_quote_string(conn, dest)}
|
|
1320
|
+
AND TABLE_NAME = #{mysql_quote_string(conn, table)}
|
|
1321
|
+
AND TABLE_TYPE = 'BASE TABLE'
|
|
1322
|
+
SQL
|
|
1323
|
+
return false unless rows.empty?
|
|
1324
|
+
|
|
1325
|
+
mysql_exec(conn, "CREATE TABLE #{quote_mysql_ident(dest)}.#{quote_mysql_ident(table)} LIKE #{quote_mysql_ident(base)}.#{quote_mysql_ident(table)}")
|
|
1326
|
+
true
|
|
1327
|
+
end
|
|
1328
|
+
|
|
1329
|
+
# Add foreign keys from `base.table` onto `dest.table`. Keys that already
|
|
1330
|
+
# exist on `dest` are left alone. A referenced table in `base` is retargeted
|
|
1331
|
+
# at `dest` so the worker does not point back at the test database.
|
|
1332
|
+
#
|
|
1333
|
+
# @param conn [Mysql2::Client, Trilogy]
|
|
1334
|
+
# @param base [String]
|
|
1335
|
+
# @param dest [String]
|
|
1336
|
+
# @param table [String]
|
|
1337
|
+
# @return [void]
|
|
1338
|
+
def self.copy_mysql_foreign_keys(conn, base, dest, table)
|
|
1339
|
+
existing = mysql_rows(conn, <<~SQL)
|
|
1340
|
+
SELECT CONSTRAINT_NAME AS constraint_name
|
|
1341
|
+
FROM information_schema.TABLE_CONSTRAINTS
|
|
1342
|
+
WHERE TABLE_SCHEMA = #{mysql_quote_string(conn, dest)}
|
|
1343
|
+
AND TABLE_NAME = #{mysql_quote_string(conn, table)}
|
|
1344
|
+
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
|
|
1345
|
+
SQL
|
|
1346
|
+
have = existing.map { |row| row["constraint_name"].to_s }
|
|
1347
|
+
rows = mysql_rows(conn, <<~SQL)
|
|
1348
|
+
SELECT k.CONSTRAINT_NAME AS constraint_name,
|
|
1349
|
+
k.COLUMN_NAME AS column_name,
|
|
1350
|
+
k.ORDINAL_POSITION AS ordinal_position,
|
|
1351
|
+
k.REFERENCED_TABLE_SCHEMA AS referenced_table_schema,
|
|
1352
|
+
k.REFERENCED_TABLE_NAME AS referenced_table_name,
|
|
1353
|
+
k.REFERENCED_COLUMN_NAME AS referenced_column_name,
|
|
1354
|
+
r.UPDATE_RULE AS update_rule,
|
|
1355
|
+
r.DELETE_RULE AS delete_rule,
|
|
1356
|
+
r.MATCH_OPTION AS match_option
|
|
1357
|
+
FROM information_schema.KEY_COLUMN_USAGE k
|
|
1358
|
+
INNER JOIN information_schema.REFERENTIAL_CONSTRAINTS r
|
|
1359
|
+
ON r.CONSTRAINT_SCHEMA = k.CONSTRAINT_SCHEMA
|
|
1360
|
+
AND r.CONSTRAINT_NAME = k.CONSTRAINT_NAME
|
|
1361
|
+
AND r.TABLE_NAME = k.TABLE_NAME
|
|
1362
|
+
WHERE k.TABLE_SCHEMA = #{mysql_quote_string(conn, base)}
|
|
1363
|
+
AND k.TABLE_NAME = #{mysql_quote_string(conn, table)}
|
|
1364
|
+
AND k.REFERENCED_TABLE_NAME IS NOT NULL
|
|
1365
|
+
ORDER BY k.CONSTRAINT_NAME, k.ORDINAL_POSITION
|
|
1366
|
+
SQL
|
|
1367
|
+
rows = rows.reject { |row| have.include?(row["constraint_name"].to_s) }
|
|
1368
|
+
sql = mysql_add_foreign_key_sql(dest, table, rows, base: base)
|
|
1369
|
+
mysql_exec(conn, sql) if sql
|
|
1370
|
+
end
|
|
1371
|
+
|
|
1372
|
+
# One ALTER TABLE that adds every foreign key in `rows`.
|
|
1373
|
+
#
|
|
1374
|
+
# @param dest [String]
|
|
1375
|
+
# @param table [String]
|
|
1376
|
+
# @param rows [Array<Hash>]
|
|
1377
|
+
# @param base [String] test database name. References to it are rewritten to `dest`.
|
|
1378
|
+
# @return [String, nil]
|
|
1379
|
+
def self.mysql_add_foreign_key_sql(dest, table, rows, base:)
|
|
1380
|
+
clauses = mysql_foreign_key_clauses(rows, base: base, dest: dest)
|
|
1381
|
+
return nil if clauses.empty?
|
|
1382
|
+
|
|
1383
|
+
"ALTER TABLE #{quote_mysql_ident(dest)}.#{quote_mysql_ident(table)} #{clauses.join(', ')}"
|
|
1384
|
+
end
|
|
1385
|
+
|
|
1386
|
+
# ADD CONSTRAINT fragments for `rows`, grouped by constraint name.
|
|
1387
|
+
#
|
|
1388
|
+
# @param rows [Array<Hash>]
|
|
1389
|
+
# @param base [String]
|
|
1390
|
+
# @param dest [String]
|
|
1391
|
+
# @return [Array<String>]
|
|
1392
|
+
def self.mysql_foreign_key_clauses(rows, base:, dest:)
|
|
1393
|
+
grouped = {}
|
|
1394
|
+
Array(rows).each do |row|
|
|
1395
|
+
name = row["constraint_name"].to_s
|
|
1396
|
+
next if name.empty?
|
|
1397
|
+
|
|
1398
|
+
(grouped[name] ||= []) << row
|
|
1399
|
+
end
|
|
1400
|
+
grouped.filter_map do |name, cols|
|
|
1401
|
+
ordered = cols.sort_by { |row| row["ordinal_position"].to_i }
|
|
1402
|
+
local_cols = ordered.map { |row| row["column_name"].to_s }
|
|
1403
|
+
ref_cols = ordered.map { |row| row["referenced_column_name"].to_s }
|
|
1404
|
+
ref_table = ordered.first["referenced_table_name"].to_s
|
|
1405
|
+
next if ref_table.empty? || local_cols.any?(&:empty?) || ref_cols.any?(&:empty?)
|
|
1406
|
+
|
|
1407
|
+
ref_schema = ordered.first["referenced_table_schema"].to_s
|
|
1408
|
+
ref_schema = dest if ref_schema.empty? || ref_schema == base
|
|
1409
|
+
delete_rule = mysql_referential_action(ordered.first["delete_rule"])
|
|
1410
|
+
update_rule = mysql_referential_action(ordered.first["update_rule"])
|
|
1411
|
+
match = mysql_match_clause(ordered.first["match_option"])
|
|
1412
|
+
local_sql = local_cols.map { |column| quote_mysql_ident(column) }.join(", ")
|
|
1413
|
+
ref_sql = ref_cols.map { |column| quote_mysql_ident(column) }.join(", ")
|
|
1414
|
+
target = "#{quote_mysql_ident(ref_schema)}.#{quote_mysql_ident(ref_table)}"
|
|
1415
|
+
"ADD CONSTRAINT #{quote_mysql_ident(name)} FOREIGN KEY (#{local_sql}) REFERENCES #{target} (#{ref_sql})#{match} ON DELETE #{delete_rule} ON UPDATE #{update_rule}"
|
|
1416
|
+
end
|
|
1417
|
+
end
|
|
1418
|
+
|
|
1419
|
+
# A foreign-key action safe to interpolate. Unknown text raises.
|
|
1420
|
+
#
|
|
1421
|
+
# @param value [String, nil]
|
|
1422
|
+
# @return [String]
|
|
1423
|
+
# @raise [ArgumentError] when `value` is not one of {MYSQL_REFERENTIAL_ACTIONS}.
|
|
1424
|
+
def self.mysql_referential_action(value)
|
|
1425
|
+
action = value.to_s.strip.upcase.tr("_", " ")
|
|
1426
|
+
return action if MYSQL_REFERENTIAL_ACTIONS.include?(action)
|
|
1427
|
+
|
|
1428
|
+
raise ArgumentError, "unsupported foreign key action #{value.inspect}"
|
|
1429
|
+
end
|
|
1430
|
+
|
|
1431
|
+
# MATCH fragment, or an empty string for the default NONE.
|
|
1432
|
+
#
|
|
1433
|
+
# @param value [String, nil]
|
|
1434
|
+
# @return [String]
|
|
1435
|
+
# @raise [ArgumentError] when `value` is not NONE, FULL, or PARTIAL.
|
|
1436
|
+
def self.mysql_match_clause(value)
|
|
1437
|
+
match = value.to_s.strip.upcase
|
|
1438
|
+
return "" if match.empty? || match == "NONE"
|
|
1439
|
+
return " MATCH #{match}" if %w[FULL PARTIAL].include?(match)
|
|
1440
|
+
|
|
1441
|
+
raise ArgumentError, "unsupported foreign key match #{value.inspect}"
|
|
1442
|
+
end
|
|
1443
|
+
|
|
1444
|
+
# Set `dest.table`'s AUTO_INCREMENT from the base table when the base has one.
|
|
1445
|
+
#
|
|
1446
|
+
# @param conn [Mysql2::Client, Trilogy]
|
|
1447
|
+
# @param base [String]
|
|
1448
|
+
# @param dest [String]
|
|
1449
|
+
# @param table [String]
|
|
1450
|
+
# @return [void]
|
|
1451
|
+
def self.copy_mysql_auto_increment(conn, base, dest, table)
|
|
1452
|
+
rows = mysql_rows(conn, <<~SQL)
|
|
1453
|
+
SELECT AUTO_INCREMENT AS auto_increment
|
|
1454
|
+
FROM information_schema.TABLES
|
|
1455
|
+
WHERE TABLE_SCHEMA = #{mysql_quote_string(conn, base)}
|
|
1456
|
+
AND TABLE_NAME = #{mysql_quote_string(conn, table)}
|
|
1457
|
+
SQL
|
|
1458
|
+
sql = mysql_auto_increment_sql(dest, table, rows.dig(0, "auto_increment"))
|
|
1459
|
+
mysql_exec(conn, sql) if sql
|
|
1460
|
+
end
|
|
1461
|
+
|
|
1462
|
+
# ALTER TABLE statement that sets AUTO_INCREMENT, or nil when `value` is blank.
|
|
1463
|
+
#
|
|
1464
|
+
# @param dest [String]
|
|
1465
|
+
# @param table [String]
|
|
1466
|
+
# @param value [Object]
|
|
1467
|
+
# @return [String, nil]
|
|
1468
|
+
def self.mysql_auto_increment_sql(dest, table, value)
|
|
1469
|
+
return nil if value.nil? || value.to_s.empty?
|
|
1470
|
+
|
|
1471
|
+
number = Integer(value)
|
|
1472
|
+
return nil if number < 1
|
|
1473
|
+
|
|
1474
|
+
"ALTER TABLE #{quote_mysql_ident(dest)}.#{quote_mysql_ident(table)} AUTO_INCREMENT = #{number}"
|
|
1475
|
+
rescue ArgumentError, TypeError
|
|
1476
|
+
nil
|
|
1477
|
+
end
|
|
1478
|
+
|
|
1479
|
+
# Column names that an INSERT may set. Stored and virtual generated columns
|
|
1480
|
+
# are omitted. Expression defaults stay, because they are real columns.
|
|
1481
|
+
#
|
|
1482
|
+
# @param conn [Mysql2::Client, Trilogy]
|
|
1483
|
+
# @param base [String]
|
|
1484
|
+
# @param table [String]
|
|
1485
|
+
# @return [Array<String>]
|
|
1486
|
+
def self.copyable_mysql_columns(conn, base, table)
|
|
1487
|
+
rows = mysql_rows(conn, <<~SQL)
|
|
1488
|
+
SELECT COLUMN_NAME AS column_name, EXTRA AS extra
|
|
1489
|
+
FROM information_schema.COLUMNS
|
|
1490
|
+
WHERE TABLE_SCHEMA = #{mysql_quote_string(conn, base)}
|
|
1491
|
+
AND TABLE_NAME = #{mysql_quote_string(conn, table)}
|
|
1492
|
+
ORDER BY ORDINAL_POSITION
|
|
1493
|
+
SQL
|
|
1494
|
+
rows.filter_map do |row|
|
|
1495
|
+
name = row["column_name"].to_s
|
|
1496
|
+
next if name.empty?
|
|
1497
|
+
next if row["extra"].to_s.match?(/VIRTUAL GENERATED|STORED GENERATED/i)
|
|
1498
|
+
|
|
1499
|
+
name
|
|
1500
|
+
end
|
|
1501
|
+
end
|
|
1502
|
+
|
|
1503
|
+
# Run a statement that does not need its result rows.
|
|
1504
|
+
#
|
|
1505
|
+
# @param conn [Mysql2::Client, Trilogy]
|
|
1506
|
+
# @param sql [String]
|
|
1507
|
+
# @return [void]
|
|
1508
|
+
def self.mysql_exec(conn, sql)
|
|
1509
|
+
conn.query(sql)
|
|
1510
|
+
nil
|
|
1511
|
+
end
|
|
1512
|
+
|
|
1513
|
+
# Result rows as string-keyed hashes. mysql2 yields hashes from `each`.
|
|
1514
|
+
# trilogy yields them from `each_hash`. Keys are downcased so aliases match.
|
|
1515
|
+
#
|
|
1516
|
+
# @param conn [Mysql2::Client, Trilogy]
|
|
1517
|
+
# @param sql [String]
|
|
1518
|
+
# @return [Array<Hash>]
|
|
1519
|
+
def self.mysql_rows(conn, sql)
|
|
1520
|
+
result = conn.query(sql)
|
|
1521
|
+
return [] if result.nil?
|
|
1522
|
+
|
|
1523
|
+
rows = []
|
|
1524
|
+
if result.respond_to?(:each_hash)
|
|
1525
|
+
result.each_hash { |row| rows << stringify_mysql_row(row) }
|
|
1526
|
+
else
|
|
1527
|
+
result.each(as: :hash) { |row| rows << stringify_mysql_row(row) }
|
|
1528
|
+
end
|
|
1529
|
+
rows
|
|
1530
|
+
end
|
|
1531
|
+
|
|
1532
|
+
# @param row [Hash]
|
|
1533
|
+
# @return [Hash]
|
|
1534
|
+
def self.stringify_mysql_row(row)
|
|
1535
|
+
row.to_h.transform_keys { |key| key.to_s.downcase }
|
|
1536
|
+
end
|
|
1537
|
+
|
|
1538
|
+
# Quote a string for a SQL literal with the client the app loaded.
|
|
1539
|
+
#
|
|
1540
|
+
# @param conn [Mysql2::Client, Trilogy]
|
|
1541
|
+
# @param value [String]
|
|
1542
|
+
# @return [String]
|
|
1543
|
+
def self.mysql_quote_string(conn, value)
|
|
1544
|
+
"'#{conn.escape(value.to_s)}'"
|
|
1545
|
+
end
|
|
1546
|
+
|
|
1547
|
+
# Quote a MySQL identifier. Backticks inside the name are doubled.
|
|
1548
|
+
#
|
|
1549
|
+
# @param name [String]
|
|
1550
|
+
# @return [String]
|
|
1551
|
+
def self.quote_mysql_ident(name)
|
|
1552
|
+
raise ArgumentError, "database name is empty" if name.nil? || name.empty?
|
|
1553
|
+
raise ArgumentError, "database name contains a null" if name.include?("\0")
|
|
1554
|
+
|
|
1555
|
+
"`#{name.to_s.gsub('`', '``')}`"
|
|
1556
|
+
end
|
|
1557
|
+
|
|
1558
|
+
# True when `error` is already a provision failure this module raised.
|
|
1559
|
+
#
|
|
1560
|
+
# @param error [StandardError]
|
|
1561
|
+
# @return [Boolean]
|
|
1562
|
+
def self.provision_wrapped?(error)
|
|
1563
|
+
message = error.message
|
|
1564
|
+
message.start_with?("could not provision", "another Mutineer run", "refusing to drop")
|
|
1565
|
+
end
|
|
137
1566
|
end
|
|
138
1567
|
end
|