ros-apartment 4.0.0.alpha2 → 4.0.0.alpha3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: efc3d3a91e17846826ac5d5ca707cd17bdc5a5923b18fb4c0162ac87f24014d0
4
- data.tar.gz: 5c1787f4e9cee805842e6035fcc2769415dfccdf90c3c2b354315271315638a2
3
+ metadata.gz: 0b2254c1f3f95622ba9fa45147fcc2f5b2036db4c5c75a2d68c3718772df6149
4
+ data.tar.gz: efcc9149b0aaaa6c7b614cc981848f02e999259eaa0826120b4df16b555f6caf
5
5
  SHA512:
6
- metadata.gz: 486bb0cd94225925ada99a497c89e314536300a261b898c3575a5008211e1e8f1632a9755dadae096f5814eaee00920e9652f70f8feb8676ff58897fc4262f2c
7
- data.tar.gz: e9b88d3c86be4e0891ff584e85c822979c6146ece01ce2bab17f442603e8add4aef3e826157157df3325722e39209a70bf94ffdd218504abdbf6be7dd4af29cf
6
+ metadata.gz: 35b3bdeb392ed750be9a20868082f1708e2ae3c4c8449cef5bda163d3e2e911409274abf9a1f8161811678192c8aaaa21c2f4d6a177a5932a9567942172bc234
7
+ data.tar.gz: 3d0909cc593dcbd014f21626a2565e0e92d373cb74aa768f308b1033f689fbdea1aa48a2f886294e581d4d3954b1d719b15ab3277990a8fde216449eed40485f
data/README.md CHANGED
@@ -99,11 +99,15 @@ All options are set in `config/initializers/apartment.rb` inside an `Apartment.c
99
99
 
100
100
  ### Pool Settings
101
101
 
102
- `tenant_pool_size`: connections per tenant pool (default: 5).
102
+ `tenant_pool_size`: max connections per tenant pool. Default `nil` — each tenant pool inherits the app's base pool size (`DB_POOL_SIZE` / `pool:` in `database.yml`). Set it to size tenant pools independently of the app pool (e.g. to bound total connections across many schema-per-tenant pools).
103
103
 
104
- `pool_idle_timeout`: seconds before an idle tenant pool is eligible for reaping (default: 300).
104
+ `pool_idle_timeout`: seconds an idle tenant pool must exceed before it is eligible for reaping (default: 300).
105
105
 
106
- `max_total_connections`: hard cap across all tenant pools; nil for unlimited (default: nil).
106
+ `reaper_interval`: seconds between background reap passes. Default `nil` derives from `pool_idle_timeout`. Set it lower to reap more often without shrinking the idle window.
107
+
108
+ `max_total_connections`: ceiling on the number of live tenant pools; `nil` for unlimited (default: `nil`). Enforced synchronously at pool-creation time (see `pool_overflow_policy`) and trimmed continuously by the background reaper. Total backend connections ≈ `max_total_connections × tenant_pool_size`.
109
+
110
+ `pool_overflow_policy`: behavior when a new pool would breach `max_total_connections` and every existing pool is pinned or in use (no idle pool to evict). `:evict_idle` (default) — allow the new pool, emit a `cap_unmet` notification (soft cap, prioritizes availability). `:raise` — raise `Apartment::PoolCapacityReached` (hard cap, sheds load). When an idle pool *is* available it is always evicted inline regardless of policy. See `docs/designs/pool-admission-control.md`.
107
111
 
108
112
  ### Elevator (Request Tenant Detection)
109
113
 
@@ -59,11 +59,11 @@ lib/apartment/
59
59
 
60
60
  ### pool_manager.rb — Pool Cache
61
61
 
62
- `Concurrent::Map` storing connection pools by tenant key. Monotonic clock timestamps for idle/LRU tracking. `stats_for` returns `{ seconds_idle: N }`. `clear` disconnects all pools before clearing.
62
+ `Concurrent::Map` storing connection pools by tenant key. Monotonic clock timestamps for idle/LRU tracking. `stats_for` returns `{ seconds_idle: N }`. `clear` disconnects all pools before clearing. When `max_total_connections` is set, `Apartment.configure` wires an `admission_controller` (the reaper) so cold creates route through a serialized, capacity-bounded path; otherwise the lock-free `compute_if_absent` fast path is used. See `docs/designs/pool-admission-control.md`.
63
63
 
64
- ### pool_reaper.rb — Pool Eviction
64
+ ### pool_reaper.rb — Pool Eviction + Admission
65
65
 
66
- Background `Concurrent::TimerTask` instance that evicts idle and excess tenant pools. Created by `Apartment.configure`, stored as `Apartment.pool_reaper`. Deregisters evicted pools from AR's ConnectionHandler. Default tenant is never evicted.
66
+ Background `Concurrent::TimerTask` that evicts idle and excess tenant pools, and also serves as the pool manager's synchronous admission controller (`admit!`) when a cap is configured — evicting the LRU idle pool inline before a new one is established, applying `pool_overflow_policy` (`:evict_idle` / `:raise`) when no idle pool can be freed. A single `evict_tenant` primitive backs the timer (idle/LRU) and admission paths. Reap cadence (`interval`/`reaper_interval`) is decoupled from the idle window (`idle_timeout`/`pool_idle_timeout`). Created by `Apartment.configure`, stored as `Apartment.pool_reaper`. Deregisters evicted pools from AR's ConnectionHandler. Default tenant is never evicted.
67
67
 
68
68
  ### adapters/abstract_adapter.rb — Base Adapter
69
69
 
@@ -30,7 +30,8 @@ module Apartment
30
30
  strategy: Apartment.config.tenant_strategy,
31
31
  adapter_name: effective_base['adapter']
32
32
  )
33
- resolve_connection_config(tenant, base_config: effective_base)
33
+ config = resolve_connection_config(tenant, base_config: effective_base)
34
+ apply_tenant_pool_size(config)
34
35
  end
35
36
 
36
37
  # Resolve a tenant-specific connection config hash.
@@ -259,6 +260,19 @@ module Apartment
259
260
  connection_config.transform_keys(&:to_s)
260
261
  end
261
262
 
263
+ # Cap the tenant pool's max checkout size to Apartment.config.tenant_pool_size
264
+ # when configured. Defaults to nil — the tenant pool inherits the base/default
265
+ # pool's `pool:` (the app's DB_POOL_SIZE), preserving pre-4.0.0.alpha3 behavior.
266
+ # Set tenant_pool_size to size each per-tenant pool independently of the app pool
267
+ # (e.g. to bound total connections across many schema-per-tenant pools). The config
268
+ # is string-keyed here; HashConfig symbolizes it and reads `:pool` for the size.
269
+ def apply_tenant_pool_size(config)
270
+ size = Apartment.config.tenant_pool_size
271
+ return config unless size
272
+
273
+ config.merge('pool' => size)
274
+ end
275
+
262
276
  # Connection config for pinned models on the separate-pool path.
263
277
  # For schema strategy, pins schema_search_path to the default tenant
264
278
  # (plus persistent schemas) so the connection resolves tables and FK
@@ -16,7 +16,8 @@ module Apartment
16
16
 
17
17
  attr_accessor :tenants_provider, :default_tenant,
18
18
  :default_tenant_switch_allowed,
19
- :tenant_pool_size, :pool_idle_timeout, :max_total_connections,
19
+ :tenant_pool_size, :pool_idle_timeout, :reaper_interval,
20
+ :max_total_connections, :pool_overflow_policy,
20
21
  :seed_after_create, :seed_data_file,
21
22
  :schema_load_strategy, :schema_file,
22
23
  :parallel_migration_threads,
@@ -33,9 +34,11 @@ module Apartment
33
34
  @default_tenant = nil
34
35
  @default_tenant_switch_allowed = true
35
36
  @excluded_models = []
36
- @tenant_pool_size = 5
37
+ @tenant_pool_size = nil
37
38
  @pool_idle_timeout = 300
39
+ @reaper_interval = nil
38
40
  @max_total_connections = nil
41
+ @pool_overflow_policy = :evict_idle
39
42
  @seed_after_create = false
40
43
  @seed_data_file = nil
41
44
  @schema_load_strategy = nil
@@ -117,6 +120,9 @@ module Apartment
117
120
  def apply_defaults!
118
121
  # PostgreSQL's default schema is 'public'; avoid forcing every user to set it.
119
122
  @default_tenant ||= 'public' if @tenant_strategy == :schema
123
+ # Reap on the idle-timeout cadence unless an explicit interval decouples
124
+ # the two (reap more often without shrinking the idle window).
125
+ @reaper_interval = @pool_idle_timeout if @reaper_interval.nil?
120
126
  end
121
127
 
122
128
  # Validate configuration completeness and consistency.
@@ -144,19 +150,32 @@ module Apartment
144
150
 
145
151
  @postgres_config&.validate!
146
152
 
147
- unless @tenant_pool_size.is_a?(Integer) && @tenant_pool_size.positive?
148
- raise(ConfigurationError, "tenant_pool_size must be a positive integer, got: #{@tenant_pool_size.inspect}")
153
+ if @tenant_pool_size && (!@tenant_pool_size.is_a?(Integer) || @tenant_pool_size < 1)
154
+ raise(ConfigurationError,
155
+ "tenant_pool_size must be a positive integer or nil, got: #{@tenant_pool_size.inspect}")
149
156
  end
150
157
 
151
158
  unless @pool_idle_timeout.is_a?(Numeric) && @pool_idle_timeout.positive?
152
159
  raise(ConfigurationError, "pool_idle_timeout must be a positive number, got: #{@pool_idle_timeout.inspect}")
153
160
  end
154
161
 
162
+ # nil is valid pre-apply_defaults! (it derives from pool_idle_timeout); a
163
+ # set value must be a positive number.
164
+ if @reaper_interval && (!@reaper_interval.is_a?(Numeric) || !@reaper_interval.positive?)
165
+ raise(ConfigurationError, "reaper_interval must be a positive number or nil, got: #{@reaper_interval.inspect}")
166
+ end
167
+
155
168
  if @max_total_connections && (!@max_total_connections.is_a?(Integer) || @max_total_connections < 1)
156
169
  raise(ConfigurationError,
157
170
  "max_total_connections must be a positive integer or nil, got: #{@max_total_connections.inspect}")
158
171
  end
159
172
 
173
+ unless %i[evict_idle raise].include?(@pool_overflow_policy)
174
+ raise(ConfigurationError,
175
+ 'pool_overflow_policy must be :evict_idle or :raise, ' \
176
+ "got: #{@pool_overflow_policy.inspect}")
177
+ end
178
+
160
179
  unless [nil, :schema_rb, :sql].include?(@schema_load_strategy)
161
180
  raise(ConfigurationError, "Invalid schema_load_strategy: #{@schema_load_strategy.inspect}. " \
162
181
  'Must be nil, :schema_rb, or :sql')
@@ -33,6 +33,26 @@ module Apartment
33
33
  # Raised when the tenant connection pool is exhausted.
34
34
  class PoolExhausted < ApartmentError; end
35
35
 
36
+ # Raised when admitting a new tenant pool would exceed max_total_connections
37
+ # and no idle pool can be evicted to make room, under the :raise overflow
38
+ # policy. Distinct from PoolExhausted (a single pool's connections) — this is
39
+ # the process-wide pool-count ceiling. See docs/designs/pool-admission-control.md.
40
+ class PoolCapacityReached < ApartmentError
41
+ attr_reader :max_total, :current
42
+
43
+ def initialize(max_total: nil, current: nil)
44
+ @max_total = max_total
45
+ @current = current
46
+ super(
47
+ "Tenant pool capacity reached: #{current.inspect} pools open, " \
48
+ "max_total_connections is #{max_total.inspect}, and no idle pool could " \
49
+ 'be evicted to admit another (all pinned or in use). Raise ' \
50
+ 'max_total_connections, reduce concurrent tenants, or set ' \
51
+ 'pool_overflow_policy to :evict_idle to allow soft overflow.'
52
+ )
53
+ end
54
+ end
55
+
36
56
  # Raised when schema loading fails during tenant creation.
37
57
  class SchemaLoadError < ApartmentError; end
38
58
 
@@ -32,6 +32,13 @@ module Apartment
32
32
  pool_key = "#{tenant}:#{role}"
33
33
 
34
34
  Apartment.pool_manager.fetch_or_create(pool_key) do
35
+ # RE-ENTRANCY: when max_total_connections is set, this block runs under
36
+ # PoolManager's @create_mutex (non-reentrant). Nothing here may resolve
37
+ # ActiveRecord::Base.connection_pool for the current tenant — it would
38
+ # re-enter fetch_or_create and self-deadlock. `super` resolves the
39
+ # default pool (bypasses the patch), and check_pending_migrations? /
40
+ # schema-cache load operate on the explicit `pool`, so all are safe.
41
+ # Keep it that way if you add work to this block.
35
42
  # Resolve base config from the current role's default pool when available.
36
43
  # Falls back to nil (adapter uses its own base_config) when the default pool
37
44
  # is not accessible — e.g., in worker threads during parallel migration where
@@ -63,9 +70,20 @@ module Apartment
63
70
  shard: shard_key
64
71
  )
65
72
 
66
- raise(Apartment::PendingMigrationError, tenant) if check_pending_migrations?(pool)
67
-
68
- load_tenant_schema_cache(tenant, pool) if cfg.schema_cache_per_tenant
73
+ # establish_connection has registered the shard in AR's ConnectionHandler.
74
+ # If a post-establish check raises, the pool is returned to neither the
75
+ # caller nor PoolManager — it would be orphaned: live in AR but invisible
76
+ # to the reaper and to max_total accounting (a connection leak that also
77
+ # undercounts the cap). Deregister it before re-raising so AR and the
78
+ # manager stay consistent. The next request re-establishes cleanly.
79
+ begin
80
+ raise(Apartment::PendingMigrationError, tenant) if check_pending_migrations?(pool)
81
+
82
+ load_tenant_schema_cache(tenant, pool) if cfg.schema_cache_per_tenant
83
+ rescue StandardError
84
+ Apartment.deregister_shard(pool_key)
85
+ raise
86
+ end
69
87
 
70
88
  pool
71
89
  end
@@ -80,7 +98,7 @@ module Apartment
80
98
 
81
99
  def check_pending_migrations?(pool)
82
100
  return false unless Apartment.config.check_pending_migrations
83
- return false unless defined?(Rails) && Rails.env.local? # rubocop:disable Rails/UnknownEnv
101
+ return false unless defined?(Rails) && Rails.env.local?
84
102
  return false if Apartment::Current.migrating
85
103
 
86
104
  pool.migration_context.needs_migration?
@@ -4,17 +4,25 @@ require 'concurrent'
4
4
 
5
5
  module Apartment
6
6
  class PoolManager
7
+ # Set by Apartment.configure to the PoolReaper when max_total_connections is
8
+ # configured. nil (no cap) keeps the lock-free compute_if_absent fast path.
9
+ attr_accessor :admission_controller
10
+
7
11
  def initialize
8
12
  @pools = Concurrent::Map.new
9
13
  @timestamps = Concurrent::Map.new
14
+ @create_mutex = Mutex.new
15
+ @admission_controller = nil
10
16
  end
11
17
 
12
18
  # Fetch an existing pool or create one via the block.
13
19
  # Timestamp is updated after pool creation to avoid orphaned timestamps if the block raises.
20
+ # When an admission controller is wired (a cap is configured), cold creates
21
+ # go through the bounded path so the pool count cannot exceed max_total.
14
22
  def fetch_or_create(tenant_key, &)
15
- pool = @pools.compute_if_absent(tenant_key, &)
16
- touch(tenant_key)
17
- pool
23
+ return fetch_or_admit(tenant_key, &) if @admission_controller
24
+
25
+ touch_and_return(tenant_key, @pools.compute_if_absent(tenant_key, &))
18
26
  end
19
27
 
20
28
  def get(tenant_key)
@@ -119,6 +127,32 @@ module Apartment
119
127
 
120
128
  private
121
129
 
130
+ # Capacity-bounded creation path. Serializes cold creates so the admission
131
+ # controller's capacity check + eviction + insert is atomic across creators;
132
+ # the new pool is only inserted after admit! confirms (or makes) room. The
133
+ # hot path (existing pool) stays lock-free in fetch_or_create. Establishing
134
+ # the connection under the lock is deliberate: it serializes only cold
135
+ # creates (once per tenant per worker) — the price of a hard count bound.
136
+ def fetch_or_admit(tenant_key)
137
+ existing = @pools[tenant_key]
138
+ return touch_and_return(tenant_key, existing) if existing
139
+
140
+ @create_mutex.synchronize do
141
+ cached = @pools[tenant_key]
142
+ return touch_and_return(tenant_key, cached) if cached
143
+
144
+ @admission_controller.admit!(tenant_key)
145
+ pool = yield
146
+ @pools[tenant_key] = pool
147
+ touch_and_return(tenant_key, pool)
148
+ end
149
+ end
150
+
151
+ def touch_and_return(tenant_key, pool)
152
+ touch(tenant_key)
153
+ pool
154
+ end
155
+
122
156
  def touch(tenant_key)
123
157
  @timestamps[tenant_key] = monotonic_now
124
158
  end
@@ -7,7 +7,13 @@ module Apartment
7
7
  # Evicts idle and excess tenant pools on a background timer.
8
8
  # Complementary to ActiveRecord's ConnectionPool::Reaper which handles
9
9
  # intra-pool connection reaping — this handles inter-pool (tenant) eviction.
10
- class PoolReaper
10
+ class PoolReaper # rubocop:disable Metrics/ClassLength
11
+ # Reap cadence (seconds) and the idle window (seconds) a pool must exceed
12
+ # before it is eligible for idle eviction. Decoupled so a deployment can
13
+ # reap frequently without shrinking the idle window. Exposed for
14
+ # introspection and wiring assertions.
15
+ attr_reader :interval, :idle_timeout
16
+
11
17
  # True when Rails' transactional-fixture machinery has pinned the pool
12
18
  # (ConnectionPool#pin_connection!, Rails 7.1+). Evicting or discarding a
13
19
  # pinned pool strands the fixture transaction; teardown then errors or
@@ -24,7 +30,8 @@ module Apartment
24
30
  end
25
31
 
26
32
  def initialize(pool_manager:, interval:, idle_timeout:, max_total: nil,
27
- default_tenant: nil, shard_key_prefix: nil, on_evict: nil)
33
+ default_tenant: nil, shard_key_prefix: nil, on_evict: nil,
34
+ overflow_policy: :evict_idle)
28
35
  raise(ArgumentError, 'interval must be a positive number') unless interval.is_a?(Numeric) && interval.positive?
29
36
  unless idle_timeout.is_a?(Numeric) && idle_timeout.positive?
30
37
  raise(ArgumentError, 'idle_timeout must be a positive number')
@@ -40,10 +47,29 @@ module Apartment
40
47
  @default_tenant = default_tenant
41
48
  @shard_key_prefix = shard_key_prefix
42
49
  @on_evict = on_evict
50
+ @overflow_policy = overflow_policy
43
51
  @mutex = Mutex.new
44
52
  @timer = nil
45
53
  end
46
54
 
55
+ # Synchronously enforce max_total before a new tenant pool is admitted.
56
+ # Called by {PoolManager#fetch_or_create} under its creation lock (so the
57
+ # capacity check, eviction, and insert are atomic w.r.t. other creators).
58
+ # Evicts LRU idle (non-protected, non-default) pools until there is room for
59
+ # one more; if none can be freed, applies the overflow policy. A no-op when
60
+ # no cap is configured. See docs/designs/pool-admission-control.md.
61
+ def admit!(incoming_tenant_key)
62
+ return unless @max_total
63
+
64
+ loop do
65
+ break if @pool_manager.stats[:total_pools] < @max_total
66
+ break unless evict_one_for_admission(incoming_tenant_key)
67
+ end
68
+ return if @pool_manager.stats[:total_pools] < @max_total
69
+
70
+ apply_overflow_policy
71
+ end
72
+
47
73
  def start
48
74
  @mutex.synchronize do
49
75
  stop_internal
@@ -98,17 +124,61 @@ module Apartment
98
124
  next if default_tenant_pool?(tenant)
99
125
  next if protected_pool?(tenant, eviction_reason: :idle)
100
126
 
101
- pool = @pool_manager.remove(tenant)
102
- deregister_from_ar_handler(tenant)
103
- Instrumentation.instrument(:evict, tenant: tenant, reason: :idle)
104
- @on_evict&.call(tenant, pool)
105
- count += 1
127
+ count += 1 if evict_tenant(tenant, reason: :idle)
106
128
  rescue StandardError => e
107
129
  warn "[Apartment::PoolReaper] Failed to evict tenant #{tenant}: #{e.class}: #{e.message}"
108
130
  end
109
131
  count
110
132
  end
111
133
 
134
+ # Single eviction primitive shared by the timer paths (idle/LRU) and the
135
+ # synchronous admission path: drop from the manager, deregister from AR's
136
+ # ConnectionHandler (which disconnects the pool), instrument, and fire the
137
+ # on_evict hook. The +reason+ flows into the :evict event payload.
138
+ def evict_tenant(tenant, reason:)
139
+ # The timer (no lock) and admission (under @create_mutex) use different
140
+ # locks, so both can target the same idle tenant. The loser's remove
141
+ # returns nil — bail before firing a duplicate :evict event or calling
142
+ # on_evict with a nil pool.
143
+ return nil unless (pool = @pool_manager.remove(tenant))
144
+
145
+ deregister_from_ar_handler(tenant)
146
+ Instrumentation.instrument(:evict, tenant: tenant, reason: reason)
147
+ @on_evict&.call(tenant, pool)
148
+ pool
149
+ end
150
+
151
+ # Evict the single LRU evictable pool to make room for an incoming one,
152
+ # skipping the incoming key, the default tenant, and pinned/in-use pools.
153
+ # Returns the evicted tenant key, or nil if nothing is evictable.
154
+ def evict_one_for_admission(incoming_tenant_key)
155
+ @pool_manager.lru_tenants(count: @pool_manager.stats[:total_pools]).each do |tenant|
156
+ next if tenant == incoming_tenant_key
157
+ next if default_tenant_pool?(tenant)
158
+ next if protected_pool?(tenant, eviction_reason: :admission)
159
+
160
+ evict_tenant(tenant, reason: :admission)
161
+ return tenant
162
+ rescue StandardError => e
163
+ warn "[Apartment::PoolReaper] Failed to evict tenant #{tenant} for admission: #{e.class}: #{e.message}"
164
+ end
165
+ nil
166
+ end
167
+
168
+ # Applied when the cap can't be met by eviction (every other pool is pinned
169
+ # or in use). :evict_idle degrades to a soft cap — allow the new pool, surface
170
+ # the breach via :cap_unmet. :raise fails the admission so the caller sheds
171
+ # load. See docs/designs/pool-admission-control.md.
172
+ def apply_overflow_policy
173
+ current = @pool_manager.stats[:total_pools]
174
+ case @overflow_policy
175
+ when :raise
176
+ raise(Apartment::PoolCapacityReached.new(max_total: @max_total, current: current))
177
+ else
178
+ Instrumentation.instrument(:cap_unmet, max_total: @max_total, current: current, unevicted: 1)
179
+ end
180
+ end
181
+
112
182
  def evict_lru
113
183
  total = @pool_manager.stats[:total_pools]
114
184
  excess = total - @max_total
@@ -135,11 +205,7 @@ module Apartment
135
205
  next if default_tenant_pool?(tenant)
136
206
  next if protected_pool?(tenant, eviction_reason: :lru)
137
207
 
138
- pool = @pool_manager.remove(tenant)
139
- deregister_from_ar_handler(tenant)
140
- Instrumentation.instrument(:evict, tenant: tenant, reason: :lru)
141
- @on_evict&.call(tenant, pool)
142
- evicted += 1
208
+ evicted += 1 if evict_tenant(tenant, reason: :lru)
143
209
  rescue StandardError => e
144
210
  warn "[Apartment::PoolReaper] Failed to evict tenant #{tenant}: #{e.class}: #{e.message}"
145
211
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Apartment
4
- VERSION = '4.0.0.alpha2'
4
+ VERSION = '4.0.0.alpha3'
5
5
  end
data/lib/apartment.rb CHANGED
@@ -158,16 +158,7 @@ module Apartment # rubocop:disable Metrics/ModuleLength
158
158
  @built_in_tenant_validator&.shutdown
159
159
  @built_in_tenant_validator = nil
160
160
  @config = new_config
161
- @pool_manager = PoolManager.new
162
- @pool_reaper = PoolReaper.new(
163
- pool_manager: @pool_manager,
164
- interval: new_config.pool_idle_timeout,
165
- idle_timeout: new_config.pool_idle_timeout,
166
- max_total: new_config.max_total_connections,
167
- default_tenant: new_config.default_tenant,
168
- shard_key_prefix: new_config.shard_key_prefix
169
- )
170
- @pool_reaper.start
161
+ setup_pools!(new_config)
171
162
  @config
172
163
  end
173
164
 
@@ -263,6 +254,25 @@ module Apartment # rubocop:disable Metrics/ModuleLength
263
254
  BUILT_IN_VALIDATOR_MUTEX.synchronize { @built_in_tenant_validator ||= TenantValidator.new }
264
255
  end
265
256
 
257
+ # Build the pool manager + reaper for a freshly-validated config and start
258
+ # the background reaper. When max_total_connections is set, wire the reaper
259
+ # as the pool manager's admission controller so cold creates are bounded
260
+ # synchronously; otherwise the manager keeps its lock-free create path.
261
+ def setup_pools!(new_config)
262
+ @pool_manager = PoolManager.new
263
+ @pool_reaper = PoolReaper.new(
264
+ pool_manager: @pool_manager,
265
+ interval: new_config.reaper_interval,
266
+ idle_timeout: new_config.pool_idle_timeout,
267
+ max_total: new_config.max_total_connections,
268
+ default_tenant: new_config.default_tenant,
269
+ shard_key_prefix: new_config.shard_key_prefix,
270
+ overflow_policy: new_config.pool_overflow_policy
271
+ )
272
+ @pool_manager.admission_controller = @pool_reaper if new_config.max_total_connections
273
+ @pool_reaper.start
274
+ end
275
+
266
276
  # Safely tear down old state. Stops the reaper first (so it doesn't
267
277
  # evict mid-cleanup), then deregisters tenant pools from AR's
268
278
  # ConnectionHandler, then clears the pool manager.
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.alpha2
4
+ version: 4.0.0.alpha3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Brunner