safe-pg-migrations 0.0.0 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: abc17a43f436f429bc2f8983a45b1a61af67a4f8
4
- data.tar.gz: f1c0fdc3261c34fe99218e935e502c639d36f4f1
2
+ SHA256:
3
+ metadata.gz: d806334c473708774d2180dbe91d2483b49f0d27b0a223a2973bc95d986d4030
4
+ data.tar.gz: 9b5c2d329d5cc1bd12c80944420e20eb5454e5b0a0f7fda2d6ec3fc3c3a5e042
5
5
  SHA512:
6
- metadata.gz: 3bc4cb7434a1b9059efbc0c69a7a3f73a329b478023db08b0f53d5345ad4346798be592b581e0db0413a574c6c72b2369dcad98402ae4f89a88411525967211d
7
- data.tar.gz: 670b3ef74768301708b85e904defe0566dedfb04762ea85fb2cccf0dccb666a614719430e36e6204e399a7861c59494843750fb6321add67144a49c3fc941846
6
+ metadata.gz: 6786e68cce9dbd91a4633c80526a1fd63f6fbfa335d6e2b53d29b71ba0b566f9bb32169745bb13f89a059b82b0c0b21585a9bffdb9ebc751aa0e4a467f0f7934
7
+ data.tar.gz: 299a69f1d9413279fd61908a7d8f6eb65bd4c9912abcc1101ca8acbfee39c40d33086efa28a9f2133c0679c1ad9cdbbfe3ce84338f2175ad882b7d1ace707654
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2018 Doctolib
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,256 @@
1
+ # safe-pg-migrations
2
+
3
+ ActiveRecord migrations for Postgres made safe.
4
+
5
+ ## Requirements
6
+
7
+ - Ruby 2.5+
8
+ - Rails 5.2+
9
+ - PostgreSQL 9.3+
10
+
11
+ ## Usage
12
+
13
+ Just drop this line in your Gemfile:
14
+
15
+ ```rb
16
+ gem 'safe-pg-migrations'
17
+ ```
18
+
19
+ ## Example
20
+
21
+ Consider the following migration:
22
+
23
+ ```rb
24
+ class AddAdminToUsers < ActiveRecord::Migration[5.2]
25
+ def change
26
+ add_column :users, :admin, :boolean, default: false, null: false
27
+ end
28
+ end
29
+ ```
30
+
31
+ If the `users` table is large, running this migration on a live Postgres 9 database will likely cause downtime. **Safe PG Migrations** hooks into Active Record so that the following gets executed instead:
32
+
33
+ ```rb
34
+ class AddAdminToUsers < ActiveRecord::Migration[5.2]
35
+ # Do not wrap the migration in a transaction so that locks are held for a shorter time.
36
+ disable_ddl_transaction!
37
+
38
+ def change
39
+ # Lower Postgres' lock timeout to avoid statement queueing. Acts like a seatbelt.
40
+ execute "SET lock_timeout TO '5s'" # The lock_timeout duration is customizable.
41
+
42
+ # Add the column without the default value and the not-null constraint.
43
+ add_column :users, :admin, :boolean
44
+
45
+ # Set the column's default value.
46
+ change_column_default :users, :admin, false
47
+
48
+ # Backfill the column in batches.
49
+ User.in_batches.update_all(admin: false)
50
+
51
+ # Add the not-null constraint. Beforehand, set a short statement timeout so that
52
+ # Postgres does not spend too much time performing the full table scan to verify
53
+ # the column contains no nulls.
54
+ execute "SET statement_timeout TO '5s'"
55
+ change_column_null :users, :admin, false
56
+ end
57
+ end
58
+ ```
59
+
60
+ Under the hood, **Safe PG Migrations** patches `ActiveRecord::Migration` and extends `ActiveRecord::Base.connection` to make potentially dangerous methods—like `add_column`—safe.
61
+
62
+ ## Motivation
63
+
64
+ Writing a safe migration can be daunting. Numerous articles have been written on the topic and a few gems are trying to address the problem. Even for someone who has a pretty good command of Postgres, remembering all the subtleties of explicit locking is not a piece of cake.
65
+
66
+ Active Record means developers don't have to be proficient in SQL to interact with a database. In the same way, **Safe PG Migrations** was created so that developers don't have to understand the ins and outs of Postgres to write a safe migration.
67
+
68
+ ## Feature set
69
+
70
+ <details><summary>Lock timeout</summary>
71
+
72
+ Most DDL operations (e.g. adding a column, removing a column or adding a default value to a column) take an `ACCESS EXCLUSIVE` lock on the table they are altering. While these operations wait to acquire their lock, other statements are blocked. Before running a migration, **Safe PG Migrations** sets a short lock timeout (default to 5 seconds) so that statements are not blocked for too long.
73
+
74
+ See [PostgreSQL Alter Table and Long Transactions](http://www.joshuakehn.com/2017/9/9/postgresql-alter-table-and-long-transactions.html) and [Migrations and Long Transactions](https://www.fin.com/post/2018/1/migrations-and-long-transactions) for detailed explanations of the matter.
75
+ </details>
76
+
77
+ <details><summary>Statement timeout</summary>
78
+
79
+ Adding a foreign key or a not-null constraint can take a lot of time on a large table. The problem is that those operations take `ACCESS EXCLUSIVE` locks. We clearly don't want them to hold these locks for too long. Thus, **Safe PG Migrations** runs them with a short statement timeout (default to 5 seconds).
80
+
81
+ See [Zero-downtime Postgres migrations - the hard parts](https://gocardless.com/blog/zero-downtime-postgres-migrations-the-hard-parts/) for a detailed explanation on the subject.
82
+ </details>
83
+
84
+ <details><summary>Prevent wrapping migrations in transaction</summary>
85
+
86
+ When **Safe PG Migrations** is used, migrations are not wrapped in a transaction. This is for several reasons:
87
+
88
+ - We want to release locks as soon as possible.
89
+ - In order to be able to retry statements that have failed because of a lock timeout, we have to be outside a transaction.
90
+ - In order to add an index concurrently, we have to be outside a transaction.
91
+
92
+ Note that if a migration fails, it won't be rollbacked. This can result in migrations being partially applied. In that case, they need to be manually reverted.
93
+
94
+ </details>
95
+
96
+ <details>
97
+ <summary>Safe <code>add_column</code></summary>
98
+
99
+ **Safe PG Migrations** gracefully handle the upgrade to PG11 by **not** backfilling default value for existing rows, as the [database engine is now natively handling it](https://www.postgresql.org/docs/11/ddl-alter.html#DDL-ALTER-ADDING-A-COLUMN).
100
+
101
+ Beware though, when adding a volatile default value:
102
+ ```ruby
103
+ add_column :users, :created_at, default: 'clock_timestamp()'
104
+ ```
105
+ PG will still needs to update every row of the table, and will most likely statement timeout for big table. In this case, your best bet is to add the column without default, set the default, and backfill existing rows.
106
+
107
+ <blockquote>
108
+
109
+ **Note: Pre-postgre 11**
110
+ Adding a column with a default value and a not-null constraint is [dangerous](https://wework.github.io/data/2015/11/05/add-columns-with-default-values-to-large-tables-in-rails-postgres/).
111
+
112
+ **Safe PG Migrations** makes it safe by:
113
+
114
+ 1. Adding the column without the default value and the not null constraint,
115
+ 2. Then set the default value on the column,
116
+ 3. Then backfilling the column,
117
+ 4. And then adding the not null constraint with a short statement timeout.
118
+
119
+ Note: the addition of the not null constraint may timeout. In that case, you may want to add the not-null constraint as initially not valid and validate it in a separate statement. See [Adding a not-null constraint on Postgres with minimal locking](https://medium.com/doctolib-engineering/adding-a-not-null-constraint-on-pg-faster-with-minimal-locking-38b2c00c4d1c).
120
+
121
+ </blockquote>
122
+
123
+ </details>
124
+
125
+ <details><summary>Safe <code>add_index</code> and <code>remove_index</code></summary>
126
+
127
+ Creating an index requires a `SHARE` lock on the target table which blocks all write on the table while the index is created (which can take some time on a large table). This is usually not practical in a live environment. Thus, **Safe PG Migrations** ensures indexes are created concurrently.
128
+
129
+ As `CREATE INDEX CONCURRENTLY` and `DROP INDEX CONCURRENTLY` are non-blocking operations (ie: read/write operations on the table are still possible), **Safe PG Migrations** sets a lock timeout to 30 seconds for those 2 specific statements.
130
+
131
+ If you still get lock timeout while adding / removing indexes, it might be for one of those reasons:
132
+
133
+ - Long-running queries are active on the table. To create / remove an index, PG needs to wait for the queries that are actually running to finish before starting the index creation / removal. The blocking activity logger might help you to pinpoint the culprit queries.
134
+ - A vacuum / autovacuum is running on the table, holding a ShareUpdateExclusiveLock, you are most likely out of luck for the current migration, but you may try to [optimize your autovacuums settings](https://www.percona.com/blog/2018/08/10/tuning-autovacuum-in-postgresql-and-autovacuum-internals/).
135
+
136
+ </details>
137
+
138
+ <details><summary>safe <code>add_foreign_key</code> (and <code>add_reference</code>)</summary>
139
+
140
+ Adding a foreign key requires a `SHARE ROW EXCLUSIVE` lock, which **prevent writing in the tables** while the migration is running.
141
+
142
+ Adding the constraint itself is rather fast, the major part of the time is spent on validating this constraint. Thus safe-pg-migrations ensures that adding a foreign key holds blocking locks for the least amount of time by splitting the foreign key creation in two steps:
143
+
144
+ 1. adding the constraint *without validation*, will not validate existing rows;
145
+ 2. validating the constraint, will validate existing rows in the table, without blocking read or write on the table
146
+
147
+ </details>
148
+
149
+ <details><summary>Retry after lock timeout</summary>
150
+
151
+ When a statement fails with a lock timeout, **Safe PG Migrations** retries it (5 times max) [list of retryable statments](https://github.com/doctolib/safe-pg-migrations/blob/66933256252b6bbf12e404b829a361dbba30e684/lib/safe-pg-migrations/plugins/statement_retrier.rb#L5)
152
+ </details>
153
+
154
+ <details><summary>Blocking activity logging</summary>
155
+
156
+ If a statement fails with a lock timeout, **Safe PG Migrations** will try to tell you what was the blocking statement.
157
+ </details>
158
+
159
+ <details><summary>Verbose SQL logging</summary>
160
+
161
+ For any operation, **Safe PG Migrations** can output the performed SQL queries. This feature is enabled by default in a production Rails environment. If you want to explicit enable it, for example in your development environment you can use:
162
+ ```bash
163
+ export SAFE_PG_MIGRATIONS_VERBOSE=1
164
+ ```
165
+
166
+ Instead of the traditional output:
167
+ ```ruby
168
+ add_index :users, :age
169
+
170
+ == 20191215132355 SampleIndex: migrating ======================================
171
+ -- add_index(:users, :age)
172
+ -> add_index("users", :age, {:algorithm=>:concurrently})
173
+ -> 0.0175s
174
+ == 20191215132355 SampleIndex: migrated (0.0200s) =============================
175
+ ```
176
+ **Safe PG Migrations** will output the following logs:
177
+ ```ruby
178
+ add_index :users, :age
179
+
180
+ == 20191215132355 SampleIndex: migrating ======================================
181
+ (0.3ms) SHOW lock_timeout
182
+ (0.3ms) SET lock_timeout TO '5s'
183
+ -- add_index(:users, :age)
184
+ -> add_index("users", :age, {:algorithm=>:concurrently})
185
+ (0.3ms) SHOW statement_timeout
186
+ (0.3ms) SET statement_timeout TO 0
187
+ (0.3ms) SHOW lock_timeout
188
+ (0.3ms) SET lock_timeout TO '30s'
189
+ (3.5ms) CREATE INDEX CONCURRENTLY "index_users_on_age" ON "users" ("age")
190
+ (0.3ms) SET lock_timeout TO '5s'
191
+ (0.2ms) SET statement_timeout TO '1min'
192
+ -> 0.0093s
193
+ (0.2ms) SET lock_timeout TO '0'
194
+ == 20191215132355 SampleIndex: migrated (0.0114s) =============================
195
+ ```
196
+ So you can actually check that the `CREATE INDEX` statement will be performed concurrently, without any statement timeout and with a lock timeout of 30 seconds.
197
+
198
+ *Nb: The `SHOW` statements are used by **Safe PG Migrations** to query settings for their original values in order to restore them after the work is done*
199
+
200
+ </details>
201
+
202
+ ## Configuration
203
+
204
+ **Safe PG Migrations** can be customized, here is an example of a Rails initializer (the values are the default ones):
205
+
206
+ ```ruby
207
+ SafePgMigrations.config.safe_timeout = 5.seconds # Lock and statement timeout used for all DDL operations except from CREATE / DROP INDEX
208
+
209
+ SafePgMigrations.config.index_lock_timeout = 30.seconds # Lock timeout used for CREATE / DROP INDEX
210
+
211
+ SafePgMigrations.config.blocking_activity_logger_margin = 1.second # Delay to output blocking queries before timeout. Must be smaller than safe_timeout and index_lock_timeout
212
+
213
+ SafePgMigrations.config.batch_size = 1000 # Size of the batches used for backfilling when adding a column with a default value pre-PG11
214
+
215
+ SafePgMigrations.config.retry_delay = 1.minute # Delay between retries for retryable statements
216
+
217
+ SafePgMigrations.config.max_tries = 5 # Number of retries before abortion of the migration
218
+ ```
219
+
220
+ ## Runnings tests
221
+
222
+ ```bash
223
+ bundle
224
+ psql -h localhost -U postgres -c 'CREATE DATABASE safe_pg_migrations_test'
225
+ rake test
226
+ ```
227
+
228
+ ## Authors
229
+
230
+ - [Matthieu Prat](https://github.com/matthieuprat)
231
+ - [Romain Choquet](https://github.com/rchoquet)
232
+ - [Paul-Etienne Coisne](https://github.com/coisnepe)
233
+
234
+ ## License
235
+
236
+ [MIT](https://github.com/doctolib/safe-pg-migrations/blob/master/LICENSE) © [Doctolib](https://github.com/doctolib/)
237
+
238
+ ## Additional resources
239
+
240
+ Alternatives:
241
+
242
+ - https://github.com/gocardless/activerecord-safer_migrations
243
+ - https://github.com/ankane/strong_migrations
244
+ - https://github.com/LendingHome/zero_downtime_migrations
245
+
246
+ Interesting reads:
247
+
248
+ - [When Postgres blocks: 7 tips for dealing with locks](https://www.citusdata.com/blog/2018/02/22/seven-tips-for-dealing-with-postgres-locks/)
249
+ - [Migrations and Long Transactions in Postgres
250
+ ](https://www.fin.com/post/2018/1/migrations-and-long-transactions)
251
+ - [PostgreSQL Alter Table and Long Transactions](http://www.joshuakehn.com/2017/9/9/postgresql-alter-table-and-long-transactions.html)
252
+ - [Adding a NOT NULL CONSTRAINT on PG Faster with Minimal Locking](https://medium.com/doctolib-engineering/adding-a-not-null-constraint-on-pg-faster-with-minimal-locking-38b2c00c4d1c)
253
+ - [Adding columns with default values to really large tables in Postgres + Rails](https://wework.github.io/data/2015/11/05/add-columns-with-default-values-to-large-tables-in-rails-postgres/)
254
+ - [Rails migrations with no downtime](https://pedro.herokuapp.com/past/2011/7/13/rails_migrations_with_no_downtime/)
255
+ - [Safe Operations For High Volume PostgreSQL](https://www.braintreepayments.com/blog/safe-operations-for-high-volume-postgresql/)
256
+ - [Rails Migrations with Zero Downtime](https://blog.codeship.com/rails-migrations-zero-downtime/)
@@ -1,5 +1,3 @@
1
- class SafePgMigrations
2
- def self.hello
3
- puts 'Hello world!'
4
- end
5
- end
1
+ # frozen_string_literal: true
2
+
3
+ require 'safe-pg-migrations/railtie' if defined?(Rails)
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'safe-pg-migrations/configuration'
4
+ require 'safe-pg-migrations/plugins/verbose_sql_logger'
5
+ require 'safe-pg-migrations/plugins/blocking_activity_logger'
6
+ require 'safe-pg-migrations/plugins/statement_insurer'
7
+ require 'safe-pg-migrations/plugins/statement_retrier'
8
+ require 'safe-pg-migrations/plugins/idem_potent_statements'
9
+
10
+ module SafePgMigrations
11
+ # Order matters: the bottom-most plugin will have precedence
12
+ PLUGINS = [
13
+ BlockingActivityLogger,
14
+ IdemPotentStatements,
15
+ StatementRetrier,
16
+ StatementInsurer,
17
+ ].freeze
18
+
19
+ class << self
20
+ attr_reader :current_migration, :pg_version_num
21
+
22
+ def setup_and_teardown(migration, connection)
23
+ @pg_version_num = get_pg_version_num(connection)
24
+ @alternate_connection = nil
25
+ @current_migration = migration
26
+ stdout_sql_logger = VerboseSqlLogger.new.setup if verbose?
27
+ PLUGINS.each { |plugin| connection.extend(plugin) }
28
+
29
+ connection.with_setting(:lock_timeout, SafePgMigrations.config.pg_safe_timeout) { yield }
30
+ ensure
31
+ close_alternate_connection
32
+ @current_migration = nil
33
+ stdout_sql_logger&.teardown
34
+ end
35
+
36
+ def get_pg_version_num(connection)
37
+ connection.query_value('SHOW server_version_num').to_i
38
+ end
39
+
40
+ def alternate_connection
41
+ @alternate_connection ||= ActiveRecord::Base.connection_pool.send(:new_connection)
42
+ end
43
+
44
+ def close_alternate_connection
45
+ return unless @alternate_connection
46
+
47
+ @alternate_connection.disconnect!
48
+ @alternate_connection = nil
49
+ end
50
+
51
+ def say(*args)
52
+ return unless current_migration
53
+
54
+ current_migration.say(*args)
55
+ end
56
+
57
+ def say_method_call(method, *args)
58
+ say "#{method}(#{args.map(&:inspect) * ', '})", true
59
+ end
60
+
61
+ def verbose?
62
+ return ENV['SAFE_PG_MIGRATIONS_VERBOSE'] == '1' if ENV['SAFE_PG_MIGRATIONS_VERBOSE']
63
+ return Rails.env.production? if defined?(Rails)
64
+
65
+ false
66
+ end
67
+
68
+ def config
69
+ @config ||= Configuration.new
70
+ end
71
+ end
72
+
73
+ module Migration
74
+ def exec_migration(connection, direction)
75
+ SafePgMigrations.setup_and_teardown(self, connection) do
76
+ super(connection, direction)
77
+ end
78
+ end
79
+
80
+ def disable_ddl_transaction
81
+ true
82
+ end
83
+
84
+ SAFE_METHODS = %i[execute add_column add_index add_reference add_belongs_to change_column_null].freeze
85
+ SAFE_METHODS.each do |method|
86
+ define_method method do |*args|
87
+ return super(*args) unless respond_to?(:safety_assured)
88
+
89
+ safety_assured { super(*args) }
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/numeric/time'
4
+
5
+ module SafePgMigrations
6
+ class Configuration
7
+ attr_accessor :safe_timeout
8
+ attr_accessor :index_lock_timeout
9
+ attr_accessor :blocking_activity_logger_margin
10
+ attr_accessor :batch_size
11
+ attr_accessor :retry_delay
12
+ attr_accessor :max_tries
13
+
14
+ def initialize
15
+ self.safe_timeout = 5.seconds
16
+ self.index_lock_timeout = 30.seconds
17
+ self.blocking_activity_logger_margin = 1.second
18
+ self.batch_size = 1000
19
+ self.retry_delay = 1.minute
20
+ self.max_tries = 5
21
+ end
22
+
23
+ def pg_safe_timeout
24
+ pg_duration(safe_timeout)
25
+ end
26
+
27
+ def pg_index_lock_timeout
28
+ pg_duration(index_lock_timeout)
29
+ end
30
+
31
+ def pg_duration(duration)
32
+ value, unit = duration.integer? ? [duration, 's'] : [(duration * 1000).to_i, 'ms']
33
+ "#{value}#{unit}"
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafePgMigrations
4
+ module BlockingActivityLogger
5
+ SELECT_BLOCKING_QUERIES_SQL = <<~SQL.squish
6
+ SELECT blocking_activity.query
7
+ FROM pg_catalog.pg_locks blocked_locks
8
+ JOIN pg_catalog.pg_stat_activity blocked_activity
9
+ ON blocked_activity.pid = blocked_locks.pid
10
+ JOIN pg_catalog.pg_locks blocking_locks
11
+ ON blocking_locks.locktype = blocked_locks.locktype
12
+ AND blocking_locks.DATABASE IS NOT DISTINCT FROM blocked_locks.DATABASE
13
+ AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
14
+ AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
15
+ AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
16
+ AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
17
+ AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
18
+ AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
19
+ AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
20
+ AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
21
+ AND blocking_locks.pid != blocked_locks.pid
22
+ JOIN pg_catalog.pg_stat_activity blocking_activity
23
+ ON blocking_activity.pid = blocking_locks.pid
24
+ WHERE blocked_locks.pid = %d
25
+ SQL
26
+
27
+ %i[
28
+ add_column remove_column add_foreign_key remove_foreign_key change_column_default
29
+ change_column_null create_table add_index remove_index
30
+ ].each do |method|
31
+ define_method method do |*args, &block|
32
+ log_blocking_queries(method) { super(*args, &block) }
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def delay_before_logging(method)
39
+ timeout_delay =
40
+ if %i[add_index remove_index].include?(method)
41
+ SafePgMigrations.config.index_lock_timeout
42
+ else
43
+ SafePgMigrations.config.safe_timeout
44
+ end
45
+
46
+ timeout_delay - SafePgMigrations.config.blocking_activity_logger_margin
47
+ end
48
+
49
+ def log_blocking_queries(method)
50
+ blocking_queries_retriever_thread =
51
+ Thread.new do
52
+ sleep delay_before_logging(method)
53
+ SafePgMigrations.alternate_connection.query_values(SELECT_BLOCKING_QUERIES_SQL % raw_connection.backend_pid)
54
+ end
55
+
56
+ yield
57
+
58
+ blocking_queries_retriever_thread.kill
59
+ rescue ActiveRecord::LockWaitTimeout
60
+ SafePgMigrations.say 'Lock timeout.', true
61
+ queries =
62
+ begin
63
+ blocking_queries_retriever_thread.value
64
+ rescue StandardError => e
65
+ SafePgMigrations.say("Error while retrieving blocking queries: #{e}", true)
66
+ nil
67
+ end
68
+
69
+ raise if queries.nil?
70
+
71
+ if queries.empty?
72
+ SafePgMigrations.say 'Could not find any blocking query.', true
73
+ else
74
+ SafePgMigrations.say(
75
+ "Statement was being blocked by the following #{'query'.pluralize(queries.size)}:", true
76
+ )
77
+ SafePgMigrations.say '', true
78
+ queries.each { |query| SafePgMigrations.say " #{query}", true }
79
+ SafePgMigrations.say(
80
+ 'Beware, some of those queries might run in a transaction. In this case the locking query might be '\
81
+ 'located elsewhere in the transaction',
82
+ true
83
+ )
84
+ SafePgMigrations.say '', true
85
+ end
86
+
87
+ raise
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafePgMigrations
4
+ module IdemPotentStatements
5
+ def add_index(table_name, column_name, **options)
6
+ index_name = options.key?(:name) ? options[:name].to_s : index_name(table_name, index_column_names(column_name))
7
+ return super unless index_name_exists?(table_name, index_name)
8
+
9
+ return if index_valid?(index_name)
10
+
11
+ remove_index(table_name, name: index_name)
12
+ super
13
+ end
14
+
15
+ def add_column(table_name, column_name, type, options = {})
16
+ return super unless column_exists?(table_name, column_name)
17
+
18
+ SafePgMigrations.say("/!\\ Column '#{column_name}' already exists in '#{table_name}'. Skipping statement.", true)
19
+ end
20
+
21
+ def remove_column(table_name, column_name, type = nil, options = {})
22
+ return super if column_exists?(table_name, column_name)
23
+
24
+ SafePgMigrations.say("/!\\ Column '#{column_name}' not found on table '#{table_name}'. Skipping statement.", true)
25
+ end
26
+
27
+ def remove_index(table_name, options = {})
28
+ index_name = options.key?(:name) ? options[:name].to_s : index_name(table_name, options)
29
+
30
+ return super if index_name_exists?(table_name, index_name)
31
+
32
+ SafePgMigrations.say("/!\\ Index '#{index_name}' not found on table '#{table_name}'. Skipping statement.", true)
33
+ end
34
+
35
+ def add_foreign_key(from_table, to_table, **options)
36
+ options_or_to_table = options.slice(:name, :column).presence || to_table
37
+ return super unless foreign_key_exists?(from_table, options_or_to_table)
38
+
39
+ SafePgMigrations.say(
40
+ "/!\\ Foreign key '#{from_table}' -> '#{to_table}' already exists. Skipping statement.",
41
+ true
42
+ )
43
+ end
44
+
45
+ def create_table(table_name, comment: nil, **options)
46
+ return super unless table_exists?(table_name)
47
+
48
+ SafePgMigrations.say(
49
+ "/!\\ Table '#{table_name}' already exists. Skipping statement.",
50
+ true
51
+ )
52
+ end
53
+
54
+ private
55
+
56
+ def index_valid?(index_name)
57
+ query_value <<~SQL.squish
58
+ SELECT indisvalid
59
+ FROM pg_index i
60
+ JOIN pg_class c
61
+ ON i.indexrelid = c.oid
62
+ WHERE c.relname = '#{index_name}';
63
+ SQL
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafePgMigrations
4
+ module StatementInsurer
5
+ PG_11_VERSION_NUM = 110_000
6
+
7
+ %i[change_column_null change_column create_table].each do |method|
8
+ define_method method do |*args, &block|
9
+ with_setting(:statement_timeout, SafePgMigrations.config.pg_safe_timeout) { super(*args, &block) }
10
+ end
11
+ end
12
+
13
+ def add_column(table_name, column_name, type, **options) # rubocop:disable Metrics/CyclomaticComplexity
14
+ need_default_value_backfill = SafePgMigrations.pg_version_num < PG_11_VERSION_NUM
15
+
16
+ default = options.delete(:default) if need_default_value_backfill
17
+ null = options.delete(:null)
18
+
19
+ if !default.nil? || null == false
20
+ SafePgMigrations.say_method_call(:add_column, table_name, column_name, type, options)
21
+ end
22
+
23
+ super
24
+
25
+ if need_default_value_backfill && !default.nil?
26
+ SafePgMigrations.say_method_call(:change_column_default, table_name, column_name, default)
27
+ change_column_default(table_name, column_name, default)
28
+
29
+ SafePgMigrations.say_method_call(:backfill_column_default, table_name, column_name)
30
+ backfill_column_default(table_name, column_name)
31
+ end
32
+
33
+ if null == false # rubocop:disable Style/GuardClause
34
+ SafePgMigrations.say_method_call(:change_column_null, table_name, column_name, null)
35
+ change_column_null(table_name, column_name, null)
36
+ end
37
+ end
38
+
39
+ def add_foreign_key(from_table, to_table, **options)
40
+ validate_present = options.key? :validate
41
+ options[:validate] = false unless validate_present
42
+
43
+ with_setting(:statement_timeout, SafePgMigrations.config.pg_safe_timeout) { super }
44
+
45
+ options_or_to_table = options.slice(:name, :column).presence || to_table
46
+ without_statement_timeout { validate_foreign_key from_table, options_or_to_table } unless validate_present
47
+ end
48
+
49
+ def add_index(table_name, column_name, **options)
50
+ options[:algorithm] = :concurrently
51
+ SafePgMigrations.say_method_call(:add_index, table_name, column_name, options)
52
+
53
+ with_index_timeouts { super }
54
+ end
55
+
56
+ def remove_index(table_name, options = {})
57
+ options = { column: options } unless options.is_a?(Hash)
58
+ options[:algorithm] = :concurrently
59
+ SafePgMigrations.say_method_call(:remove_index, table_name, options)
60
+
61
+ with_index_timeouts { super }
62
+ end
63
+
64
+ def backfill_column_default(table_name, column_name)
65
+ quoted_table_name = quote_table_name(table_name)
66
+ quoted_column_name = quote_column_name(column_name)
67
+ primary_key_offset = 0
68
+ loop do
69
+ ids = query_values <<~SQL.squish
70
+ SELECT id FROM #{quoted_table_name} WHERE id > #{primary_key_offset}
71
+ ORDER BY id LIMIT #{SafePgMigrations.config.batch_size}
72
+ SQL
73
+ break if ids.empty?
74
+
75
+ primary_key_offset = ids.last
76
+ execute <<~SQL.squish
77
+ UPDATE #{quoted_table_name} SET #{quoted_column_name} = DEFAULT WHERE id IN (#{ids.join(',')})
78
+ SQL
79
+ end
80
+ end
81
+
82
+ def with_setting(key, value)
83
+ old_value = query_value("SHOW #{key}")
84
+ execute("SET #{key} TO #{quote(value)}")
85
+ begin
86
+ yield
87
+ ensure
88
+ begin
89
+ execute("SET #{key} TO #{quote(old_value)}")
90
+ rescue ActiveRecord::StatementInvalid => e
91
+ # Swallow `PG::InFailedSqlTransaction` exceptions so as to keep the
92
+ # original exception (if any).
93
+ raise unless e.cause.is_a?(PG::InFailedSqlTransaction)
94
+ end
95
+ end
96
+ end
97
+
98
+ def without_statement_timeout
99
+ with_setting(:statement_timeout, 0) { yield }
100
+ end
101
+
102
+ def with_index_timeouts
103
+ without_statement_timeout do
104
+ with_setting(:lock_timeout, SafePgMigrations.config.pg_index_lock_timeout) do
105
+ yield
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafePgMigrations
4
+ module StatementRetrier
5
+ RETRIABLE_SCHEMA_STATEMENTS = %i[
6
+ add_column add_foreign_key remove_foreign_key change_column_default
7
+ change_column_null add_index remove_index remove_column
8
+ ].freeze
9
+
10
+ RETRIABLE_SCHEMA_STATEMENTS.each do |method|
11
+ define_method method do |*args, &block|
12
+ retry_if_lock_timeout { super(*args, &block) }
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def retry_if_lock_timeout
19
+ remaining_tries = SafePgMigrations.config.max_tries
20
+ begin
21
+ remaining_tries -= 1
22
+ yield
23
+ rescue ActiveRecord::LockWaitTimeout
24
+ raise if transaction_open? # Retrying is useless if we're inside a transaction.
25
+ raise unless remaining_tries > 0
26
+
27
+ retry_delay = SafePgMigrations.config.retry_delay
28
+ SafePgMigrations.say "Retrying in #{retry_delay} seconds...", true
29
+ sleep retry_delay
30
+ SafePgMigrations.say 'Retrying now.', true
31
+ retry
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafePgMigrations
4
+ class VerboseSqlLogger
5
+ def setup
6
+ @activerecord_logger_was = ActiveRecord::Base.logger
7
+ @verbose_query_logs_was = ActiveRecord::Base.verbose_query_logs
8
+ @colorize_logging_was = ActiveRecord::LogSubscriber.colorize_logging
9
+
10
+ disable_marginalia if defined?(Marginalia)
11
+
12
+ stdout_logger = Logger.new($stdout, formatter: ->(_severity, _time, _progname, query) { "#{query}\n" })
13
+ ActiveRecord::Base.logger = stdout_logger
14
+ ActiveRecord::LogSubscriber.colorize_logging = colorize_logging?
15
+ # Do not output caller method, we know it is coming from the migration
16
+ ActiveRecord::Base.verbose_query_logs = false
17
+ self
18
+ end
19
+
20
+ def teardown
21
+ ActiveRecord::Base.verbose_query_logs = @verbose_query_logs_was
22
+ ActiveRecord::LogSubscriber.colorize_logging = @colorize_logging_was
23
+ ActiveRecord::Base.logger = @activerecord_logger_was
24
+ enable_marginalia if defined?(Marginalia)
25
+ end
26
+
27
+ private
28
+
29
+ def colorize_logging?
30
+ defined?(Rails) && Rails.env.development?
31
+ end
32
+
33
+ # Marginalia annotations will most likely pollute the output
34
+ def disable_marginalia
35
+ @marginalia_components_were = Marginalia::Comment.components
36
+ Marginalia::Comment.components = []
37
+ end
38
+
39
+ def enable_marginalia
40
+ Marginalia::Comment.components = @marginalia_components_were
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'safe-pg-migrations/base'
4
+
5
+ module SafePgMigrations
6
+ class Railtie < Rails::Railtie
7
+ initializer 'safe_pg_migrations.insert_into_active_record' do
8
+ ActiveSupport.on_load :active_record do
9
+ ActiveRecord::Migration.prepend(SafePgMigrations::Migration)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafePgMigrations
4
+ VERSION = '1.1.0'
5
+ end
metadata CHANGED
@@ -1,28 +1,179 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: safe-pg-migrations
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthieu Prat
8
8
  - Romain Choquet
9
- autorequire:
9
+ autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2018-10-08 00:00:00.000000000 Z
13
- dependencies: []
12
+ date: 2020-11-03 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: activerecord
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '5.2'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ version: '5.2'
28
+ - !ruby/object:Gem::Dependency
29
+ name: activesupport
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: '5.2'
35
+ type: :runtime
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: '5.2'
42
+ - !ruby/object:Gem::Dependency
43
+ name: bundler
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ type: :development
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ - !ruby/object:Gem::Dependency
57
+ name: minitest
58
+ requirement: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '0'
63
+ type: :development
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ - !ruby/object:Gem::Dependency
71
+ name: mocha
72
+ requirement: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ type: :development
78
+ prerelease: false
79
+ version_requirements: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ - !ruby/object:Gem::Dependency
85
+ name: pg
86
+ requirement: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '0'
91
+ type: :development
92
+ prerelease: false
93
+ version_requirements: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '0'
98
+ - !ruby/object:Gem::Dependency
99
+ name: pry
100
+ requirement: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '0'
105
+ type: :development
106
+ prerelease: false
107
+ version_requirements: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '0'
112
+ - !ruby/object:Gem::Dependency
113
+ name: pry-coolline
114
+ requirement: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '0'
119
+ type: :development
120
+ prerelease: false
121
+ version_requirements: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - ">="
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
126
+ - !ruby/object:Gem::Dependency
127
+ name: rake
128
+ requirement: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - ">="
131
+ - !ruby/object:Gem::Version
132
+ version: '0'
133
+ type: :development
134
+ prerelease: false
135
+ version_requirements: !ruby/object:Gem::Requirement
136
+ requirements:
137
+ - - ">="
138
+ - !ruby/object:Gem::Version
139
+ version: '0'
140
+ - !ruby/object:Gem::Dependency
141
+ name: rubocop
142
+ requirement: !ruby/object:Gem::Requirement
143
+ requirements:
144
+ - - ">="
145
+ - !ruby/object:Gem::Version
146
+ version: '0'
147
+ type: :development
148
+ prerelease: false
149
+ version_requirements: !ruby/object:Gem::Requirement
150
+ requirements:
151
+ - - ">="
152
+ - !ruby/object:Gem::Version
153
+ version: '0'
14
154
  description: Make your PG migrations safe.
15
155
  email: matthieuprat@gmail.com
16
156
  executables: []
17
157
  extensions: []
18
158
  extra_rdoc_files: []
19
159
  files:
160
+ - LICENSE
161
+ - README.md
20
162
  - lib/safe-pg-migrations.rb
21
- homepage: http://rubygems.org/doctolib/safe-pg-migrations
163
+ - lib/safe-pg-migrations/base.rb
164
+ - lib/safe-pg-migrations/configuration.rb
165
+ - lib/safe-pg-migrations/plugins/blocking_activity_logger.rb
166
+ - lib/safe-pg-migrations/plugins/idem_potent_statements.rb
167
+ - lib/safe-pg-migrations/plugins/statement_insurer.rb
168
+ - lib/safe-pg-migrations/plugins/statement_retrier.rb
169
+ - lib/safe-pg-migrations/plugins/verbose_sql_logger.rb
170
+ - lib/safe-pg-migrations/railtie.rb
171
+ - lib/safe-pg-migrations/version.rb
172
+ homepage: https://github.com/doctolib/safe-pg-migrations
22
173
  licenses:
23
174
  - MIT
24
175
  metadata: {}
25
- post_install_message:
176
+ post_install_message:
26
177
  rdoc_options: []
27
178
  require_paths:
28
179
  - lib
@@ -30,16 +181,16 @@ required_ruby_version: !ruby/object:Gem::Requirement
30
181
  requirements:
31
182
  - - ">="
32
183
  - !ruby/object:Gem::Version
33
- version: '0'
184
+ version: '2.5'
34
185
  required_rubygems_version: !ruby/object:Gem::Requirement
35
186
  requirements:
36
187
  - - ">="
37
188
  - !ruby/object:Gem::Version
38
189
  version: '0'
39
190
  requirements: []
40
- rubyforge_project:
41
- rubygems_version: 2.6.13
42
- signing_key:
191
+ rubyforge_project:
192
+ rubygems_version: 2.7.3
193
+ signing_key:
43
194
  specification_version: 4
44
195
  summary: Make your PG migrations safe.
45
196
  test_files: []