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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/.rubocop.yml +99 -2
  4. data/.ruby-version +1 -1
  5. data/AGENTS.md +19 -0
  6. data/Appraisals +15 -0
  7. data/CLAUDE.md +210 -0
  8. data/README.md +55 -8
  9. data/Rakefile +6 -5
  10. data/docs/adapters.md +177 -0
  11. data/docs/architecture.md +274 -0
  12. data/docs/elevators.md +226 -0
  13. data/docs/images/log_example.png +0 -0
  14. data/lib/apartment/CLAUDE.md +300 -0
  15. data/lib/apartment/adapters/CLAUDE.md +314 -0
  16. data/lib/apartment/adapters/abstract_adapter.rb +24 -15
  17. data/lib/apartment/adapters/jdbc_mysql_adapter.rb +1 -1
  18. data/lib/apartment/adapters/jdbc_postgresql_adapter.rb +3 -3
  19. data/lib/apartment/adapters/mysql2_adapter.rb +2 -2
  20. data/lib/apartment/adapters/postgresql_adapter.rb +42 -19
  21. data/lib/apartment/adapters/sqlite3_adapter.rb +7 -7
  22. data/lib/apartment/console.rb +1 -1
  23. data/lib/apartment/custom_console.rb +7 -7
  24. data/lib/apartment/elevators/CLAUDE.md +292 -0
  25. data/lib/apartment/elevators/domain.rb +1 -1
  26. data/lib/apartment/elevators/generic.rb +1 -1
  27. data/lib/apartment/elevators/host_hash.rb +3 -3
  28. data/lib/apartment/elevators/subdomain.rb +9 -5
  29. data/lib/apartment/log_subscriber.rb +1 -1
  30. data/lib/apartment/migrator.rb +2 -2
  31. data/lib/apartment/model.rb +1 -1
  32. data/lib/apartment/railtie.rb +3 -3
  33. data/lib/apartment/tasks/CLAUDE.md +107 -0
  34. data/lib/apartment/tasks/enhancements.rb +1 -1
  35. data/lib/apartment/tasks/schema_dumper.rb +109 -0
  36. data/lib/apartment/tasks/task_helper.rb +273 -35
  37. data/lib/apartment/tenant.rb +3 -3
  38. data/lib/apartment/version.rb +1 -1
  39. data/lib/apartment.rb +35 -10
  40. data/lib/generators/apartment/install/install_generator.rb +1 -1
  41. data/lib/generators/apartment/install/templates/apartment.rb +2 -2
  42. data/lib/tasks/apartment.rake +64 -37
  43. data/ros-apartment.gemspec +3 -3
  44. metadata +26 -15
@@ -0,0 +1,292 @@
1
+ # lib/apartment/elevators/ - Rack Middleware for Tenant Switching
2
+
3
+ This directory contains Rack middleware components ("elevators") that automatically detect and switch to the appropriate tenant based on incoming HTTP requests.
4
+
5
+ ## Purpose
6
+
7
+ Elevators intercept incoming requests and establish tenant context **before** the application processes the request. This eliminates the need for manual tenant switching in controllers.
8
+
9
+ ## Metaphor
10
+
11
+ Like a physical elevator taking you to different floors, these middleware components "elevate" your request to the correct tenant context.
12
+
13
+ ## File Structure
14
+
15
+ ```
16
+ elevators/
17
+ ├── generic.rb # Base elevator with customizable logic
18
+ ├── subdomain.rb # Switch based on subdomain (e.g., acme.example.com)
19
+ ├── first_subdomain.rb # Switch based on first subdomain in chain
20
+ ├── domain.rb # Switch based on domain (excluding www and TLD)
21
+ ├── host.rb # Switch based on full hostname
22
+ └── host_hash.rb # Switch based on hostname → tenant hash mapping
23
+ ```
24
+
25
+ ## How Elevators Work
26
+
27
+ ### Rack Middleware Pattern
28
+
29
+ All elevators are Rack middleware that intercept requests, extract tenant identifier, switch context, invoke next middleware, and ensure cleanup. See `generic.rb` for base implementation.
30
+
31
+ ### Request Lifecycle with Elevator
32
+
33
+ HTTP Request → Elevator extracts tenant → Switch to tenant → Application processes → Automatic cleanup (ensure block) → HTTP Response
34
+
35
+ **See**: `Generic#call` method for middleware call pattern.
36
+
37
+ ## Generic Elevator - Base Class
38
+
39
+ **Location**: `generic.rb`
40
+
41
+ ### Purpose
42
+
43
+ Provides base implementation and allows custom tenant resolution via Proc or subclass.
44
+
45
+ ### Implementation
46
+
47
+ Accepts optional Proc in initializer or expects `parse_tenant_name(request)` override in subclass. See `Generic` class implementation in `generic.rb`.
48
+
49
+ ### Usage Patterns
50
+
51
+ **With Proc**: Pass Proc to Generic that extracts tenant from Rack::Request.
52
+
53
+ **Via Subclass**: Inherit from Generic and override `parse_tenant_name`.
54
+
55
+ **See**: `generic.rb` and README.md for usage examples.
56
+
57
+ ## Subdomain Elevator
58
+
59
+ **Location**: `subdomain.rb`
60
+
61
+ ### Strategy
62
+
63
+ Extract first subdomain from hostname.
64
+
65
+ ### Implementation
66
+
67
+ Uses `request.subdomain` and checks against `excluded_subdomains` class attribute. Returns nil for excluded subdomains. See `Subdomain#parse_tenant_name` in `subdomain.rb`.
68
+
69
+ ### Configuration
70
+
71
+ Add to middleware stack in `application.rb` and configure `excluded_subdomains` class attribute. See README.md for examples.
72
+
73
+ ### Behavior
74
+
75
+ | Request URL | Subdomain | Excluded? | Tenant |
76
+ |------------------------------|-----------|-----------|-------------|
77
+ | http://acme.example.com | acme | No | acme |
78
+ | http://widgets.example.com | widgets | No | widgets |
79
+ | http://www.example.com | www | Yes | (default) |
80
+ | http://api.example.com | api | Yes | (default) |
81
+ | http://example.com | (empty) | N/A | (default) |
82
+
83
+ ### Why PublicSuffix Dependency?
84
+
85
+ **Rationale**: International domains require proper TLD parsing. Without PublicSuffix, `example.co.uk` would incorrectly parse `.uk` as the TLD rather than `.co.uk`, causing subdomain extraction to fail.
86
+
87
+ **Trade-off**: Adds gem dependency, but necessary for international domain support.
88
+
89
+ ## FirstSubdomain Elevator
90
+
91
+ **Location**: `first_subdomain.rb`
92
+
93
+ ### Strategy
94
+
95
+ Extract **first** subdomain from chain (for nested subdomains).
96
+
97
+ ### Implementation
98
+
99
+ Splits subdomain on `.` and takes first part. See `FirstSubdomain#parse_tenant_name` in `first_subdomain.rb`.
100
+
101
+ ### Configuration
102
+
103
+ Add to middleware stack and configure excluded subdomains. See README.md for configuration.
104
+
105
+ ### Use Case
106
+
107
+ Multi-level subdomain structures where tenant is always leftmost:
108
+ - `{tenant}.api.example.com`
109
+ - `{tenant}.app.example.com`
110
+ - `{tenant}.staging.example.com`
111
+
112
+ ### Note
113
+
114
+ In current v3 implementation, `Subdomain` and `FirstSubdomain` may behave identically depending on Rails version due to how `request.subdomain` works. For true nested support, test thoroughly or use custom elevator.
115
+
116
+ ## Domain Elevator
117
+
118
+ **Location**: `domain.rb`
119
+
120
+ ### Strategy
121
+
122
+ Use domain name (excluding 'www' and top-level domain) as tenant.
123
+
124
+ ### Implementation
125
+
126
+ Extracts domain name excluding TLD and 'www' prefix. See `Domain#parse_tenant_name` in `domain.rb`.
127
+
128
+ ### Configuration
129
+
130
+ Add to middleware stack. See README.md.
131
+
132
+ ### Use Case
133
+
134
+ When full domain (not subdomain) identifies tenant:
135
+ - `acme-corp.com` → tenant: acme-corp
136
+ - `widgets-inc.com` → tenant: widgets-inc
137
+
138
+ ## Host Elevator
139
+
140
+ **Location**: `host.rb`
141
+
142
+ ### Strategy
143
+
144
+ Use **full hostname** as tenant, optionally ignoring specified first subdomains.
145
+
146
+ ### Implementation
147
+
148
+ Uses full hostname as tenant, optionally ignoring specified first subdomains. See `Host#parse_tenant_name` in `host.rb`.
149
+
150
+ ### Configuration
151
+
152
+ Add to middleware stack and configure `ignored_first_subdomains`. See README.md.
153
+
154
+ ### Use Case
155
+
156
+ When each full hostname represents a different tenant:
157
+ - Tenants use custom domains: `acme-corp.com`, `widgets-inc.net`
158
+ - Internal apps: `billing.internal.company.com`, `crm.internal.company.com`
159
+
160
+ ## HostHash Elevator
161
+
162
+ **Location**: `host_hash.rb`
163
+
164
+ ### Strategy
165
+
166
+ Direct **mapping** from hostname to tenant name via hash.
167
+
168
+ ### Implementation
169
+
170
+ Accepts hash mapping hostnames to tenant names. See `HostHash` implementation in `host_hash.rb`.
171
+
172
+ ### Configuration
173
+
174
+ Pass hash to HostHash initializer when adding to middleware stack. See README.md for examples.
175
+
176
+ ### Use Cases
177
+
178
+ - **Custom domains**: Each tenant has their own domain
179
+ - **Explicit mapping**: No parsing logic, direct control
180
+ - **Different TLDs**: .com, .io, .net, etc.
181
+
182
+ ### Advantages
183
+
184
+ - ✅ Explicit control
185
+ - ✅ No parsing ambiguity
186
+ - ✅ Works with any hostname pattern
187
+
188
+ ### Disadvantages
189
+
190
+ - ❌ Requires manual configuration per tenant
191
+ - ❌ Not dynamic (requires app restart for changes)
192
+ - ❌ Doesn't scale to hundreds of tenants
193
+
194
+ ## Middleware Positioning
195
+
196
+ ### Why Position Matters
197
+
198
+ **Critical constraint**: Elevators must run before session and authentication middleware.
199
+
200
+ **Why this matters**: Session middleware loads user data based on session ID. If session loads before tenant is established, you get the wrong tenant's session data. This creates security vulnerabilities where User A sees User B's data.
201
+
202
+ **Example failure**: Without proper positioning, `www.acme.com` might load session data from `widgets.com` tenant if session middleware runs first.
203
+
204
+ **How to verify**: Run `Rails.application.middleware` and confirm elevator appears before `ActionDispatch::Session::CookieStore` and authentication middleware like `Warden::Manager`.
205
+
206
+ ## Creating Custom Elevators
207
+
208
+ ### Method 1: Using Proc with Generic
209
+
210
+ Pass Proc to Generic elevator for inline tenant detection logic. See `generic.rb` and README.md.
211
+
212
+ ### Method 2: Subclassing Generic
213
+
214
+ Create custom class inheriting from Generic, override `parse_tenant_name(request)`. Supports multi-strategy fallback logic. See `generic.rb` for base class.
215
+
216
+ ## Error Handling
217
+
218
+ ### Handling Missing Tenants
219
+
220
+ Custom elevators can rescue `Apartment::TenantNotFound` and return appropriate HTTP responses (404, redirect, etc.). See `generic.rb` for base call pattern.
221
+
222
+ ### Custom Error Pages
223
+
224
+ Override `call(env)` method to wrap `super` in rescue block and handle errors. See existing elevator implementations for patterns.
225
+
226
+ ## Testing Elevators
227
+
228
+ ### Unit Testing
229
+
230
+ Use `Rack::MockRequest` to create test requests and mock `Apartment::Tenant.switch`. See `spec/unit/elevators/` for test patterns.
231
+
232
+ ### Integration Testing
233
+
234
+ Create test tenants in before hooks, make requests to different subdomains/hosts, verify correct tenant context. See `spec/integration/` for examples.
235
+
236
+ ## Performance Considerations
237
+
238
+ ### Why Caching Matters for Custom Elevators
239
+
240
+ **Problem**: If your custom elevator queries the database to resolve tenant (e.g., looking up tenant by API key), you add database latency to **every request**.
241
+
242
+ **Impact**: 10-50ms per request × thousands of requests = significant overhead.
243
+
244
+ **Solution**: Cache tenant name lookups. Trade-off is stale cache if tenants are renamed, but this is rare.
245
+
246
+ ### Why Preloaded Hash Maps Beat Database Queries
247
+
248
+ **Database query approach**: SELECT tenant_name FROM tenants WHERE subdomain = ? — runs on every request.
249
+
250
+ **Hash map approach**: Loaded once at boot, O(1) lookup with no I/O.
251
+
252
+ **Trade-off**: Hash maps don't update without restart, but for most applications tenant-to-subdomain mapping is stable.
253
+
254
+ ### Why Monitor Elevator Performance
255
+
256
+ **Hidden cost**: Elevator runs on every request. 10ms overhead is 10% latency penalty on a 100ms request.
257
+
258
+ **Target**: Elevator should complete in <5ms. If >100ms, investigate and add logging.
259
+
260
+ ## Common Issues
261
+
262
+ ### Issue: Elevator Not Triggering
263
+
264
+ **Symptoms**: Tenant always default
265
+
266
+ **Causes**: Elevator not in middleware stack, `parse_tenant_name` returning nil, or incorrect middleware positioning
267
+
268
+ **Debug**: Add logging to `parse_tenant_name` to inspect extracted tenant values.
269
+
270
+ ### Issue: TenantNotFound Errors
271
+
272
+ **Symptoms**: 500 errors on some requests
273
+
274
+ **Causes**: Tenant doesn't exist or subdomain not in tenant list
275
+
276
+ **Solution**: Add error handling in custom elevator or validate tenant existence before switching.
277
+
278
+ ## Best Practices
279
+
280
+ 1. **Position elevators early** in middleware stack
281
+ 2. **Handle errors gracefully** (don't expose internals)
282
+ 3. **Cache lookups** if using database queries
283
+ 4. **Test thoroughly** with multiple tenants
284
+ 5. **Monitor performance** (log slow switches)
285
+ 6. **Document custom logic** for maintainability
286
+
287
+ ## References
288
+
289
+ - Rack middleware: https://github.com/rack/rack/wiki/Middleware
290
+ - Rack::Request: https://www.rubydoc.info/github/rack/rack/Rack/Request
291
+ - Rails middleware: https://guides.rubyonrails.org/rails_on_rack.html
292
+ - Generic elevator: `generic.rb`
@@ -16,7 +16,7 @@ module Apartment
16
16
  def parse_tenant_name(request)
17
17
  return nil if request.host.blank?
18
18
 
19
- request.host.match(/(www\.)?(?<sld>[^.]*)/)['sld']
19
+ request.host.match(/(?:www\.)?(?<sld>[^.]*)/)['sld']
20
20
  end
21
21
  end
22
22
  end
@@ -26,7 +26,7 @@ module Apartment
26
26
  end
27
27
 
28
28
  def parse_tenant_name(_request)
29
- raise 'Override'
29
+ raise('Override')
30
30
  end
31
31
  end
32
32
  end
@@ -9,14 +9,14 @@ module Apartment
9
9
  #
10
10
  class HostHash < Generic
11
11
  def initialize(app, hash = {}, processor = nil)
12
- super app, processor
12
+ super(app, processor)
13
13
  @hash = hash
14
14
  end
15
15
 
16
16
  def parse_tenant_name(request)
17
17
  unless @hash.key?(request.host)
18
- raise TenantNotFound,
19
- "Cannot find tenant for host #{request.host}"
18
+ raise(TenantNotFound,
19
+ "Cannot find tenant for host #{request.host}")
20
20
  end
21
21
 
22
22
  @hash[request.host]
@@ -22,8 +22,9 @@ module Apartment
22
22
  def parse_tenant_name(request)
23
23
  request_subdomain = subdomain(request.host)
24
24
 
25
- # If the domain acquired is set to be excluded, set the tenant to whatever is currently
26
- # next in line in the schema search path.
25
+ # Excluded subdomains (www, api, admin) return nil uses default tenant.
26
+ # Returning nil instead of default_tenant name allows Apartment to decide
27
+ # the fallback behavior.
27
28
  tenant = if self.class.excluded_subdomains.include?(request_subdomain)
28
29
  nil
29
30
  else
@@ -35,11 +36,11 @@ module Apartment
35
36
 
36
37
  protected
37
38
 
38
- # *Almost* a direct ripoff of ActionDispatch::Request subdomain methods
39
+ # Subdomain extraction using PublicSuffix to handle international TLDs correctly.
40
+ # Examples: api.example.com → "api", www.example.co.uk → "www"
39
41
 
40
- # Only care about the first subdomain for the database name
41
42
  def subdomain(host)
42
- subdomains(host).first
43
+ subdomains(host).first # Only first subdomain matters for tenant resolution
43
44
  end
44
45
 
45
46
  def subdomains(host)
@@ -50,6 +51,7 @@ module Apartment
50
51
  !ip_host?(host) && domain_valid?(host)
51
52
  end
52
53
 
54
+ # Reject IP addresses (127.0.0.1, 192.168.1.1) - no subdomain concept
53
55
  def ip_host?(host)
54
56
  !/^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/.match(host).nil?
55
57
  end
@@ -58,6 +60,8 @@ module Apartment
58
60
  PublicSuffix.valid?(host, ignore_private: true)
59
61
  end
60
62
 
63
+ # PublicSuffix.parse handles TLDs correctly: example.co.uk has TLD "co.uk"
64
+ # .trd (third-level domain) returns subdomain parts, excluding TLD
61
65
  def parse_host(host)
62
66
  (PublicSuffix.parse(host, ignore_private: true).trd || '').split('.')
63
67
  end
@@ -14,7 +14,7 @@ module Apartment
14
14
 
15
15
  private
16
16
 
17
- def debug(progname = nil, &blk)
17
+ def debug(progname = nil, &)
18
18
  progname = " #{apartment_log}#{progname}" unless progname.nil?
19
19
 
20
20
  super
@@ -4,12 +4,12 @@ require 'apartment/tenant'
4
4
 
5
5
  module Apartment
6
6
  module Migrator
7
- extend self
7
+ module_function
8
8
 
9
9
  # Migrate to latest
10
10
  def migrate(database)
11
11
  Tenant.switch(database) do
12
- version = ENV['VERSION'] ? ENV['VERSION'].to_i : nil
12
+ version = ENV['VERSION']&.to_i
13
13
 
14
14
  migration_scope_block = ->(migration) { ENV['SCOPE'].blank? || (ENV['SCOPE'] == migration.scope) }
15
15
 
@@ -14,7 +14,7 @@ module Apartment
14
14
  # Modifying the cache key to have a reference to the current tenant,
15
15
  # so the cached statement is referring only to the tenant in which we've
16
16
  # executed this
17
- cache_key = if key.is_a? String
17
+ cache_key = if key.is_a?(String)
18
18
  "#{Apartment::Tenant.current}_#{key}"
19
19
  else
20
20
  # NOTE: In Rails 6.0.4 we start receiving an ActiveRecord::Reflection::BelongsToReflection
@@ -48,12 +48,12 @@ module Apartment
48
48
  # NOTE: Load the custom log subscriber if enabled
49
49
  if Apartment.active_record_log
50
50
  ActiveSupport::Notifications.notifier.listeners_for('sql.active_record').each do |listener|
51
- next unless listener.instance_variable_get('@delegate').is_a?(ActiveRecord::LogSubscriber)
51
+ next unless listener.instance_variable_get(:@delegate).is_a?(ActiveRecord::LogSubscriber)
52
52
 
53
- ActiveSupport::Notifications.unsubscribe listener
53
+ ActiveSupport::Notifications.unsubscribe(listener)
54
54
  end
55
55
 
56
- Apartment::LogSubscriber.attach_to :active_record
56
+ Apartment::LogSubscriber.attach_to(:active_record)
57
57
  end
58
58
  end
59
59
 
@@ -0,0 +1,107 @@
1
+ # lib/apartment/tasks/ - Rake Task Infrastructure
2
+
3
+ This directory contains modules that support Apartment's rake task operations, particularly tenant migrations with optional parallelism.
4
+
5
+ ## Problem Context
6
+
7
+ Multi-tenant PostgreSQL applications using schema-per-tenant isolation face operational challenges:
8
+
9
+ 1. **Migration time scales linearly**: 100 tenants × 2 seconds each = 3+ minutes blocking deploys
10
+ 2. **Rails assumes single-schema**: Built-in migration tasks don't iterate over tenant schemas
11
+ 3. **Parallel execution has pitfalls**: Database connections, advisory locks, and platform differences create subtle failure modes
12
+
13
+ ## Files
14
+
15
+ ### task_helper.rb
16
+
17
+ **Purpose**: Orchestrates tenant iteration for rake tasks with optional parallel execution.
18
+
19
+ **Key decisions**:
20
+
21
+ - **Result-based error handling**: Operations return `Result` structs instead of raising exceptions. This allows migrations to continue for other tenants when one fails, with aggregated reporting at the end.
22
+
23
+ - **Platform-aware parallelism**: macOS has documented issues with libpq after `fork()` due to GSS/Kerberos state. We auto-detect the platform and choose threads (safe everywhere) or processes (faster on Linux) accordingly. Developers can override via `parallel_strategy` config.
24
+
25
+ - **Advisory lock management**: Rails uses `pg_advisory_lock` to prevent concurrent migrations. With parallel tenant migrations, all workers compete for the same lock, causing deadlocks. We disable advisory locks during parallel execution. **This shifts responsibility to the developer** to ensure migrations are parallel-safe.
26
+
27
+ **When to use parallel migrations**:
28
+
29
+ Use when you have many tenants and your migrations only touch tenant-specific objects. Avoid when migrations create extensions, modify shared types, or have cross-tenant dependencies.
30
+
31
+ **Configuration options** (set in `config/initializers/apartment.rb`):
32
+
33
+ | Option | Default | Purpose |
34
+ |--------|---------|---------|
35
+ | `parallel_migration_threads` | `0` | Worker count. 0 = sequential (safest) |
36
+ | `parallel_strategy` | `:auto` | `:auto`, `:threads`, or `:processes` |
37
+ | `manage_advisory_locks` | `true` | Disable locks during parallel execution |
38
+
39
+ ### schema_dumper.rb
40
+
41
+ **Purpose**: Ensures schema is dumped from the public schema after tenant migrations.
42
+
43
+ **Why this exists**: After `rails db:migrate`, Rails dumps the current schema. Without intervention, this could capture the last-migrated tenant's schema rather than the authoritative public schema. We switch to the default tenant before invoking the dump.
44
+
45
+ **Rails convention compliance**: Respects all relevant Rails settings:
46
+ - `dump_schema_after_migration`: Global toggle for automatic dumps
47
+ - `schema_format`: `:ruby` produces schema.rb, `:sql` produces structure.sql
48
+ - `database_tasks`, `replica`, `schema_dump`: Per-database settings
49
+
50
+ ### enhancements.rb
51
+
52
+ **Purpose**: Hooks Apartment tasks into Rails' standard `db:migrate` and `db:rollback` tasks.
53
+
54
+ **Design choice**: We enhance rather than replace Rails tasks. Running `rails db:migrate` automatically migrates all tenant schemas after the public schema.
55
+
56
+ ## Relationship to Other Components
57
+
58
+ - **Apartment::Migrator** (`lib/apartment/migrator.rb`): The actual migration execution logic. TaskHelper coordinates which tenants to migrate; Migrator handles the per-tenant work.
59
+
60
+ - **Rake tasks** (`lib/tasks/apartment.rake`): Define the public task interface (`apartment:migrate`, etc.). These tasks use TaskHelper for iteration.
61
+
62
+ - **Configuration** (`lib/apartment.rb`): Parallel execution settings live in the main Apartment module.
63
+
64
+ ## Common Failure Modes
65
+
66
+ ### Connection pool exhaustion
67
+
68
+ **Symptom**: "could not obtain a connection from the pool" errors
69
+
70
+ **Cause**: `parallel_migration_threads` exceeds database pool size
71
+
72
+ **Fix**: Ensure `pool` in `database.yml` > `parallel_migration_threads`
73
+
74
+ ### Advisory lock deadlocks
75
+
76
+ **Symptom**: Migrations hang indefinitely
77
+
78
+ **Cause**: Multiple workers waiting for the same advisory lock
79
+
80
+ **Fix**: Ensure `manage_advisory_locks: true` (default) when using parallelism
81
+
82
+ ### macOS fork crashes
83
+
84
+ **Symptom**: Segfaults or GSS-API errors when using process-based parallelism on macOS
85
+
86
+ **Cause**: libpq doesn't support fork() cleanly on macOS
87
+
88
+ **Fix**: Use `parallel_strategy: :threads` or rely on `:auto` detection
89
+
90
+ ### Empty tenant name errors
91
+
92
+ **Symptom**: `PG::SyntaxError: zero-length delimited identifier`
93
+
94
+ **Cause**: `tenant_names` proc returned empty strings or nil values
95
+
96
+ **Fix**: Fixed in v3.4.0 - empty values are now filtered automatically
97
+
98
+ ## Testing Considerations
99
+
100
+ Parallel execution paths are difficult to unit test due to process isolation and connection state. The test suite verifies:
101
+
102
+ - Correct delegation between sequential/parallel paths
103
+ - Platform detection logic
104
+ - Advisory lock ENV management
105
+ - Result aggregation and error capture
106
+
107
+ Integration testing of actual parallel execution happens in CI across Linux (processes) and macOS (threads) runners.
@@ -46,7 +46,7 @@ module Apartment
46
46
  end
47
47
 
48
48
  def inserted_task_name(task)
49
- task.name.sub(/db:/, 'apartment:')
49
+ task.name.sub('db:', 'apartment:')
50
50
  end
51
51
  end
52
52
  end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Apartment
4
+ module Tasks
5
+ # Handles automatic schema dumping after tenant migrations.
6
+ #
7
+ # ## Problem Context
8
+ #
9
+ # After running `rails db:migrate`, Rails dumps the schema to capture the
10
+ # current database structure. With Apartment, tenant migrations modify
11
+ # individual schemas but the canonical structure lives in the public/default
12
+ # schema. Without explicit handling, the schema could be dumped from the
13
+ # last-migrated tenant schema instead of the authoritative public schema.
14
+ #
15
+ # ## Why This Approach
16
+ #
17
+ # We switch to the default tenant before dumping to ensure the schema file
18
+ # reflects the public schema structure. This is correct because:
19
+ #
20
+ # 1. All tenant schemas are created from the same schema file
21
+ # 2. The public schema is the source of truth for structure
22
+ # 3. Tenant-specific data differences don't affect schema structure
23
+ #
24
+ # ## Rails Convention Compliance
25
+ #
26
+ # We respect several Rails configurations rather than inventing our own:
27
+ #
28
+ # - `config.active_record.dump_schema_after_migration`: Global toggle
29
+ # - `config.active_record.schema_format`: `:ruby` for schema.rb, `:sql` for structure.sql
30
+ # - `database_tasks: true/false`: Per-database migration responsibility
31
+ # - `replica: true`: Excludes read replicas from schema operations
32
+ # - `schema_dump: false`: Per-database schema dump toggle
33
+ #
34
+ # The `db:schema:dump` task respects `schema_format` and produces either
35
+ # schema.rb or structure.sql accordingly.
36
+ #
37
+ # ## Gotchas
38
+ #
39
+ # - Schema dump failures are logged but don't fail the migration. This
40
+ # prevents a secondary concern from blocking critical migrations.
41
+ # - Multi-database setups must mark one connection with `database_tasks: true`
42
+ # to indicate which database owns schema management.
43
+ # - Don't call `Rails.application.load_tasks` here; if invoked from a rake
44
+ # task, it re-triggers apartment enhancements causing recursion.
45
+ module SchemaDumper
46
+ class << self
47
+ # Entry point called after successful migrations. Checks all relevant
48
+ # Rails settings before attempting dump.
49
+ def dump_if_enabled
50
+ return unless rails_dump_schema_enabled?
51
+
52
+ db_config = find_schema_dump_config
53
+ return if db_config.nil?
54
+
55
+ schema_dump_setting = db_config.configuration_hash[:schema_dump]
56
+ return if schema_dump_setting == false
57
+
58
+ Apartment::Tenant.switch(Apartment.default_tenant) do
59
+ dump_schema
60
+ end
61
+ rescue StandardError => e
62
+ # Log but don't fail - schema dump is secondary to migration success
63
+ Rails.logger.warn("[Apartment] Schema dump failed: #{e.message}")
64
+ end
65
+
66
+ private
67
+
68
+ # Finds the database configuration responsible for schema management.
69
+ # Multi-database setups use `database_tasks: true` to mark the primary
70
+ # migration database. Falls back to 'primary' named config.
71
+ def find_schema_dump_config
72
+ configs = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env)
73
+
74
+ migration_config = configs.find { |c| c.database_tasks? && !c.replica? }
75
+ return migration_config if migration_config
76
+
77
+ configs.find { |c| c.name == 'primary' }
78
+ end
79
+
80
+ # Invokes the standard Rails schema dump task. We reenable first
81
+ # because Rake tasks can only run once per session by default.
82
+ def dump_schema
83
+ if task_defined?('db:schema:dump')
84
+ Rails.logger.info('[Apartment] Dumping schema from default tenant...')
85
+ Rake::Task['db:schema:dump'].reenable
86
+ Rake::Task['db:schema:dump'].invoke
87
+ Rails.logger.info('[Apartment] Schema dump completed.')
88
+ else
89
+ Rails.logger.warn('[Apartment] db:schema:dump task not found')
90
+ end
91
+ end
92
+
93
+ # Safe task existence check. Avoids load_tasks which would cause
94
+ # recursive enhancement loading when called from apartment rake tasks.
95
+ def task_defined?(task_name)
96
+ Rake::Task.task_defined?(task_name)
97
+ end
98
+
99
+ # Checks Rails' global schema dump setting. Older Rails versions
100
+ # may not have this method, so we default to enabled.
101
+ def rails_dump_schema_enabled?
102
+ return true unless ActiveRecord::Base.respond_to?(:dump_schema_after_migration)
103
+
104
+ ActiveRecord::Base.dump_schema_after_migration
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end