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
|
@@ -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`
|
|
@@ -9,14 +9,14 @@ module Apartment
|
|
|
9
9
|
#
|
|
10
10
|
class HostHash < Generic
|
|
11
11
|
def initialize(app, hash = {}, processor = nil)
|
|
12
|
-
super
|
|
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
|
|
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
|
-
#
|
|
26
|
-
#
|
|
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
|
-
#
|
|
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
|
data/lib/apartment/migrator.rb
CHANGED
|
@@ -4,12 +4,12 @@ require 'apartment/tenant'
|
|
|
4
4
|
|
|
5
5
|
module Apartment
|
|
6
6
|
module Migrator
|
|
7
|
-
|
|
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']
|
|
12
|
+
version = ENV['VERSION']&.to_i
|
|
13
13
|
|
|
14
14
|
migration_scope_block = ->(migration) { ENV['SCOPE'].blank? || (ENV['SCOPE'] == migration.scope) }
|
|
15
15
|
|
data/lib/apartment/model.rb
CHANGED
|
@@ -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?
|
|
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
|
data/lib/apartment/railtie.rb
CHANGED
|
@@ -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(
|
|
51
|
+
next unless listener.instance_variable_get(:@delegate).is_a?(ActiveRecord::LogSubscriber)
|
|
52
52
|
|
|
53
|
-
ActiveSupport::Notifications.unsubscribe
|
|
53
|
+
ActiveSupport::Notifications.unsubscribe(listener)
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
-
Apartment::LogSubscriber.attach_to
|
|
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.
|
|
@@ -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
|