safe-pg-migrations 3.1.4 → 4.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 06ca39e801ba6836f1059e9a3f66c9d54d1f1920a39f421904ae592944ae4281
4
- data.tar.gz: b15dec6a1f06f7a6801ec32a497672059d7132792495a47745fb4f2916bf9a78
3
+ metadata.gz: 3edbea6934dae51bfef3563e0d24e04d3b589bf417ffd9635687265f1b973613
4
+ data.tar.gz: b4714af68927b76b3580b9d74b83d96f73bdaa8127395ec030379d29886d70b0
5
5
  SHA512:
6
- metadata.gz: 330c11df6898b0a4a13114e75f1bcdec8ae9935c8ebbba427bf634a6d4ba896c6e56994edeaa761ede31c024b60bdca82282181057585469d358a2ae7dc6aec2
7
- data.tar.gz: 47623a5dd9404a438af11dd7334b7989da829b775128f4076d3ea7e6a236e2ce2a068d38603aae9e4501b89e8988a4e6d14da54501afaa1c2f927aca6f087992
6
+ metadata.gz: 774fac16319205c8bafb5e1f346aa23bfc6de00df261bb307ec0a095be56da56242c2103fb3e9dfc7e9f4d7ce0023817eb5f8680476d3e0dc61fd328ebb23a72
7
+ data.tar.gz: 7f6b0661848d67fb0193155b21211486413fa39d1769caa7524f8429bc8303f1b71faba7fd502f0a137352c09f61a4487644cb62bf710fcd903b4d281cde326d
data/README.md CHANGED
@@ -116,11 +116,25 @@ PG will still needs to update every row of the table, and will most likely state
116
116
  <details>
117
117
  <summary>Safe add_column - adding a volatile default value</summary>
118
118
 
119
- **Safe PG Migrations** provides the extra option parameter `default_value_backfill:`. When your migration is adding a volatile default value, the option `:update_in_batches` can be set. It will automatically backfill the value in a safe manner.
119
+ > **⚠️ ERROR**
120
+ >
121
+ > Using `default_value_backfill: :update_in_batches` with **volatile defaults** (like `NOW()`, `clock_timestamp()`, `random()`, `gen_random_uuid()`) is **not allowed** and will raise an error.
122
+ >
123
+ > Volatile defaults are non-deterministic functions that are evaluated per row, causing migrations to hang for a very long time on large tables. You must backfill them manually with proper monitoring and control.
124
+ >
125
+ > **Non-volatile defaults** (like fixed strings, numbers, booleans) are still safe to use with automatic backfill.
126
+
127
+ **Safe PG Migrations** provides the extra option parameter `default_value_backfill:`. When your migration is adding a **non-volatile** default value, the option `:update_in_batches` can be set. It will automatically backfill the value in a safe manner.
120
128
 
121
129
  ```ruby
130
+ # ✅ SAFE - non-volatile default with automatic backfill
122
131
  safety_assured do
123
- add_column :users, :created_at, default: 'clock_timestamp()', default_value_backfill: :update_in_batches
132
+ add_column :users, :status, :string, default: 'active', default_value_backfill: :update_in_batches
133
+ end
134
+
135
+ # ❌ NOT ALLOWED - volatile default with automatic backfill (will raise an error)
136
+ safety_assured do
137
+ add_column :users, :created_at, :datetime, default: -> { 'clock_timestamp()' }, default_value_backfill: :update_in_batches
124
138
  end
125
139
  ```
126
140
 
@@ -132,18 +146,44 @@ More specifically, it will:
132
146
  4. change the column to `null: false`, if defined in the parameters, following the algorithm we have defined below.
133
147
 
134
148
  ---
135
- **NOTE**
136
-
137
- Data backfill take time. If your table is big, your migrations will (safely) hangs for a while. You might want to backfill data manually instead, to do so you will need two migrations
138
-
139
- 1. First migration :
140
-
141
- a. adds the column without default and without null constraint;
142
-
143
- b. add the default value.
144
-
145
- 2. manual data backfill (rake task, manual operation, ...)
146
- 3. Second migration which change the column to null false (with **Safe PG Migrations**, `change_column_null` is safe and can be used; see section below)
149
+ **NOTE: Manual Backfill for Volatile Defaults**
150
+
151
+ For **volatile defaults** (non-deterministic functions), you MUST backfill manually. Split the operation into multiple steps in this EXACT order:
152
+
153
+ 1. **ALTER COLUMN SET DEFAULT** (for new and updated rows)
154
+ ```ruby
155
+ change_column_default :users, :created_at, -> { 'NOW()' }
156
+ ```
157
+
158
+ 2. **ADD CONSTRAINT CHECK NOT NULL NOT VALID** (for new and updated rows, only if you need NOT NULL)
159
+ ```ruby
160
+ add_check_constraint :users, "created_at IS NOT NULL",
161
+ name: "check_users_created_at_not_null",
162
+ validate: false
163
+ ```
164
+
165
+ 3. **BACKFILL** the column using a job (chunk over the primary key)
166
+ ```ruby
167
+ # Your own script to backfill in batches
168
+ User.in_batches.update_all("created_at = NOW()")
169
+ ```
170
+
171
+ 4. **VALIDATE CONSTRAINT** (check whole table, only if you added the constraint)
172
+ ```ruby
173
+ validate_check_constraint :users, name: "check_users_created_at_not_null"
174
+ ```
175
+
176
+ 5. **ALTER COLUMN SET NOT NULL** (only if you need NOT NULL)
177
+ ```ruby
178
+ change_column_null :users, :created_at, false
179
+ ```
180
+
181
+ 6. **DROP CONSTRAINT** (only if you added the constraint)
182
+ ```ruby
183
+ remove_check_constraint :users, name: "check_users_created_at_not_null"
184
+ ```
185
+
186
+ For **non-volatile defaults**, data backfill with `:update_in_batches` is safe but takes time. If your table is very large, you might want to backfill manually for better control.
147
187
  ---
148
188
 
149
189
  `default_value_backfill:` also accept the value `:auto` which is set by default. In this case, **Safe PG Migrations** will not backfill data and will let PostgreSQL handle it itself.
@@ -161,7 +201,7 @@ It is also possible to set a threshold for the table size, above which the migra
161
201
 
162
202
  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.
163
203
 
164
- 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.
204
+ 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 statement and lock timeout to 0 seconds for those 2 specific statements.
165
205
 
166
206
  If you still get lock timeout while adding / removing indexes, it might be for one of those reasons:
167
207
 
@@ -7,6 +7,7 @@ require 'safe-pg-migrations/helpers/index_helper'
7
7
  require 'safe-pg-migrations/helpers/batch_over'
8
8
  require 'safe-pg-migrations/helpers/session_setting_management'
9
9
  require 'safe-pg-migrations/helpers/statements_helper'
10
+ require 'safe-pg-migrations/helpers/volatile_default'
10
11
  require 'safe-pg-migrations/plugins/verbose_sql_logger'
11
12
  require 'safe-pg-migrations/plugins/blocking_activity_logger'
12
13
  require 'safe-pg-migrations/plugins/statement_insurer/add_column'
@@ -2,6 +2,31 @@
2
2
 
3
3
  module SafePgMigrations
4
4
  module Helpers
5
+ # This helper class allows to iterate over records in batches, in a similar
6
+ # way to ActiveRecord's `in_batches` method with the :use_ranges option,
7
+ # which was introduced in ActiveRecord 7.1, see:
8
+ #
9
+ # - https://api.rubyonrails.org/classes/ActiveRecord/Batches.html#method-i-in_batches
10
+ # - https://github.com/rails/rails/blob/v7.1.0/activerecord/CHANGELOG.md
11
+ # - https://github.com/rails/rails/pull/45414
12
+ # - https://github.com/rails/rails/commit/620f24782977b8e53e06cf0e2c905a591936e990
13
+ #
14
+ # In ActiveRecord 8.1, `in_baches(use_ranges: true)` was optimized further
15
+ # to use less cpu, memory, and bandwidth, see:
16
+ #
17
+ # - https://github.com/rails/rails/releases/tag/v8.1.0
18
+ # - https://github.com/rails/rails/pull/51243
19
+ # - https://github.com/rails/rails/commit/c097bf6c24443323da8fe64030dd963951121dea
20
+ #
21
+ # If using ActiveRecord 8.1 or later, it's recommended to use the built-in
22
+ # method, e.g.
23
+ #
24
+ # User.in_batches(of: 100, use_ranges: true).each { |batch| ... }
25
+ #
26
+ # Otherwise, this helper can be used as a fallback:
27
+ #
28
+ # SafePgMigrations::Helpers::BatchOver.new(User, of: 100).each_batch { |batch| ... }
29
+ #
5
30
  class BatchOver
6
31
  def initialize(model, of: SafePgMigrations.config.backfill_batch_size)
7
32
  @model = model
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafePgMigrations
4
+ module Helpers
5
+ module VolatileDefault
6
+ VOLATILE_DEFAULT_PATTERNS = [
7
+ /\bclock_timestamp\s*\(/i,
8
+ /\bnow\s*\(/i,
9
+ /\bcurrent_timestamp\b/i,
10
+ /\bcurrent_time\b/i,
11
+ /\bcurrent_date\b/i,
12
+ /\brandom\s*\(/i,
13
+ /\buuid_generate/i,
14
+ /\bgen_random_uuid\s*\(/i,
15
+ /\btimeofday\s*\(/i,
16
+ /\btransaction_timestamp\s*\(/i,
17
+ /\bstatement_timestamp\s*\(/i,
18
+ /\bnextval\s*\(/i,
19
+ /\bgen_random_bytes\s*\(/i,
20
+ ].freeze
21
+
22
+ module_function
23
+
24
+ def volatile_default?(default)
25
+ return false if default.nil?
26
+ return true if default.is_a?(Proc)
27
+ return false unless default.is_a?(String)
28
+
29
+ VOLATILE_DEFAULT_PATTERNS.any? { |pattern| default.match?(pattern) }
30
+ end
31
+ end
32
+ end
33
+ end
@@ -8,6 +8,11 @@ module SafePgMigrations
8
8
 
9
9
  options.delete(:default_value_backfill)
10
10
 
11
+ default = options[:default]
12
+
13
+ # Raise if using automatic backfill with a volatile default
14
+ raise_on_volatile_default(table_name, column_name, default) if volatile_default?(default)
15
+
11
16
  raise <<~ERROR unless backfill_column_default_safe?(table_name)
12
17
  Table #{table_name} has more than #{SafePgMigrations.config.default_value_backfill_threshold} rows.
13
18
  Backfilling the default value for column #{column_name} on table #{table_name} would take too long.
@@ -53,11 +58,55 @@ module SafePgMigrations
53
58
 
54
59
  Helpers::Logger.say_method_call(:backfill_column_default, table_name, column_name)
55
60
 
56
- Helpers::BatchOver.new(model).each_batch do |batch|
61
+ batch_handler = lambda do |batch|
57
62
  batch.update_all("#{quoted_column_name} = DEFAULT")
58
63
 
59
64
  sleep SafePgMigrations.config.backfill_pause
60
65
  end
66
+
67
+ backfill_batch_size = SafePgMigrations.config.backfill_batch_size
68
+
69
+ if ActiveRecord.version >= Gem::Version.new('8.1')
70
+ model.in_batches(of: backfill_batch_size, use_ranges: true).each(&batch_handler)
71
+ else
72
+ Helpers::BatchOver.new(model, of: backfill_batch_size).each_batch(&batch_handler)
73
+ end
74
+ end
75
+
76
+ def volatile_default?(default)
77
+ Helpers::VolatileDefault.volatile_default?(default)
78
+ end
79
+
80
+ def raise_on_volatile_default(table_name, column_name, default)
81
+ default_display = default.is_a?(Proc) ? '<Proc>' : default
82
+
83
+ raise <<~ERROR
84
+ Using default_value_backfill: :update_in_batches with volatile default '#{default_display}'
85
+ on #{table_name}.#{column_name} is not allowed.
86
+
87
+ Volatile defaults are non-deterministic functions like gen_random_uuid(), now(), or clock_timestamp().
88
+ They are evaluated per row and can cause migrations to hang for a very long time on large tables.
89
+ You should backfill them "manually" with proper monitoring and control.
90
+
91
+ Split the operation into multiple steps in this EXACT order:
92
+
93
+ 1. ALTER COLUMN SET DEFAULT (for new and updated rows)
94
+ change_column_default :#{table_name}, :#{column_name}, '#{default_display}'
95
+ 2. ADD CONSTRAINT CHECK NOT NULL NOT VALID (for new and updated rows)
96
+ # Only if you need NOT NULL:
97
+ add_check_constraint :#{table_name}, "#{column_name} IS NOT NULL", name: "check_#{table_name}_#{column_name}_not_null", validate: false
98
+ 3. BACKFILL the column (using a job or something else, chucking by PK)
99
+ # Your own script to backfill in batches
100
+ 4. VALIDATE CONSTRAINT (check whole table)
101
+ # Only if you added the constraint in step 3:
102
+ validate_check_constraint :#{table_name}, name: "check_#{table_name}_#{column_name}_not_null"
103
+ 5. ALTER COLUMN SET NOT NULL
104
+ # Only if you need NOT NULL:
105
+ change_column_null :#{table_name}, :#{column_name}, false
106
+ 6. DROP CONSTRAINT
107
+ # Only if you added the constraint in step 3:
108
+ remove_check_constraint :#{table_name}, name: "check_#{table_name}_#{column_name}_not_null"
109
+ ERROR
61
110
  end
62
111
  end
63
112
  end
@@ -47,18 +47,14 @@ module SafePgMigrations
47
47
  super do |td|
48
48
  yield td if block_given?
49
49
  td.indexes.map! do |key, index_options|
50
- index_options[:algorithm] ||= :default
50
+ index_options[:algorithm] = nil unless index_options.key?(:algorithm)
51
51
  [key, index_options]
52
52
  end
53
53
  end
54
54
  end
55
55
 
56
56
  def add_index(table_name, column_name, **options)
57
- if options[:algorithm] == :default
58
- options.delete :algorithm
59
- else
60
- options[:algorithm] = :concurrently
61
- end
57
+ options[:algorithm] = :concurrently unless options.key?(:algorithm)
62
58
 
63
59
  Helpers::Logger.say_method_call(:add_index, table_name, column_name, **options)
64
60
  without_timeout { super(table_name, column_name, **options) }
@@ -12,27 +12,12 @@ module SafePgMigrations
12
12
  next unless method == :add_column
13
13
 
14
14
  options = args.last.is_a?(Hash) ? args.last : {}
15
-
15
+ default = options[:default]
16
16
  default_value_backfill = options.fetch(:default_value_backfill, :auto)
17
17
 
18
- if default_value_backfill == :update_in_batches
19
- check_message = <<~CHECK
20
- default_value_backfill: :update_in_batches will take time if the table is too big.
21
-
22
- Your configuration sets a pause of #{SafePgMigrations.config.backfill_pause} seconds between batches of
23
- #{SafePgMigrations.config.backfill_batch_size} rows. Each batch execution will take time as well. Please
24
- check that the estimated duration of the migration is acceptable
25
- before adding `safety_assured`.
26
- CHECK
27
-
28
- check_message += <<~CHECK if SafePgMigrations.config.default_value_backfill_threshold
18
+ next unless default_value_backfill == :update_in_batches
29
19
 
30
- Also, please note that SafePgMigrations is configured to raise if the table has more than
31
- #{SafePgMigrations.config.default_value_backfill_threshold} rows.
32
- CHECK
33
-
34
- stop! check_message
35
- end
20
+ stop! StrongMigrationsIntegration.send(:backfill_check_message, default)
36
21
  end
37
22
  end
38
23
 
@@ -41,6 +26,39 @@ module SafePgMigrations
41
26
  def strong_migration_available?
42
27
  Object.const_defined? :StrongMigrations
43
28
  end
29
+
30
+ def backfill_check_message(default)
31
+ if Helpers::VolatileDefault.volatile_default?(default)
32
+ default_display = default.is_a?(Proc) ? '<Proc>' : default
33
+
34
+ <<~CHECK
35
+ Using default_value_backfill: :update_in_batches with volatile default '#{default_display}' is not allowed.
36
+
37
+ Volatile defaults (like NOW(), clock_timestamp(), random()) are evaluated per row and can cause
38
+ migrations to hang for a very long time on large tables.
39
+
40
+ Please backfill volatile defaults manually instead. See the safe-pg-migrations README for the
41
+ recommended approach.
42
+ CHECK
43
+ else
44
+ check_message = <<~CHECK
45
+ default_value_backfill: :update_in_batches will take time if the table is too big.
46
+
47
+ Your configuration sets a pause of #{SafePgMigrations.config.backfill_pause} seconds between batches of
48
+ #{SafePgMigrations.config.backfill_batch_size} rows. Each batch execution will take time as well. Please
49
+ check that the estimated duration of the migration is acceptable
50
+ before adding `safety_assured`.
51
+ CHECK
52
+
53
+ check_message += <<~CHECK if SafePgMigrations.config.default_value_backfill_threshold
54
+
55
+ Also, please note that SafePgMigrations is configured to raise if the table has more than
56
+ #{SafePgMigrations.config.default_value_backfill_threshold} rows.
57
+ CHECK
58
+
59
+ check_message
60
+ end
61
+ end
44
62
  end
45
63
 
46
64
  SAFE_METHODS = %i[
@@ -64,9 +82,20 @@ module SafePgMigrations
64
82
  def add_column(table_name, *args, **options)
65
83
  return super unless respond_to?(:safety_assured)
66
84
 
67
- return safety_assured { super } if options.fetch(:default_value_backfill, :auto) == :auto
85
+ default_value_backfill = options.fetch(:default_value_backfill, :auto)
68
86
 
87
+ # Auto backfill is safe - use safety_assured
88
+ return safety_assured { super } if default_value_backfill == :auto
89
+
90
+ # :update_in_batches always requires explicit safety_assured (volatile defaults will be
91
+ # blocked by the check above before reaching this point)
69
92
  super
70
93
  end
94
+
95
+ private
96
+
97
+ def volatile_default?(default)
98
+ Helpers::VolatileDefault.volatile_default?(default)
99
+ end
71
100
  end
72
101
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafePgMigrations
4
- VERSION = '3.1.4'
4
+ VERSION = '4.0.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: safe-pg-migrations
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.4
4
+ version: 4.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthieu Prat
@@ -11,7 +11,7 @@ authors:
11
11
  autorequire:
12
12
  bindir: bin
13
13
  cert_chain: []
14
- date: 2025-03-26 00:00:00.000000000 Z
14
+ date: 2026-03-13 00:00:00.000000000 Z
15
15
  dependencies:
16
16
  - !ruby/object:Gem::Dependency
17
17
  name: activerecord
@@ -61,6 +61,7 @@ files:
61
61
  - lib/safe-pg-migrations/helpers/satisfied_helper.rb
62
62
  - lib/safe-pg-migrations/helpers/session_setting_management.rb
63
63
  - lib/safe-pg-migrations/helpers/statements_helper.rb
64
+ - lib/safe-pg-migrations/helpers/volatile_default.rb
64
65
  - lib/safe-pg-migrations/plugins/blocking_activity_logger.rb
65
66
  - lib/safe-pg-migrations/plugins/idempotent_statements.rb
66
67
  - lib/safe-pg-migrations/plugins/statement_insurer.rb