ros-apartment 4.0.0.alpha1 → 4.0.0.alpha2
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/README.md +67 -3
- data/config/default.yml +9 -0
- data/lib/apartment/CLAUDE.md +23 -4
- data/lib/apartment/adapters/abstract_adapter.rb +100 -18
- data/lib/apartment/adapters/mysql2_adapter.rb +48 -0
- data/lib/apartment/adapters/postgresql_database_adapter.rb +29 -0
- data/lib/apartment/adapters/postgresql_schema_adapter.rb +56 -1
- data/lib/apartment/adapters/sqlite3_adapter.rb +14 -0
- data/lib/apartment/cli/migrations.rb +1 -1
- data/lib/apartment/cli/seeds.rb +1 -1
- data/lib/apartment/cli/tenants.rb +2 -2
- data/lib/apartment/concerns/model.rb +111 -6
- data/lib/apartment/config.rb +51 -3
- data/lib/apartment/configs/postgresql_config.rb +13 -4
- data/lib/apartment/current.rb +8 -1
- data/lib/apartment/elevators/CLAUDE.md +10 -7
- data/lib/apartment/elevators/generic.rb +77 -6
- data/lib/apartment/errors.rb +73 -0
- data/lib/apartment/lifecycle.rb +32 -0
- data/lib/apartment/migrator.rb +2 -2
- data/lib/apartment/patches/connection_handling.rb +10 -5
- data/lib/apartment/patches/live_tenant_propagation.rb +53 -0
- data/lib/apartment/pool_manager.rb +15 -0
- data/lib/apartment/pool_reaper.rb +87 -3
- data/lib/apartment/railtie.rb +111 -3
- data/lib/apartment/schema_cache.rb +1 -1
- data/lib/apartment/tenant.rb +252 -13
- data/lib/apartment/tenant_validator.rb +186 -0
- data/lib/apartment/test_fixtures.rb +34 -0
- data/lib/apartment/version.rb +1 -1
- data/lib/apartment.rb +139 -12
- data/lib/generators/apartment/install/templates/apartment.rb +10 -2
- data/lib/rubocop/apartment.rb +4 -0
- data/lib/rubocop/cop/apartment/no_direct_current_write.rb +79 -0
- data/lib/rubocop/cop/apartment/prefer_block_switch.rb +39 -0
- data/ros-apartment.gemspec +1 -1
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: efc3d3a91e17846826ac5d5ca707cd17bdc5a5923b18fb4c0162ac87f24014d0
|
|
4
|
+
data.tar.gz: 5c1787f4e9cee805842e6035fcc2769415dfccdf90c3c2b354315271315638a2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 486bb0cd94225925ada99a497c89e314536300a261b898c3575a5008211e1e8f1632a9755dadae096f5814eaee00920e9652f70f8feb8676ff58897fc4262f2c
|
|
7
|
+
data.tar.gz: e9b88d3c86be4e0891ff584e85c822979c6146ece01ce2bab17f442603e8add4aef3e826157157df3325722e39209a70bf94ffdd218504abdbf6be7dd4af29cf
|
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
|
|
|
@@ -112,7 +112,7 @@ config.elevator = :subdomain
|
|
|
112
112
|
config.elevator_options = {}
|
|
113
113
|
```
|
|
114
114
|
|
|
115
|
-
The Railtie auto-inserts elevator middleware
|
|
115
|
+
The Railtie auto-inserts elevator middleware after `ActionDispatch::Callbacks` (just before cookies/sessions in full mode; works in API mode too).
|
|
116
116
|
|
|
117
117
|
See the [Elevators](#elevators) section for available options.
|
|
118
118
|
|
|
@@ -217,7 +217,14 @@ Apartment.configure do |config|
|
|
|
217
217
|
end
|
|
218
218
|
```
|
|
219
219
|
|
|
220
|
-
The Railtie inserts the elevator
|
|
220
|
+
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.
|
|
221
|
+
|
|
222
|
+
If you need different positioning, skip `config.elevator` and insert manually:
|
|
223
|
+
|
|
224
|
+
```ruby
|
|
225
|
+
# config/application.rb
|
|
226
|
+
config.middleware.insert_before 'Warden::Manager', Apartment::Elevators::Subdomain
|
|
227
|
+
```
|
|
221
228
|
|
|
222
229
|
### Custom Elevator
|
|
223
230
|
|
|
@@ -304,6 +311,28 @@ Workaround: add `include Apartment::Model` and `pin_tenant` on the abstract clas
|
|
|
304
311
|
|
|
305
312
|
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
313
|
|
|
314
|
+
## ActionController::Live Streaming
|
|
315
|
+
|
|
316
|
+
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:
|
|
317
|
+
|
|
318
|
+
```ruby
|
|
319
|
+
class StreamingController < ApplicationController
|
|
320
|
+
include ActionController::Live
|
|
321
|
+
|
|
322
|
+
def show
|
|
323
|
+
response.headers['Content-Type'] = 'text/event-stream'
|
|
324
|
+
# Apartment::Tenant.current returns the request's tenant here,
|
|
325
|
+
# even though we're now executing on the OS thread Rails spawned
|
|
326
|
+
# for streaming.
|
|
327
|
+
response.stream.write("data: #{{ tenant: Apartment::Tenant.current }.to_json}\n\n")
|
|
328
|
+
ensure
|
|
329
|
+
response.stream.close
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
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).
|
|
335
|
+
|
|
307
336
|
## Background Workers
|
|
308
337
|
|
|
309
338
|
Use block-scoped switching in jobs:
|
|
@@ -323,6 +352,19 @@ For automatic tenant propagation:
|
|
|
323
352
|
- [apartment-sidekiq](https://github.com/rails-on-services/apartment-sidekiq)
|
|
324
353
|
- [apartment-activejob](https://github.com/rails-on-services/apartment-activejob)
|
|
325
354
|
|
|
355
|
+
A job that forgets to switch runs in the default tenant — for `Rails.cache` and
|
|
356
|
+
other `Tenant.current`-derived resources that silently contaminate another
|
|
357
|
+
tenant's keyspace. Guard routed work with `Apartment::Tenant.require_tenant!`
|
|
358
|
+
(raises unless a real, non-default tenant is active) and pinned/global work with
|
|
359
|
+
`require_default_tenant!`. See [Tenant-Aware Caching](docs/caching.md) for the
|
|
360
|
+
routed-vs-pinned model and the two-store recipe.
|
|
361
|
+
|
|
362
|
+
## Convenience Methods
|
|
363
|
+
|
|
364
|
+
`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.
|
|
365
|
+
|
|
366
|
+
`Apartment.excluded_models` returns the excluded models list (delegates to `config.excluded_models`). Deprecated in v4; use `Apartment::Model` + `pin_tenant` instead.
|
|
367
|
+
|
|
326
368
|
## Troubleshooting
|
|
327
369
|
|
|
328
370
|
If tenant switching raises unexpected errors, verify that `tenants_provider` returns valid tenant names and that the tenant exists in the database.
|
|
@@ -331,6 +373,28 @@ If tenant switching raises unexpected errors, verify that `tenants_provider` ret
|
|
|
331
373
|
|
|
332
374
|
See the [upgrade guide](docs/upgrading-to-v4.md) for a complete list of breaking changes and migration steps.
|
|
333
375
|
|
|
376
|
+
## RuboCop cops
|
|
377
|
+
|
|
378
|
+
Apartment ships two optional RuboCop cops that enforce the block-form
|
|
379
|
+
tenant-switching discipline. Enable them in your application's `.rubocop.yml`:
|
|
380
|
+
|
|
381
|
+
```yaml
|
|
382
|
+
require: rubocop/apartment
|
|
383
|
+
inherit_gem:
|
|
384
|
+
ros-apartment: config/default.yml
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
- **`Apartment/NoDirectCurrentWrite`** (error) — bans assigning
|
|
388
|
+
`Apartment::Current.tenant` / `.previous_tenant` directly. Change tenant context
|
|
389
|
+
with `Apartment::Tenant.switch(tenant) { ... }` (or `with_default_tenant` for
|
|
390
|
+
global work), which guarantees a restore via `ensure`.
|
|
391
|
+
- **`Apartment/PreferBlockSwitch`** (warning) — nudges `Apartment::Tenant.switch!`
|
|
392
|
+
toward the block form. `reset` is not flagged.
|
|
393
|
+
|
|
394
|
+
Both match the qualified `Apartment::` receiver only. Scope them to your
|
|
395
|
+
application code with the standard `Exclude:` keys if needed. See
|
|
396
|
+
[`docs/designs/rubocop-cops.md`](docs/designs/rubocop-cops.md) for the rationale.
|
|
397
|
+
|
|
334
398
|
## Contributing
|
|
335
399
|
|
|
336
400
|
1. Check [existing issues](https://github.com/rails-on-services/apartment/issues) and [discussions](https://github.com/rails-on-services/apartment/discussions)
|
data/config/default.yml
ADDED
|
@@ -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
|
data/lib/apartment/CLAUDE.md
CHANGED
|
@@ -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,
|
|
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,
|
|
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.
|
|
@@ -60,7 +67,7 @@ Background `Concurrent::TimerTask` instance that evicts idle and excess tenant p
|
|
|
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!`.
|
|
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
|
|
|
@@ -96,37 +96,83 @@ module Apartment
|
|
|
96
96
|
end
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
-
#
|
|
99
|
+
# Whether pinned models can share the tenant's connection pool using
|
|
100
|
+
# qualified table names instead of establish_connection.
|
|
101
|
+
#
|
|
102
|
+
# Returns false by default (separate pool). Subclasses override to
|
|
103
|
+
# return true when the engine supports cross-schema/database queries,
|
|
104
|
+
# gated by config.force_separate_pinned_pool.
|
|
105
|
+
def shared_pinned_connection?
|
|
106
|
+
false
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Request-path fail-safe contract. The elevator wraps the
|
|
110
|
+
# tenant switch; on one of these error classes it asks
|
|
111
|
+
# #tenant_container_gone? whether the tenant's storage actually vanished (a
|
|
112
|
+
# cross-process drop) rather than an app-level failure. An empty list
|
|
113
|
+
# disables the rescue, so an adapter that does not implement the seams
|
|
114
|
+
# never converts an error into a 404.
|
|
115
|
+
def failsafe_error_classes
|
|
116
|
+
[]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Whether +error+, raised while serving +tenant+, means the tenant's
|
|
120
|
+
# container (schema/database/file) no longer exists — so the validator
|
|
121
|
+
# should evict the name and the request should 404 instead of surfacing a
|
|
122
|
+
# 500. Composed from a cheap error-shape check and an authoritative
|
|
123
|
+
# existence probe, both conservative by default so the base adapter never
|
|
124
|
+
# reclassifies. Subclasses override the seams.
|
|
125
|
+
def tenant_container_gone?(error, tenant)
|
|
126
|
+
return false unless container_error?(unwrap_db_error(error))
|
|
127
|
+
|
|
128
|
+
!tenant_container_exists?(tenant)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Qualify a pinned model's table_name so it targets the default
|
|
132
|
+
# tenant's tables from any tenant connection. Subclasses must
|
|
133
|
+
# implement when shared_pinned_connection? returns true.
|
|
134
|
+
def qualify_pinned_table_name(_klass)
|
|
135
|
+
raise(NotImplementedError,
|
|
136
|
+
"#{self.class}#qualify_pinned_table_name must be implemented when shared_pinned_connection? is true")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Process all pinned models. When shared_pinned_connection? is true, qualifies
|
|
140
|
+
# table names for shared pool routing. Otherwise, establishes separate connections.
|
|
100
141
|
def process_pinned_models
|
|
101
142
|
return if Apartment.pinned_models.empty?
|
|
102
143
|
|
|
103
144
|
Apartment.pinned_models.each do |klass|
|
|
104
145
|
process_pinned_model(klass)
|
|
146
|
+
rescue StandardError => e
|
|
147
|
+
raise(Apartment::ConfigurationError,
|
|
148
|
+
"Failed to process pinned model #{klass.name}: #{e.class}: #{e.message}")
|
|
105
149
|
end
|
|
106
150
|
end
|
|
107
151
|
|
|
108
152
|
# Process a single pinned model. Called by process_pinned_models (batch)
|
|
109
153
|
# and by Apartment::Model.pin_tenant (when activated? is true).
|
|
154
|
+
#
|
|
155
|
+
# When shared_pinned_connection? is true, qualifies the table name so
|
|
156
|
+
# the model uses the tenant's pool (preserving transactional integrity).
|
|
157
|
+
# Otherwise, establishes a separate connection pool (required when
|
|
158
|
+
# cross-database queries are impossible).
|
|
110
159
|
def process_pinned_model(klass)
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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)
|
|
160
|
+
# Ensure the concern is included — models registered via the
|
|
161
|
+
# excluded_models shim may not have it yet. Uses apartment_mark_pinned!
|
|
162
|
+
# (not pin_tenant) to avoid recursion back into process_pinned_model.
|
|
163
|
+
unless klass.respond_to?(:apartment_pinned_processed?)
|
|
164
|
+
klass.include(Apartment::Model)
|
|
165
|
+
klass.apartment_mark_pinned!
|
|
166
|
+
end
|
|
125
167
|
|
|
126
|
-
return
|
|
168
|
+
return if klass.apartment_pinned_processed?
|
|
127
169
|
|
|
128
|
-
|
|
129
|
-
|
|
170
|
+
if shared_pinned_connection?
|
|
171
|
+
qualify_pinned_table_name(klass)
|
|
172
|
+
else
|
|
173
|
+
klass.establish_connection(pinned_model_config)
|
|
174
|
+
klass.apartment_mark_processed!
|
|
175
|
+
end
|
|
130
176
|
end
|
|
131
177
|
|
|
132
178
|
# Deprecated: use process_pinned_models instead.
|
|
@@ -169,6 +215,28 @@ module Apartment
|
|
|
169
215
|
|
|
170
216
|
private
|
|
171
217
|
|
|
218
|
+
# --- Missing-tenant fail-safe seams -------------------------------------
|
|
219
|
+
|
|
220
|
+
# Does +error+ look like a missing-container error for this engine?
|
|
221
|
+
# Base: never, so the default adapter classifies nothing.
|
|
222
|
+
def container_error?(_error)
|
|
223
|
+
false
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Authoritative check that the tenant's container exists. Base: assume it
|
|
227
|
+
# does, so an unimplemented adapter never evicts a live tenant.
|
|
228
|
+
def tenant_container_exists?(_tenant)
|
|
229
|
+
true
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# ConnectionHandling wraps non-Apartment errors raised during pool
|
|
233
|
+
# resolution in Apartment::ApartmentError with the original as #cause; the
|
|
234
|
+
# schema-strategy query error arrives unwrapped. Inspect the cause when
|
|
235
|
+
# present so both shapes classify the same.
|
|
236
|
+
def unwrap_db_error(error)
|
|
237
|
+
error.is_a?(Apartment::ApartmentError) && error.cause ? error.cause : error
|
|
238
|
+
end
|
|
239
|
+
|
|
172
240
|
def grant_tenant_privileges(tenant)
|
|
173
241
|
app_role = Apartment.config.app_role
|
|
174
242
|
return unless app_role
|
|
@@ -191,6 +259,20 @@ module Apartment
|
|
|
191
259
|
connection_config.transform_keys(&:to_s)
|
|
192
260
|
end
|
|
193
261
|
|
|
262
|
+
# Connection config for pinned models on the separate-pool path.
|
|
263
|
+
# For schema strategy, pins schema_search_path to the default tenant
|
|
264
|
+
# (plus persistent schemas) so the connection resolves tables and FK
|
|
265
|
+
# constraints in the correct schema.
|
|
266
|
+
# For database strategies, returns base_config unchanged.
|
|
267
|
+
def pinned_model_config
|
|
268
|
+
config = base_config
|
|
269
|
+
return config unless Apartment.config.tenant_strategy == :schema
|
|
270
|
+
|
|
271
|
+
persistent = Apartment.config.postgres_config&.persistent_schemas || []
|
|
272
|
+
search_path = [default_tenant, *persistent].map { |s| %("#{s}") }.join(',')
|
|
273
|
+
config.merge('schema_search_path' => search_path)
|
|
274
|
+
end
|
|
275
|
+
|
|
194
276
|
def rails_env
|
|
195
277
|
unless defined?(Rails)
|
|
196
278
|
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)
|
data/lib/apartment/cli/seeds.rb
CHANGED
|
@@ -37,7 +37,7 @@ module Apartment
|
|
|
37
37
|
|
|
38
38
|
desc 'list', 'List all tenants'
|
|
39
39
|
def list
|
|
40
|
-
Apartment.
|
|
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.
|
|
59
|
+
tenants = Apartment.tenant_names
|
|
60
60
|
failed = []
|
|
61
61
|
tenants.each do |t|
|
|
62
62
|
say("Creating tenant: #{t}") unless quiet?
|