ros-apartment 4.0.0.alpha1 → 4.0.0.alpha3

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +74 -6
  3. data/config/default.yml +9 -0
  4. data/lib/apartment/CLAUDE.md +26 -7
  5. data/lib/apartment/adapters/abstract_adapter.rb +115 -19
  6. data/lib/apartment/adapters/mysql2_adapter.rb +48 -0
  7. data/lib/apartment/adapters/postgresql_database_adapter.rb +29 -0
  8. data/lib/apartment/adapters/postgresql_schema_adapter.rb +56 -1
  9. data/lib/apartment/adapters/sqlite3_adapter.rb +14 -0
  10. data/lib/apartment/cli/migrations.rb +1 -1
  11. data/lib/apartment/cli/seeds.rb +1 -1
  12. data/lib/apartment/cli/tenants.rb +2 -2
  13. data/lib/apartment/concerns/model.rb +111 -6
  14. data/lib/apartment/config.rb +74 -7
  15. data/lib/apartment/configs/postgresql_config.rb +13 -4
  16. data/lib/apartment/current.rb +8 -1
  17. data/lib/apartment/elevators/CLAUDE.md +10 -7
  18. data/lib/apartment/elevators/generic.rb +77 -6
  19. data/lib/apartment/errors.rb +93 -0
  20. data/lib/apartment/lifecycle.rb +32 -0
  21. data/lib/apartment/migrator.rb +2 -2
  22. data/lib/apartment/patches/connection_handling.rb +32 -9
  23. data/lib/apartment/patches/live_tenant_propagation.rb +53 -0
  24. data/lib/apartment/pool_manager.rb +52 -3
  25. data/lib/apartment/pool_reaper.rb +165 -15
  26. data/lib/apartment/railtie.rb +111 -3
  27. data/lib/apartment/schema_cache.rb +1 -1
  28. data/lib/apartment/tenant.rb +252 -13
  29. data/lib/apartment/tenant_validator.rb +186 -0
  30. data/lib/apartment/test_fixtures.rb +34 -0
  31. data/lib/apartment/version.rb +1 -1
  32. data/lib/apartment.rb +159 -22
  33. data/lib/generators/apartment/install/templates/apartment.rb +10 -2
  34. data/lib/rubocop/apartment.rb +4 -0
  35. data/lib/rubocop/cop/apartment/no_direct_current_write.rb +79 -0
  36. data/lib/rubocop/cop/apartment/prefer_block_switch.rb +39 -0
  37. data/ros-apartment.gemspec +1 -1
  38. metadata +9 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ff587e273664aea9b3fba6477c64cdd5309dfa75a7b47715e6671452aa209788
4
- data.tar.gz: 1684f55b0be423185a8f8b6b41270028a1cfb07bd240afee5325ba6f9da4b6ca
3
+ metadata.gz: 0b2254c1f3f95622ba9fa45147fcc2f5b2036db4c5c75a2d68c3718772df6149
4
+ data.tar.gz: efcc9149b0aaaa6c7b614cc981848f02e999259eaa0826120b4df16b555f6caf
5
5
  SHA512:
6
- metadata.gz: 0b0a44d91f78033ace32559a0d8e8095ba287dfa98cd07c084a5f3f9b79341a21cd3ba36a439412360f37d4147dc83e8fbba944a92a01ffa8d799d23eb746050
7
- data.tar.gz: efba1ef8faff63c0ce3795e29d1f16fad2eaf894ba62346ee8aa37cf8fb000c86caa59b865567ee4490898bca4ec2cee19aba16fd0643bee9e8b5ee2bfff8409
6
+ metadata.gz: 35b3bdeb392ed750be9a20868082f1708e2ae3c4c8449cef5bda163d3e2e911409274abf9a1f8161811678192c8aaaa21c2f4d6a177a5932a9567942172bc234
7
+ data.tar.gz: 3d0909cc593dcbd014f21626a2565e0e92d373cb74aa768f308b1033f689fbdea1aa48a2f886294e581d4d3954b1d719b15ab3277990a8fde216449eed40485f
data/README.md CHANGED
@@ -60,7 +60,7 @@ The generated initializer at `config/initializers/apartment.rb` configures Apart
60
60
  Apartment.configure do |config|
61
61
  config.tenant_strategy = :schema # :schema (PostgreSQL) or :database_name (MySQL/SQLite)
62
62
  config.tenants_provider = -> { Customer.pluck(:subdomain) }
63
- config.default_tenant = 'public'
63
+ config.default_tenant = 'public' # auto-defaults for :schema; required for :database_name
64
64
  end
65
65
  ```
66
66
 
@@ -99,11 +99,15 @@ All options are set in `config/initializers/apartment.rb` inside an `Apartment.c
99
99
 
100
100
  ### Pool Settings
101
101
 
102
- `tenant_pool_size`: connections per tenant pool (default: 5).
102
+ `tenant_pool_size`: max connections per tenant pool. Default `nil` — each tenant pool inherits the app's base pool size (`DB_POOL_SIZE` / `pool:` in `database.yml`). Set it to size tenant pools independently of the app pool (e.g. to bound total connections across many schema-per-tenant pools).
103
103
 
104
- `pool_idle_timeout`: seconds before an idle tenant pool is eligible for reaping (default: 300).
104
+ `pool_idle_timeout`: seconds an idle tenant pool must exceed before it is eligible for reaping (default: 300).
105
105
 
106
- `max_total_connections`: hard cap across all tenant pools; nil for unlimited (default: nil).
106
+ `reaper_interval`: seconds between background reap passes. Default `nil` derives from `pool_idle_timeout`. Set it lower to reap more often without shrinking the idle window.
107
+
108
+ `max_total_connections`: ceiling on the number of live tenant pools; `nil` for unlimited (default: `nil`). Enforced synchronously at pool-creation time (see `pool_overflow_policy`) and trimmed continuously by the background reaper. Total backend connections ≈ `max_total_connections × tenant_pool_size`.
109
+
110
+ `pool_overflow_policy`: behavior when a new pool would breach `max_total_connections` and every existing pool is pinned or in use (no idle pool to evict). `:evict_idle` (default) — allow the new pool, emit a `cap_unmet` notification (soft cap, prioritizes availability). `:raise` — raise `Apartment::PoolCapacityReached` (hard cap, sheds load). When an idle pool *is* available it is always evicted inline regardless of policy. See `docs/designs/pool-admission-control.md`.
107
111
 
108
112
  ### Elevator (Request Tenant Detection)
109
113
 
@@ -112,7 +116,7 @@ config.elevator = :subdomain
112
116
  config.elevator_options = {}
113
117
  ```
114
118
 
115
- The Railtie auto-inserts elevator middleware. No manual `config.middleware.use` needed.
119
+ The Railtie auto-inserts elevator middleware after `ActionDispatch::Callbacks` (just before cookies/sessions in full mode; works in API mode too).
116
120
 
117
121
  See the [Elevators](#elevators) section for available options.
118
122
 
@@ -217,7 +221,14 @@ Apartment.configure do |config|
217
221
  end
218
222
  ```
219
223
 
220
- The Railtie inserts the elevator as middleware automatically. You do not need `config.middleware.use` or `config.middleware.insert_before`.
224
+ The Railtie inserts the elevator after `ActionDispatch::Callbacks` automatically. In the full middleware stack this places it just before cookies, sessions, and authentication. In API mode (where cookies/sessions are absent), `Callbacks` is still present so the elevator works without changes.
225
+
226
+ If you need different positioning, skip `config.elevator` and insert manually:
227
+
228
+ ```ruby
229
+ # config/application.rb
230
+ config.middleware.insert_before 'Warden::Manager', Apartment::Elevators::Subdomain
231
+ ```
221
232
 
222
233
  ### Custom Elevator
223
234
 
@@ -304,6 +315,28 @@ Workaround: add `include Apartment::Model` and `pin_tenant` on the abstract clas
304
315
 
305
316
  The common pattern of `ApplicationRecord` using `connects_to` with multiple roles (writing/reading) on the same database works correctly; Apartment keys pools by `tenant:role` and respects Rails' role routing.
306
317
 
318
+ ## ActionController::Live Streaming
319
+
320
+ Apartment v4 handles tenant propagation across `ActionController::Live`'s spawned streaming thread automatically, under both `:thread` and `:fiber` isolation. Including `ActionController::Live` in your controller is sufficient — no additional configuration:
321
+
322
+ ```ruby
323
+ class StreamingController < ApplicationController
324
+ include ActionController::Live
325
+
326
+ def show
327
+ response.headers['Content-Type'] = 'text/event-stream'
328
+ # Apartment::Tenant.current returns the request's tenant here,
329
+ # even though we're now executing on the OS thread Rails spawned
330
+ # for streaming.
331
+ response.stream.write("data: #{{ tenant: Apartment::Tenant.current }.to_json}\n\n")
332
+ ensure
333
+ response.stream.close
334
+ end
335
+ end
336
+ ```
337
+
338
+ How it works: Apartment prepends `ActionController::Live#process` with a patch that backports [rails/rails#56902](https://github.com/rails/rails/pull/56902) to released Rails versions — it points `Thread.current.active_support_execution_state` at the request fiber's hash for the duration of the request, so Rails' own `share_with` carries all `CurrentAttributes` (apartment's tenant plus any app-defined ones) into the spawned streaming thread. User-spawned threads or fibers *inside* a Live action (`Thread.new`, `Async { }`, raw `Fiber.new`) escape the patch and need explicit `Apartment::Tenant.switch` wrapping. See the [upgrading guide](docs/upgrading-to-v4.md) and [`docs/designs/rails-boundary-tenancy.md`](docs/designs/rails-boundary-tenancy.md).
339
+
307
340
  ## Background Workers
308
341
 
309
342
  Use block-scoped switching in jobs:
@@ -323,6 +356,19 @@ For automatic tenant propagation:
323
356
  - [apartment-sidekiq](https://github.com/rails-on-services/apartment-sidekiq)
324
357
  - [apartment-activejob](https://github.com/rails-on-services/apartment-activejob)
325
358
 
359
+ A job that forgets to switch runs in the default tenant — for `Rails.cache` and
360
+ other `Tenant.current`-derived resources that silently contaminate another
361
+ tenant's keyspace. Guard routed work with `Apartment::Tenant.require_tenant!`
362
+ (raises unless a real, non-default tenant is active) and pinned/global work with
363
+ `require_default_tenant!`. See [Tenant-Aware Caching](docs/caching.md) for the
364
+ routed-vs-pinned model and the two-store recipe.
365
+
366
+ ## Convenience Methods
367
+
368
+ `Apartment.tenant_names` returns the current tenant list (delegates to `config.tenants_provider.call`). Preserves the v3 API so existing call sites work without changes.
369
+
370
+ `Apartment.excluded_models` returns the excluded models list (delegates to `config.excluded_models`). Deprecated in v4; use `Apartment::Model` + `pin_tenant` instead.
371
+
326
372
  ## Troubleshooting
327
373
 
328
374
  If tenant switching raises unexpected errors, verify that `tenants_provider` returns valid tenant names and that the tenant exists in the database.
@@ -331,6 +377,28 @@ If tenant switching raises unexpected errors, verify that `tenants_provider` ret
331
377
 
332
378
  See the [upgrade guide](docs/upgrading-to-v4.md) for a complete list of breaking changes and migration steps.
333
379
 
380
+ ## RuboCop cops
381
+
382
+ Apartment ships two optional RuboCop cops that enforce the block-form
383
+ tenant-switching discipline. Enable them in your application's `.rubocop.yml`:
384
+
385
+ ```yaml
386
+ require: rubocop/apartment
387
+ inherit_gem:
388
+ ros-apartment: config/default.yml
389
+ ```
390
+
391
+ - **`Apartment/NoDirectCurrentWrite`** (error) — bans assigning
392
+ `Apartment::Current.tenant` / `.previous_tenant` directly. Change tenant context
393
+ with `Apartment::Tenant.switch(tenant) { ... }` (or `with_default_tenant` for
394
+ global work), which guarantees a restore via `ensure`.
395
+ - **`Apartment/PreferBlockSwitch`** (warning) — nudges `Apartment::Tenant.switch!`
396
+ toward the block form. `reset` is not flagged.
397
+
398
+ Both match the qualified `Apartment::` receiver only. Scope them to your
399
+ application code with the standard `Exclude:` keys if needed. See
400
+ [`docs/designs/rubocop-cops.md`](docs/designs/rubocop-cops.md) for the rationale.
401
+
334
402
  ## Contributing
335
403
 
336
404
  1. Check [existing issues](https://github.com/rails-on-services/apartment/issues) and [discussions](https://github.com/rails-on-services/apartment/discussions)
@@ -0,0 +1,9 @@
1
+ Apartment/NoDirectCurrentWrite:
2
+ Description: 'Use the block-form switch instead of writing Apartment::Current directly.'
3
+ Enabled: true
4
+ Severity: error
5
+
6
+ Apartment/PreferBlockSwitch:
7
+ Description: 'Prefer the block-form Apartment::Tenant.switch over switch!.'
8
+ Enabled: true
9
+ Severity: warning
@@ -14,9 +14,9 @@ lib/apartment/
14
14
  │ ├── trilogy_adapter.rb # Database-per-tenant on MySQL (trilogy driver, inherits Mysql2Adapter)
15
15
  │ └── sqlite3_adapter.rb # File-per-tenant (FileUtils lifecycle)
16
16
  ├── concerns/ # ActiveRecord concerns for tenant-aware models
17
- │ └── model.rb # Apartment::Model concern: pin_tenant, apartment_pinned?
17
+ │ └── model.rb # Apartment::Model concern: pin_tenant, pinned identity, table-name helpers
18
18
  ├── configs/ # Database-specific config objects
19
- │ ├── postgresql_config.rb # PostgresqlConfig: persistent_schemas, enforce_search_path_reset
19
+ │ ├── postgresql_config.rb # PostgresqlConfig: persistent_schemas, include_schemas_in_dump
20
20
  │ └── mysql_config.rb # MysqlConfig: placeholder
21
21
  ├── elevators/ # Rack middleware for tenant detection (see CLAUDE.md); v4 uses constructor keyword args, no class-level state; Generic, Subdomain, FirstSubdomain, Domain, Host, HostHash, Header
22
22
  ├── patches/ # ActiveRecord patches for tenant-aware connections
@@ -42,6 +42,13 @@ lib/apartment/
42
42
 
43
43
  `switch(tenant) { ... }` sets `Current.tenant` via ensure block. Delegates lifecycle ops (`create`, `drop`, `migrate`, `seed`) to `Apartment.adapter`. No thread-local state — uses `CurrentAttributes` for fiber safety.
44
44
 
45
+ **Tenant-context guard family — two axes (#427)**:
46
+ - **Explicitness axis** (raw `Current.tenant`, ignores the `default_tenant` fallback): `tenant_switched?` / `assert_tenant_switched!(message:)`. Answers "did this code explicitly enter a tenant?" — test discipline. Renamed from `inside_tenant?` / `assert_inside_tenant!` (no aliases).
47
+ - **Identity axis** (effective `Tenant.current`, `to_s`-normalized): `in_tenant?` / `require_tenant!` (real, non-default; `require_tenant!` returns the name and raises `TenantRequired`), `in_default_tenant?` / `require_default_tenant!` (default; returns the name, raises `DefaultTenantRequired` in a real tenant or `DefaultTenantNotConfigured` when no default is set). `cache_namespace` wraps `require_tenant!` for fail-closed namespace procs; `with_default_tenant { }` enters the default/pinned context (guard-exempt, restore-on-raise). For runtime routed/pinned decisions (cache, jobs). See `docs/caching.md` and `docs/designs/tenant-aware-caching.md`.
48
+
49
+ **Strict-mode switch guard (4.0.0.alpha3)**:
50
+ - `switch(name) { ... }` calls `guard_default_tenant_switch!`, which raises when `Apartment.config.default_tenant_switch_allowed` is `false` AND `name == default_tenant`. `switch!`, `reset`, and `with_default_tenant` are exempt by design — they remain the unguarded paths back to the default tenant. The flag defaults to `true` (permissive) for all strategies; new PG `:schema` apps wanting strict semantics opt in. See `docs/testing.md`.
51
+
45
52
  ### config.rb — Configuration
46
53
 
47
54
  `Apartment.configure { |c| ... }` builds config, validates, freezes. Prepare-then-swap pattern: failed configure preserves previous working config. Frozen after validation — tests must reconfigure, not stub.
@@ -52,15 +59,15 @@ lib/apartment/
52
59
 
53
60
  ### pool_manager.rb — Pool Cache
54
61
 
55
- `Concurrent::Map` storing connection pools by tenant key. Monotonic clock timestamps for idle/LRU tracking. `stats_for` returns `{ seconds_idle: N }`. `clear` disconnects all pools before clearing.
62
+ `Concurrent::Map` storing connection pools by tenant key. Monotonic clock timestamps for idle/LRU tracking. `stats_for` returns `{ seconds_idle: N }`. `clear` disconnects all pools before clearing. When `max_total_connections` is set, `Apartment.configure` wires an `admission_controller` (the reaper) so cold creates route through a serialized, capacity-bounded path; otherwise the lock-free `compute_if_absent` fast path is used. See `docs/designs/pool-admission-control.md`.
56
63
 
57
- ### pool_reaper.rb — Pool Eviction
64
+ ### pool_reaper.rb — Pool Eviction + Admission
58
65
 
59
- Background `Concurrent::TimerTask` instance that evicts idle and excess tenant pools. Created by `Apartment.configure`, stored as `Apartment.pool_reaper`. Deregisters evicted pools from AR's ConnectionHandler. Default tenant is never evicted.
66
+ Background `Concurrent::TimerTask` that evicts idle and excess tenant pools, and also serves as the pool manager's synchronous admission controller (`admit!`) when a cap is configured — evicting the LRU idle pool inline before a new one is established, applying `pool_overflow_policy` (`:evict_idle` / `:raise`) when no idle pool can be freed. A single `evict_tenant` primitive backs the timer (idle/LRU) and admission paths. Reap cadence (`interval`/`reaper_interval`) is decoupled from the idle window (`idle_timeout`/`pool_idle_timeout`). Created by `Apartment.configure`, stored as `Apartment.pool_reaper`. Deregisters evicted pools from AR's ConnectionHandler. Default tenant is never evicted.
60
67
 
61
68
  ### adapters/abstract_adapter.rb — Base Adapter
62
69
 
63
- Lifecycle ops (`create`, `drop`, `migrate`, `seed`), `ActiveSupport::Callbacks` on `:create`/`:switch`, `resolve_connection_config` (abstract — subclasses override), `process_excluded_models`, `environmentify`, `base_config` (stringified `connection_config`), `rails_env` (guarded `Rails.env` access). Constructor takes `connection_config` (raw AR hash, not `Apartment::Config`).
70
+ Lifecycle ops (`create`, `drop`, `migrate`, `seed`), `ActiveSupport::Callbacks` on `:create`/`:switch`, `resolve_connection_config` (abstract — subclasses override), `process_excluded_models`, `environmentify`, `base_config` (stringified `connection_config`), `rails_env` (guarded `Rails.env` access). `process_pinned_model` / `qualify_pinned_table_name` call **`Apartment::Model` class methods** (`apartment_explicit_table_name?`, `apartment_mark_processed!`, etc.); no `instance_variable_*` on arbitrary model classes. Constructor takes `connection_config` (raw AR hash, not `Apartment::Config`).
64
71
 
65
72
  ### Concrete Adapters (Phase 2.2)
66
73
 
@@ -74,7 +81,19 @@ All inherit from `AbstractAdapter`. Override `resolve_connection_config`, `creat
74
81
 
75
82
  ### concerns/model.rb — Model Pinning Concern
76
83
 
77
- `Apartment::Model` provides `pin_tenant` (class method) to declare a model as pinned to the default tenant. Registered models bypass the `ConnectionHandling` patch. Zeitwerk-safe: works whether called before or after `activate!`. `apartment_pinned?` checks the class and its superclass chain.
84
+ `Apartment::Model` provides `pin_tenant` (class method) to declare a model as pinned to the default tenant. Registered models bypass the `ConnectionHandling` patch when the adapter uses a separate pool; when shared pinned connections are enabled, routing follows the tenant pool (see design docs). Zeitwerk-safe: works whether called before or after `activate!`.
85
+
86
+ **Identity:** `apartment_pinned?` — the class answers whether it is pinned (ivars + superclass walk). `Apartment.pinned_model?(klass)` delegates to `klass.apartment_pinned?` when the concern is included; otherwise it falls back to registry lookup (`pinned_models`) for `excluded_models` shim classes that never included the concern.
87
+
88
+ **Table naming:** `apartment_explicit_table_name?` — whether `self.table_name` was explicitly set vs convention (compares `@table_name` to `compute_table_name`). Lives here so adapters do not read `@table_name` or call `compute_table_name` from outside; **class instance variable access for pinning is confined to this concern**.
89
+
90
+ **Lifecycle:** `apartment_pinned_processed?`, `apartment_mark_processed!`, `apartment_restore!` — qualification state and teardown. Adapters call these; `Apartment.clear_config` uses `apartment_restore!` with `respond_to?` so shim-registered models without the concern still clear safely. `apartment_mark_pinned!` — sets the pinned flag without triggering processing (used by `process_pinned_model` for shim classes to avoid `pin_tenant` recursion).
91
+
92
+ **Guards:** `pin_tenant` raises `ArgumentError` if called on a non-AR class or module. For anonymous classes (`Class.new`), it warns that `TracePoint(:end)` won't fire and skips deferral; call `process_pinned_model` explicitly after assigning the constant.
93
+
94
+ **Deferred processing:** When `Apartment.activated?` is true (Zeitwerk lazy-load path), `pin_tenant` defers `process_pinned_model` via `apartment_defer_processing!` — a one-shot `TracePoint(:end)` constrained to `Thread.current`. Only `:end` is used: `:b_return` fires for ALL block returns in class context (each, include hooks) and would trigger prematurely; `:raise` is not used because rescued raises still produce `:end` (MRI verified). For `Class.new { }` (tests, runtime metaprogramming), `:end` does not fire; call `process_pinned_model` explicitly. Models loaded before `activate!` are unaffected (processed in batch by `Tenant.init`). Reopening a pinned class does not re-trigger processing (idempotent via `apartment_pinned?`).
95
+
96
+ **Shim compatibility:** `process_pinned_model` dynamically includes `Apartment::Model` on classes registered via the `excluded_models` shim that lack the concern. This is a runtime `include` on a partially-booted class — acceptable for the legacy shim path but new code should always use `include Apartment::Model` + `pin_tenant` explicitly.
78
97
 
79
98
  ### railtie.rb — v4 Rails Integration
80
99
 
@@ -30,7 +30,8 @@ module Apartment
30
30
  strategy: Apartment.config.tenant_strategy,
31
31
  adapter_name: effective_base['adapter']
32
32
  )
33
- resolve_connection_config(tenant, base_config: effective_base)
33
+ config = resolve_connection_config(tenant, base_config: effective_base)
34
+ apply_tenant_pool_size(config)
34
35
  end
35
36
 
36
37
  # Resolve a tenant-specific connection config hash.
@@ -96,37 +97,83 @@ module Apartment
96
97
  end
97
98
  end
98
99
 
99
- # Process all pinned models establish separate connections pinned to default tenant.
100
+ # Whether pinned models can share the tenant's connection pool using
101
+ # qualified table names instead of establish_connection.
102
+ #
103
+ # Returns false by default (separate pool). Subclasses override to
104
+ # return true when the engine supports cross-schema/database queries,
105
+ # gated by config.force_separate_pinned_pool.
106
+ def shared_pinned_connection?
107
+ false
108
+ end
109
+
110
+ # Request-path fail-safe contract. The elevator wraps the
111
+ # tenant switch; on one of these error classes it asks
112
+ # #tenant_container_gone? whether the tenant's storage actually vanished (a
113
+ # cross-process drop) rather than an app-level failure. An empty list
114
+ # disables the rescue, so an adapter that does not implement the seams
115
+ # never converts an error into a 404.
116
+ def failsafe_error_classes
117
+ []
118
+ end
119
+
120
+ # Whether +error+, raised while serving +tenant+, means the tenant's
121
+ # container (schema/database/file) no longer exists — so the validator
122
+ # should evict the name and the request should 404 instead of surfacing a
123
+ # 500. Composed from a cheap error-shape check and an authoritative
124
+ # existence probe, both conservative by default so the base adapter never
125
+ # reclassifies. Subclasses override the seams.
126
+ def tenant_container_gone?(error, tenant)
127
+ return false unless container_error?(unwrap_db_error(error))
128
+
129
+ !tenant_container_exists?(tenant)
130
+ end
131
+
132
+ # Qualify a pinned model's table_name so it targets the default
133
+ # tenant's tables from any tenant connection. Subclasses must
134
+ # implement when shared_pinned_connection? returns true.
135
+ def qualify_pinned_table_name(_klass)
136
+ raise(NotImplementedError,
137
+ "#{self.class}#qualify_pinned_table_name must be implemented when shared_pinned_connection? is true")
138
+ end
139
+
140
+ # Process all pinned models. When shared_pinned_connection? is true, qualifies
141
+ # table names for shared pool routing. Otherwise, establishes separate connections.
100
142
  def process_pinned_models
101
143
  return if Apartment.pinned_models.empty?
102
144
 
103
145
  Apartment.pinned_models.each do |klass|
104
146
  process_pinned_model(klass)
147
+ rescue StandardError => e
148
+ raise(Apartment::ConfigurationError,
149
+ "Failed to process pinned model #{klass.name}: #{e.class}: #{e.message}")
105
150
  end
106
151
  end
107
152
 
108
153
  # Process a single pinned model. Called by process_pinned_models (batch)
109
154
  # and by Apartment::Model.pin_tenant (when activated? is true).
155
+ #
156
+ # When shared_pinned_connection? is true, qualifies the table name so
157
+ # the model uses the tenant's pool (preserving transactional integrity).
158
+ # Otherwise, establishes a separate connection pool (required when
159
+ # cross-database queries are impossible).
110
160
  def process_pinned_model(klass)
111
- # Idempotent: skip if already processed. Uses a class-level flag rather
112
- # than connection_specification_name comparison the spec name differs
113
- # from ActiveRecord::Base for ApplicationRecord subclasses even before
114
- # establish_connection, so it's not a reliable "already processed" signal.
115
- return if klass.instance_variable_get(:@apartment_connection_established)
116
-
117
- # Use base_config (the adapter's raw connection config) rather than
118
- # resolve_connection_config(default_tenant). For database-per-tenant
119
- # strategies (MySQL, SQLite), resolve_connection_config would set the
120
- # database key to the default tenant NAME (e.g. 'default'), not the
121
- # actual default database (e.g. 'apartment_v4_test'). base_config
122
- # points to the real default database.
123
- klass.establish_connection(base_config)
124
- klass.instance_variable_set(:@apartment_connection_established, true)
161
+ # Ensure the concern is included models registered via the
162
+ # excluded_models shim may not have it yet. Uses apartment_mark_pinned!
163
+ # (not pin_tenant) to avoid recursion back into process_pinned_model.
164
+ unless klass.respond_to?(:apartment_pinned_processed?)
165
+ klass.include(Apartment::Model)
166
+ klass.apartment_mark_pinned!
167
+ end
125
168
 
126
- return unless Apartment.config.tenant_strategy == :schema
169
+ return if klass.apartment_pinned_processed?
127
170
 
128
- table = klass.table_name.split('.').last
129
- klass.table_name = "#{default_tenant}.#{table}"
171
+ if shared_pinned_connection?
172
+ qualify_pinned_table_name(klass)
173
+ else
174
+ klass.establish_connection(pinned_model_config)
175
+ klass.apartment_mark_processed!
176
+ end
130
177
  end
131
178
 
132
179
  # Deprecated: use process_pinned_models instead.
@@ -169,6 +216,28 @@ module Apartment
169
216
 
170
217
  private
171
218
 
219
+ # --- Missing-tenant fail-safe seams -------------------------------------
220
+
221
+ # Does +error+ look like a missing-container error for this engine?
222
+ # Base: never, so the default adapter classifies nothing.
223
+ def container_error?(_error)
224
+ false
225
+ end
226
+
227
+ # Authoritative check that the tenant's container exists. Base: assume it
228
+ # does, so an unimplemented adapter never evicts a live tenant.
229
+ def tenant_container_exists?(_tenant)
230
+ true
231
+ end
232
+
233
+ # ConnectionHandling wraps non-Apartment errors raised during pool
234
+ # resolution in Apartment::ApartmentError with the original as #cause; the
235
+ # schema-strategy query error arrives unwrapped. Inspect the cause when
236
+ # present so both shapes classify the same.
237
+ def unwrap_db_error(error)
238
+ error.is_a?(Apartment::ApartmentError) && error.cause ? error.cause : error
239
+ end
240
+
172
241
  def grant_tenant_privileges(tenant)
173
242
  app_role = Apartment.config.app_role
174
243
  return unless app_role
@@ -191,6 +260,33 @@ module Apartment
191
260
  connection_config.transform_keys(&:to_s)
192
261
  end
193
262
 
263
+ # Cap the tenant pool's max checkout size to Apartment.config.tenant_pool_size
264
+ # when configured. Defaults to nil — the tenant pool inherits the base/default
265
+ # pool's `pool:` (the app's DB_POOL_SIZE), preserving pre-4.0.0.alpha3 behavior.
266
+ # Set tenant_pool_size to size each per-tenant pool independently of the app pool
267
+ # (e.g. to bound total connections across many schema-per-tenant pools). The config
268
+ # is string-keyed here; HashConfig symbolizes it and reads `:pool` for the size.
269
+ def apply_tenant_pool_size(config)
270
+ size = Apartment.config.tenant_pool_size
271
+ return config unless size
272
+
273
+ config.merge('pool' => size)
274
+ end
275
+
276
+ # Connection config for pinned models on the separate-pool path.
277
+ # For schema strategy, pins schema_search_path to the default tenant
278
+ # (plus persistent schemas) so the connection resolves tables and FK
279
+ # constraints in the correct schema.
280
+ # For database strategies, returns base_config unchanged.
281
+ def pinned_model_config
282
+ config = base_config
283
+ return config unless Apartment.config.tenant_strategy == :schema
284
+
285
+ persistent = Apartment.config.postgres_config&.persistent_schemas || []
286
+ search_path = [default_tenant, *persistent].map { |s| %("#{s}") }.join(',')
287
+ config.merge('schema_search_path' => search_path)
288
+ end
289
+
194
290
  def rails_env
195
291
  unless defined?(Rails)
196
292
  raise(Apartment::ConfigurationError,
@@ -10,11 +10,41 @@ module Apartment
10
10
  # to the environmentified tenant name. Lifecycle operations (create/drop)
11
11
  # execute DDL against the default connection.
12
12
  class Mysql2Adapter < AbstractAdapter
13
+ def shared_pinned_connection?
14
+ !Apartment.config.force_separate_pinned_pool
15
+ end
16
+
17
+ def qualify_pinned_table_name(klass)
18
+ db_name = base_config['database']
19
+
20
+ if klass.apartment_explicit_table_name?
21
+ original = klass.table_name
22
+ table = original.sub(/\A[^.]+\./, '')
23
+ klass.table_name = "#{db_name}.#{table}"
24
+ klass.apartment_mark_processed!(:explicit, original)
25
+ else
26
+ original_prefix = klass.table_name_prefix
27
+ klass.table_name_prefix = "#{db_name}."
28
+ klass.reset_table_name
29
+ klass.apartment_mark_processed!(:convention, original_prefix)
30
+ end
31
+ end
32
+
13
33
  def resolve_connection_config(tenant, base_config: nil)
14
34
  config = base_config || send(:base_config)
15
35
  config.merge('database' => environmentify(tenant))
16
36
  end
17
37
 
38
+ # The database-per-tenant missing-tenant error: connecting to a dropped
39
+ # database raises ActiveRecord::NoDatabaseError (MySQL error 1049) — an
40
+ # unambiguous signal. It surfaces raw at query time, or wrapped in
41
+ # ApartmentError when ConnectionHandling resolves the pool (the dev-mode
42
+ # pending-migration check), so both are listed; #container_error? gates on
43
+ # the unwrapped NoDatabaseError. Inherited by TrilogyAdapter.
44
+ def failsafe_error_classes
45
+ [ActiveRecord::NoDatabaseError, Apartment::ApartmentError]
46
+ end
47
+
18
48
  protected
19
49
 
20
50
  def create_tenant(tenant)
@@ -38,6 +68,24 @@ module Apartment
38
68
  "GRANT SELECT, INSERT, UPDATE, DELETE ON #{connection.quote_table_name(db_name)}.* TO #{quoted_role}@'%'"
39
69
  )
40
70
  end
71
+
72
+ def container_error?(error)
73
+ error.is_a?(ActiveRecord::NoDatabaseError)
74
+ end
75
+
76
+ # Authoritative existence check on the DEFAULT connection:
77
+ # information_schema.schemata is server-global and reachable from any
78
+ # database, and the rescue runs after switch restored Current.tenant to
79
+ # default. The tenant's database is the environmentified name. A probe
80
+ # failure means we cannot prove it gone, so report it as existing and let
81
+ # the original error re-raise.
82
+ def tenant_container_exists?(tenant)
83
+ conn = ActiveRecord::Base.connection
84
+ quoted = conn.quote(environmentify(tenant))
85
+ !conn.select_value("SELECT 1 FROM information_schema.schemata WHERE schema_name = #{quoted}").nil?
86
+ rescue StandardError
87
+ true
88
+ end
41
89
  end
42
90
  end
43
91
  end
@@ -15,6 +15,16 @@ module Apartment
15
15
  config.merge('database' => environmentify(tenant))
16
16
  end
17
17
 
18
+ # The database-per-tenant missing-tenant error: connecting to a dropped
19
+ # database raises ActiveRecord::NoDatabaseError (PG SQLSTATE 3D000) — an
20
+ # unambiguous signal, unlike the schema strategy's 42P01. It surfaces raw at
21
+ # query time, or wrapped in ApartmentError when ConnectionHandling resolves
22
+ # the pool (the dev-mode pending-migration check), so both are listed;
23
+ # #container_error? gates on the unwrapped NoDatabaseError.
24
+ def failsafe_error_classes
25
+ [ActiveRecord::NoDatabaseError, Apartment::ApartmentError]
26
+ end
27
+
18
28
  protected
19
29
 
20
30
  def create_tenant(tenant)
@@ -42,6 +52,25 @@ module Apartment
42
52
  # (GRANT CONNECT on server, table grants inside tenant DB).
43
53
  # Use the callable app_role escape hatch for this strategy.
44
54
  # See docs/designs/v4-phase5-rbac-roles-schema-cache.md.
55
+
56
+ private
57
+
58
+ def container_error?(error)
59
+ error.is_a?(ActiveRecord::NoDatabaseError)
60
+ end
61
+
62
+ # Authoritative existence check on the DEFAULT connection: pg_database is a
63
+ # cluster-global catalog reachable from any database, and the rescue runs
64
+ # after switch restored Current.tenant to default. The tenant's database is
65
+ # the environmentified name. A probe failure means we cannot prove it gone,
66
+ # so report it as existing and let the original error re-raise.
67
+ def tenant_container_exists?(tenant)
68
+ conn = ActiveRecord::Base.connection
69
+ quoted = conn.quote(environmentify(tenant))
70
+ !conn.select_value("SELECT 1 FROM pg_database WHERE datname = #{quoted}").nil?
71
+ rescue StandardError
72
+ true
73
+ end
45
74
  end
46
75
  end
47
76
  end
@@ -12,14 +12,48 @@ module Apartment
12
12
  # Apartment.config.postgres_config. Lifecycle operations (create/drop)
13
13
  # execute DDL against the default connection.
14
14
  class PostgresqlSchemaAdapter < AbstractAdapter
15
+ def shared_pinned_connection?
16
+ !Apartment.config.force_separate_pinned_pool
17
+ end
18
+
19
+ def qualify_pinned_table_name(klass)
20
+ if klass.apartment_explicit_table_name?
21
+ original = klass.table_name
22
+ table = original.sub(/\A[^.]+\./, '')
23
+ klass.table_name = "#{default_tenant}.#{table}"
24
+ klass.apartment_mark_processed!(:explicit, original)
25
+ else
26
+ original_prefix = klass.table_name_prefix
27
+ klass.table_name_prefix = "#{default_tenant}."
28
+ klass.reset_table_name
29
+ klass.apartment_mark_processed!(:convention, original_prefix)
30
+ end
31
+ end
32
+
15
33
  def resolve_connection_config(tenant, base_config: nil)
16
34
  config = base_config || send(:base_config)
17
35
  persistent = Apartment.config.postgres_config&.persistent_schemas || []
18
- search_path = [tenant, *persistent].join(',')
36
+ search_path = [tenant, *persistent].map { |s| %("#{s}") }.join(',')
19
37
 
20
38
  config.merge('schema_search_path' => search_path)
21
39
  end
22
40
 
41
+ # The schema-strategy missing-tenant error: a dropped schema is not caught
42
+ # at switch time (search_path accepts a non-existent schema silently) — it
43
+ # surfaces on the first query as ActiveRecord::StatementInvalid
44
+ # (PG::UndefinedTable, 42P01). That is the same shape as a missing table in
45
+ # a *live* schema, so #tenant_container_exists? does the disambiguating
46
+ # to_regnamespace check.
47
+ #
48
+ # ApartmentError is included because ConnectionHandling wraps errors raised
49
+ # during pool resolution (e.g. the dev-mode pending-migration check, which
50
+ # queries schema_migrations in the gone schema) as ApartmentError with the
51
+ # StatementInvalid as #cause; #container_error? then unwraps and classifies
52
+ # it the same as the query-time case, and re-raises any other ApartmentError.
53
+ def failsafe_error_classes
54
+ [ActiveRecord::StatementInvalid, Apartment::ApartmentError]
55
+ end
56
+
23
57
  protected
24
58
 
25
59
  def create_tenant(tenant)
@@ -34,6 +68,27 @@ module Apartment
34
68
 
35
69
  private
36
70
 
71
+ # Any StatementInvalid is a candidate; the authoritative call is the
72
+ # existence probe below, so a missing table in a live schema (same 42P01)
73
+ # correctly re-raises rather than 404ing.
74
+ def container_error?(error)
75
+ error.is_a?(ActiveRecord::StatementInvalid)
76
+ end
77
+
78
+ # Authoritative existence check, run on the DEFAULT connection: the
79
+ # elevator's switch ensure-block has already restored Current.tenant before
80
+ # the fail-safe rescue runs, so ActiveRecord::Base.connection targets the
81
+ # default pool rather than the gone tenant. to_regnamespace returns NULL for
82
+ # a missing schema. If the probe itself errors (e.g. the database is down),
83
+ # we cannot prove the schema is gone — report it as existing so the original
84
+ # error re-raises instead of masking infrastructure failure as a 404.
85
+ def tenant_container_exists?(tenant)
86
+ conn = ActiveRecord::Base.connection
87
+ conn.select_value("SELECT to_regnamespace(#{conn.quote(tenant)}) IS NOT NULL")
88
+ rescue StandardError
89
+ true
90
+ end
91
+
37
92
  def grant_privileges(tenant, connection, role_name) # rubocop:disable Metrics/MethodLength
38
93
  quoted_schema = connection.quote_table_name(tenant)
39
94
  quoted_role = connection.quote_table_name(role_name)
@@ -18,6 +18,20 @@ module Apartment
18
18
  config.merge('database' => File.join(db_dir, "#{environmentify(tenant)}.sqlite3"))
19
19
  end
20
20
 
21
+ # No missing-tenant fail-safe override on purpose — keep the conservative
22
+ # AbstractAdapter default (failsafe_error_classes == []). SQLite gives no
23
+ # sound "container gone" signal: connecting to a dropped file auto-recreates
24
+ # it empty, so by the time the elevator's rescue runs File.exist? is true
25
+ # and the only query error is "no such table" (StatementInvalid) — identical
26
+ # to a missing table in a live tenant, or to a freshly created tenant with
27
+ # no schema loaded (schema_load_strategy nil). A zero-tables heuristic would
28
+ # 404 valid-but-empty tenants; there is no authoritative catalog (unlike
29
+ # pg_database / information_schema) to distinguish dropped from unpopulated.
30
+ # Auto-create is also load-bearing — create_tenant relies on it — so it
31
+ # cannot be disabled to force a clean missing-file error. SQLite file-per-
32
+ # tenant is a dev/test strategy, not a multi-process target, so the
33
+ # cross-process drop gap this guards barely applies. See the design doc.
34
+
21
35
  protected
22
36
 
23
37
  def create_tenant(tenant)
@@ -79,7 +79,7 @@ module Apartment
79
79
  end
80
80
 
81
81
  def rollback_all
82
- tenants = Apartment.config.tenants_provider.call
82
+ tenants = Apartment.tenant_names
83
83
  failed = []
84
84
  tenants.each do |t|
85
85
  rollback_single(t)
@@ -29,7 +29,7 @@ module Apartment
29
29
  end
30
30
 
31
31
  def seed_all
32
- tenants = Apartment.config.tenants_provider.call
32
+ tenants = Apartment.tenant_names
33
33
  failed = []
34
34
  tenants.each do |t|
35
35
  say("Seeding tenant: #{t}")
@@ -37,7 +37,7 @@ module Apartment
37
37
 
38
38
  desc 'list', 'List all tenants'
39
39
  def list
40
- Apartment.config.tenants_provider.call.each { |t| say(t) }
40
+ Apartment.tenant_names.each { |t| say(t) }
41
41
  end
42
42
 
43
43
  desc 'current', 'Show current tenant'
@@ -56,7 +56,7 @@ module Apartment
56
56
  end
57
57
 
58
58
  def create_all
59
- tenants = Apartment.config.tenants_provider.call
59
+ tenants = Apartment.tenant_names
60
60
  failed = []
61
61
  tenants.each do |t|
62
62
  say("Creating tenant: #{t}") unless quiet?