ros-apartment 3.2.0 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.gitignore +2 -0
- data/.rubocop.yml +99 -2
- data/.ruby-version +1 -1
- data/AGENTS.md +19 -0
- data/Appraisals +15 -0
- data/CLAUDE.md +210 -0
- data/README.md +55 -8
- data/Rakefile +6 -5
- data/docs/adapters.md +177 -0
- data/docs/architecture.md +274 -0
- data/docs/elevators.md +226 -0
- data/docs/images/log_example.png +0 -0
- data/lib/apartment/CLAUDE.md +300 -0
- data/lib/apartment/adapters/CLAUDE.md +314 -0
- data/lib/apartment/adapters/abstract_adapter.rb +24 -15
- data/lib/apartment/adapters/jdbc_mysql_adapter.rb +1 -1
- data/lib/apartment/adapters/jdbc_postgresql_adapter.rb +3 -3
- data/lib/apartment/adapters/mysql2_adapter.rb +2 -2
- data/lib/apartment/adapters/postgresql_adapter.rb +42 -19
- data/lib/apartment/adapters/sqlite3_adapter.rb +7 -7
- data/lib/apartment/console.rb +1 -1
- data/lib/apartment/custom_console.rb +7 -7
- data/lib/apartment/elevators/CLAUDE.md +292 -0
- data/lib/apartment/elevators/domain.rb +1 -1
- data/lib/apartment/elevators/generic.rb +1 -1
- data/lib/apartment/elevators/host_hash.rb +3 -3
- data/lib/apartment/elevators/subdomain.rb +9 -5
- data/lib/apartment/log_subscriber.rb +1 -1
- data/lib/apartment/migrator.rb +2 -2
- data/lib/apartment/model.rb +1 -1
- data/lib/apartment/railtie.rb +3 -3
- data/lib/apartment/tasks/CLAUDE.md +107 -0
- data/lib/apartment/tasks/enhancements.rb +1 -1
- data/lib/apartment/tasks/schema_dumper.rb +109 -0
- data/lib/apartment/tasks/task_helper.rb +273 -35
- data/lib/apartment/tenant.rb +3 -3
- data/lib/apartment/version.rb +1 -1
- data/lib/apartment.rb +35 -10
- data/lib/generators/apartment/install/install_generator.rb +1 -1
- data/lib/generators/apartment/install/templates/apartment.rb +2 -2
- data/lib/tasks/apartment.rake +64 -37
- data/ros-apartment.gemspec +3 -3
- metadata +26 -15
|
@@ -1,54 +1,292 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'active_support/core_ext/module/delegation'
|
|
4
|
+
|
|
3
5
|
module Apartment
|
|
6
|
+
# Coordinates tenant operations for rake tasks with parallel execution support.
|
|
7
|
+
#
|
|
8
|
+
# ## Problem Context
|
|
9
|
+
#
|
|
10
|
+
# Multi-tenant applications with many schemas face slow migration times when
|
|
11
|
+
# running sequentially. A 100-tenant system with 2-second migrations takes
|
|
12
|
+
# 3+ minutes sequentially but ~20 seconds with 10 parallel workers.
|
|
13
|
+
#
|
|
14
|
+
# ## Why This Design
|
|
15
|
+
#
|
|
16
|
+
# Parallel database migrations introduce two categories of problems:
|
|
17
|
+
#
|
|
18
|
+
# 1. **Platform-specific fork safety**: macOS/Windows have issues with libpq
|
|
19
|
+
# (PostgreSQL C library) after fork() due to GSS/Kerberos state corruption.
|
|
20
|
+
# Linux handles fork() cleanly. We auto-detect and choose the safe strategy.
|
|
21
|
+
#
|
|
22
|
+
# 2. **PostgreSQL advisory lock deadlocks**: Rails uses advisory locks to
|
|
23
|
+
# prevent concurrent migrations. When multiple processes/threads migrate
|
|
24
|
+
# different schemas simultaneously, they deadlock competing for the same
|
|
25
|
+
# lock. We disable advisory locks during parallel execution, which means
|
|
26
|
+
# **you accept responsibility for ensuring your migrations are parallel-safe**.
|
|
27
|
+
#
|
|
28
|
+
# ## When to Use Parallel Migrations
|
|
29
|
+
#
|
|
30
|
+
# This is an advanced feature. Use it when:
|
|
31
|
+
# - You have many tenants and sequential migration time is problematic
|
|
32
|
+
# - Your migrations only modify tenant-specific schema objects
|
|
33
|
+
# - You've verified your migrations don't have cross-schema side effects
|
|
34
|
+
#
|
|
35
|
+
# Stick with sequential execution (the default) when:
|
|
36
|
+
# - Migrations create/modify extensions, types, or shared objects
|
|
37
|
+
# - Migrations have ordering dependencies across tenants
|
|
38
|
+
# - You're unsure whether parallel execution is safe for your use case
|
|
39
|
+
#
|
|
40
|
+
# ## Gotchas
|
|
41
|
+
#
|
|
42
|
+
# - The `parallel_migration_threads` count should be less than your connection
|
|
43
|
+
# pool size to avoid connection exhaustion.
|
|
44
|
+
# - Empty/nil tenant names from `tenant_names` proc are filtered to prevent
|
|
45
|
+
# PostgreSQL "zero-length delimited identifier" errors.
|
|
46
|
+
# - Process-based parallelism requires fresh connections in each fork;
|
|
47
|
+
# thread-based parallelism shares the pool but needs explicit checkout.
|
|
48
|
+
#
|
|
49
|
+
# @see Apartment.parallel_migration_threads
|
|
50
|
+
# @see Apartment.parallel_strategy
|
|
51
|
+
# @see Apartment.manage_advisory_locks
|
|
4
52
|
module TaskHelper
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
53
|
+
# Captures outcome per tenant for aggregated reporting. Allows migrations
|
|
54
|
+
# to continue for remaining tenants even when one fails.
|
|
55
|
+
Result = Struct.new(:tenant, :success, :error, keyword_init: true)
|
|
56
|
+
|
|
57
|
+
class << self
|
|
58
|
+
# Primary entry point for tenant iteration. Automatically selects
|
|
59
|
+
# sequential or parallel execution based on configuration.
|
|
60
|
+
#
|
|
61
|
+
# @yield [String] tenant name
|
|
62
|
+
# @return [Array<Result>] outcome for each tenant
|
|
63
|
+
def each_tenant(&)
|
|
64
|
+
return [] if tenants_without_default.empty?
|
|
65
|
+
|
|
66
|
+
if parallel_migration_threads.positive?
|
|
67
|
+
each_tenant_parallel(&)
|
|
68
|
+
else
|
|
69
|
+
each_tenant_sequential(&)
|
|
9
70
|
end
|
|
10
71
|
end
|
|
11
|
-
end
|
|
12
72
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
73
|
+
# Sequential execution: simpler, no connection management complexity.
|
|
74
|
+
# Used when parallel_migration_threads is 0 (the default).
|
|
75
|
+
def each_tenant_sequential
|
|
76
|
+
tenants_without_default.map do |tenant|
|
|
77
|
+
Rails.application.executor.wrap do
|
|
78
|
+
yield(tenant)
|
|
79
|
+
end
|
|
80
|
+
Result.new(tenant: tenant, success: true, error: nil)
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
Result.new(tenant: tenant, success: false, error: e.message)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
16
85
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
86
|
+
# Parallel execution wrapper. Disables advisory locks for the duration,
|
|
87
|
+
# then delegates to platform-appropriate parallelism strategy.
|
|
88
|
+
def each_tenant_parallel(&)
|
|
89
|
+
with_advisory_locks_disabled do
|
|
90
|
+
case resolve_parallel_strategy
|
|
91
|
+
when :processes
|
|
92
|
+
each_tenant_in_processes(&)
|
|
93
|
+
else
|
|
94
|
+
each_tenant_in_threads(&)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
20
98
|
|
|
21
|
-
|
|
22
|
-
|
|
99
|
+
# Process-based parallelism via fork(). Faster on Linux due to
|
|
100
|
+
# copy-on-write memory and no GIL contention. Each forked process
|
|
101
|
+
# gets isolated memory, so we must clear inherited connections
|
|
102
|
+
# and establish fresh ones.
|
|
103
|
+
def each_tenant_in_processes
|
|
104
|
+
Parallel.map(tenants_without_default, in_processes: parallel_migration_threads) do |tenant|
|
|
105
|
+
# Forked processes inherit parent's connection handles but the
|
|
106
|
+
# underlying sockets are invalid. Must reconnect before any DB work.
|
|
107
|
+
ActiveRecord::Base.connection_handler.clear_all_connections!(:all)
|
|
108
|
+
reconnect_for_parallel_execution
|
|
23
109
|
|
|
24
|
-
|
|
25
|
-
|
|
110
|
+
Rails.application.executor.wrap do
|
|
111
|
+
yield(tenant)
|
|
112
|
+
end
|
|
113
|
+
Result.new(tenant: tenant, success: true, error: nil)
|
|
114
|
+
rescue StandardError => e
|
|
115
|
+
Result.new(tenant: tenant, success: false, error: e.message)
|
|
116
|
+
ensure
|
|
117
|
+
ActiveRecord::Base.connection_handler.clear_all_connections!(:all)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
26
120
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
121
|
+
# Thread-based parallelism. Safe on all platforms but subject to GIL
|
|
122
|
+
# for CPU-bound work (migrations are typically I/O-bound, so this is fine).
|
|
123
|
+
# Threads share the connection pool, so we reconfigure once before
|
|
124
|
+
# spawning and restore after completion.
|
|
125
|
+
def each_tenant_in_threads
|
|
126
|
+
original_config = ActiveRecord::Base.connection_db_config.configuration_hash
|
|
127
|
+
reconnect_for_parallel_execution
|
|
30
128
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
129
|
+
Parallel.map(tenants_without_default, in_threads: parallel_migration_threads) do |tenant|
|
|
130
|
+
# Explicit connection checkout prevents pool exhaustion when
|
|
131
|
+
# thread count exceeds pool size minus buffer.
|
|
132
|
+
ActiveRecord::Base.connection_pool.with_connection do
|
|
133
|
+
Rails.application.executor.wrap do
|
|
134
|
+
yield(tenant)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
Result.new(tenant: tenant, success: true, error: nil)
|
|
138
|
+
rescue StandardError => e
|
|
139
|
+
Result.new(tenant: tenant, success: false, error: e.message)
|
|
140
|
+
end
|
|
141
|
+
ensure
|
|
142
|
+
ActiveRecord::Base.connection_handler.clear_all_connections!(:all)
|
|
143
|
+
ActiveRecord::Base.establish_connection(original_config) if original_config
|
|
144
|
+
end
|
|
34
145
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
146
|
+
# Auto-detection logic for parallelism strategy. Only Linux gets
|
|
147
|
+
# process-based parallelism by default due to macOS libpq fork issues.
|
|
148
|
+
def resolve_parallel_strategy
|
|
149
|
+
strategy = Apartment.parallel_strategy
|
|
150
|
+
|
|
151
|
+
return :threads if strategy == :threads
|
|
152
|
+
return :processes if strategy == :processes
|
|
153
|
+
|
|
154
|
+
fork_safe_platform? ? :processes : :threads
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Platform detection. Conservative: only Linux is considered fork-safe.
|
|
158
|
+
# macOS has documented issues with libpq, GSS-API, and Kerberos after fork.
|
|
159
|
+
# See: https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE
|
|
160
|
+
def fork_safe_platform?
|
|
161
|
+
RUBY_PLATFORM.include?('linux')
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Advisory lock management. Rails acquires pg_advisory_lock during migrations
|
|
165
|
+
# to prevent concurrent schema changes. With parallel tenant migrations,
|
|
166
|
+
# this causes deadlocks since all workers compete for the same lock.
|
|
167
|
+
#
|
|
168
|
+
# **Important**: Disabling advisory locks shifts responsibility to you.
|
|
169
|
+
# Your migrations must be safe to run concurrently across tenants. If your
|
|
170
|
+
# migrations modify shared resources, create extensions, or have other
|
|
171
|
+
# cross-schema side effects, parallel execution may cause failures.
|
|
172
|
+
# When in doubt, use sequential execution (parallel_migration_threads = 0).
|
|
173
|
+
#
|
|
174
|
+
# Uses ENV var because Rails checks it at connection establishment time,
|
|
175
|
+
# and we need it disabled before Parallel spawns workers.
|
|
176
|
+
def with_advisory_locks_disabled
|
|
177
|
+
return yield unless parallel_migration_threads.positive?
|
|
178
|
+
return yield unless Apartment.manage_advisory_locks
|
|
179
|
+
|
|
180
|
+
original_env_value = ENV.fetch('DISABLE_ADVISORY_LOCKS', nil)
|
|
181
|
+
begin
|
|
182
|
+
ENV['DISABLE_ADVISORY_LOCKS'] = 'true'
|
|
183
|
+
yield
|
|
184
|
+
ensure
|
|
185
|
+
if original_env_value.nil?
|
|
186
|
+
ENV.delete('DISABLE_ADVISORY_LOCKS')
|
|
187
|
+
else
|
|
188
|
+
ENV['DISABLE_ADVISORY_LOCKS'] = original_env_value
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
41
192
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
193
|
+
# Re-establishes database connection for parallel execution.
|
|
194
|
+
# When manage_advisory_locks is true, disables advisory locks in the
|
|
195
|
+
# connection config (belt-and-suspenders with the ENV var approach).
|
|
196
|
+
# When false, reconnects with existing config unchanged.
|
|
197
|
+
def reconnect_for_parallel_execution
|
|
198
|
+
current_config = ActiveRecord::Base.connection_db_config.configuration_hash
|
|
45
199
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
200
|
+
new_config = if Apartment.manage_advisory_locks
|
|
201
|
+
current_config.merge(advisory_locks: false)
|
|
202
|
+
else
|
|
203
|
+
current_config
|
|
204
|
+
end
|
|
50
205
|
|
|
51
|
-
|
|
206
|
+
ActiveRecord::Base.establish_connection(new_config)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Delegate to Apartment.parallel_migration_threads
|
|
210
|
+
delegate :parallel_migration_threads, to: Apartment
|
|
211
|
+
|
|
212
|
+
# Get list of tenants excluding the default tenant
|
|
213
|
+
# Also filters out blank/empty tenant names to prevent errors
|
|
214
|
+
#
|
|
215
|
+
# @return [Array<String>] tenant names
|
|
216
|
+
def tenants_without_default
|
|
217
|
+
(tenants - [Apartment.default_tenant]).reject { |t| t.nil? || t.to_s.strip.empty? }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Get list of all tenants to operate on
|
|
221
|
+
# Supports DB env var for targeting specific tenants
|
|
222
|
+
# Filters out blank tenant names for safety
|
|
223
|
+
#
|
|
224
|
+
# @return [Array<String>] tenant names
|
|
225
|
+
def tenants
|
|
226
|
+
result = ENV['DB'] ? ENV['DB'].split(',').map(&:strip) : Apartment.tenant_names || []
|
|
227
|
+
result.reject { |t| t.nil? || t.to_s.strip.empty? }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Display warning if tenant list is empty
|
|
231
|
+
def warn_if_tenants_empty
|
|
232
|
+
return unless tenants.empty? && ENV['IGNORE_EMPTY_TENANTS'] != 'true'
|
|
233
|
+
|
|
234
|
+
puts <<~WARNING
|
|
235
|
+
[WARNING] - The list of tenants to migrate appears to be empty. This could mean a few things:
|
|
236
|
+
|
|
237
|
+
1. You may not have created any, in which case you can ignore this message
|
|
238
|
+
2. You've run `apartment:migrate` directly without loading the Rails environment
|
|
239
|
+
* `apartment:migrate` is now deprecated. Tenants will automatically be migrated with `db:migrate`
|
|
240
|
+
|
|
241
|
+
Note that your tenants currently haven't been migrated. You'll need to run `db:migrate` to rectify this.
|
|
242
|
+
WARNING
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Display summary of operation results
|
|
246
|
+
#
|
|
247
|
+
# @param operation [String] name of the operation (e.g., "Migration", "Rollback")
|
|
248
|
+
# @param results [Array<Result>] results from each_tenant
|
|
249
|
+
def display_summary(operation, results)
|
|
250
|
+
return if results.empty?
|
|
251
|
+
|
|
252
|
+
succeeded = results.count(&:success)
|
|
253
|
+
failed = results.reject(&:success)
|
|
254
|
+
|
|
255
|
+
puts "\n=== #{operation} Summary ==="
|
|
256
|
+
puts "Succeeded: #{succeeded}/#{results.size} tenants"
|
|
257
|
+
|
|
258
|
+
return if failed.empty?
|
|
259
|
+
|
|
260
|
+
puts "Failed: #{failed.size} tenants"
|
|
261
|
+
failed.each do |result|
|
|
262
|
+
puts " - #{result.tenant}: #{result.error}"
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Create a tenant with logging
|
|
267
|
+
#
|
|
268
|
+
# @param tenant_name [String] name of tenant to create
|
|
269
|
+
def create_tenant(tenant_name)
|
|
270
|
+
puts("Creating #{tenant_name} tenant")
|
|
271
|
+
Apartment::Tenant.create(tenant_name)
|
|
272
|
+
rescue Apartment::TenantExists => e
|
|
273
|
+
puts "Tried to create already existing tenant: #{e}"
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Migrate a single tenant with error handling based on strategy
|
|
277
|
+
#
|
|
278
|
+
# @param tenant_name [String] name of tenant to migrate
|
|
279
|
+
def migrate_tenant(tenant_name)
|
|
280
|
+
strategy = Apartment.db_migrate_tenant_missing_strategy
|
|
281
|
+
create_tenant(tenant_name) if strategy == :create_tenant
|
|
282
|
+
|
|
283
|
+
puts("Migrating #{tenant_name} tenant")
|
|
284
|
+
Apartment::Migrator.migrate(tenant_name)
|
|
285
|
+
rescue Apartment::TenantNotFound => e
|
|
286
|
+
raise(e) if strategy == :raise_exception
|
|
287
|
+
|
|
288
|
+
puts e.message
|
|
289
|
+
end
|
|
52
290
|
end
|
|
53
291
|
end
|
|
54
292
|
end
|
data/lib/apartment/tenant.rb
CHANGED
|
@@ -32,13 +32,13 @@ module Apartment
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
begin
|
|
35
|
-
require
|
|
35
|
+
require("apartment/adapters/#{adapter_method}")
|
|
36
36
|
rescue LoadError
|
|
37
|
-
raise
|
|
37
|
+
raise("The adapter `#{adapter_method}` is not yet supported")
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
unless respond_to?(adapter_method)
|
|
41
|
-
raise
|
|
41
|
+
raise(AdapterNotFound, "database configuration specifies nonexistent #{config[:adapter]} adapter")
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
send(adapter_method, config)
|
data/lib/apartment/version.rb
CHANGED
data/lib/apartment.rb
CHANGED
|
@@ -28,7 +28,8 @@ module Apartment
|
|
|
28
28
|
WRITER_METHODS = %i[tenant_names database_schema_file excluded_models
|
|
29
29
|
persistent_schemas connection_class
|
|
30
30
|
db_migrate_tenants db_migrate_tenant_missing_strategy seed_data_file
|
|
31
|
-
parallel_migration_threads pg_excluded_names
|
|
31
|
+
parallel_migration_threads pg_excluded_names
|
|
32
|
+
parallel_strategy manage_advisory_locks].freeze
|
|
32
33
|
|
|
33
34
|
attr_accessor(*ACCESSOR_METHODS)
|
|
34
35
|
attr_writer(*WRITER_METHODS)
|
|
@@ -41,7 +42,7 @@ module Apartment
|
|
|
41
42
|
|
|
42
43
|
# configure apartment with available options
|
|
43
44
|
def configure
|
|
44
|
-
yield
|
|
45
|
+
yield(self) if block_given?
|
|
45
46
|
end
|
|
46
47
|
|
|
47
48
|
def tenant_names
|
|
@@ -57,7 +58,7 @@ module Apartment
|
|
|
57
58
|
end
|
|
58
59
|
|
|
59
60
|
def db_config_for(tenant)
|
|
60
|
-
|
|
61
|
+
tenants_with_config[tenant] || connection_config
|
|
61
62
|
end
|
|
62
63
|
|
|
63
64
|
# Whether or not db:migrate should also migrate tenants
|
|
@@ -68,9 +69,10 @@ module Apartment
|
|
|
68
69
|
@db_migrate_tenants = true
|
|
69
70
|
end
|
|
70
71
|
|
|
71
|
-
# How to handle
|
|
72
|
-
#
|
|
73
|
-
#
|
|
72
|
+
# How to handle missing tenants during db:migrate
|
|
73
|
+
# :rescue_exception (default) - Log error, continue with other tenants
|
|
74
|
+
# :raise_exception - Stop migration immediately
|
|
75
|
+
# :create_tenant - Automatically create missing tenant and migrate
|
|
74
76
|
def db_migrate_tenant_missing_strategy
|
|
75
77
|
valid = %i[rescue_exception raise_exception create_tenant]
|
|
76
78
|
value = @db_migrate_tenant_missing_strategy || :rescue_exception
|
|
@@ -80,7 +82,7 @@ module Apartment
|
|
|
80
82
|
key_name = 'config.db_migrate_tenant_missing_strategy'
|
|
81
83
|
opt_names = valid.join(', ')
|
|
82
84
|
|
|
83
|
-
raise
|
|
85
|
+
raise(ApartmentError, "Option #{value} not valid for `#{key_name}`. Use one of #{opt_names}")
|
|
84
86
|
end
|
|
85
87
|
|
|
86
88
|
# Default to empty array
|
|
@@ -92,6 +94,24 @@ module Apartment
|
|
|
92
94
|
@parallel_migration_threads || 0
|
|
93
95
|
end
|
|
94
96
|
|
|
97
|
+
# Parallelism strategy for migrations
|
|
98
|
+
# :auto (default) - Detect platform: processes on Linux, threads on macOS/Windows
|
|
99
|
+
# :threads - Always use threads (safer, works everywhere)
|
|
100
|
+
# :processes - Always use processes (faster on Linux, may crash on macOS/Windows)
|
|
101
|
+
def parallel_strategy
|
|
102
|
+
@parallel_strategy || :auto
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Whether to manage PostgreSQL advisory locks during parallel migrations
|
|
106
|
+
# When true and parallel_migration_threads > 0, advisory locks are disabled
|
|
107
|
+
# during migration to prevent deadlocks, then restored afterward.
|
|
108
|
+
# Default: true
|
|
109
|
+
def manage_advisory_locks
|
|
110
|
+
return @manage_advisory_locks if defined?(@manage_advisory_locks)
|
|
111
|
+
|
|
112
|
+
@manage_advisory_locks = true
|
|
113
|
+
end
|
|
114
|
+
|
|
95
115
|
def persistent_schemas
|
|
96
116
|
@persistent_schemas || []
|
|
97
117
|
end
|
|
@@ -126,14 +146,19 @@ module Apartment
|
|
|
126
146
|
def extract_tenant_config
|
|
127
147
|
return {} unless @tenant_names
|
|
128
148
|
|
|
149
|
+
# Execute callable (proc/lambda) to get dynamic tenant list from database
|
|
129
150
|
values = @tenant_names.respond_to?(:call) ? @tenant_names.call : @tenant_names
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
151
|
+
|
|
152
|
+
# Normalize arrays to hash format (tenant_name => connection_config)
|
|
153
|
+
unless values.is_a?(Hash)
|
|
154
|
+
values = values.index_with do |_tenant|
|
|
155
|
+
connection_config
|
|
133
156
|
end
|
|
134
157
|
end
|
|
135
158
|
values.with_indifferent_access
|
|
136
159
|
rescue ActiveRecord::StatementInvalid
|
|
160
|
+
# Database query failed (table doesn't exist yet, connection issue)
|
|
161
|
+
# Return empty hash to allow app to boot without tenants
|
|
137
162
|
{}
|
|
138
163
|
end
|
|
139
164
|
end
|
|
@@ -5,7 +5,7 @@ module Apartment
|
|
|
5
5
|
source_root File.expand_path('templates', __dir__)
|
|
6
6
|
|
|
7
7
|
def copy_files
|
|
8
|
-
template
|
|
8
|
+
template('apartment.rb', File.join('config', 'initializers', 'apartment.rb'))
|
|
9
9
|
end
|
|
10
10
|
end
|
|
11
11
|
end
|
|
@@ -50,7 +50,7 @@ Apartment.configure do |config|
|
|
|
50
50
|
# end
|
|
51
51
|
# end
|
|
52
52
|
#
|
|
53
|
-
config.tenant_names = -> { ToDo_Tenant_Or_User_Model.pluck
|
|
53
|
+
config.tenant_names = -> { ToDo_Tenant_Or_User_Model.pluck(:database) }
|
|
54
54
|
|
|
55
55
|
# PostgreSQL:
|
|
56
56
|
# Specifies whether to use PostgreSQL schemas or create a new database per Tenant.
|
|
@@ -111,6 +111,6 @@ end
|
|
|
111
111
|
# }
|
|
112
112
|
|
|
113
113
|
# Rails.application.config.middleware.use Apartment::Elevators::Domain
|
|
114
|
-
Rails.application.config.middleware.use
|
|
114
|
+
Rails.application.config.middleware.use(Apartment::Elevators::Subdomain)
|
|
115
115
|
# Rails.application.config.middleware.use Apartment::Elevators::FirstSubdomain
|
|
116
116
|
# Rails.application.config.middleware.use Apartment::Elevators::Host
|
data/lib/tasks/apartment.rake
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
require 'apartment/migrator'
|
|
4
4
|
require 'apartment/tasks/task_helper'
|
|
5
|
+
require 'apartment/tasks/schema_dumper'
|
|
5
6
|
require 'parallel'
|
|
6
7
|
|
|
7
|
-
apartment_namespace = namespace
|
|
8
|
-
desc
|
|
9
|
-
task :
|
|
8
|
+
apartment_namespace = namespace(:apartment) do
|
|
9
|
+
desc('Create all tenants')
|
|
10
|
+
task(create: :environment) do
|
|
10
11
|
Apartment::TaskHelper.warn_if_tenants_empty
|
|
11
12
|
|
|
12
13
|
Apartment::TaskHelper.tenants.each do |tenant|
|
|
@@ -14,8 +15,8 @@ apartment_namespace = namespace :apartment do
|
|
|
14
15
|
end
|
|
15
16
|
end
|
|
16
17
|
|
|
17
|
-
desc
|
|
18
|
-
task :
|
|
18
|
+
desc('Drop all tenants')
|
|
19
|
+
task(drop: :environment) do
|
|
19
20
|
Apartment::TaskHelper.tenants.each do |tenant|
|
|
20
21
|
puts("Dropping #{tenant} tenant")
|
|
21
22
|
Apartment::Tenant.drop(tenant)
|
|
@@ -24,16 +25,29 @@ apartment_namespace = namespace :apartment do
|
|
|
24
25
|
end
|
|
25
26
|
end
|
|
26
27
|
|
|
27
|
-
desc
|
|
28
|
-
task :
|
|
28
|
+
desc('Migrate all tenants')
|
|
29
|
+
task(migrate: :environment) do
|
|
29
30
|
Apartment::TaskHelper.warn_if_tenants_empty
|
|
30
|
-
|
|
31
|
+
|
|
32
|
+
results = Apartment::TaskHelper.each_tenant do |tenant|
|
|
31
33
|
Apartment::TaskHelper.migrate_tenant(tenant)
|
|
32
34
|
end
|
|
35
|
+
|
|
36
|
+
Apartment::TaskHelper.display_summary('Migration', results)
|
|
37
|
+
|
|
38
|
+
# Dump schema after successful migrations
|
|
39
|
+
if results.all?(&:success)
|
|
40
|
+
Apartment::Tasks::SchemaDumper.dump_if_enabled
|
|
41
|
+
else
|
|
42
|
+
puts '[Apartment] Skipping schema dump due to migration failures'
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Exit with non-zero status if any tenant failed
|
|
46
|
+
exit(1) if results.any? { |r| !r.success }
|
|
33
47
|
end
|
|
34
48
|
|
|
35
|
-
desc
|
|
36
|
-
task :
|
|
49
|
+
desc('Seed all tenants')
|
|
50
|
+
task(seed: :environment) do
|
|
37
51
|
Apartment::TaskHelper.warn_if_tenants_empty
|
|
38
52
|
|
|
39
53
|
Apartment::TaskHelper.each_tenant do |tenant|
|
|
@@ -47,54 +61,67 @@ apartment_namespace = namespace :apartment do
|
|
|
47
61
|
end
|
|
48
62
|
end
|
|
49
63
|
|
|
50
|
-
desc
|
|
51
|
-
task :
|
|
64
|
+
desc('Rolls the migration back to the previous version (specify steps w/ STEP=n) across all tenants.')
|
|
65
|
+
task(rollback: :environment) do
|
|
52
66
|
Apartment::TaskHelper.warn_if_tenants_empty
|
|
53
67
|
|
|
54
|
-
step = ENV
|
|
68
|
+
step = ENV.fetch('STEP', '1').to_i
|
|
55
69
|
|
|
56
|
-
Apartment::TaskHelper.each_tenant do |tenant|
|
|
70
|
+
results = Apartment::TaskHelper.each_tenant do |tenant|
|
|
57
71
|
puts("Rolling back #{tenant} tenant")
|
|
58
|
-
Apartment::Migrator.rollback
|
|
59
|
-
|
|
60
|
-
|
|
72
|
+
Apartment::Migrator.rollback(tenant, step)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
Apartment::TaskHelper.display_summary('Rollback', results)
|
|
76
|
+
|
|
77
|
+
# Dump schema after successful rollback
|
|
78
|
+
if results.all?(&:success)
|
|
79
|
+
Apartment::Tasks::SchemaDumper.dump_if_enabled
|
|
80
|
+
else
|
|
81
|
+
puts '[Apartment] Skipping schema dump due to rollback failures'
|
|
61
82
|
end
|
|
83
|
+
|
|
84
|
+
exit(1) if results.any? { |r| !r.success }
|
|
62
85
|
end
|
|
63
86
|
|
|
64
|
-
namespace
|
|
65
|
-
desc
|
|
66
|
-
task :
|
|
87
|
+
namespace(:migrate) do
|
|
88
|
+
desc('Runs the "up" for a given migration VERSION across all tenants.')
|
|
89
|
+
task(up: :environment) do
|
|
67
90
|
Apartment::TaskHelper.warn_if_tenants_empty
|
|
68
91
|
|
|
69
|
-
version = ENV
|
|
70
|
-
raise
|
|
92
|
+
version = ENV.fetch('VERSION', nil)&.to_i
|
|
93
|
+
raise('VERSION is required') unless version
|
|
71
94
|
|
|
72
|
-
Apartment::TaskHelper.each_tenant do |tenant|
|
|
95
|
+
results = Apartment::TaskHelper.each_tenant do |tenant|
|
|
73
96
|
puts("Migrating #{tenant} tenant up")
|
|
74
|
-
Apartment::Migrator.run
|
|
75
|
-
rescue Apartment::TenantNotFound => e
|
|
76
|
-
puts e.message
|
|
97
|
+
Apartment::Migrator.run(:up, tenant, version)
|
|
77
98
|
end
|
|
99
|
+
|
|
100
|
+
Apartment::TaskHelper.display_summary('Migrate Up', results)
|
|
101
|
+
Apartment::Tasks::SchemaDumper.dump_if_enabled if results.all?(&:success)
|
|
102
|
+
exit(1) if results.any? { |r| !r.success }
|
|
78
103
|
end
|
|
79
104
|
|
|
80
|
-
desc
|
|
81
|
-
task :
|
|
105
|
+
desc('Runs the "down" for a given migration VERSION across all tenants.')
|
|
106
|
+
task(down: :environment) do
|
|
82
107
|
Apartment::TaskHelper.warn_if_tenants_empty
|
|
83
108
|
|
|
84
|
-
version = ENV
|
|
85
|
-
raise
|
|
109
|
+
version = ENV.fetch('VERSION', nil)&.to_i
|
|
110
|
+
raise('VERSION is required') unless version
|
|
86
111
|
|
|
87
|
-
Apartment::TaskHelper.each_tenant do |tenant|
|
|
112
|
+
results = Apartment::TaskHelper.each_tenant do |tenant|
|
|
88
113
|
puts("Migrating #{tenant} tenant down")
|
|
89
|
-
Apartment::Migrator.run
|
|
90
|
-
rescue Apartment::TenantNotFound => e
|
|
91
|
-
puts e.message
|
|
114
|
+
Apartment::Migrator.run(:down, tenant, version)
|
|
92
115
|
end
|
|
116
|
+
|
|
117
|
+
Apartment::TaskHelper.display_summary('Migrate Down', results)
|
|
118
|
+
Apartment::Tasks::SchemaDumper.dump_if_enabled if results.all?(&:success)
|
|
119
|
+
exit(1) if results.any? { |r| !r.success }
|
|
93
120
|
end
|
|
94
121
|
|
|
95
|
-
desc
|
|
96
|
-
task
|
|
97
|
-
if ENV
|
|
122
|
+
desc('Rolls back the tenant one migration and re migrate up (options: STEP=x, VERSION=x).')
|
|
123
|
+
task(:redo) do
|
|
124
|
+
if ENV.fetch('VERSION', nil)
|
|
98
125
|
apartment_namespace['migrate:down'].invoke
|
|
99
126
|
apartment_namespace['migrate:up'].invoke
|
|
100
127
|
else
|
data/ros-apartment.gemspec
CHANGED
|
@@ -32,9 +32,9 @@ Gem::Specification.new do |s|
|
|
|
32
32
|
|
|
33
33
|
s.required_ruby_version = '>= 3.1'
|
|
34
34
|
|
|
35
|
-
s.add_dependency('activerecord', '>=
|
|
36
|
-
s.add_dependency('activesupport', '>=
|
|
35
|
+
s.add_dependency('activerecord', '>= 7.0.0', '< 8.2')
|
|
36
|
+
s.add_dependency('activesupport', '>= 7.0.0', '< 8.2')
|
|
37
37
|
s.add_dependency('parallel', '< 2.0')
|
|
38
|
-
s.add_dependency('public_suffix', '>= 2.0.5', '
|
|
38
|
+
s.add_dependency('public_suffix', '>= 2.0.5', '< 7')
|
|
39
39
|
s.add_dependency('rack', '>= 1.3.6', '< 4.0')
|
|
40
40
|
end
|