lhm-teak 3.6.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 +7 -0
- data/.github/workflows/test.yml +43 -0
- data/.gitignore +12 -0
- data/.rubocop.yml +183 -0
- data/.travis.yml +21 -0
- data/Appraisals +24 -0
- data/CHANGELOG.md +254 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +67 -0
- data/LICENSE +27 -0
- data/README.md +335 -0
- data/Rakefile +33 -0
- data/dev.yml +45 -0
- data/docker-compose.yml +60 -0
- data/gemfiles/activerecord_5.2.gemfile +9 -0
- data/gemfiles/activerecord_5.2.gemfile.lock +66 -0
- data/gemfiles/activerecord_6.0.gemfile +7 -0
- data/gemfiles/activerecord_6.0.gemfile.lock +68 -0
- data/gemfiles/activerecord_6.1.gemfile +7 -0
- data/gemfiles/activerecord_6.1.gemfile.lock +67 -0
- data/gemfiles/activerecord_7.0.0.alpha2.gemfile +7 -0
- data/gemfiles/activerecord_7.0.0.alpha2.gemfile.lock +65 -0
- data/lhm.gemspec +38 -0
- data/lib/lhm/atomic_switcher.rb +46 -0
- data/lib/lhm/chunk_finder.rb +62 -0
- data/lib/lhm/chunk_insert.rb +61 -0
- data/lib/lhm/chunker.rb +95 -0
- data/lib/lhm/cleanup/current.rb +71 -0
- data/lib/lhm/command.rb +48 -0
- data/lib/lhm/connection.rb +108 -0
- data/lib/lhm/entangler.rb +112 -0
- data/lib/lhm/intersection.rb +51 -0
- data/lib/lhm/invoker.rb +100 -0
- data/lib/lhm/locked_switcher.rb +76 -0
- data/lib/lhm/migration.rb +51 -0
- data/lib/lhm/migrator.rb +244 -0
- data/lib/lhm/printer.rb +63 -0
- data/lib/lhm/proxysql_helper.rb +10 -0
- data/lib/lhm/railtie.rb +9 -0
- data/lib/lhm/sql_helper.rb +77 -0
- data/lib/lhm/sql_retry.rb +180 -0
- data/lib/lhm/table.rb +121 -0
- data/lib/lhm/table_name.rb +23 -0
- data/lib/lhm/test_support.rb +35 -0
- data/lib/lhm/throttler/slave_lag.rb +162 -0
- data/lib/lhm/throttler/threads_running.rb +53 -0
- data/lib/lhm/throttler/time.rb +29 -0
- data/lib/lhm/throttler.rb +36 -0
- data/lib/lhm/timestamp.rb +11 -0
- data/lib/lhm/version.rb +6 -0
- data/lib/lhm-shopify.rb +1 -0
- data/lib/lhm.rb +156 -0
- data/scripts/helpers/wait-for-dbs.sh +21 -0
- data/scripts/mysql/reader/create_replication.sql +10 -0
- data/scripts/mysql/writer/create_test_db.sql +1 -0
- data/scripts/mysql/writer/create_users.sql +6 -0
- data/scripts/proxysql/proxysql.cnf +117 -0
- data/shipit.rubygems.yml +0 -0
- data/spec/.lhm.example +4 -0
- data/spec/README.md +58 -0
- data/spec/fixtures/bigint_table.ddl +4 -0
- data/spec/fixtures/composite_primary_key.ddl +6 -0
- data/spec/fixtures/composite_primary_key_dest.ddl +6 -0
- data/spec/fixtures/custom_primary_key.ddl +6 -0
- data/spec/fixtures/custom_primary_key_dest.ddl +6 -0
- data/spec/fixtures/destination.ddl +6 -0
- data/spec/fixtures/lines.ddl +7 -0
- data/spec/fixtures/origin.ddl +6 -0
- data/spec/fixtures/permissions.ddl +5 -0
- data/spec/fixtures/small_table.ddl +4 -0
- data/spec/fixtures/tracks.ddl +5 -0
- data/spec/fixtures/users.ddl +14 -0
- data/spec/fixtures/wo_id_int_column.ddl +6 -0
- data/spec/integration/atomic_switcher_spec.rb +129 -0
- data/spec/integration/chunk_insert_spec.rb +30 -0
- data/spec/integration/chunker_spec.rb +269 -0
- data/spec/integration/cleanup_spec.rb +147 -0
- data/spec/integration/database.yml +25 -0
- data/spec/integration/entangler_spec.rb +68 -0
- data/spec/integration/integration_helper.rb +252 -0
- data/spec/integration/invoker_spec.rb +33 -0
- data/spec/integration/lhm_spec.rb +659 -0
- data/spec/integration/lock_wait_timeout_spec.rb +30 -0
- data/spec/integration/locked_switcher_spec.rb +50 -0
- data/spec/integration/proxysql_spec.rb +34 -0
- data/spec/integration/sql_retry/db_connection_helper.rb +52 -0
- data/spec/integration/sql_retry/lock_wait_spec.rb +127 -0
- data/spec/integration/sql_retry/lock_wait_timeout_test_helper.rb +114 -0
- data/spec/integration/sql_retry/proxysql_helper.rb +22 -0
- data/spec/integration/sql_retry/retry_with_proxysql_spec.rb +109 -0
- data/spec/integration/table_spec.rb +83 -0
- data/spec/integration/toxiproxy_helper.rb +40 -0
- data/spec/test_helper.rb +69 -0
- data/spec/unit/atomic_switcher_spec.rb +29 -0
- data/spec/unit/chunk_finder_spec.rb +73 -0
- data/spec/unit/chunk_insert_spec.rb +67 -0
- data/spec/unit/chunker_spec.rb +176 -0
- data/spec/unit/connection_spec.rb +111 -0
- data/spec/unit/entangler_spec.rb +187 -0
- data/spec/unit/intersection_spec.rb +51 -0
- data/spec/unit/lhm_spec.rb +46 -0
- data/spec/unit/locked_switcher_spec.rb +46 -0
- data/spec/unit/migrator_spec.rb +144 -0
- data/spec/unit/printer_spec.rb +85 -0
- data/spec/unit/sql_helper_spec.rb +28 -0
- data/spec/unit/table_name_spec.rb +39 -0
- data/spec/unit/table_spec.rb +47 -0
- data/spec/unit/throttler/slave_lag_spec.rb +322 -0
- data/spec/unit/throttler/threads_running_spec.rb +64 -0
- data/spec/unit/throttler_spec.rb +124 -0
- data/spec/unit/unit_helper.rb +26 -0
- metadata +366 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Copyright (c) 2011 - 2013, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
|
|
2
|
+
# Schmidt
|
|
3
|
+
|
|
4
|
+
require 'lhm/command'
|
|
5
|
+
require 'lhm/sql_helper'
|
|
6
|
+
require 'lhm/sql_retry'
|
|
7
|
+
require 'lhm/connection'
|
|
8
|
+
|
|
9
|
+
module Lhm
|
|
10
|
+
class Entangler
|
|
11
|
+
include Command
|
|
12
|
+
include SqlHelper
|
|
13
|
+
|
|
14
|
+
attr_reader :connection
|
|
15
|
+
|
|
16
|
+
LOG_PREFIX = "Entangler"
|
|
17
|
+
|
|
18
|
+
# Creates entanglement between two tables. All creates, updates and deletes
|
|
19
|
+
# to origin will be repeated on the destination table.
|
|
20
|
+
def initialize(migration, connection = nil)
|
|
21
|
+
@intersection = migration.intersection
|
|
22
|
+
@origin = migration.origin
|
|
23
|
+
@destination = migration.destination
|
|
24
|
+
@connection = connection
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def entangle
|
|
28
|
+
[
|
|
29
|
+
create_delete_trigger,
|
|
30
|
+
create_insert_trigger,
|
|
31
|
+
create_update_trigger
|
|
32
|
+
]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def untangle
|
|
36
|
+
[
|
|
37
|
+
"drop trigger if exists `#{ trigger(:del) }`",
|
|
38
|
+
"drop trigger if exists `#{ trigger(:ins) }`",
|
|
39
|
+
"drop trigger if exists `#{ trigger(:upd) }`"
|
|
40
|
+
]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def create_insert_trigger
|
|
44
|
+
strip %Q{
|
|
45
|
+
create trigger `#{ trigger(:ins) }`
|
|
46
|
+
after insert on `#{ @origin.name }` for each row
|
|
47
|
+
replace into `#{ @destination.name }` (#{ @intersection.destination.joined }) #{ SqlHelper.annotation }
|
|
48
|
+
values (#{ @intersection.origin.typed('NEW') })
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def create_update_trigger
|
|
53
|
+
strip %Q{
|
|
54
|
+
create trigger `#{ trigger(:upd) }`
|
|
55
|
+
after update on `#{ @origin.name }` for each row
|
|
56
|
+
replace into `#{ @destination.name }` (#{ @intersection.destination.joined }) #{ SqlHelper.annotation }
|
|
57
|
+
values (#{ @intersection.origin.typed('NEW') })
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def create_delete_trigger
|
|
62
|
+
strip %Q{
|
|
63
|
+
create trigger `#{ trigger(:del) }`
|
|
64
|
+
after delete on `#{ @origin.name }` for each row
|
|
65
|
+
delete ignore from `#{ @destination.name }` #{ SqlHelper.annotation }
|
|
66
|
+
where `#{ @destination.name }`.`id` = OLD.`id`
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def trigger(type)
|
|
71
|
+
"lhmt_#{ type }_#{ @origin.name }"[0...64]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def expected_triggers
|
|
75
|
+
[trigger(:ins), trigger(:upd), trigger(:del)]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def validate
|
|
79
|
+
unless @connection.data_source_exists?(@origin.name)
|
|
80
|
+
error("#{ @origin.name } does not exist")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
unless @connection.data_source_exists?(@destination.name)
|
|
84
|
+
error("#{ @destination.name } does not exist")
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def before
|
|
89
|
+
entangle.each do |stmt|
|
|
90
|
+
@connection.execute(stmt, should_retry: true, log_prefix: LOG_PREFIX)
|
|
91
|
+
end
|
|
92
|
+
Lhm.logger.info("Created triggers on #{@origin.name}")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def after
|
|
96
|
+
untangle.each do |stmt|
|
|
97
|
+
@connection.execute(stmt, should_retry: true, log_prefix: LOG_PREFIX)
|
|
98
|
+
end
|
|
99
|
+
Lhm.logger.info("Dropped triggers on #{@origin.name}")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def revert
|
|
103
|
+
after
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def strip(sql)
|
|
109
|
+
sql.strip.gsub(/\n */, "\n")
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Copyright (c) 2011 - 2013, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
|
|
2
|
+
# Schmidt
|
|
3
|
+
|
|
4
|
+
module Lhm
|
|
5
|
+
# Determine and format columns common to origin and destination.
|
|
6
|
+
class Intersection
|
|
7
|
+
def initialize(origin, destination, renames = {})
|
|
8
|
+
@origin = origin
|
|
9
|
+
@destination = destination
|
|
10
|
+
@renames = renames
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def origin
|
|
14
|
+
(common + @renames.keys).extend(Joiners)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def destination
|
|
18
|
+
(common + @renames.values).extend(Joiners)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def common
|
|
24
|
+
(@origin.columns.keys & @destination.columns.keys).sort
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
module Joiners
|
|
28
|
+
def escaped
|
|
29
|
+
map { |name| tick(name) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def joined
|
|
33
|
+
escaped.join(', ')
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def typed(type)
|
|
37
|
+
map { |name| qualified(name, type) }.join(', ')
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def qualified(name, type)
|
|
43
|
+
"`#{ type }`.`#{ name }`"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def tick(name)
|
|
47
|
+
"`#{ name }`"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/lhm/invoker.rb
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Copyright (c) 2011 - 2013, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
|
|
2
|
+
# Schmidt
|
|
3
|
+
|
|
4
|
+
require 'lhm/chunker'
|
|
5
|
+
require 'lhm/entangler'
|
|
6
|
+
require 'lhm/atomic_switcher'
|
|
7
|
+
require 'lhm/locked_switcher'
|
|
8
|
+
require 'lhm/migrator'
|
|
9
|
+
|
|
10
|
+
module Lhm
|
|
11
|
+
# Copies an origin table to an altered destination table. Live activity is
|
|
12
|
+
# synchronized into the destination table using triggers.
|
|
13
|
+
#
|
|
14
|
+
# Once the origin and destination tables have converged, origin is archived
|
|
15
|
+
# and replaced by destination.
|
|
16
|
+
class Invoker
|
|
17
|
+
include SqlHelper
|
|
18
|
+
LOCK_WAIT_TIMEOUT_DELTA = 10
|
|
19
|
+
INNODB_LOCK_WAIT_TIMEOUT_MAX = 1073741824.freeze # https://dev.mysql.com/doc/refman/5.7/en/innodb-parameters.html#sysvar_innodb_lock_wait_timeout
|
|
20
|
+
LOCK_WAIT_TIMEOUT_MAX = 31536000.freeze # https://dev.mysql.com/doc/refman/5.7/en/server-system-variables.html
|
|
21
|
+
|
|
22
|
+
attr_reader :migrator, :connection
|
|
23
|
+
|
|
24
|
+
def initialize(origin, connection)
|
|
25
|
+
@connection = connection
|
|
26
|
+
@migrator = Migrator.new(origin, connection)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def set_session_lock_wait_timeouts
|
|
30
|
+
global_innodb_lock_wait_timeout = @connection.select_one("SHOW GLOBAL VARIABLES LIKE 'innodb_lock_wait_timeout'")
|
|
31
|
+
global_lock_wait_timeout = @connection.select_one("SHOW GLOBAL VARIABLES LIKE 'lock_wait_timeout'")
|
|
32
|
+
|
|
33
|
+
if global_innodb_lock_wait_timeout
|
|
34
|
+
desired_innodb_lock_wait_timeout = global_innodb_lock_wait_timeout['Value'].to_i + LOCK_WAIT_TIMEOUT_DELTA
|
|
35
|
+
if desired_innodb_lock_wait_timeout <= INNODB_LOCK_WAIT_TIMEOUT_MAX
|
|
36
|
+
@connection.execute("SET SESSION innodb_lock_wait_timeout=#{desired_innodb_lock_wait_timeout}")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
if global_lock_wait_timeout
|
|
41
|
+
desired_lock_wait_timeout = global_lock_wait_timeout['Value'].to_i + LOCK_WAIT_TIMEOUT_DELTA
|
|
42
|
+
if desired_lock_wait_timeout <= LOCK_WAIT_TIMEOUT_MAX
|
|
43
|
+
@connection.execute("SET SESSION lock_wait_timeout=#{desired_lock_wait_timeout}")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def run(options = {})
|
|
49
|
+
normalize_options(options)
|
|
50
|
+
set_session_lock_wait_timeouts
|
|
51
|
+
migration = @migrator.run
|
|
52
|
+
entangler = Entangler.new(migration, @connection)
|
|
53
|
+
|
|
54
|
+
entangler.run do
|
|
55
|
+
options[:verifier] ||= Proc.new { |conn| triggers_still_exist?(conn, entangler) }
|
|
56
|
+
options.fetch(:chunker_class, Chunker).new(migration, @connection, options).run
|
|
57
|
+
raise "Required triggers do not exist" unless triggers_still_exist?(@connection, entangler)
|
|
58
|
+
if options[:atomic_switch]
|
|
59
|
+
AtomicSwitcher.new(migration, @connection).run
|
|
60
|
+
else
|
|
61
|
+
LockedSwitcher.new(migration, @connection).run
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def triggers_still_exist?(conn, entangler)
|
|
67
|
+
triggers = conn.select_values("SHOW TRIGGERS LIKE '%#{migrator.origin.name}'").select { |name| name =~ /^lhmt/ }
|
|
68
|
+
triggers.sort == entangler.expected_triggers.sort
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def normalize_options(options)
|
|
74
|
+
Lhm.logger.info "Starting LHM run on table=#{@migrator.name}"
|
|
75
|
+
|
|
76
|
+
unless options.include?(:atomic_switch)
|
|
77
|
+
if supports_atomic_switch?
|
|
78
|
+
options[:atomic_switch] = true
|
|
79
|
+
else
|
|
80
|
+
raise Error.new(
|
|
81
|
+
"Using mysql #{version_string}. You must explicitly set " \
|
|
82
|
+
'options[:atomic_switch] (re SqlHelper#supports_atomic_switch?)')
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
if options[:throttler]
|
|
87
|
+
throttler_options = options[:throttler_options] || {}
|
|
88
|
+
options[:throttler] = Throttler::Factory.create_throttler(options[:throttler], throttler_options)
|
|
89
|
+
else
|
|
90
|
+
options[:throttler] = Lhm.throttler
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
Lhm.connection.retry_config = options[:retriable] || {}
|
|
94
|
+
|
|
95
|
+
rescue => e
|
|
96
|
+
Lhm.logger.error "LHM run failed with exception=#{e.class} message=#{e.message}"
|
|
97
|
+
raise
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Copyright (c) 2011 - 2013, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
|
|
2
|
+
# Schmidt
|
|
3
|
+
|
|
4
|
+
require 'lhm/command'
|
|
5
|
+
require 'lhm/migration'
|
|
6
|
+
require 'lhm/sql_helper'
|
|
7
|
+
|
|
8
|
+
module Lhm
|
|
9
|
+
# Switches origin with destination table nonatomically using a locked write.
|
|
10
|
+
# LockedSwitcher adopts the Facebook strategy, with the following caveat:
|
|
11
|
+
#
|
|
12
|
+
# "Since alter table causes an implicit commit in innodb, innodb locks get
|
|
13
|
+
# released after the first alter table. So any transaction that sneaks in
|
|
14
|
+
# after the first alter table and before the second alter table gets
|
|
15
|
+
# a 'table not found' error. The second alter table is expected to be very
|
|
16
|
+
# fast though because copytable is not visible to other transactions and so
|
|
17
|
+
# there is no need to wait."
|
|
18
|
+
#
|
|
19
|
+
class LockedSwitcher
|
|
20
|
+
include Command
|
|
21
|
+
include SqlHelper
|
|
22
|
+
|
|
23
|
+
attr_reader :connection
|
|
24
|
+
|
|
25
|
+
LOG_PREFIX = "LockedSwitcher"
|
|
26
|
+
|
|
27
|
+
def initialize(migration, connection = nil)
|
|
28
|
+
@migration = migration
|
|
29
|
+
@connection = connection
|
|
30
|
+
@origin = migration.origin
|
|
31
|
+
@destination = migration.destination
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def statements
|
|
35
|
+
uncommitted { switch }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def switch
|
|
39
|
+
[
|
|
40
|
+
"lock table `#{ @origin.name }` write, `#{ @destination.name }` write",
|
|
41
|
+
"alter table `#{ @origin.name }` rename `#{ @migration.archive_name }`",
|
|
42
|
+
"alter table `#{ @destination.name }` rename `#{ @origin.name }`",
|
|
43
|
+
'commit',
|
|
44
|
+
'unlock tables'
|
|
45
|
+
]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def uncommitted
|
|
49
|
+
[
|
|
50
|
+
'set @lhm_auto_commit = @@session.autocommit',
|
|
51
|
+
'set session autocommit = 0',
|
|
52
|
+
yield,
|
|
53
|
+
'set session autocommit = @lhm_auto_commit'
|
|
54
|
+
].flatten
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate
|
|
58
|
+
unless @connection.data_source_exists?(@origin.name) &&
|
|
59
|
+
@connection.data_source_exists?(@destination.name)
|
|
60
|
+
error "`#{ @origin.name }` and `#{ @destination.name }` must exist"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def revert
|
|
67
|
+
@connection.execute(tagged('unlock tables'))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def execute
|
|
71
|
+
statements.each do |stmt|
|
|
72
|
+
@connection.execute(tagged(stmt))
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Copyright (c) 2011 - 2013, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
|
|
2
|
+
# Schmidt
|
|
3
|
+
|
|
4
|
+
require 'lhm/intersection'
|
|
5
|
+
require 'lhm/timestamp'
|
|
6
|
+
|
|
7
|
+
module Lhm
|
|
8
|
+
class Migration
|
|
9
|
+
attr_reader :origin, :destination, :renames
|
|
10
|
+
|
|
11
|
+
def initialize(origin, destination, conditions = nil, renames = {}, time = Time.now)
|
|
12
|
+
@origin = origin
|
|
13
|
+
@destination = destination
|
|
14
|
+
@conditions = conditions
|
|
15
|
+
@renames = renames
|
|
16
|
+
@table_name = TableName.new(@origin.name, time)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def conditions
|
|
20
|
+
if @conditions.kind_of?(Proc)
|
|
21
|
+
@conditions.call
|
|
22
|
+
else
|
|
23
|
+
@conditions
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def archive_name
|
|
28
|
+
@archive_name ||= @table_name.archived
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def intersection
|
|
32
|
+
Intersection.new(@origin, @destination, @renames)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def origin_name
|
|
36
|
+
@table_name.original
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def origin_columns
|
|
40
|
+
@origin_columns ||= intersection.origin.typed(origin_name)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def destination_name
|
|
44
|
+
@destination_name ||= destination.name
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def destination_columns
|
|
48
|
+
@destination_columns ||= intersection.destination.joined
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/lhm/migrator.rb
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# Copyright (c) 2011 - 2013, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
|
|
2
|
+
# Schmidt
|
|
3
|
+
|
|
4
|
+
require 'lhm/command'
|
|
5
|
+
require 'lhm/migration'
|
|
6
|
+
require 'lhm/sql_helper'
|
|
7
|
+
require 'lhm/table'
|
|
8
|
+
|
|
9
|
+
module Lhm
|
|
10
|
+
# Copies existing schema and applies changes using alter on the empty table.
|
|
11
|
+
# `run` returns a Migration which can be used for the remaining process.
|
|
12
|
+
class Migrator
|
|
13
|
+
include Command
|
|
14
|
+
include SqlHelper
|
|
15
|
+
|
|
16
|
+
attr_reader :name, :statements, :connection, :conditions, :renames, :origin
|
|
17
|
+
|
|
18
|
+
def initialize(table, connection = nil)
|
|
19
|
+
@connection = connection
|
|
20
|
+
@origin = table
|
|
21
|
+
@name = table.destination_name
|
|
22
|
+
@statements = []
|
|
23
|
+
@renames = {}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Alter a table with a custom statement
|
|
27
|
+
#
|
|
28
|
+
# @example
|
|
29
|
+
#
|
|
30
|
+
# Lhm.change_table(:users) do |m|
|
|
31
|
+
# m.ddl("ALTER TABLE #{m.name} ADD COLUMN age INT(11)")
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
# @param [String] statement SQL alter statement
|
|
35
|
+
# @note
|
|
36
|
+
#
|
|
37
|
+
# Don't write the table name directly into the statement. Use the #name
|
|
38
|
+
# getter instead, because the alter statement will be executed against a
|
|
39
|
+
# temporary table.
|
|
40
|
+
#
|
|
41
|
+
def ddl(statement)
|
|
42
|
+
statements << statement
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Add a column to a table
|
|
46
|
+
#
|
|
47
|
+
# @example
|
|
48
|
+
#
|
|
49
|
+
# Lhm.change_table(:users) do |m|
|
|
50
|
+
# m.add_column(:comment, "VARCHAR(12) DEFAULT '0'")
|
|
51
|
+
# end
|
|
52
|
+
#
|
|
53
|
+
# @param [String] name Name of the column to add
|
|
54
|
+
# @param [String] definition Valid SQL column definition
|
|
55
|
+
def add_column(name, definition)
|
|
56
|
+
ddl('alter table `%s` add column `%s` %s' % [@name, name, definition])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Change an existing column to a new definition
|
|
60
|
+
#
|
|
61
|
+
# @example
|
|
62
|
+
#
|
|
63
|
+
# Lhm.change_table(:users) do |m|
|
|
64
|
+
# m.change_column(:comment, "VARCHAR(12) DEFAULT '0' NOT NULL")
|
|
65
|
+
# end
|
|
66
|
+
#
|
|
67
|
+
# @param [String] name Name of the column to change
|
|
68
|
+
# @param [String] definition Valid SQL column definition
|
|
69
|
+
def change_column(name, definition)
|
|
70
|
+
ddl('alter table `%s` modify column `%s` %s' % [@name, name, definition])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Rename an existing column.
|
|
74
|
+
#
|
|
75
|
+
# @example
|
|
76
|
+
#
|
|
77
|
+
# Lhm.change_table(:users) do |m|
|
|
78
|
+
# m.rename_column(:login, :username)
|
|
79
|
+
# end
|
|
80
|
+
#
|
|
81
|
+
# @param [String] old Name of the column to change
|
|
82
|
+
# @param [String] nu New name to use for the column
|
|
83
|
+
def rename_column(old, nu)
|
|
84
|
+
col = @origin.columns[old.to_s]
|
|
85
|
+
|
|
86
|
+
definition = col[:type]
|
|
87
|
+
|
|
88
|
+
definition += ' NOT NULL' unless col[:is_nullable] == "YES"
|
|
89
|
+
definition += " DEFAULT #{@connection.quote(col[:column_default])}" if col[:column_default]
|
|
90
|
+
definition += " COMMENT #{@connection.quote(col[:comment])}" if col[:comment]
|
|
91
|
+
definition += " COLLATE #{@connection.quote(col[:collate])}" if col[:collate]
|
|
92
|
+
|
|
93
|
+
ddl('alter table `%s` change column `%s` `%s` %s' % [@name, old, nu, definition])
|
|
94
|
+
@renames[old.to_s] = nu.to_s
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Remove a column from a table
|
|
98
|
+
#
|
|
99
|
+
# @example
|
|
100
|
+
#
|
|
101
|
+
# Lhm.change_table(:users) do |m|
|
|
102
|
+
# m.remove_column(:comment)
|
|
103
|
+
# end
|
|
104
|
+
#
|
|
105
|
+
# @param [String] name Name of the column to delete
|
|
106
|
+
def remove_column(name)
|
|
107
|
+
ddl('alter table `%s` drop `%s`' % [@name, name])
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Add an index to a table
|
|
111
|
+
#
|
|
112
|
+
# @example
|
|
113
|
+
#
|
|
114
|
+
# Lhm.change_table(:users) do |m|
|
|
115
|
+
# m.add_index(:comment)
|
|
116
|
+
# m.add_index([:username, :created_at])
|
|
117
|
+
# m.add_index("comment(10)")
|
|
118
|
+
# end
|
|
119
|
+
#
|
|
120
|
+
# @param [String, Symbol, Array<String, Symbol>] columns
|
|
121
|
+
# A column name given as String or Symbol. An Array of Strings or Symbols
|
|
122
|
+
# for compound indexes. It's possible to pass a length limit.
|
|
123
|
+
# @param [String, Symbol] index_name
|
|
124
|
+
# Optional name of the index to be created
|
|
125
|
+
def add_index(columns, index_name = nil)
|
|
126
|
+
ddl(index_ddl(columns, false, index_name))
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Add a unique index to a table
|
|
130
|
+
#
|
|
131
|
+
# @example
|
|
132
|
+
#
|
|
133
|
+
# Lhm.change_table(:users) do |m|
|
|
134
|
+
# m.add_unique_index(:comment)
|
|
135
|
+
# m.add_unique_index([:username, :created_at])
|
|
136
|
+
# m.add_unique_index("comment(10)")
|
|
137
|
+
# end
|
|
138
|
+
#
|
|
139
|
+
# @param [String, Symbol, Array<String, Symbol>] columns
|
|
140
|
+
# A column name given as String or Symbol. An Array of Strings or Symbols
|
|
141
|
+
# for compound indexes. It's possible to pass a length limit.
|
|
142
|
+
# @param [String, Symbol] index_name
|
|
143
|
+
# Optional name of the index to be created
|
|
144
|
+
def add_unique_index(columns, index_name = nil)
|
|
145
|
+
ddl(index_ddl(columns, true, index_name))
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Remove an index from a table
|
|
149
|
+
#
|
|
150
|
+
# @example
|
|
151
|
+
#
|
|
152
|
+
# Lhm.change_table(:users) do |m|
|
|
153
|
+
# m.remove_index(:comment)
|
|
154
|
+
# m.remove_index([:username, :created_at])
|
|
155
|
+
# end
|
|
156
|
+
#
|
|
157
|
+
# @param [String, Symbol, Array<String, Symbol>] columns
|
|
158
|
+
# A column name given as String or Symbol. An Array of Strings or Symbols
|
|
159
|
+
# for compound indexes.
|
|
160
|
+
# @param [String, Symbol] index_name
|
|
161
|
+
# Optional name of the index to be removed
|
|
162
|
+
def remove_index(columns, index_name = nil)
|
|
163
|
+
columns = [columns].flatten.map(&:to_sym)
|
|
164
|
+
from_origin = @origin.indices.find { |_, cols| cols.map(&:to_sym) == columns }
|
|
165
|
+
index_name ||= from_origin[0] unless from_origin.nil?
|
|
166
|
+
index_name ||= idx_name(@origin.name, columns)
|
|
167
|
+
ddl('drop index `%s` on `%s`' % [index_name, @name])
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Filter the data that is copied into the new table by the provided SQL.
|
|
171
|
+
# This SQL will be inserted into the copy directly after the "from"
|
|
172
|
+
# statement - so be sure to use inner/outer join syntax and not cross joins.
|
|
173
|
+
#
|
|
174
|
+
# @example Add a conditions filter to the migration.
|
|
175
|
+
# Lhm.change_table(:sounds) do |m|
|
|
176
|
+
# m.filter("inner join users on users.`id` = sounds.`user_id` and sounds.`public` = 1")
|
|
177
|
+
# end
|
|
178
|
+
#
|
|
179
|
+
# @example Add a dynamic conditions filter to the migration.
|
|
180
|
+
# Lhm.change_table(:sounds) do |m|
|
|
181
|
+
# m.filter(-> { "where sounds.created_at <= '#{Time.now.to_fs(:db)}'" })
|
|
182
|
+
# end
|
|
183
|
+
#
|
|
184
|
+
# @param [ String, Proc ] sql The sql filter or a proc that returns a sql filter.
|
|
185
|
+
#
|
|
186
|
+
# @return [ String, Proc ] The sql filter.
|
|
187
|
+
def filter(sql)
|
|
188
|
+
@conditions = sql
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
private
|
|
192
|
+
|
|
193
|
+
def validate
|
|
194
|
+
unless @connection.data_source_exists?(@origin.name)
|
|
195
|
+
error("could not find origin table #{ @origin.name }")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
unless @origin.satisfies_id_column_requirement?
|
|
199
|
+
error('origin does not satisfy `id` key requirements')
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
dest = @origin.destination_name
|
|
203
|
+
|
|
204
|
+
if @connection.data_source_exists?(dest)
|
|
205
|
+
error("#{ dest } should not exist; not cleaned up from previous run?")
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def execute
|
|
210
|
+
destination_create
|
|
211
|
+
@statements.each do |stmt|
|
|
212
|
+
@connection.execute(tagged(stmt))
|
|
213
|
+
end
|
|
214
|
+
Migration.new(@origin, destination_read, conditions, renames)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def destination_create
|
|
218
|
+
original = %{CREATE TABLE `#{ @origin.name }`}
|
|
219
|
+
replacement = %{CREATE TABLE `#{ @origin.destination_name }`}
|
|
220
|
+
stmt = @origin.ddl.gsub(original, replacement)
|
|
221
|
+
@connection.execute(tagged(stmt))
|
|
222
|
+
|
|
223
|
+
Lhm.logger.info("Created destination table #{@origin.destination_name}")
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def destination_read
|
|
227
|
+
Table.parse(@origin.destination_name, connection)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def index_ddl(cols, unique = nil, index_name = nil)
|
|
231
|
+
assert_valid_idx_name(index_name)
|
|
232
|
+
type = unique ? 'unique index' : 'index'
|
|
233
|
+
index_name ||= idx_name(@origin.name, cols)
|
|
234
|
+
parts = [type, index_name, @name, idx_spec(cols)]
|
|
235
|
+
'create %s `%s` on `%s` (%s)' % parts
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def assert_valid_idx_name(index_name)
|
|
239
|
+
if index_name && !(index_name.is_a?(String) || index_name.is_a?(Symbol))
|
|
240
|
+
raise ArgumentError, 'index_name must be a string or symbol'
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
data/lib/lhm/printer.rb
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module Lhm
|
|
2
|
+
module Printer
|
|
3
|
+
class Output
|
|
4
|
+
def write(message)
|
|
5
|
+
print message
|
|
6
|
+
end
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
class Base
|
|
10
|
+
def initialize
|
|
11
|
+
@output = Output.new
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class Percentage
|
|
16
|
+
def initialize
|
|
17
|
+
@max_length = 0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def notify(lowest, highest)
|
|
21
|
+
return if !highest || highest == 0
|
|
22
|
+
|
|
23
|
+
# The argument lowest represents the next_to_insert row id, and highest represents the
|
|
24
|
+
# maximum id upto which chunker has to copy the data.
|
|
25
|
+
# If all the rows are inserted upto highest, then lowest passed here from chunker was
|
|
26
|
+
# highest + 1, which leads to the printer printing the progress > 100%.
|
|
27
|
+
return if lowest >= highest
|
|
28
|
+
|
|
29
|
+
message = "%.2f%% (#{lowest}/#{highest}) complete" % (lowest.to_f / highest * 100.0)
|
|
30
|
+
write(message)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def end
|
|
34
|
+
write('100% complete')
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def exception(e)
|
|
38
|
+
Lhm.logger.error("failed: #{e}")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def write(message)
|
|
44
|
+
if (extra = @max_length - message.length) < 0
|
|
45
|
+
@max_length = message.length
|
|
46
|
+
extra = 0
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
Lhm.logger.info(message)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class Dot < Base
|
|
54
|
+
def notify(*)
|
|
55
|
+
@output.write '.'
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def end
|
|
59
|
+
@output.write "\n"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|