ros-apartment 3.1.0 → 3.3.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/.rubocop.yml +142 -8
  4. data/.ruby-version +1 -1
  5. data/Appraisals +125 -30
  6. data/CLAUDE.md +210 -0
  7. data/CODE_OF_CONDUCT.md +71 -0
  8. data/Gemfile +15 -0
  9. data/README.md +49 -31
  10. data/Rakefile +44 -25
  11. data/docs/adapters.md +177 -0
  12. data/docs/architecture.md +274 -0
  13. data/docs/elevators.md +226 -0
  14. data/docs/images/log_example.png +0 -0
  15. data/lib/apartment/CLAUDE.md +300 -0
  16. data/lib/apartment/active_record/connection_handling.rb +2 -2
  17. data/lib/apartment/active_record/postgres/schema_dumper.rb +20 -0
  18. data/lib/apartment/active_record/postgresql_adapter.rb +19 -4
  19. data/lib/apartment/adapters/CLAUDE.md +314 -0
  20. data/lib/apartment/adapters/abstract_adapter.rb +24 -15
  21. data/lib/apartment/adapters/jdbc_mysql_adapter.rb +1 -1
  22. data/lib/apartment/adapters/jdbc_postgresql_adapter.rb +3 -3
  23. data/lib/apartment/adapters/mysql2_adapter.rb +2 -2
  24. data/lib/apartment/adapters/postgresql_adapter.rb +55 -36
  25. data/lib/apartment/adapters/sqlite3_adapter.rb +7 -7
  26. data/lib/apartment/console.rb +1 -1
  27. data/lib/apartment/custom_console.rb +7 -7
  28. data/lib/apartment/deprecation.rb +2 -5
  29. data/lib/apartment/elevators/CLAUDE.md +292 -0
  30. data/lib/apartment/elevators/domain.rb +1 -1
  31. data/lib/apartment/elevators/generic.rb +1 -1
  32. data/lib/apartment/elevators/host_hash.rb +3 -3
  33. data/lib/apartment/elevators/subdomain.rb +9 -5
  34. data/lib/apartment/log_subscriber.rb +1 -1
  35. data/lib/apartment/migrator.rb +17 -5
  36. data/lib/apartment/model.rb +1 -1
  37. data/lib/apartment/railtie.rb +3 -3
  38. data/lib/apartment/tasks/enhancements.rb +1 -1
  39. data/lib/apartment/tasks/task_helper.rb +6 -4
  40. data/lib/apartment/tenant.rb +3 -3
  41. data/lib/apartment/version.rb +1 -1
  42. data/lib/apartment.rb +23 -11
  43. data/lib/generators/apartment/install/install_generator.rb +1 -1
  44. data/lib/generators/apartment/install/templates/apartment.rb +2 -2
  45. data/lib/tasks/apartment.rake +25 -25
  46. data/ros-apartment.gemspec +10 -35
  47. metadata +44 -245
  48. data/.rubocop_todo.yml +0 -439
  49. /data/{CHANGELOG.md → legacy_CHANGELOG.md} +0 -0
@@ -0,0 +1,274 @@
1
+ # Apartment v3 Architecture - Design Decisions
2
+
3
+ **Core files**: `lib/apartment.rb`, `lib/apartment/tenant.rb`
4
+
5
+ ## Architectural Philosophy
6
+
7
+ Apartment v3 uses **thread-local state** for tenant tracking. Each thread maintains its own adapter instance, enabling concurrent request handling without cross-contamination.
8
+
9
+ **Critical design constraint**: This architecture is **not fiber-safe**. The v4 refactor addresses this limitation.
10
+
11
+ ## Core Design Patterns
12
+
13
+ ### 1. Adapter Pattern
14
+
15
+ **Why**: Different databases require fundamentally different isolation strategies (PostgreSQL schemas vs MySQL databases vs SQLite files).
16
+
17
+ **Implementation**: `AbstractAdapter` defines lifecycle, database-specific subclasses implement mechanics.
18
+
19
+ **Trade-off**: Adds abstraction layer but enables multi-database support.
20
+
21
+ **See**: `lib/apartment/adapters/`
22
+
23
+ ### 2. Delegation Pattern
24
+
25
+ **Why**: Simplify public API while maintaining internal flexibility.
26
+
27
+ **Implementation**: `Apartment::Tenant` delegates all operations to the thread-local adapter instance.
28
+
29
+ **Benefit**: Swap adapter implementations without changing user-facing code.
30
+
31
+ **See**: `lib/apartment/tenant.rb` - uses `def_delegators`
32
+
33
+ ### 3. Thread-Local Storage Pattern
34
+
35
+ **Why**: Concurrent requests need isolated tenant contexts.
36
+
37
+ **Implementation**: Adapter stored in `Thread.current[:apartment_adapter]`.
38
+
39
+ **Safe for**:
40
+ - Multi-threaded web servers (Puma, Falcon)
41
+ - Background job processors (Sidekiq with threading)
42
+ - Concurrent requests to different tenants
43
+
44
+ **Unsafe for**:
45
+ - Fiber-based async frameworks (fibers share thread storage)
46
+ - Manual thread management with shared state
47
+
48
+ **Alternative considered**: Global state with mutex locking. Rejected due to contention and complexity.
49
+
50
+ **See**: `Apartment::Tenant.adapter` method in `tenant.rb`
51
+
52
+ ### 4. Callback Pattern
53
+
54
+ **Why**: Users need extension points without modifying gem code.
55
+
56
+ **Implementation**: ActiveSupport::Callbacks on `:create` and `:switch` events.
57
+
58
+ **Use cases**: Logging, notifications, analytics, APM integration.
59
+
60
+ **See**: Callback definitions in `AbstractAdapter` class
61
+
62
+ ### 5. Strategy Pattern (Elevators)
63
+
64
+ **Why**: Different applications need different tenant resolution mechanisms (subdomain, domain, header, session).
65
+
66
+ **Implementation**: Pluggable Rack middleware with customizable `parse_tenant_name`.
67
+
68
+ **Benefit**: Easy to add custom strategies without changing core.
69
+
70
+ **See**: `lib/apartment/elevators/`
71
+
72
+ ## Component Interaction
73
+
74
+ ### Request Processing Flow
75
+
76
+ **Path**: Rack request → Elevator → Adapter → Database
77
+
78
+ **Key decision points**:
79
+ 1. **Elevator positioning**: Must be before session/auth middleware. Why? Tenant context must be established before session data loads, otherwise wrong tenant's sessions leak.
80
+
81
+ 2. **Automatic cleanup**: `ensure` blocks in `switch()` guarantee tenant rollback even on exceptions. Why? Prevents connection staying in wrong tenant after errors.
82
+
83
+ 3. **Query cache management**: Explicitly preserve across switches. Why? Rails disables during connection establishment; must manually restore to maintain performance.
84
+
85
+ **See**: `lib/apartment/elevators/generic.rb` - base middleware pattern
86
+
87
+ ### Tenant Creation Flow
88
+
89
+ **Path**: User code → Adapter → Database → Schema import → Seeding
90
+
91
+ **Key decisions**:
92
+ 1. **Callback execution**: Wraps entire creation in callbacks. Why? Logging and notifications must capture the complete operation.
93
+
94
+ 2. **Switch during creation**: Import and seed run in tenant context. Why? Schema loading must target new tenant, not default.
95
+
96
+ 3. **Transaction handling**: Detect existing transactions (RSpec). Why? Avoid nested transactions that PostgreSQL rejects.
97
+
98
+ **See**: `AbstractAdapter#create` method
99
+
100
+ ### Configuration Resolution
101
+
102
+ **Why dynamic tenant lists?**: Tenants change at runtime (new signups, deletions). Static lists become stale.
103
+
104
+ **Implementation**: `tenant_names` can be callable (proc/lambda) that queries database.
105
+
106
+ **Critical handling**: Rescue `ActiveRecord::StatementInvalid` during boot. Why? Table might not exist yet (migrations pending). Return empty array to allow app to start.
107
+
108
+ **See**: `Apartment.extract_tenant_config` method
109
+
110
+ ## Data Flow Differences by Database
111
+
112
+ ### PostgreSQL Schema Strategy
113
+
114
+ **Mechanism**: Single connection pool, `SET search_path` per query.
115
+
116
+ **Why this works**: PostgreSQL schemas are namespaces. Queries resolve to first matching table in search path.
117
+
118
+ **Memory efficiency**: Connection pool shared across all tenants. Only schema metadata grows with tenant count.
119
+
120
+ **Performance**: Sub-millisecond switching (simple SQL command).
121
+
122
+ **Limitation**: All tenants in same database. Backup/restore is database-wide.
123
+
124
+ ### MySQL Database Strategy
125
+
126
+ **Mechanism**: Separate connection pool per tenant.
127
+
128
+ **Why different from PostgreSQL**: MySQL lacks robust schema support. Database is natural isolation unit.
129
+
130
+ **Memory cost**: Each active tenant requires connection pool (~20MB).
131
+
132
+ **Performance**: Slower switching (connection establishment overhead).
133
+
134
+ **Benefit**: Complete isolation. Can backup/restore individual tenants.
135
+
136
+ ### SQLite File Strategy
137
+
138
+ **Mechanism**: Separate database file per tenant.
139
+
140
+ **Why file-based**: SQLite is single-file by design.
141
+
142
+ **Use case**: Testing and development only. Concurrent writes cause locking issues.
143
+
144
+ ## Memory Management
145
+
146
+ ### PostgreSQL (Shared Pool)
147
+ - Constant base: ~50MB for connection pool
148
+ - Growth: Only schema metadata (minimal)
149
+ - Scales to: 100+ tenants easily
150
+
151
+ ### MySQL (Pool Per Tenant)
152
+ - Base per tenant: ~20MB connection pool
153
+ - Growth: Linear with active tenant count
154
+ - Consider: LRU cache for connection pools (not implemented in v3)
155
+
156
+ ## Thread Safety Analysis
157
+
158
+ ### What's Safe
159
+
160
+ **Multi-threaded request handling**: Each thread gets isolated adapter instance via `Thread.current`.
161
+
162
+ **Concurrent tenant access**: Thread 1 can be in tenant_a while Thread 2 is in tenant_b without interference.
163
+
164
+ **Background jobs**: Sidekiq workers are threads, get their own adapters.
165
+
166
+ ### What's Unsafe
167
+
168
+ **Fiber switching**: Fibers within a thread share `Thread.current`. Fiber-based async (EventMachine, async gem) will have cross-contamination.
169
+
170
+ **Manual thread pooling with shared state**: Don't share adapter instances across threads.
171
+
172
+ **Solution**: v4 refactor uses `ActiveSupport::CurrentAttributes` which is fiber-safe.
173
+
174
+ ## Error Handling Philosophy
175
+
176
+ ### Fail Fast vs Graceful Degradation
177
+
178
+ **Tenant not found**: Raise exception. Why? Better to show error than serve wrong data.
179
+
180
+ **Tenant creation collision**: Raise exception. Why? Concurrent creation attempts indicate application bug.
181
+
182
+ **Rollback failure**: Fall back to default tenant. Why? Better to serve default data than crash entire request.
183
+
184
+ **Configuration errors**: Raise on boot. Why? Invalid config should prevent startup, not cause runtime failures.
185
+
186
+ ## Excluded Models - Design Rationale
187
+
188
+ **Problem**: Some models (User, Company) exist globally, not per-tenant.
189
+
190
+ **Solution**: Establish separate connections that bypass tenant switching.
191
+
192
+ **Implementation**: PostgreSQL explicitly qualifies table names (`public.users`). MySQL uses separate connection.
193
+
194
+ **Why not conditional logic?**: Separate connections are cleaner than "if excluded, do X else do Y" throughout codebase.
195
+
196
+ **Limitation**: `has_and_belongs_to_many` doesn't work with excluded models. Must use `has_many :through` instead.
197
+
198
+ **See**: `AbstractAdapter#process_excluded_models` method
199
+
200
+ ## Configuration Design
201
+
202
+ ### Why Callable tenant_names?
203
+
204
+ **Problem**: Static arrays become stale as tenants are created/deleted.
205
+
206
+ **Solution**: Accept proc/lambda that queries database dynamically.
207
+
208
+ **Trade-off**: Extra query on each access. Consider caching.
209
+
210
+ ### Why Hash Format for Multi-Server?
211
+
212
+ **Problem**: Different tenants might live on different database servers.
213
+
214
+ **Solution**: Hash maps tenant name to full connection config.
215
+
216
+ **Benefit**: Enables horizontal scaling and geographic distribution.
217
+
218
+ **See**: README.md examples and `Apartment.db_config_for` method
219
+
220
+ ## Performance Design Decisions
221
+
222
+ ### Why Query Cache Preservation?
223
+
224
+ **Impact**: 10-30% performance improvement on cache-heavy workloads.
225
+
226
+ **Cost**: Extra bookkeeping on every switch.
227
+
228
+ **Decision**: Worth it. Query cache is critical for Rails performance.
229
+
230
+ ### Why Connection Verification?
231
+
232
+ **call to verify!**: Ensures connection is live after establishment.
233
+
234
+ **Why needed**: Stale connections from pool can cause mysterious failures.
235
+
236
+ **Cost**: Extra network round-trip, but prevents worse failures.
237
+
238
+ ## Extension Points
239
+
240
+ ### For Users
241
+
242
+ 1. **Custom elevators**: Subclass `Generic`, override `parse_tenant_name`
243
+ 2. **Callbacks**: Hook into `:create` and `:switch` events
244
+ 3. **Custom adapters**: Subclass `AbstractAdapter` for new databases
245
+
246
+ ### Design Principle
247
+
248
+ **Open for extension, closed for modification**: Users can add behavior without changing gem code.
249
+
250
+ ## Limitations & Known Issues
251
+
252
+ ### v3 Constraints
253
+
254
+ 1. **Thread-local only**: Not fiber-safe
255
+ 2. **Single adapter type**: Can't mix PostgreSQL schemas and MySQL databases in one app
256
+ 3. **No horizontal sharding**: Each adapter connects to single database cluster
257
+ 4. **Global excluded models**: Can't have different exclusions per tenant
258
+
259
+ ### Why These Exist
260
+
261
+ Historical decisions made before newer Rails features (sharding, CurrentAttributes) existed.
262
+
263
+ ### v4 Improvements
264
+
265
+ The `man/spec-restart` branch refactor addresses most limitations via connection-pool-per-tenant architecture.
266
+
267
+ ## References
268
+
269
+ - Main module: `lib/apartment.rb`
270
+ - Public API: `lib/apartment/tenant.rb`
271
+ - Adapters: `lib/apartment/adapters/*.rb`
272
+ - Elevators: `lib/apartment/elevators/*.rb`
273
+ - Thread storage: Ruby documentation on `Thread.current`
274
+ - Rails connection pooling: Rails guides
data/docs/elevators.md ADDED
@@ -0,0 +1,226 @@
1
+ # Apartment Elevators - Middleware Design
2
+
3
+ **Key files**: `lib/apartment/elevators/*.rb`
4
+
5
+ ## Purpose
6
+
7
+ Elevators are Rack middleware that automatically detect tenant from HTTP requests and establish tenant context before application code runs.
8
+
9
+ **Name metaphor**: Like elevators transport you between building floors, these middleware transport requests between tenant contexts.
10
+
11
+ ## Design Decision: Why Middleware?
12
+
13
+ **Problem**: Manual tenant switching in controllers is error-prone. Easy to forget, creates boilerplate.
14
+
15
+ **Solution**: Rack middleware intercepts all requests, switches tenant automatically based on request attributes.
16
+
17
+ **Trade-off**: Adds middleware overhead (minimal) but eliminates entire class of bugs.
18
+
19
+ ## Critical Positioning Requirement
20
+
21
+ **Rule**: Elevators MUST be positioned before session/authentication middleware.
22
+
23
+ **Why**: Session data is tenant-specific. Loading session before establishing tenant context causes data leakage.
24
+
25
+ **How to verify**: `Rails.application.middleware` lists order. Elevator should appear before `ActionDispatch::Session` and `Warden::Manager`.
26
+
27
+ **See**: Configuration examples in README.md
28
+
29
+ ## Available Elevator Strategies
30
+
31
+ **Files**: All in `lib/apartment/elevators/`
32
+
33
+ ### Subdomain Elevator
34
+
35
+ **File**: `subdomain.rb`
36
+
37
+ **Strategy**: Extract first subdomain as tenant name.
38
+
39
+ **Why PublicSuffix gem?**: Handles international TLDs correctly. `example.co.uk` has TLD `.co.uk`, not just `.uk`.
40
+
41
+ **Exclusion mechanism**: Configurable list of ignored subdomains (www, admin, api). Returns nil for excluded, which uses default tenant.
42
+
43
+ **Why class-level exclusions?**: Shared across all instances. Set once in initializer.
44
+
45
+ ### Domain Elevator
46
+
47
+ **File**: `domain.rb`
48
+
49
+ **Strategy**: Use domain name (excluding www and TLD) as tenant.
50
+
51
+ **Use case**: When domain itself identifies tenant (acme.com vs widgets.com), not subdomain.
52
+
53
+ ### Host Elevator
54
+
55
+ **File**: `host.rb`
56
+
57
+ **Strategy**: Use full hostname as tenant name.
58
+
59
+ **Ignored subdomains**: Optional configuration to strip www/app from beginning.
60
+
61
+ **Use case**: Custom domains where full hostname matters.
62
+
63
+ ### HostHash Elevator
64
+
65
+ **File**: `host_hash.rb`
66
+
67
+ **Strategy**: Direct hash mapping from hostname to tenant name.
68
+
69
+ **Why needed?**: When hostname→tenant mapping is arbitrary or complex.
70
+
71
+ **Trade-off**: Requires explicit configuration per tenant. Not dynamic.
72
+
73
+ ### Generic Elevator
74
+
75
+ **File**: `generic.rb`
76
+
77
+ **Purpose**: Base class for custom elevators. Accept Proc for inline logic or subclass for complex scenarios.
78
+
79
+ **Extension point**: Override `parse_tenant_name(request)` method.
80
+
81
+ **See**: Examples in file comments
82
+
83
+ ## Design Patterns
84
+
85
+ ### Why Return nil for Excluded?
86
+
87
+ Returning nil (not default_tenant name) allows Apartment core to handle fallback logic. Separation of concerns.
88
+
89
+ ### Why ensure Block in call()?
90
+
91
+ Guarantees tenant cleanup even if application code raises. Prevents request bleeding into next request's tenant context.
92
+
93
+ ### Why Rack::Request Object?
94
+
95
+ Standard interface. Access to host, headers, session, cookies. Database-independent.
96
+
97
+ ## Request Lifecycle
98
+
99
+ **Sequence**:
100
+ 1. Rack request enters application
101
+ 2. Elevator middleware intercepts (positioned early)
102
+ 3. Calls `parse_tenant_name(request)` - strategy determines tenant
103
+ 4. Calls `Apartment::Tenant.switch(tenant) { @app.call(env) }`
104
+ 5. Application processes in tenant context
105
+ 6. Ensure block resets tenant after response
106
+
107
+ **Critical**: Step 6 happens even on exceptions. Why? Prevent tenant leakage.
108
+
109
+ ## Performance Considerations
110
+
111
+ ### Caching Tenant Lookups
112
+
113
+ If `parse_tenant_name` does database queries, consider caching:
114
+ - Subdomain→tenant mapping cached for 5-10 minutes
115
+ - Invalidate cache when tenants created/deleted
116
+
117
+ **Why needed?**: Elevator runs on EVERY request. Database query per request adds latency.
118
+
119
+ **Not implemented in v3**: Users must implement caching in custom elevators.
120
+
121
+ ### Why Not Cache in Gem?
122
+
123
+ Different applications have different caching strategies (Redis, Memcached, Rails.cache). Prescribing one limits flexibility.
124
+
125
+ ## Error Handling Philosophy
126
+
127
+ **Default behavior**: Exceptions propagate. TenantNotFound crashes request.
128
+
129
+ **Rationale**: Better to show error than serve wrong data or default data without user realizing.
130
+
131
+ **Alternative**: Custom elevator can rescue and return 404/redirect.
132
+
133
+ **See**: docs/adapters.md for error hierarchy
134
+
135
+ ## Extension Points
136
+
137
+ ### Creating Custom Elevators
138
+
139
+ **Two approaches**:
140
+
141
+ 1. **Inline Proc**: For simple logic, pass Proc to Generic
142
+ 2. **Subclass**: For complex logic, override `parse_tenant_name`
143
+
144
+ **When to subclass**:
145
+ - Multi-strategy fallback (header → session → subdomain)
146
+ - Database lookups with caching
147
+ - Complex validation/transformation logic
148
+
149
+ **See**: `generic.rb` for base implementation
150
+
151
+ ### Common Custom Patterns
152
+
153
+ **Header-based**: API requests with `X-Tenant-ID` header
154
+ **Session-based**: Tenant selected in login flow, stored in session
155
+ **API key-based**: Database lookup from authentication token
156
+ **Hybrid**: Try multiple strategies in priority order
157
+
158
+ ## Common Pitfalls
159
+
160
+ ### Pitfall: Elevator After Session Middleware
161
+
162
+ **Symptom**: Wrong tenant's session data appearing
163
+
164
+ **Cause**: Session loaded before tenant switched
165
+
166
+ **Fix**: Reposition elevator before session middleware
167
+
168
+ ### Pitfall: Database Queries in parse_tenant_name
169
+
170
+ **Symptom**: Slow request times, database overload
171
+
172
+ **Cause**: Query on every request without caching
173
+
174
+ **Fix**: Implement caching layer
175
+
176
+ ### Pitfall: Not Handling Exclusions
177
+
178
+ **Symptom**: www.example.com creates "www" tenant, admin pages switch tenants
179
+
180
+ **Cause**: No exclusion configuration
181
+
182
+ **Fix**: Configure `excluded_subdomains`
183
+
184
+ ### Pitfall: Returning Tenant Name That Doesn't Exist
185
+
186
+ **Symptom**: TenantNotFound errors
187
+
188
+ **Cause**: No validation before switching
189
+
190
+ **Fix**: Add existence check in custom elevator or handle error
191
+
192
+ ## Testing Elevators
193
+
194
+ **Challenge**: Elevators are middleware, not models/controllers.
195
+
196
+ **Solution**: Use `Rack::MockRequest` to simulate requests with different hosts.
197
+
198
+ **Pattern**: Mock `Apartment::Tenant.switch` to verify correct tenant extracted.
199
+
200
+ **See**: `spec/unit/elevators/` for examples
201
+
202
+ ## Integration with Background Jobs
203
+
204
+ **Important**: Elevators only affect web requests. Background jobs need separate tenant handling.
205
+
206
+ **Solution**: Job frameworks must capture and restore tenant (apartment-sidekiq gem).
207
+
208
+ **Why separate?**: Jobs aren't HTTP requests. No Rack middleware involved.
209
+
210
+ ## Multi-Elevator Setup
211
+
212
+ **Possible but discouraged**: Multiple elevators in middleware stack.
213
+
214
+ **Why discouraged**: Last elevator wins. Complex, hard to debug.
215
+
216
+ **Alternative**: Single custom elevator with multi-strategy logic.
217
+
218
+ ## References
219
+
220
+ - Generic base: `lib/apartment/elevators/generic.rb`
221
+ - Subdomain implementation: `lib/apartment/elevators/subdomain.rb`
222
+ - Domain implementation: `lib/apartment/elevators/domain.rb`
223
+ - Host implementations: `lib/apartment/elevators/host.rb`, `host_hash.rb`
224
+ - First subdomain: `lib/apartment/elevators/first_subdomain.rb`
225
+ - Rack middleware: https://github.com/rack/rack/wiki/Middleware
226
+ - PublicSuffix gem: https://github.com/weppos/publicsuffix-ruby
Binary file