lhm-teak 3.6.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (112) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/test.yml +43 -0
  3. data/.gitignore +12 -0
  4. data/.rubocop.yml +183 -0
  5. data/.travis.yml +21 -0
  6. data/Appraisals +24 -0
  7. data/CHANGELOG.md +254 -0
  8. data/Gemfile +5 -0
  9. data/Gemfile.lock +67 -0
  10. data/LICENSE +27 -0
  11. data/README.md +335 -0
  12. data/Rakefile +33 -0
  13. data/dev.yml +45 -0
  14. data/docker-compose.yml +60 -0
  15. data/gemfiles/activerecord_5.2.gemfile +9 -0
  16. data/gemfiles/activerecord_5.2.gemfile.lock +66 -0
  17. data/gemfiles/activerecord_6.0.gemfile +7 -0
  18. data/gemfiles/activerecord_6.0.gemfile.lock +68 -0
  19. data/gemfiles/activerecord_6.1.gemfile +7 -0
  20. data/gemfiles/activerecord_6.1.gemfile.lock +67 -0
  21. data/gemfiles/activerecord_7.0.0.alpha2.gemfile +7 -0
  22. data/gemfiles/activerecord_7.0.0.alpha2.gemfile.lock +65 -0
  23. data/lhm.gemspec +38 -0
  24. data/lib/lhm/atomic_switcher.rb +46 -0
  25. data/lib/lhm/chunk_finder.rb +62 -0
  26. data/lib/lhm/chunk_insert.rb +61 -0
  27. data/lib/lhm/chunker.rb +95 -0
  28. data/lib/lhm/cleanup/current.rb +71 -0
  29. data/lib/lhm/command.rb +48 -0
  30. data/lib/lhm/connection.rb +108 -0
  31. data/lib/lhm/entangler.rb +112 -0
  32. data/lib/lhm/intersection.rb +51 -0
  33. data/lib/lhm/invoker.rb +100 -0
  34. data/lib/lhm/locked_switcher.rb +76 -0
  35. data/lib/lhm/migration.rb +51 -0
  36. data/lib/lhm/migrator.rb +244 -0
  37. data/lib/lhm/printer.rb +63 -0
  38. data/lib/lhm/proxysql_helper.rb +10 -0
  39. data/lib/lhm/railtie.rb +9 -0
  40. data/lib/lhm/sql_helper.rb +77 -0
  41. data/lib/lhm/sql_retry.rb +180 -0
  42. data/lib/lhm/table.rb +121 -0
  43. data/lib/lhm/table_name.rb +23 -0
  44. data/lib/lhm/test_support.rb +35 -0
  45. data/lib/lhm/throttler/slave_lag.rb +162 -0
  46. data/lib/lhm/throttler/threads_running.rb +53 -0
  47. data/lib/lhm/throttler/time.rb +29 -0
  48. data/lib/lhm/throttler.rb +36 -0
  49. data/lib/lhm/timestamp.rb +11 -0
  50. data/lib/lhm/version.rb +6 -0
  51. data/lib/lhm-shopify.rb +1 -0
  52. data/lib/lhm.rb +156 -0
  53. data/scripts/helpers/wait-for-dbs.sh +21 -0
  54. data/scripts/mysql/reader/create_replication.sql +10 -0
  55. data/scripts/mysql/writer/create_test_db.sql +1 -0
  56. data/scripts/mysql/writer/create_users.sql +6 -0
  57. data/scripts/proxysql/proxysql.cnf +117 -0
  58. data/shipit.rubygems.yml +0 -0
  59. data/spec/.lhm.example +4 -0
  60. data/spec/README.md +58 -0
  61. data/spec/fixtures/bigint_table.ddl +4 -0
  62. data/spec/fixtures/composite_primary_key.ddl +6 -0
  63. data/spec/fixtures/composite_primary_key_dest.ddl +6 -0
  64. data/spec/fixtures/custom_primary_key.ddl +6 -0
  65. data/spec/fixtures/custom_primary_key_dest.ddl +6 -0
  66. data/spec/fixtures/destination.ddl +6 -0
  67. data/spec/fixtures/lines.ddl +7 -0
  68. data/spec/fixtures/origin.ddl +6 -0
  69. data/spec/fixtures/permissions.ddl +5 -0
  70. data/spec/fixtures/small_table.ddl +4 -0
  71. data/spec/fixtures/tracks.ddl +5 -0
  72. data/spec/fixtures/users.ddl +14 -0
  73. data/spec/fixtures/wo_id_int_column.ddl +6 -0
  74. data/spec/integration/atomic_switcher_spec.rb +129 -0
  75. data/spec/integration/chunk_insert_spec.rb +30 -0
  76. data/spec/integration/chunker_spec.rb +269 -0
  77. data/spec/integration/cleanup_spec.rb +147 -0
  78. data/spec/integration/database.yml +25 -0
  79. data/spec/integration/entangler_spec.rb +68 -0
  80. data/spec/integration/integration_helper.rb +252 -0
  81. data/spec/integration/invoker_spec.rb +33 -0
  82. data/spec/integration/lhm_spec.rb +659 -0
  83. data/spec/integration/lock_wait_timeout_spec.rb +30 -0
  84. data/spec/integration/locked_switcher_spec.rb +50 -0
  85. data/spec/integration/proxysql_spec.rb +34 -0
  86. data/spec/integration/sql_retry/db_connection_helper.rb +52 -0
  87. data/spec/integration/sql_retry/lock_wait_spec.rb +127 -0
  88. data/spec/integration/sql_retry/lock_wait_timeout_test_helper.rb +114 -0
  89. data/spec/integration/sql_retry/proxysql_helper.rb +22 -0
  90. data/spec/integration/sql_retry/retry_with_proxysql_spec.rb +109 -0
  91. data/spec/integration/table_spec.rb +83 -0
  92. data/spec/integration/toxiproxy_helper.rb +40 -0
  93. data/spec/test_helper.rb +69 -0
  94. data/spec/unit/atomic_switcher_spec.rb +29 -0
  95. data/spec/unit/chunk_finder_spec.rb +73 -0
  96. data/spec/unit/chunk_insert_spec.rb +67 -0
  97. data/spec/unit/chunker_spec.rb +176 -0
  98. data/spec/unit/connection_spec.rb +111 -0
  99. data/spec/unit/entangler_spec.rb +187 -0
  100. data/spec/unit/intersection_spec.rb +51 -0
  101. data/spec/unit/lhm_spec.rb +46 -0
  102. data/spec/unit/locked_switcher_spec.rb +46 -0
  103. data/spec/unit/migrator_spec.rb +144 -0
  104. data/spec/unit/printer_spec.rb +85 -0
  105. data/spec/unit/sql_helper_spec.rb +28 -0
  106. data/spec/unit/table_name_spec.rb +39 -0
  107. data/spec/unit/table_spec.rb +47 -0
  108. data/spec/unit/throttler/slave_lag_spec.rb +322 -0
  109. data/spec/unit/throttler/threads_running_spec.rb +64 -0
  110. data/spec/unit/throttler_spec.rb +124 -0
  111. data/spec/unit/unit_helper.rb +26 -0
  112. 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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,10 @@
1
+ module Lhm
2
+ module ProxySQLHelper
3
+ extend self
4
+ ANNOTATION = "/*maintenance:lhm*/"
5
+
6
+ def tagged(sql)
7
+ "#{sql} #{ANNOTATION}"
8
+ end
9
+ end
10
+ end