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 +4 -4
- data/README.md +7 -3
- data/lib/apartment/CLAUDE.md +3 -3
- data/lib/apartment/adapters/abstract_adapter.rb +15 -1
- data/lib/apartment/config.rb +23 -4
- data/lib/apartment/errors.rb +20 -0
- data/lib/apartment/patches/connection_handling.rb +22 -4
- data/lib/apartment/pool_manager.rb +37 -3
- data/lib/apartment/pool_reaper.rb +78 -12
- data/lib/apartment/version.rb +1 -1
- data/lib/apartment.rb +20 -10
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0b2254c1f3f95622ba9fa45147fcc2f5b2036db4c5c75a2d68c3718772df6149
|
|
4
|
+
data.tar.gz: efcc9149b0aaaa6c7b614cc981848f02e999259eaa0826120b4df16b555f6caf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 (
|
|
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
|
|
104
|
+
`pool_idle_timeout`: seconds an idle tenant pool must exceed before it is eligible for reaping (default: 300).
|
|
105
105
|
|
|
106
|
-
`
|
|
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
|
|
data/lib/apartment/CLAUDE.md
CHANGED
|
@@ -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`
|
|
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
|
data/lib/apartment/config.rb
CHANGED
|
@@ -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, :
|
|
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 =
|
|
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
|
-
|
|
148
|
-
raise(ConfigurationError,
|
|
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')
|
data/lib/apartment/errors.rb
CHANGED
|
@@ -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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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?
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/apartment/version.rb
CHANGED
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
|
-
|
|
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.
|