ros-apartment 4.0.0.alpha10 → 4.0.0.alpha12
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/LICENSE +21 -0
- data/README.md +2 -1
- data/lib/apartment/CLAUDE.md +17 -2
- data/lib/apartment/adapters/abstract_adapter.rb +177 -5
- data/lib/apartment/adapters/mysql2_adapter.rb +4 -14
- data/lib/apartment/adapters/postgresql_schema_adapter.rb +4 -12
- data/lib/apartment/concerns/model.rb +138 -21
- data/lib/apartment/patches/connection_registry.rb +284 -0
- data/lib/apartment/version.rb +1 -1
- data/lib/apartment.rb +14 -1
- data/ros-apartment.gemspec +5 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2bb81b710aa16f388e8861ad84d358eee5ec751ff6cce1ad3121513e9a0ef46e
|
|
4
|
+
data.tar.gz: 49adf108763c408b893a314f223756697ccaf21b950087656b357aeb93f33dcd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 773ee7eff98a2f2287c53aef4b1b8172c97b2251c3d82e3f4b099e60b9602f7093cec92b8bdef9669d71a864bece4f767a2ba7dad9109816a80e8478b6087ed7
|
|
7
|
+
data.tar.gz: 1cf4884cd5b476e57481e8f928c39575e95cdc75249b86fa24dbfeb6885d28f9af1567795f22d1d71270cd402755711727f6614ecc566998d85a47cae780635c
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2010-2026 Ryan Brunner, Brad Robertson, Rui Baltazar, Mauricio Novelo, and the ros-apartment contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
[](https://badge.fury.io/rb/ros-apartment)
|
|
4
4
|
[](https://github.com/rails-on-services/apartment/actions/workflows/ci.yml)
|
|
5
5
|
[](https://codecov.io/gh/rails-on-services/apartment)
|
|
6
|
+
[](https://www.greptile.com/?utm_source=oss_badge&utm_medium=readme&utm_campaign=greptile_for_open_source)
|
|
6
7
|
|
|
7
8
|
*Database-level multitenancy for Rails and ActiveRecord*
|
|
8
9
|
|
|
@@ -487,4 +488,4 @@ application code with the standard `Exclude:` keys if needed. See
|
|
|
487
488
|
|
|
488
489
|
## License
|
|
489
490
|
|
|
490
|
-
|
|
491
|
+
MIT — see [LICENSE](LICENSE). SPDX identifier: `MIT`.
|
data/lib/apartment/CLAUDE.md
CHANGED
|
@@ -21,6 +21,7 @@ lib/apartment/
|
|
|
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
|
|
23
23
|
│ ├── connection_handling.rb # Prepends on AR::Base — tenant-aware connection_pool
|
|
24
|
+
│ ├── connection_registry.rb # Prepends on AR's PoolManager + ConnectionHandler — serializes the pool registry
|
|
24
25
|
│ └── postgresql_sequence_name.rb # Prepends on the PG adapter — schema-agnostic Model.sequence_name memoization
|
|
25
26
|
├── tasks/ # Rake task utilities; v4.rake for apartment:create/drop/migrate/seed/rollback
|
|
26
27
|
├── config.rb # Configuration with validate!/freeze!
|
|
@@ -62,6 +63,16 @@ lib/apartment/
|
|
|
62
63
|
|
|
63
64
|
`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 a pool budget is configured (`max_tenant_pools` and/or `max_tenant_connections`, resolved by `Config#effective_pool_budget`; `max_total_connections` is the deprecated alias of `max_tenant_pools`), `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-connection-budget.md` and `docs/designs/pool-admission-control.md`.
|
|
64
65
|
|
|
66
|
+
### patches/connection_registry.rb — AR Registry Serialization
|
|
67
|
+
|
|
68
|
+
Rails' `ActiveRecord::ConnectionAdapters::PoolManager` (**not** Apartment's same-named class — always fully qualify inside `Apartment::Patches`) stores every pool in a plain nested Hash, unsynchronized, because upstream only writes it at boot. Pool-per-tenant writes it forever, so a cold tenant switch could add a shard key while another thread was iterating, and MRI's iteration guard (per-Hash, not per-thread) failed the *switch* with `RuntimeError: can't add a new key into hash during iteration`.
|
|
69
|
+
|
|
70
|
+
The readers are Rails' own, all reaching the registry through `ConnectionHandler#each_connection_pool`: `ActiveRecord::QueryCache.run` (every executor run — the start of every request and job), `ConnectionPool::ExecutorHooks.complete` (every executor completion), `Base.clear_query_caches_for_current_thread` (after writes), `ActiveRecord.all_open_transactions`, and `clear_active_connections!` / `clear_all_connections!` / `flush_idle_connections!`. **Not** AR's `ConnectionPool::Reaper` — verified across 7.2/8.0/8.1, it keeps a private mutex-protected `WeakRef` list per reaping frequency and never touches this registry. Target the readers above when reasoning about synchronization or writing regression tests.
|
|
71
|
+
|
|
72
|
+
`ConnectionRegistry.apply!` (called by `activate!`) prepends two wrappers sharing one process-wide `Monitor`: `PoolManagerSync` over all eight `PoolManager` accessors — reads included, because the outer Hash's default proc mutates on a miss — and `HandlerSync` over `ConnectionHandler#set_pool_manager` (kept `private`), whose `||=` is a non-atomic upsert. `each_pool_config` snapshots under the lock and yields outside it; never hold this lock across a caller's block, since Rails disconnects pools inside those blocks. `SYNC` is a **leaf lock** — nothing acquired under it, no IO under it — which is the whole deadlock-freedom argument.
|
|
73
|
+
|
|
74
|
+
`serialize!` **fails closed**: it resolves each guarded method with `method_defined?` / `private_method_defined?` (matching what `super` actually needs, so an upstream method merely *moved* to a superclass or module still counts) and raises `Apartment::ConfigurationError` at `activate!` when one is genuinely gone. It only *warns* when upstream has grown a public accessor we do not guard — a hole beats a boot failure. See `docs/designs/ar-connection-registry-thread-safety.md`.
|
|
75
|
+
|
|
65
76
|
### pool_reaper.rb — Pool Eviction + Admission
|
|
66
77
|
|
|
67
78
|
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.
|
|
@@ -86,9 +97,13 @@ All inherit from `AbstractAdapter`. Override `resolve_connection_config`, `creat
|
|
|
86
97
|
|
|
87
98
|
**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.
|
|
88
99
|
|
|
89
|
-
**Table naming:** `apartment_explicit_table_name?` — whether
|
|
100
|
+
**Table naming:** `apartment_explicit_table_name?` — whether the cached `@table_name` is one Rails' convention machinery would rebuild (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**. It selects a **restore** strategy only — it is *not* a qualification discriminator, and using it as one shipped three silent no-ops (see `docs/designs/v4-shared-pinned-connections.md`): `compute_table_name` honours `full_table_name_prefix` only on its `base_class?` branch, so prefix-based qualification is discarded outright for subclasses and for models whose module parent defines `table_name_prefix`. Qualification always assigns `table_name` directly.
|
|
101
|
+
|
|
102
|
+
**Lifecycle:** `apartment_pinned_processed?`, `apartment_mark_processed!`, `apartment_restore!` — qualification state and teardown. Paths are `:computed` (convention rebuilds the name; restore drops the `@table_name` override and recomputes), `:explicit` (restore assigns the saved name back verbatim), `:prefix` (abstract base; restore puts back the app's `table_name_prefix`), and `nil` (separate-pool; nothing to undo). **Abstract bases are the one case still qualified via `table_name_prefix`**, because `pin_tenant` early-returns once a superclass is pinned — so concrete descendants are never registered and only a `class_attribute` broadcast reaches them. 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).
|
|
103
|
+
|
|
104
|
+
**Subclasses of a pinned model:** `pin_tenant` is idempotent **per class**, not per hierarchy — it keys on the class's own flag, not `apartment_pinned?` (which walks the superclass chain). A subclass declaring its own table must register and qualify on its own merits, since the parent's qualification cannot reach a different table; keying on the chain made that call silently no-op. A subclass that *shares* the parent's table still needs nothing and is skipped at qualification time by `AbstractAdapter#inherits_pinned_table?`. A subclass that declares its own table and is never registered gets a boot warning (`warn_unregistered_pinned_subclasses`, descendants-based, so complete only under eager loading). See `docs/designs/v4-shared-pinned-connections.md`.
|
|
90
105
|
|
|
91
|
-
**
|
|
106
|
+
**Descendant memos:** Rails memoizes `@table_name` per class and never invalidates a descendant's copy when an ancestor changes, so an early read (initializer, gem, route constraint) would freeze the *unqualified* name and the pinned model would read the tenant's table forever. `qualify_pinned_table_name` and `apartment_restore!` bracket their mutation with `apartment_descendants_inheriting_table_name` (collected **before**, while a stale memo is still distinguishable from a declaration) and `apartment_resync_descendant_table_names!` (`reset_table_name` after, which clears `@quoted_table_name`/`@arel_table` via Rails' own setter).
|
|
92
107
|
|
|
93
108
|
**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.
|
|
94
109
|
|
|
@@ -148,12 +148,96 @@ module Apartment
|
|
|
148
148
|
!tenant_container_exists?(tenant)
|
|
149
149
|
end
|
|
150
150
|
|
|
151
|
-
#
|
|
152
|
-
# tenant
|
|
153
|
-
# implement when shared_pinned_connection? returns true.
|
|
154
|
-
def
|
|
151
|
+
# The namespace that makes the default tenant's tables reachable from any
|
|
152
|
+
# tenant connection — a schema (PostgreSQL) or a database (MySQL).
|
|
153
|
+
# Subclasses must implement when shared_pinned_connection? returns true.
|
|
154
|
+
def pinned_table_qualifier
|
|
155
155
|
raise(NotImplementedError,
|
|
156
|
-
"#{self.class}#
|
|
156
|
+
"#{self.class}#pinned_table_qualifier must be implemented when shared_pinned_connection? is true")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Qualify a pinned model's table_name so it targets the default tenant's
|
|
160
|
+
# tables from any tenant connection.
|
|
161
|
+
#
|
|
162
|
+
# Always assigns table_name directly. The tempting alternative — set
|
|
163
|
+
# table_name_prefix and let Rails recompose — is unsound, because
|
|
164
|
+
# compute_table_name only consults full_table_name_prefix on its
|
|
165
|
+
# base_class? branch:
|
|
166
|
+
#
|
|
167
|
+
# * a class that is not its own base_class gets base_class.table_name
|
|
168
|
+
# verbatim, so the prefix is discarded outright;
|
|
169
|
+
# * full_table_name_prefix prefers the first module parent that responds
|
|
170
|
+
# to table_name_prefix, so an engine-namespaced model ignores the
|
|
171
|
+
# prefix set on the class itself;
|
|
172
|
+
# * overwriting the prefix drops one the app set, silently retargeting
|
|
173
|
+
# the model at a different table.
|
|
174
|
+
#
|
|
175
|
+
# Each case left the model resolving to the *tenant's* table with no
|
|
176
|
+
# error. Reading table_name first lets Rails compute the conventional
|
|
177
|
+
# name — honouring any prefix, suffix, or nesting the app declared —
|
|
178
|
+
# before we qualify the result.
|
|
179
|
+
def qualify_pinned_table_name(klass)
|
|
180
|
+
# Captured before the mutation below: afterwards there is no way to
|
|
181
|
+
# tell a descendant's stale memo from a table it declared itself.
|
|
182
|
+
inheriting = klass.apartment_descendants_inheriting_table_name
|
|
183
|
+
apply_pinned_qualification(klass)
|
|
184
|
+
klass.apartment_resync_descendant_table_names!(inheriting)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def apply_pinned_qualification(klass)
|
|
188
|
+
return qualify_pinned_table_name_prefix(klass) if klass.abstract_class?
|
|
189
|
+
return klass.apartment_mark_processed! if inherits_pinned_table?(klass)
|
|
190
|
+
|
|
191
|
+
# Captured before the assignment below, which would otherwise make
|
|
192
|
+
# every model look explicitly named.
|
|
193
|
+
path = klass.apartment_explicit_table_name? ? :explicit : :computed
|
|
194
|
+
original = klass.table_name
|
|
195
|
+
klass.table_name = "#{pinned_table_qualifier}.#{original.sub(/\A[^.]+\./, '')}"
|
|
196
|
+
klass.apartment_mark_processed!(path, (original if path == :explicit))
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# An abstract class has no table of its own — table_name is nil — so
|
|
200
|
+
# there is nothing to assign. Pinning one is a supported pattern (an
|
|
201
|
+
# abstract `connects_to` base is pinned so Apartment does not build
|
|
202
|
+
# tenant pools for it), and its qualifier still has to reach the concrete
|
|
203
|
+
# descendants that inherit the pin.
|
|
204
|
+
#
|
|
205
|
+
# Those descendants are never qualified directly: pin_tenant early-returns
|
|
206
|
+
# once any superclass is pinned (apartment_pinned? walks the chain), so
|
|
207
|
+
# they are never registered and process_pinned_models never sees them.
|
|
208
|
+
# table_name_prefix is a class_attribute, so setting it here broadcasts
|
|
209
|
+
# down the inheritance chain and each descendant composes it in its own
|
|
210
|
+
# compute_table_name. Any prefix the app set is preserved rather than
|
|
211
|
+
# overwritten, so `myapp_` becomes `<qualifier>.myapp_`.
|
|
212
|
+
#
|
|
213
|
+
# This is the one place the prefix mechanism is still correct, because
|
|
214
|
+
# here it is a broadcast to other classes rather than an attempt to
|
|
215
|
+
# qualify this class's own name.
|
|
216
|
+
def qualify_pinned_table_name_prefix(klass)
|
|
217
|
+
original_prefix = klass.table_name_prefix
|
|
218
|
+
klass.table_name_prefix = "#{pinned_table_qualifier}.#{original_prefix}"
|
|
219
|
+
klass.apartment_mark_processed!(:prefix, original_prefix)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Whether +klass+ reaches its table through an already-pinned base class
|
|
223
|
+
# and so needs no qualification of its own. Rails resolves a subclass's
|
|
224
|
+
# table through base_class.table_name, which the base's qualification
|
|
225
|
+
# already covers; assigning here would freeze a copy of the base's
|
|
226
|
+
# qualified name onto the child and desynchronise the two on teardown.
|
|
227
|
+
#
|
|
228
|
+
# Scoped narrowly on purpose. A subclass that declares its own table —
|
|
229
|
+
# the transitional shape when migrating an STI child off a pinned
|
|
230
|
+
# parent's table — is NOT covered and qualifies normally, because the
|
|
231
|
+
# parent's qualification cannot reach a different table. And a subclass
|
|
232
|
+
# whose base class is not pinned (e.g. an app model extending a gem's
|
|
233
|
+
# model) is not covered either, which is the case that motivated
|
|
234
|
+
# qualifying by assignment in the first place.
|
|
235
|
+
def inherits_pinned_table?(klass)
|
|
236
|
+
return false if klass.base_class?
|
|
237
|
+
return false if klass.apartment_explicit_table_name?
|
|
238
|
+
|
|
239
|
+
base = klass.base_class
|
|
240
|
+
base.respond_to?(:apartment_pinned?) && base.apartment_pinned?
|
|
157
241
|
end
|
|
158
242
|
|
|
159
243
|
# Process all pinned models. When shared_pinned_connection? is true, qualifies
|
|
@@ -167,6 +251,86 @@ module Apartment
|
|
|
167
251
|
raise(Apartment::ConfigurationError,
|
|
168
252
|
"Failed to process pinned model #{klass.name}: #{e.class}: #{e.message}")
|
|
169
253
|
end
|
|
254
|
+
|
|
255
|
+
warn_unregistered_pinned_subclasses
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Warn about subclasses of a pinned model that declare their own table and
|
|
259
|
+
# were never registered. Such a class inherits apartment_pinned? through
|
|
260
|
+
# the superclass walk but gets no qualification, so on a shared-connection
|
|
261
|
+
# adapter it silently reads the *tenant's* table — and on a separate-pool
|
|
262
|
+
# adapter a genuinely tenant-scoped one silently reads the default's. The
|
|
263
|
+
# shape is transitional (migrating an STI child off a pinned parent's
|
|
264
|
+
# table), which is exactly when a silent read is most costly: the symptom
|
|
265
|
+
# looks like a botched backfill.
|
|
266
|
+
#
|
|
267
|
+
# Detection walks descendants, so it is complete under eager loading
|
|
268
|
+
# (production boot, CI) and partial under Zeitwerk lazy loading. That is
|
|
269
|
+
# tolerable for a warning and would not be for a raise — which is why this
|
|
270
|
+
# warns rather than raising.
|
|
271
|
+
# descendants is transitive, so every pinned class in one inheritance
|
|
272
|
+
# chain sees the same unregistered descendant. Deduplicate, and attribute
|
|
273
|
+
# each warning to the *nearest* pinned ancestor — the one whose pin the
|
|
274
|
+
# subclass actually inherits — so the message is deterministic rather than
|
|
275
|
+
# dependent on registry iteration order.
|
|
276
|
+
def warn_unregistered_pinned_subclasses
|
|
277
|
+
# Snapshot the registry and walk it outside its own lock. Concurrent::Set
|
|
278
|
+
# synchronizes every method on CRuby, #each included, so iterating in
|
|
279
|
+
# place would hold a process-wide monitor across descendant walking and
|
|
280
|
+
# stderr I/O. Same leaf-lock discipline as Patches::ConnectionRegistry.
|
|
281
|
+
pinned = Apartment.pinned_models.to_a
|
|
282
|
+
registered = Set.new(pinned)
|
|
283
|
+
seen = Set.new
|
|
284
|
+
|
|
285
|
+
pinned.each do |klass|
|
|
286
|
+
next unless klass.respond_to?(:descendants)
|
|
287
|
+
|
|
288
|
+
klass.descendants.each do |sub|
|
|
289
|
+
check_pinned_subclass(klass, sub, registered) if seen.add?(sub)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Advisory only: this runs from process_pinned_models, which Tenant.init
|
|
295
|
+
# calls in after_initialize, so it must never be able to fail a boot.
|
|
296
|
+
# Rails' naming machinery raises on shapes we do not control — an
|
|
297
|
+
# anonymous descendant has no model_name, and Class.new(SomeBase) is
|
|
298
|
+
# everywhere in test suites.
|
|
299
|
+
def check_pinned_subclass(klass, sub, registered)
|
|
300
|
+
return unless unregistered_pinned_subclass?(sub, registered)
|
|
301
|
+
|
|
302
|
+
warn_unqualified_subclass(nearest_pinned_ancestor(sub, registered) || klass, sub)
|
|
303
|
+
rescue StandardError => e
|
|
304
|
+
warn "[Apartment] could not check pinned subclass #{sub.inspect}: #{e.class}: #{e.message}"
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# The closest registered ancestor above +klass+, i.e. the pin it inherits.
|
|
308
|
+
def nearest_pinned_ancestor(klass, registered)
|
|
309
|
+
ancestor = klass.superclass
|
|
310
|
+
while ancestor.is_a?(Class) && ancestor < ActiveRecord::Base
|
|
311
|
+
return ancestor if registered.include?(ancestor)
|
|
312
|
+
|
|
313
|
+
ancestor = ancestor.superclass
|
|
314
|
+
end
|
|
315
|
+
nil
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def unregistered_pinned_subclass?(sub, registered)
|
|
319
|
+
return false if registered.include?(sub)
|
|
320
|
+
# Anonymous classes have no model_name for Rails to compute a table
|
|
321
|
+
# from, and nothing actionable to name in a warning.
|
|
322
|
+
return false if sub.name.nil?
|
|
323
|
+
return false unless sub.respond_to?(:apartment_explicit_table_name?)
|
|
324
|
+
return false if sub.abstract_class?
|
|
325
|
+
|
|
326
|
+
sub.apartment_explicit_table_name?
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def warn_unqualified_subclass(klass, sub)
|
|
330
|
+
warn "[Apartment] #{sub.name || sub.inspect} inherits a pin from " \
|
|
331
|
+
"#{klass.name || klass.inspect} but declares its own table " \
|
|
332
|
+
"(#{sub.table_name}) and was never registered, so it is not qualified. " \
|
|
333
|
+
"Call pin_tenant on it if it should read the default tenant's data."
|
|
170
334
|
end
|
|
171
335
|
|
|
172
336
|
# Process a single pinned model. Called by process_pinned_models (batch)
|
|
@@ -187,6 +351,14 @@ module Apartment
|
|
|
187
351
|
|
|
188
352
|
return if klass.apartment_pinned_processed?
|
|
189
353
|
|
|
354
|
+
# A subclass that reaches its table through an already-pinned base needs
|
|
355
|
+
# nothing on either path. Qualifying would freeze a copy of the base's
|
|
356
|
+
# name onto it; establishing a connection would hand it a *different*
|
|
357
|
+
# pool from its parent, splitting two classes that share one physical
|
|
358
|
+
# table across connections and breaking transactional integrity between
|
|
359
|
+
# them. Mark processed with a nil path so teardown skips it too.
|
|
360
|
+
return klass.apartment_mark_processed! if inherits_pinned_table?(klass)
|
|
361
|
+
|
|
190
362
|
if shared_pinned_connection?
|
|
191
363
|
qualify_pinned_table_name(klass)
|
|
192
364
|
else
|
|
@@ -14,20 +14,10 @@ module Apartment
|
|
|
14
14
|
!Apartment.config.force_separate_pinned_pool
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
17
|
+
# Pinned tables live in the default tenant's database; every tenant
|
|
18
|
+
# connection can reach them by database-qualifying the name.
|
|
19
|
+
def pinned_table_qualifier
|
|
20
|
+
base_config['database']
|
|
31
21
|
end
|
|
32
22
|
|
|
33
23
|
def resolve_connection_config(tenant, base_config: nil)
|
|
@@ -18,18 +18,10 @@ module Apartment
|
|
|
18
18
|
!Apartment.config.force_separate_pinned_pool
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
klass.table_name = "#{default_tenant}.#{table}"
|
|
26
|
-
klass.apartment_mark_processed!(:explicit, original)
|
|
27
|
-
else
|
|
28
|
-
original_prefix = klass.table_name_prefix
|
|
29
|
-
klass.table_name_prefix = "#{default_tenant}."
|
|
30
|
-
klass.reset_table_name
|
|
31
|
-
klass.apartment_mark_processed!(:convention, original_prefix)
|
|
32
|
-
end
|
|
21
|
+
# Pinned tables live in the default tenant's schema; every tenant
|
|
22
|
+
# connection can reach them by schema-qualifying the name.
|
|
23
|
+
def pinned_table_qualifier
|
|
24
|
+
default_tenant
|
|
33
25
|
end
|
|
34
26
|
|
|
35
27
|
def resolve_connection_config(tenant, base_config: nil)
|
|
@@ -16,12 +16,23 @@ module Apartment
|
|
|
16
16
|
# their connection always targets the default tenant's database/schema.
|
|
17
17
|
#
|
|
18
18
|
# Safe to call before or after Apartment.activate!.
|
|
19
|
-
# Idempotent:
|
|
19
|
+
# Idempotent per class: a second call on the same class is a no-op.
|
|
20
|
+
#
|
|
21
|
+
# Deliberately keyed on this class's own flag, NOT on apartment_pinned?
|
|
22
|
+
# (which walks the superclass chain). A subclass of a pinned model that
|
|
23
|
+
# declares its own table has to be registered and qualified on its own
|
|
24
|
+
# merits — the parent's qualification cannot reach a different table.
|
|
25
|
+
# Keying on the chain made that call accept-and-do-nothing, which is the
|
|
26
|
+
# wrong answer even for a shape apps should avoid: an API call must
|
|
27
|
+
# either work or be absent, never silently no-op. Subclasses that share
|
|
28
|
+
# the parent's table still need nothing, and are skipped at
|
|
29
|
+
# qualification time rather than here (see
|
|
30
|
+
# AbstractAdapter#inherits_pinned_table?).
|
|
20
31
|
def pin_tenant
|
|
21
32
|
unless is_a?(Class) && self < ActiveRecord::Base
|
|
22
33
|
raise(ArgumentError, "pin_tenant can only be called on ActiveRecord model classes, got #{inspect}")
|
|
23
34
|
end
|
|
24
|
-
return if apartment_pinned
|
|
35
|
+
return if @apartment_pinned
|
|
25
36
|
|
|
26
37
|
@apartment_pinned = true
|
|
27
38
|
Apartment.register_pinned_model(self)
|
|
@@ -54,10 +65,21 @@ module Apartment
|
|
|
54
65
|
superclass.apartment_pinned?
|
|
55
66
|
end
|
|
56
67
|
|
|
57
|
-
# Whether this model
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
68
|
+
# Whether this model's current table_name would survive a recomputation
|
|
69
|
+
# — i.e. whether Rails' convention machinery reproduces the value now
|
|
70
|
+
# cached in @table_name. False means convention rebuilds it exactly, so
|
|
71
|
+
# qualification can be undone by discarding the override; true means the
|
|
72
|
+
# name must be saved verbatim and restored verbatim.
|
|
73
|
+
#
|
|
74
|
+
# This answers a *restore* question. It is deliberately NOT a
|
|
75
|
+
# qualification discriminator: a table_name that matches convention is no
|
|
76
|
+
# evidence that setting table_name_prefix would qualify the model. Rails
|
|
77
|
+
# ignores the prefix outright for any class that is not its own
|
|
78
|
+
# base_class (compute_table_name returns base_class.table_name verbatim),
|
|
79
|
+
# and full_table_name_prefix prefers a module parent's prefix over the
|
|
80
|
+
# class's own. Qualification therefore always assigns table_name
|
|
81
|
+
# directly — see AbstractAdapter#qualify_pinned_table_name.
|
|
82
|
+
#
|
|
61
83
|
# NOTE: compute_table_name is a private Rails API; tested against
|
|
62
84
|
# Rails main as a canary in CI.
|
|
63
85
|
def apartment_explicit_table_name?
|
|
@@ -66,40 +88,110 @@ module Apartment
|
|
|
66
88
|
instance_variable_get(:@table_name) != send(:compute_table_name)
|
|
67
89
|
end
|
|
68
90
|
|
|
91
|
+
# Descendants that reach their table name through this class rather than
|
|
92
|
+
# declaring one of their own, and have already memoized it.
|
|
93
|
+
#
|
|
94
|
+
# Rails memoizes @table_name per class on first read and never
|
|
95
|
+
# invalidates a descendant's copy when an ancestor's name or prefix
|
|
96
|
+
# changes. Anything touching a descendant's table_name before
|
|
97
|
+
# qualification runs — an initializer, a gem, a route constraint, a
|
|
98
|
+
# descendants sweep — freezes the pre-qualification name, and the model
|
|
99
|
+
# then resolves to the wrong tenant's table for the life of the process.
|
|
100
|
+
#
|
|
101
|
+
# MUST be called BEFORE the ancestor is mutated: afterwards
|
|
102
|
+
# apartment_explicit_table_name? can no longer distinguish a stale memo
|
|
103
|
+
# from a genuine declaration, since the ancestor's change has moved what
|
|
104
|
+
# convention computes. Descendants without a memo are omitted — they
|
|
105
|
+
# compute lazily and will pick the new value up on their own.
|
|
106
|
+
def apartment_descendants_inheriting_table_name
|
|
107
|
+
return [] unless respond_to?(:descendants)
|
|
108
|
+
|
|
109
|
+
stale = descendants.select do |sub|
|
|
110
|
+
sub.instance_variable_defined?(:@table_name) &&
|
|
111
|
+
sub.respond_to?(:apartment_inherited_table_name) &&
|
|
112
|
+
sub.instance_variable_get(:@table_name) == sub.apartment_inherited_table_name
|
|
113
|
+
rescue StandardError
|
|
114
|
+
# Rails' naming machinery raises on shapes we do not control (an
|
|
115
|
+
# anonymous class has no model_name). Skip rather than guess.
|
|
116
|
+
false
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Reset an intermediate before anything beneath it: reset_table_name on
|
|
120
|
+
# a class under an abstract parent reads superclass.table_name — the
|
|
121
|
+
# memo, not base_class — so a grandchild reset first would re-freeze its
|
|
122
|
+
# parent's stale value. ActiveSupport's descendants happens to be
|
|
123
|
+
# ancestor-first today, but that ordering is undocumented.
|
|
124
|
+
stale.sort_by { |sub| sub.ancestors.size }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# What Rails' own reset_table_name would recompute for this class right
|
|
128
|
+
# now. Mirrors ActiveRecord::ModelSchema#reset_table_name deliberately.
|
|
129
|
+
#
|
|
130
|
+
# NOT compute_table_name: the two disagree for a class whose superclass
|
|
131
|
+
# is abstract. reset_table_name prefers `superclass.table_name`, while
|
|
132
|
+
# compute_table_name treats such a class as its own base_class and builds
|
|
133
|
+
# from its own model_name. So a concrete class under an abstract
|
|
134
|
+
# intermediate that carries a table (an abstract class sandwiched under a
|
|
135
|
+
# concrete pinned parent) inherits `foos` but computes `gkids` — and
|
|
136
|
+
# keying on compute_table_name misreads that inherited name as an
|
|
137
|
+
# explicit declaration, skipping the resync and leaving the model on the
|
|
138
|
+
# tenant's table.
|
|
139
|
+
def apartment_inherited_table_name
|
|
140
|
+
if abstract_class?
|
|
141
|
+
superclass.table_name
|
|
142
|
+
elsif superclass.abstract_class?
|
|
143
|
+
superclass.table_name || send(:compute_table_name)
|
|
144
|
+
else
|
|
145
|
+
send(:compute_table_name)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Recompute the table name of each descendant captured above, now that
|
|
150
|
+
# this class has changed. reset_table_name goes through Rails' own
|
|
151
|
+
# table_name= setter, so the derived @quoted_table_name and @arel_table
|
|
152
|
+
# caches are cleared with it.
|
|
153
|
+
def apartment_resync_descendant_table_names!(subclasses)
|
|
154
|
+
subclasses.each do |sub|
|
|
155
|
+
sub.reset_table_name
|
|
156
|
+
rescue StandardError => e
|
|
157
|
+
warn "[Apartment] could not reset table name for #{sub.name || sub.inspect}: " \
|
|
158
|
+
"#{e.class}: #{e.message}"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
69
162
|
# Whether process_pinned_model has already run for this class.
|
|
70
163
|
def apartment_pinned_processed?
|
|
71
164
|
@apartment_pinned_processed == true
|
|
72
165
|
end
|
|
73
166
|
|
|
74
|
-
# Record that qualification has been applied, and
|
|
167
|
+
# Record that qualification has been applied, and how it must be undone.
|
|
75
168
|
# Called by qualify_pinned_table_name (adapters) after mutations succeed,
|
|
76
169
|
# or by process_pinned_model after establish_connection on separate-pool path.
|
|
170
|
+
#
|
|
171
|
+
# :computed — the pre-qualification name was reproducible by convention;
|
|
172
|
+
# restore by discarding the override and recomputing.
|
|
173
|
+
# :explicit — the name was assigned in a way convention cannot rebuild;
|
|
174
|
+
# +original_value+ is that name, restored verbatim.
|
|
175
|
+
# :prefix — an abstract base, qualified by broadcasting through
|
|
176
|
+
# table_name_prefix to the descendants that inherit its pin;
|
|
177
|
+
# +original_value+ is the prefix the app had set.
|
|
178
|
+
# nil — separate-pool models; nothing to undo.
|
|
77
179
|
def apartment_mark_processed!(path = nil, original_value = nil)
|
|
78
180
|
@apartment_pinned_processed = true
|
|
79
181
|
@apartment_qualification_path = path
|
|
80
182
|
case path
|
|
81
183
|
when :explicit then @apartment_original_table_name = original_value
|
|
82
|
-
when :
|
|
184
|
+
when :prefix then @apartment_original_table_name_prefix = original_value
|
|
83
185
|
end
|
|
84
186
|
end
|
|
85
187
|
|
|
86
188
|
# Undo table name qualification and clear tracking state.
|
|
87
|
-
# Convention path: restore original prefix so reset_table_name recomputes.
|
|
88
|
-
# Explicit path: restore the original table_name that was overwritten.
|
|
89
|
-
# nil path: separate-pool models — no table name changes to undo.
|
|
90
189
|
def apartment_restore!
|
|
91
190
|
return unless @apartment_pinned_processed
|
|
92
191
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
reset_table_name
|
|
97
|
-
when :explicit
|
|
98
|
-
self.table_name = @apartment_original_table_name if @apartment_original_table_name
|
|
99
|
-
when nil then nil
|
|
100
|
-
else
|
|
101
|
-
warn "[Apartment] #{name}: unexpected qualification_path #{@apartment_qualification_path.inspect}"
|
|
102
|
-
end
|
|
192
|
+
inheriting = apartment_descendants_inheriting_table_name
|
|
193
|
+
apartment_undo_qualification!
|
|
194
|
+
apartment_resync_descendant_table_names!(inheriting)
|
|
103
195
|
|
|
104
196
|
@apartment_pinned_processed = nil
|
|
105
197
|
@apartment_qualification_path = nil
|
|
@@ -109,6 +201,31 @@ module Apartment
|
|
|
109
201
|
|
|
110
202
|
private
|
|
111
203
|
|
|
204
|
+
# Reverse whichever mutation qualify_pinned_table_name applied.
|
|
205
|
+
def apartment_undo_qualification!
|
|
206
|
+
case @apartment_qualification_path
|
|
207
|
+
when :computed then apartment_recompute_table_name!
|
|
208
|
+
when :explicit then apartment_restore_table_name!
|
|
209
|
+
when :prefix then self.table_name_prefix = @apartment_original_table_name_prefix || ''
|
|
210
|
+
when nil then nil
|
|
211
|
+
else
|
|
212
|
+
warn "[Apartment] #{name}: unexpected qualification_path #{@apartment_qualification_path.inspect}"
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Drop the qualified override and recompute from convention. The ivar is
|
|
217
|
+
# removed first so nothing can keep the qualified value; the recompute
|
|
218
|
+
# then runs through Rails' table_name= setter, which also clears the
|
|
219
|
+
# derived @quoted_table_name and @arel_table caches.
|
|
220
|
+
def apartment_recompute_table_name!
|
|
221
|
+
remove_instance_variable(:@table_name) if instance_variable_defined?(:@table_name)
|
|
222
|
+
reset_table_name
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def apartment_restore_table_name!
|
|
226
|
+
self.table_name = @apartment_original_table_name if @apartment_original_table_name
|
|
227
|
+
end
|
|
228
|
+
|
|
112
229
|
# Register a one-shot TracePoint(:end) that fires after the class body
|
|
113
230
|
# closes. Only :end is used — :b_return fires for ALL block returns in
|
|
114
231
|
# class context (each, tap, include hooks) and would trigger prematurely.
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'monitor'
|
|
4
|
+
require_relative '../errors'
|
|
5
|
+
|
|
6
|
+
module Apartment
|
|
7
|
+
module Patches
|
|
8
|
+
# Serializes access to ActiveRecord's connection registry so pool-per-tenant
|
|
9
|
+
# can register and discard shards from many threads at once.
|
|
10
|
+
#
|
|
11
|
+
# THE REGISTRY. ActiveRecord::ConnectionAdapters::PoolManager (the Rails
|
|
12
|
+
# class, not Apartment's same-named one) indexes every pool AR knows about as
|
|
13
|
+
# a plain nested Hash, +{ role => { shard => pool_config } }+, with no
|
|
14
|
+
# synchronization of any kind. Rails can afford that because upstream writes
|
|
15
|
+
# it only at boot: +establish_connection+ runs from initializers and from
|
|
16
|
+
# +connects_to+, single-threaded, and after boot the structure is read-only.
|
|
17
|
+
#
|
|
18
|
+
# WHY v4 CANNOT. A tenant pool is established lazily, on the thread that
|
|
19
|
+
# first routes to that tenant, for the life of the process — so every cold
|
|
20
|
+
# tenant switch adds a shard key to that Hash while other threads are reading
|
|
21
|
+
# it. MRI's per-Hash iteration guard turns the collision into a hard failure
|
|
22
|
+
# in the WRITER: `RuntimeError: can't add a new key into hash during
|
|
23
|
+
# iteration`, surfaced by Apartment as a failed tenant switch. The readers are
|
|
24
|
+
# routine and unavoidable, all via ConnectionHandler#each_connection_pool:
|
|
25
|
+
# ActiveRecord::QueryCache.run on every executor run (the start of every
|
|
26
|
+
# request and job), ConnectionPool::ExecutorHooks.complete on every executor
|
|
27
|
+
# completion, Base.clear_query_caches_for_current_thread after writes,
|
|
28
|
+
# ActiveRecord.all_open_transactions for transaction-callback bookkeeping, and
|
|
29
|
+
# clear_active_connections! / clear_all_connections! /
|
|
30
|
+
# flush_idle_connections!. (AR's own ConnectionPool::Reaper is NOT one of
|
|
31
|
+
# them — it keeps a private WeakRef list and never reads this registry.)
|
|
32
|
+
# Parallel migration is simply the densest producer of cold creates (one
|
|
33
|
+
# thread per tenant, all establishing at once) and therefore the easiest place
|
|
34
|
+
# to see it.
|
|
35
|
+
#
|
|
36
|
+
# A read can write, too: +get_pool_config+ / +pool_configs+ /
|
|
37
|
+
# +each_pool_config+ reach the outer Hash through +[]+, whose default proc
|
|
38
|
+
# (+Hash.new { |h, k| h[k] = {} }+) INSERTS an empty shard map on a miss. So a
|
|
39
|
+
# lookup for a not-yet-seen role is itself a write, and the guarded set below
|
|
40
|
+
# is every public accessor rather than only the obvious mutators.
|
|
41
|
+
#
|
|
42
|
+
# WHY NOT JUST LOCK APARTMENT'S OWN CALL SITES. Apartment's cold creates are
|
|
43
|
+
# already serialized against each other — Concurrent::Map's MRI backend holds
|
|
44
|
+
# a write lock across +compute_if_absent+, and the capacity-bounded path holds
|
|
45
|
+
# PoolManager's own create mutex. Neither excludes AR's readers, which is the
|
|
46
|
+
# side of the race that matters, and neither covers the discard half
|
|
47
|
+
# (+remove_connection_pool+ from Apartment::PoolReaper's timer thread — ours,
|
|
48
|
+
# not AR's — from AbstractAdapter#drop, from Migrator eviction). The registry
|
|
49
|
+
# itself is the only place that sees all of it.
|
|
50
|
+
#
|
|
51
|
+
# SCOPE. Applied from +Apartment.activate!+, not at gem load: an app that
|
|
52
|
+
# merely has the gem in its Gemfile should not pay for a lock it does not
|
|
53
|
+
# need. Prepending affects instances already created (the primary pool's
|
|
54
|
+
# manager is built during Rails' database initializer, before activate!),
|
|
55
|
+
# which is why the lock is module-level rather than per-instance state.
|
|
56
|
+
#
|
|
57
|
+
# NO DEADLOCK, BY CONSTRUCTION. SYNC is a LEAF lock: every guarded body is an
|
|
58
|
+
# in-memory Hash operation that acquires nothing else, performs no IO, and
|
|
59
|
+
# yields to no caller. Keep it that way — it is the entire deadlock-freedom
|
|
60
|
+
# argument, and Apartment's cold-create path already establishes the one lock
|
|
61
|
+
# ordering that exists (Concurrent::Map's write lock, or the capped path's
|
|
62
|
+
# create mutex, is taken FIRST and SYNC underneath it via
|
|
63
|
+
# establish_connection). Nothing acquires SYNC and then reaches for either.
|
|
64
|
+
# Upstream cooperates: +remove_pool_config+ returns the pool_config and
|
|
65
|
+
# +disconnect_pool_from_pool_manager+ calls +disconnect!+ on it only after the
|
|
66
|
+
# guarded call has returned, and +establish_connection+ builds the pool
|
|
67
|
+
# (PoolConfig#pool, under PoolConfig's own monitor) after +set_pool_config+
|
|
68
|
+
# returns — so no pool IO and no other monitor is ever nested under SYNC.
|
|
69
|
+
#
|
|
70
|
+
# COST. One uncontended monitor acquire, measured at ~90ns, on registry
|
|
71
|
+
# operations only. +get_pool_config+ is the hot one (AR resolves it per query
|
|
72
|
+
# for default-tenant and pinned traffic), where it is noise against even a
|
|
73
|
+
# cached query. Iteration copies its pool_config list under the lock and
|
|
74
|
+
# yields outside it, so per-request hooks hold the lock for the length of a
|
|
75
|
+
# Hash walk and never for the length of a caller's block.
|
|
76
|
+
module ConnectionRegistry
|
|
77
|
+
# One monitor for every registry in the process. Instances are few (one per
|
|
78
|
+
# connection name, so typically one or two) and every guarded operation is
|
|
79
|
+
# an in-memory Hash op with no IO and no yielding, so per-instance locks
|
|
80
|
+
# would buy negligible parallelism in exchange for lazy-init state on
|
|
81
|
+
# objects that already exist by the time the patch is applied.
|
|
82
|
+
#
|
|
83
|
+
# Monitor, not Mutex, purely defensively: no guarded method re-enters
|
|
84
|
+
# another today (each accessor's +super+ reads the Hash directly, and
|
|
85
|
+
# #each_pool_config yields outside the lock), and establish_connection's
|
|
86
|
+
# several acquisitions are sequential rather than nested — a Mutex would
|
|
87
|
+
# work. Reentrance costs nothing measurable here (~90ns either way) and
|
|
88
|
+
# buys tolerance for an upstream implementation in which one accessor
|
|
89
|
+
# dispatches through another.
|
|
90
|
+
SYNC = Monitor.new
|
|
91
|
+
|
|
92
|
+
# Every public accessor AR::ConnectionAdapters::PoolManager defines. Both
|
|
93
|
+
# halves of this claim are enforced at activate! time: a method that has
|
|
94
|
+
# gone missing raises, and an accessor upstream has ADDED that we therefore
|
|
95
|
+
# do not guard warns (the patch still works, but there is a hole in it).
|
|
96
|
+
POOL_MANAGER_METHODS = %i[
|
|
97
|
+
shard_names
|
|
98
|
+
role_names
|
|
99
|
+
pool_configs
|
|
100
|
+
each_pool_config
|
|
101
|
+
remove_role
|
|
102
|
+
remove_pool_config
|
|
103
|
+
get_pool_config
|
|
104
|
+
set_pool_config
|
|
105
|
+
].freeze
|
|
106
|
+
|
|
107
|
+
HANDLER_METHODS = %i[set_pool_manager].freeze
|
|
108
|
+
|
|
109
|
+
class << self
|
|
110
|
+
# Idempotent — prepend on an already-prepended module is a no-op.
|
|
111
|
+
def apply!
|
|
112
|
+
serialize!(ActiveRecord::ConnectionAdapters::PoolManager, POOL_MANAGER_METHODS, PoolManagerSync,
|
|
113
|
+
exhaustive: true)
|
|
114
|
+
serialize!(ActiveRecord::ConnectionAdapters::ConnectionHandler, HANDLER_METHODS, HandlerSync)
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
# FAILS CLOSED on a shape we cannot serialize. Both wrapper modules work by
|
|
121
|
+
# +super+, so a guarded method that no longer exists anywhere in the MRO
|
|
122
|
+
# would be a NoMethodError at first call. Raising here instead is louder and
|
|
123
|
+
# earlier: activate! runs at boot, from an explicit call, so the operator
|
|
124
|
+
# learns on deploy that this gem version does not support this ActiveRecord
|
|
125
|
+
# version. The alternative — warn and continue unpatched — reinstates a race
|
|
126
|
+
# that fails a fraction of cold tenant switches under load, which is far
|
|
127
|
+
# harder to attribute. It would also be effectively silent: the Rails-main
|
|
128
|
+
# CI canary is continue-on-error, so a warning blocks nothing.
|
|
129
|
+
#
|
|
130
|
+
# Resolution deliberately uses +method_defined?+, not +instance_methods(false)+:
|
|
131
|
+
# +super+ dispatches through the whole ancestor chain, so a method upstream
|
|
132
|
+
# merely MOVED to a superclass or an included module still works through the
|
|
133
|
+
# wrapper. Testing for a direct definition would refuse a refactor that is
|
|
134
|
+
# entirely benign, and refusing is now fatal.
|
|
135
|
+
#
|
|
136
|
+
# +exhaustive+ additionally warns (never raises) when upstream has grown a
|
|
137
|
+
# public accessor we do not guard: the patch still does its job, but that
|
|
138
|
+
# method touches the registry unsynchronized. A warning, not a raise,
|
|
139
|
+
# because a hole is strictly better than a boot failure — and the unit spec
|
|
140
|
+
# pins the exact set so it fails in CI first.
|
|
141
|
+
def serialize!(klass, method_names, wrapper, exhaustive: false)
|
|
142
|
+
missing = method_names.reject do |name|
|
|
143
|
+
klass.method_defined?(name) || klass.private_method_defined?(name)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
unless missing.empty?
|
|
147
|
+
raise(Apartment::ConfigurationError,
|
|
148
|
+
"Apartment cannot serialize #{klass} on ActiveRecord " \
|
|
149
|
+
"#{ActiveRecord::VERSION::STRING}: expected method(s) #{missing.join(', ')} " \
|
|
150
|
+
'are gone. Pool-per-tenant registers connection pools concurrently and ' \
|
|
151
|
+
'this registry is not thread-safe without them. Upgrade ros-apartment to a ' \
|
|
152
|
+
'version that supports this ActiveRecord release.')
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
warn_unguarded(klass, method_names, wrapper) if exhaustive
|
|
156
|
+
|
|
157
|
+
klass.prepend(wrapper)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def warn_unguarded(klass, method_names, wrapper)
|
|
161
|
+
# instance_methods(false) is the right question HERE (unlike above): it asks
|
|
162
|
+
# what this class itself declares, and stays correct after prepending
|
|
163
|
+
# because a prepended module's methods are not the class's own.
|
|
164
|
+
unguarded = klass.instance_methods(false) - method_names
|
|
165
|
+
return if unguarded.empty?
|
|
166
|
+
|
|
167
|
+
warn "[Apartment] #{klass} on ActiveRecord #{ActiveRecord::VERSION::STRING} declares " \
|
|
168
|
+
"method(s) #{unguarded.join(', ')} that #{wrapper.name} does not serialize. " \
|
|
169
|
+
'Concurrent tenant pool creation is protected, but those methods reach the ' \
|
|
170
|
+
'registry unsynchronized.'
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Guards AR::ConnectionAdapters::PoolManager. Fully qualified everywhere
|
|
175
|
+
# because the bare constant would resolve to Apartment::PoolManager.
|
|
176
|
+
module PoolManagerSync
|
|
177
|
+
def shard_names
|
|
178
|
+
SYNC.synchronize { super }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def role_names
|
|
182
|
+
SYNC.synchronize { super }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def pool_configs(role = nil)
|
|
186
|
+
SYNC.synchronize { super }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def remove_role(role)
|
|
190
|
+
SYNC.synchronize { super }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def remove_pool_config(role, shard)
|
|
194
|
+
SYNC.synchronize { super }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def get_pool_config(role, shard)
|
|
198
|
+
SYNC.synchronize { super }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def set_pool_config(role, shard, pool_config)
|
|
202
|
+
SYNC.synchronize { super }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Snapshot under the lock, yield outside it. Holding the registry lock
|
|
206
|
+
# across the caller's block is what makes this dangerous rather than
|
|
207
|
+
# merely slow: AR's own iterating callers disconnect pools and release
|
|
208
|
+
# connections inside the block, so the lock would be held across pool
|
|
209
|
+
# IO while cold creates queue behind it.
|
|
210
|
+
#
|
|
211
|
+
# Collected by delegating to +super+ rather than by reading the Hash, so
|
|
212
|
+
# the snapshot is exactly what upstream would have yielded on this Rails
|
|
213
|
+
# version. One visible consequence, deliberate: a shard registered
|
|
214
|
+
# mid-iteration is not yielded (a snapshot, like Concurrent::Map's
|
|
215
|
+
# iterators elsewhere in Apartment).
|
|
216
|
+
#
|
|
217
|
+
# THE RETURN VALUE IS UPSTREAM'S, NOT THE SNAPSHOT. Measured identical on
|
|
218
|
+
# 7.2 / 8.0 / 8.1: with a block, upstream returns the very Hash it walked
|
|
219
|
+
# — the inner shard map when a role is given, the outer role map when not
|
|
220
|
+
# — and the block-less form returns an Enumerator for a role but the outer
|
|
221
|
+
# Hash (having enumerated nothing) without one. A lock is no reason to
|
|
222
|
+
# narrow the contract of the method it wraps, and this is a `:nodoc:`
|
|
223
|
+
# internal that other gems wrap too, so returning our Array would be a
|
|
224
|
+
# gratuitous difference for anything that ever reads it. AR's own single
|
|
225
|
+
# caller (ConnectionHandler#each_connection_pool) discards it.
|
|
226
|
+
#
|
|
227
|
+
# The two block-less forms need opposite treatment, which is why they are
|
|
228
|
+
# not one branch:
|
|
229
|
+
#
|
|
230
|
+
# WITH a role, upstream's Enumerator is a live view of the inner Hash, and
|
|
231
|
+
# iterating it later bypasses this wrapper completely — a concurrent
|
|
232
|
+
# +set_pool_config+ takes SYNC and still mutates the Hash being walked, so
|
|
233
|
+
# MRI raises in the writer. Probed: the failure is IDENTICAL patched and
|
|
234
|
+
# unpatched, i.e. delegating here left the original race fully intact on
|
|
235
|
+
# this path. So we substitute an Enumerator over this method; its deferred
|
|
236
|
+
# traversal re-enters with a block and goes through the snapshot path
|
|
237
|
+
# above. Contract preserved — upstream's is an Enumerator too, and +size+
|
|
238
|
+
# is supplied because upstream's reports the shard count rather than nil.
|
|
239
|
+
# (Repeated iteration re-snapshots, so it stays a live view like
|
|
240
|
+
# upstream's, not a frozen one.)
|
|
241
|
+
#
|
|
242
|
+
# WITHOUT a role, upstream returns the outer role map and enumerates
|
|
243
|
+
# nothing whatsoever. There is no traversal to protect and no Enumerator to
|
|
244
|
+
# match, so substituting one would invent behavior; delegate untouched.
|
|
245
|
+
def each_pool_config(role = nil, &block)
|
|
246
|
+
unless block
|
|
247
|
+
return enum_for(__method__, role) { pool_configs(role).size } if role
|
|
248
|
+
|
|
249
|
+
return super
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
snapshot = []
|
|
253
|
+
upstream_result = SYNC.synchronize { super(role) { |pool_config| snapshot << pool_config } }
|
|
254
|
+
snapshot.each(&block)
|
|
255
|
+
|
|
256
|
+
upstream_result
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Guards the handler's manager cache. +set_pool_manager+ upserts with
|
|
261
|
+
# +||=+ on a Concurrent::Map: atomic per operation, but a read-then-write
|
|
262
|
+
# across two, so two threads establishing the first connection for the same
|
|
263
|
+
# connection name can each build a PoolManager and one is discarded —
|
|
264
|
+
# silently taking any shard already registered in it with it. Serializing
|
|
265
|
+
# the whole method makes the upsert atomic.
|
|
266
|
+
#
|
|
267
|
+
# Wrapped with argument forwarding rather than a reimplementation because
|
|
268
|
+
# the signature is version-dependent (AR >= 8.0 passes a ConnectionDescriptor
|
|
269
|
+
# where 7.2 passed the connection-name String) and the key derivation is
|
|
270
|
+
# upstream's business, not ours.
|
|
271
|
+
#
|
|
272
|
+
# Declared private to match upstream. A prepended method is public by
|
|
273
|
+
# default, and leaving it so would widen a Rails internal into public API
|
|
274
|
+
# from a patch whose only job is a lock.
|
|
275
|
+
module HandlerSync
|
|
276
|
+
def set_pool_manager(...)
|
|
277
|
+
SYNC.synchronize { super }
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
private :set_pool_manager
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
data/lib/apartment/version.rb
CHANGED
data/lib/apartment.rb
CHANGED
|
@@ -191,11 +191,24 @@ module Apartment # rubocop:disable Metrics/ModuleLength
|
|
|
191
191
|
@activated = false
|
|
192
192
|
end
|
|
193
193
|
|
|
194
|
-
# Activate the
|
|
194
|
+
# Activate the ActiveRecord patches pool-per-tenant depends on.
|
|
195
195
|
# Idempotent — prepend on an already-prepended module is a no-op.
|
|
196
|
+
#
|
|
197
|
+
# ConnectionHandling routes AR::Base.connection_pool to the current tenant's
|
|
198
|
+
# pool; ConnectionRegistry makes AR's own pool registry safe for the
|
|
199
|
+
# concurrent shard registration that routing produces. The second is not
|
|
200
|
+
# optional given the first: without it, a cold tenant switch can fail whenever
|
|
201
|
+
# any thread happens to be iterating AR's pools — which Rails does through
|
|
202
|
+
# ConnectionHandler#each_connection_pool at the start of every request or job
|
|
203
|
+
# (ActiveRecord::QueryCache.run), at the end of every one
|
|
204
|
+
# (ConnectionPool::ExecutorHooks.complete), and after writes
|
|
205
|
+
# (clear_query_caches_for_current_thread). NOT from AR's ConnectionPool::Reaper,
|
|
206
|
+
# which reads a private WeakRef list rather than the registry.
|
|
196
207
|
def activate!
|
|
197
208
|
require_relative('apartment/patches/connection_handling')
|
|
209
|
+
require_relative('apartment/patches/connection_registry')
|
|
198
210
|
ActiveRecord::Base.singleton_class.prepend(Patches::ConnectionHandling)
|
|
211
|
+
Patches::ConnectionRegistry.apply!
|
|
199
212
|
@activated = true
|
|
200
213
|
end
|
|
201
214
|
|
data/ros-apartment.gemspec
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
3
5
|
$LOAD_PATH << File.expand_path('lib', __dir__)
|
|
4
6
|
require 'apartment/version'
|
|
5
7
|
|
|
@@ -13,7 +15,9 @@ Gem::Specification.new do |s|
|
|
|
13
15
|
'through schema-based or database-based isolation strategies.'
|
|
14
16
|
s.email = ['ryan@influitive.com', 'brad@influitive.com', 'rui.p.baltazar@gmail.com', 'mauricio@campusesp.com']
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
# LICENSE ships in the gem deliberately: MIT requires the notice to travel with
|
|
19
|
+
# every distribution, and RubyGems has no other copy of it.
|
|
20
|
+
s.files = %w[ros-apartment.gemspec README.md LICENSE] + `git ls-files -- lib config`.split("\n")
|
|
17
21
|
s.require_paths = ['lib']
|
|
18
22
|
|
|
19
23
|
s.homepage = 'https://github.com/rails-on-services/apartment'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ros-apartment
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 4.0.0.
|
|
4
|
+
version: 4.0.0.alpha12
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ryan Brunner
|
|
@@ -159,6 +159,7 @@ executables: []
|
|
|
159
159
|
extensions: []
|
|
160
160
|
extra_rdoc_files: []
|
|
161
161
|
files:
|
|
162
|
+
- LICENSE
|
|
162
163
|
- README.md
|
|
163
164
|
- config/default.yml
|
|
164
165
|
- lib/apartment.rb
|
|
@@ -194,6 +195,7 @@ files:
|
|
|
194
195
|
- lib/apartment/lifecycle.rb
|
|
195
196
|
- lib/apartment/migrator.rb
|
|
196
197
|
- lib/apartment/patches/connection_handling.rb
|
|
198
|
+
- lib/apartment/patches/connection_registry.rb
|
|
197
199
|
- lib/apartment/patches/live_tenant_propagation.rb
|
|
198
200
|
- lib/apartment/patches/postgresql_sequence_name.rb
|
|
199
201
|
- lib/apartment/pool_manager.rb
|