ros-apartment 4.0.0.alpha10 → 4.0.0.alpha11

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d24210e257a6f61365a496b60f54762e4b0e9d5ef6f652d77891455fba01d6a4
4
- data.tar.gz: dd1eb795e6ca4c9bc2b22377b2feb7fd8bd9fc66efdd3d1eaaeab891bd4fa7d3
3
+ metadata.gz: ad60ba3db98c859ff52e1aa0f312d18a8a3e99a6b2703518a94f1ab4abc95eb5
4
+ data.tar.gz: a782d5dc9690202676d2e30529da89aeb226961bffaf9af7fc821292266d97c6
5
5
  SHA512:
6
- metadata.gz: 2397d625268ff6c241eb7886834f99383e650a9613048d0c8d99594083d7cb450e3b328279f56d7c14b3babf70e9ca03069b5c5c507f3a3f06591a429c8283e8
7
- data.tar.gz: 57ef4cf17beff1ee06a8abdaff9303b114be190c962f829de2465e766b1235664718e1bb534c8bee715657dbea45512c116f1467d20a287f7ea7c55e164822aa
6
+ metadata.gz: 18aaad1312c4eb812c1a7f4a1bcd676325a90db1326efcd2bb4aa71961f38e689240a589497ae6fa7bb16f362276649b36610d76b4b63284567d1999a4a20b5f
7
+ data.tar.gz: 2f445f6e7d07ef0c4146242aadbb724636fa55184e4c16400daf5870e84a46a6566530cca0a10e130813709c27a08dfed1ba8b6eb4589ab97087b37966ce0b24
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
  [![Gem Version](https://badge.fury.io/rb/ros-apartment.svg)](https://badge.fury.io/rb/ros-apartment)
4
4
  [![CI](https://github.com/rails-on-services/apartment/actions/workflows/ci.yml/badge.svg)](https://github.com/rails-on-services/apartment/actions/workflows/ci.yml)
5
5
  [![codecov](https://codecov.io/gh/rails-on-services/apartment/graph/badge.svg?token=Q4I5QL78SA)](https://codecov.io/gh/rails-on-services/apartment)
6
+ [![Greptile: The War on Bugs](https://www.greptile.com/badge.svg)](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
- [MIT License](http://www.opensource.org/licenses/MIT)
491
+ MIT — see [LICENSE](LICENSE). SPDX identifier: `MIT`.
@@ -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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Apartment
4
- VERSION = '4.0.0.alpha10'
4
+ VERSION = '4.0.0.alpha11'
5
5
  end
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 ConnectionHandling patch on ActiveRecord::Base.
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
 
@@ -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
- s.files = %w[ros-apartment.gemspec README.md] + `git ls-files -- lib config`.split("\n")
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.alpha10
4
+ version: 4.0.0.alpha11
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